4. Tensor operations¶
This section is the reference for the math/NN operation surface of the public
autograd fucina.Tensor(tags_spec) facade (src/ag/tensor.zig), the
tag-semantics lowering library behind it (src/tagged.zig), and the public
option types those operations take (src/exec.zig). Construction, data
access, and structural views (withTags, permuteTo, transpose,
alignTo, insertAxis, squeeze, split, merge, broadcastTo,
viewWithStrides, flatten, materialize, detach) are covered in §3; the
tag algebra itself in §7; the autograd engine driving the backward records
named here in §5.
4.1 The common operation contract (src/ag/tensor.zig)¶
Every operation below shares one contract, implemented by the shared tails
finishOp/finishNoGrad:
- Signature shape. Ops are methods on
Tensor(tags)takingctx: *ExecContextas the first runtime argument. Axes are chosen by comptime tag (comptime tag: Tag); misnaming a tag that the tensor does not carry is a compile error, never a runtime error. Shape problems the type system cannot see (mismatched dims, bad lengths) are recoverableTensorErrors (ShapeMismatch,InvalidShape,InvalidDataLength,IndexOutOfBounds, integer division'sDivisionByZero); the data-dependent no-match outcome ofmaskedSelect/maskedScattergets the dedicatedEmptySelectionso it stays catchable apart from those. - Ownership. Each op allocates and returns a new owned tensor; the
caller
deinits it. Operands are borrowed via*constand never consumed (the twotake*ops in §4.3 are the documented exception). While an exec scope is open on the context, returned tensors are scope-owned borrows and theirdeinitis a safe no-op (§6). - Gradients. A backward record is attached iff at least one operand
requiresGrad()and gradients are globally enabled (fucina.noGrad, §5). Families that are no-grad by design, or that restrict which operands receive gradients, say so inline; grad-incompatible calls fail witherror.UnsupportedGradient(or a more specific error named per family). Ops composed from other facade ops (nllLoss,l2Normalize,cosineSimilarity,maskedSelect,stack,unbindInto) additionally require an active exec scope when gradients are tracked — their intermediate graph nodes are function-local and only a scope can own them untilbackward; they fail witherror.ActiveExecScopeRequiredotherwise. No-grad use works unscoped. - Thread-safety. A context is single-threaded at the API surface: run
ops on one
ExecContextfrom one thread (§6). Kernels parallelize internally through the context's work pool (§9). - Option types. Options are passed as literals, so their type names are
rarely written out. The ones re-exported at the
fucinaroot areUnaryOp,Reduction,CrossEntropyOptions,StandardizeOptions,StandardizeAccumulation,StandardizeEpsMode,RouterTopKOptions,RopeMode,RopeTable,RopeTheta,MoeRhs,MoeBatchProfile,PackedRhs,PackedRhsLayout,GatedOp. The remaining option types named in this section (CompareOp,MatmulKind,MseOptions,HuberOptions,BceOptions,KlDivOptions,LinearDistillOptions,SoftmaxExtOptions,NormOrder) live insrc/exec.zigand are reached through enum/struct literals at call sites (.swiglu,.lt,.trans_b,.{ .reduction = .none }).
Snippets in this section are runnable test blocks and assume:
const std = @import("std");
const fucina = @import("fucina");
4.2 Pointwise binary ops and tag-driven broadcasting (src/ag/tensor.zig, src/tagged.zig)¶
pub fn add(self: *const Self, ctx: *ExecContext, other: anytype)
!Tensor(pointwiseResultTags(tags, TensorObject(@TypeOf(other)).axis_tags))
// same shape for: sub, mul, div; TensorObject unwraps pointer operands
add, sub, mul, div broadcast by tag name, not by position. The
result tag set is pointwiseResultTags: self's tags in order, followed by
other's tags that self does not carry. Per shared tag the dims must be
equal or one of them 1; a tag missing from one operand behaves as dim 1.
Broadcasting is a zero-stride view (no materialization); when both operands
have identical tags and shapes the kernel runs directly with no view step.
other may be a tensor value or pointer. Backward: full two-operand VJP; a
gradient flowing into a broadcast operand is reduced back over the broadcast
axes (§5).
Three more binary pointwise ops share the same tag-broadcast rule and two-operand VJP:
maximum(ctx, other)/minimum(ctx, other)— torch.maximum/minimum, full.max/.minmembers of the binary kernel enum (pooled SIMD, theadd/multier): NaN in either operand propagates NaN (NOT the IEEE maxNum rule bare@maxfollows); the gradient goes to the winning operand and is split evenly on exact ties (torch's subgradient, ±inf ties included).pow(ctx, other)—self ^ otherwithstd.math.powdomain semantics (negative base + non-integer exponent is NaN,0^0 = 1);powScalar(§4.3) is the scalar-exponent fast path. Implemented over the elemental tier (§4.4) —std.math.powhas no portable SIMD form with these domain semantics. The exponent-side gradientln(a)·a^bis meaningful only for positive bases, as in torch.
test "pointwise add broadcasts by tag" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .row, .col }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var b = try fucina.Tensor(.{.col}).variableFromSlice(&ctx, .{2}, &.{ 10, 20 });
defer b.deinit();
var y = try x.add(&ctx, &b); // result tags .{ .row, .col }; b broadcasts over .row
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 11, 22, 13, 24 }, try y.dataConst());
var loss = try y.sumAll(&ctx);
defer loss.deinit();
try loss.backward(&ctx);
var gb = (try b.grad(&ctx)).?; // broadcast VJP: gradient reduced back to .{ .col }
defer gb.deinit();
try std.testing.expectEqualSlices(f32, &.{ 2, 2 }, try gb.dataConst());
}
compiled & run in CI ✓
4.3 Scalar variants and in-place/no-grad helpers (src/ag/tensor.zig)¶
Scalar variants (all differentiable, all return a new tensor):
| Method | Value | Backward |
|---|---|---|
scale(ctx, s) |
x·s |
ScaleBackward |
addScalar(ctx, s) |
x + s |
pass-through |
subScalar(ctx, s) |
addScalar(-s) |
pass-through |
divScalar(ctx, s) |
scale(1/s) |
via scale |
powScalar(ctx, e) |
x^e (positive x) |
e·x^(e−1) |
test "scalar op variants" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{2}, &.{ 3, 4 });
defer x.deinit();
var shifted = try x.addScalar(&ctx, 1); // {4, 5}
defer shifted.deinit();
var squared = try shifted.powScalar(&ctx, 2); // {16, 25}
defer squared.deinit();
var y = try squared.scale(&ctx, 0.5); // {8, 12.5}
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 8, 12.5 }, try y.dataConst());
}
compiled & run in CI ✓
Inference-oriented helpers (the in-place and consuming ones reject
grad-requiring operands with error.UnsupportedGradient; biasAdd does
not — see its bullet):
addAxisVectorInPlace(ctx, bias, axis_tag)— adds a[axis_dim]f32 row vector along the last axisaxis_tag, mutatingselfin place.addAxisVectorUnaryInPlace(ctx, op, bias, axis_tag)— fused bias-add +UnaryOpactivation, in place.addScaledInPlace(ctx, other, alpha)—self += alpha·other(same shape), in place.takeAddNoGrad(ctx, other)/takeScaleNoGrad(ctx, s)— consumeself(it becomesundefined) and return the result, reusingself's storage when the runtime can take it in place. They additionally fail witherror.ActiveExecScopeUnsupportedon a scope-owned borrow.biasAdd(ctx, bias, axis_tag)— the out-of-place variant: a new tensor,selfunchanged. It accepts grad-requiring input:self's gradient passes through identity; the rawbiasslice receives none. For a trainable bias, use broadcastadd.
4.4 Unary ops (src/ag/tensor.zig, src/backend/ops.zig)¶
pub fn unary(self: *const Self, ctx: *ExecContext, comptime op: UnaryOp) !Self
exec.UnaryOp is the closed kernel enum; most values also have a direct
method alias:
UnaryOp |
Method | Notes |
|---|---|---|
.relu |
relu |
dedicated backward record |
.exp |
exp |
|
.sqrt |
sqrt |
|
.rsqrt |
rsqrt |
|
.sigmoid |
sigmoid |
|
.silu |
silu |
|
.log |
log |
|
.log1p |
log1p |
log(1 + x) |
.softplus |
— (unary(.softplus) only) |
log(1 + e^x), sign-stable (torch softplus, pre-threshold regime) |
.neg |
neg |
|
.abs |
abs |
|
.sin |
sin |
|
.cos |
cos |
|
.tanh |
tanh |
|
.fast_tanh |
fastTanh |
NAM rational approximation |
.gelu |
gelu |
tanh approximation (exact form of .gelu_quant) |
.quick_gelu |
quickGelu |
x·sigmoid(1.702x) |
.softcap_30 |
softcap30 |
30·tanh(x/30) logit softcap |
.softcap_15 |
softcap15 |
15·tanh(x/15) logit softcap |
.gelu_quant |
— (unary(.gelu_quant) only) |
ggml GGML_GELU_FP16 parity: f16-rounded tanh-gelu with hard clamps |
.elu |
elu |
alpha = 1, matches ggml_vec_elu_f32 |
.gelu_erf |
geluErf |
exact-erf GELU (musl erff translation, matches ggml_vec_gelu_erf_f32) |
.floor |
floor |
zero gradient a.e. (torch convention) |
.ceil |
ceil |
zero gradient a.e. |
.round |
round |
round-half-to-EVEN (torch.round), NOT half-away; 2^23 magic-number trick, scalar and SIMD legs bit-identical; zero gradient a.e. |
.sign |
sign |
±0 preserved, NaN propagates (numpy/torch); zero gradient a.e. |
.reciprocal |
reciprocal |
1/x; output-derivative backward (-out², like tanh) |
All unary ops are differentiable (§5). Related parameterized elementwise ops:
leakyRelu(ctx, negative_slope)— differentiable, dedicated backward.clamp(ctx, min_value, max_value)— differentiable; gradient is zero outside[min, max].clampMin(ctx, min_value)/clampMax(ctx, max_value)— one-sided clamps (torch'sclamp_min/clamp_max); the open side is ±inf, so values and gradients pass through it untouched.dropout(ctx, p, seed)— inverted dropout: keepsx[i]/(1−p)iff the per-element counter RNG at(seed, i)draws below1−p. The mask is never stored: forward, backward, and checkpoint recompute regenerate it from(seed, index), so the op is a pure function of(input, p, seed). Requires0 <= p < 1;p == 0returns an identity view. Pass a fresh seed per call (reusing a seed reuses the mask); eval mode is caller-side — do not call dropout at eval.
test "unary ops" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{3}, &.{ -1, 0, 1 });
defer x.deinit();
var r = try x.relu(&ctx);
defer r.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0, 0, 1 }, try r.dataConst());
var s = try x.silu(&ctx); // same as x.unary(&ctx, .silu)
defer s.deinit();
try std.testing.expectApproxEqAbs(@as(f32, 0.7310586), (try s.dataConst())[2], 1e-6);
}
compiled & run in CI ✓
Elemental ops: user-defined scalar functions (src/ag/elemental.zig)¶
pub fn elementalUnary(self, ctx, comptime Op: type, extra: anytype) !Self
pub fn elementalBinary(self, ctx, other: anytype, comptime Op: type, extra: anytype)
!Tensor(pointwiseResultTags(...)) // the standard pointwise tag rule
UnaryOp is a closed enum; elementalUnary/elementalBinary are the
user-extensible escape hatch — a convenience tier over customVjp (§5.6)
that lifts a comptime scalar Op to a differentiable eager tensor op. The
user writes scalar math only; the adapter owns buffer plumbing,
strided-input materialization, tag-driven broadcasting, broadcast-gradient
sum-reduction, needs_grad pruning, and the worker-team chunking of the
scalar loops (bitwise thread-count-neutral: disjoint pure writes).
const Square = struct {
pub fn forward(x: f32, extra: void) f32 {
_ = extra;
return x * x;
}
// Returns the propagated dL/dx, NOT the local dy/dx.
pub fn backward(x: f32, y: f32, grad_y: f32, extra: void) f32 {
_ = y;
_ = extra;
return 2 * x * grad_y;
}
};
Binary Op declares forward(a, b, extra) plus backwardA/backwardB
returning dL/da and dL/db evaluated elementwise at the broadcast result
shape; broadcast operands get their gradient sum-reduced back to their own
tags/shape exactly like add/mul. Missing declarations are compile
errors. Both ops are f32-branch only, accept strided views, and return
owned contiguous results; extra is captured by value in the backward
node (the customVjp lifetime contract: pointees must outlive backward).
Validate a new Op with fucina.gradcheck (§5.7).
An Op may additionally declare vector twins over fucina.simd.Vf32
lanes — forwardVec/backwardVec (unary), forwardVec plus
backwardAVec/backwardBVec (binary) — and the kernels then run those on
full vectors with the scalar rules on the tail (the built-in kernels'
lane/tail split). Bodies containing transcendentals want this: a scalar
rule compiles to one libm call per element, where a vector body can use
the fucina.simd helpers (vexpf, sigmoidVec, tanhVec; vector_len
is the lane count — §9.4). The scalar and vector rules must compute the
same function; lane/tail rounding may differ exactly as it does in the
built-ins.
test "elementalUnary" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).variableFromSlice(&ctx, .{3}, &.{ 1, -2, 3 });
defer x.deinit();
var y = try x.elementalUnary(&ctx, Square, {});
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 4, 9 }, try y.dataConst());
var loss = try y.sumAll(&ctx);
defer loss.deinit();
try loss.backward(&ctx);
var gx = (try x.grad(&ctx)).?;
defer gx.deinit();
try std.testing.expectEqualSlices(f32, &.{ 2, -4, 6 }, try gx.dataConst());
}
compiled & run in CI ✓
4.5 Gated activations (src/ag/tensor.zig)¶
pub fn gated(self, ctx, other: anytype, comptime op: GatedOp) !Tensor(...) // pointwise result tags
pub fn splitGated(self, ctx, comptime op: GatedOp, comptime tag: Tag, comptime out_tag: Tag)
!Tensor(replaceTag(tags, tag, out_tag))
exec.GatedOp is { .glu, .swiglu, .geglu, .swiglu_clamp10, .situ }. The
two-operand form computes self * act(other) — the second operand is
the gate — with the same tag-broadcast rule as §4.2: glu =
self·sigmoid(other), swiglu = self·silu(other), geglu =
self·gelu(other) (tanh approximation; Gemma's GeGLU). .situ (Kimi
K3's SiTU) is the one member that also transforms the up side:
25·tanh(self/25) · 4·tanh(other/4)·sigmoid(other) — a soft-bounded SiLU
gate (beta 4) on a soft-clamped up input (linear beta 25).
glu/swiglu/geglu/situ are direct aliases of gated(..., op).
Differentiable in both operands. .swiglu_clamp10 (DeepSeek V4's clamped
SwiGLU: the gate is min(gate, 10) before SiLU, up is clamped to
[-10, 10]) is inference-only — it has no backward and no split kernel,
so gated and splitGated reject it at compile time; it exists for the
MoE entries (§4.18).
splitGated halves axis tag and gates one half with the other in a single
fused kernel; the gate-half conventions differ deliberately (ggml parity):
.swiglu gates with the first half (silu(first)·second), .glu with
the second (first·sigmoid(second)). out_tag == tag is allowed.
.geglu and .situ are compile errors (no split kernel exists for
either; K3 projects gate and up separately — use the pointwise situ).
Differentiable in self.
test "gated pointwise and split-gated" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var up = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{1}, &.{2});
defer up.deinit();
var gate = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{1}, &.{1});
defer gate.deinit();
var y = try up.swiglu(&ctx, &gate); // up * silu(gate)
defer y.deinit();
try std.testing.expectApproxEqAbs(@as(f32, 2 * 0.7310586), (try y.dataConst())[0], 1e-6);
var fused = try fucina.Tensor(.{.ff}).fromSlice(&ctx, .{2}, &.{ 1, 2 });
defer fused.deinit();
var z = try fused.splitGated(&ctx, .swiglu, .ff, .d); // silu(first half) * second half
defer z.deinit();
try std.testing.expectApproxEqAbs(@as(f32, 0.7310586 * 2), (try z.dataConst())[0], 1e-6);
}
compiled & run in CI ✓
4.6 Masks, comparisons, and conditionals (src/ag/tensor.zig)¶
Mask producers emit .bool tensors (torch's comparison dtype); mask
consumers (where, maskedFill, the logical ops) take a .bool mask or
a float tensor read by truthiness (!= 0; NaN truthy). Like every typed
constant, .bool results are CALLER-owned even under an exec scope. Count
a mask with sum/sumAll (i64, §4.19) and cast with to(.f32) for the
mask-multiply idiom.
compare(ctx, op, other)—.bool, true whereself <op> otherholds.opisexec.CompareOp(.eq .ne .lt .le .gt .ge);otheris comptime-dispatched: a same-tagged tensor (same shape only) or a numeric scalar. No-grad by design (constant mask). NaN follows IEEE: any comparison involving NaN is false except.ne, which is true. Also on the typed branches: f16/bf16 compare through f32; INTEGER tensors compare natively (exact at any magnitude — token-id masks).logicalAnd,logicalOr,logicalXor(ctx, other) andlogicalNot(ctx)— elementwise logic over truthiness,.boolout. Defined on the f32 branch (floatself,.bool-or-floatother) and on the.boolbranch itself (mask combinators); same shape only.where(ctx, cond, other)—cond[i] ? self[i] : other[i]. Differentiable inselfandother;cond(.boolor float) is a non-grad mask.maskedFill(ctx, mask, value)—mask[i] ? value : self[i]. Differentiable inself(gradient zeroed where filled);valueis a constant.isnan(ctx)/isinf(ctx)/isfinite(ctx)— torch's float predicates as.boolmasks, built purely from the IEEEcomparesemantics (isnanis the self-.netest;isfiniteis-inf < x < inf, false for NaN and both infinities). Non-differentiable, unscoped-safe.any(ctx, tag)/all(ctx, tag)—.bool, true where any/every element alongtagis truthy (NaN is truthy, the torch.any/all convention), the tag removed;anyAll(ctx)/allAll(ctx)are the scalar full-tensor forms (torch with no dim). Non-differentiable (compare → i64 count → compare), unscoped-safe.bandMask(ctx, raw_shape, lower, upper)— the banded / sliding-window attention-mask CONSTRUCTOR, on two-tag.booltypes only: element(i, j)is true iffi - j <= lowerandj - i <= upper, a null bound unbounded on that side (bounds are signed?i64). Causal keep-set =(null, 0); sliding window of width W =(W - 1, 0); tril(k) keep-set =(null, k); triu(k) =(-k, null). Feed it towhere/maskedFill(viabroadcastTofor batched scores) or cast withto(.f32)for the mask-multiply idiom; contradictory bounds yield an all-false mask, not an error.bandPart(ctx, row_tag, col_tag, lower, upper)— keep thebandMaskband of the (row_tag,col_tag) plane and ZERO everything outside (tf.linalg.band_part with signed nullable bounds), at any rank carrying both tags — remaining axes pass through. Implemented as a constant 0/1 band-plane multiply, so it is differentiable inselfwith the exact same-band mask as VJP.tril(ctx, row_tag, col_tag, offset)/triu(ctx, row_tag, col_tag, offset)are the triangular special cases (torch.tril/triu with a diagonal offset):bandPart(null, offset)andbandPart(-offset, null)respectively.
test "bandMask bandPart tril build banded and triangular structure" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// Causal .bool keep-set for attention scores.
const B = fucina.Tensor(.{ .dtype = .bool, .tags = .{ .q, .k } });
var causal = try B.bandMask(&ctx, .{ 3, 3 }, null, 0);
defer causal.deinit();
try std.testing.expectEqualSlices(bool, &.{ true, false, false, true, true, false, true, true, true }, try causal.dataConst());
// tril zeroes above the diagonal; the gradient is the same triangle.
var x = try fucina.Tensor(.{ .row, .col }).variableFromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var lo = try x.tril(&ctx, .row, .col, 0);
defer lo.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 0, 3, 4 }, try lo.dataConst());
var loss = try lo.sumAll(&ctx);
defer loss.deinit();
try loss.backward(&ctx);
var gx = (try x.grad(&ctx)).?;
defer gx.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 0, 1, 1 }, try gx.dataConst());
}
test "compare produces bool masks for maskedFill and where" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{3}, &.{ -1, 0, 2 });
defer x.deinit();
var neg = try x.compare(&ctx, .lt, 0); // .bool: {true, false, false}
defer neg.deinit();
comptime std.debug.assert(@TypeOf(neg).dtype == .bool);
var y = try x.maskedFill(&ctx, &neg, 0); // relu by hand
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0, 0, 2 }, try y.dataConst());
var n_neg = try neg.sumAll(&ctx); // count the mask: i64
defer n_neg.deinit();
try std.testing.expectEqual(@as(i64, 1), try n_neg.item());
}
compiled & run in CI ✓
4.7 Reductions and scans (src/ag/tensor.zig)¶
Axis reductions remove the reduced tag from the result type; sumAll
returns the scalar Tensor(.{}).
sum(ctx, tag)/mean(ctx, tag)— differentiable.sumMany(ctx, reduce_tags_spec)— sums away several tags (innermost first); result tagsremoveTags(tags, reduce_tags).sumAll(ctx)— full reduction toTensor(.{}); read withitem().variance(ctx, tag, ddof)—ddof: u1: 0 = biased (LayerNorm convention), 1 = Bessel-corrected (torch.vardefault). Differentiable.cumsum(ctx, tag)— inclusive prefix sum, shape-preserving; differentiable (gradient = reversed suffix sum). Both passes are serial per row by default, so results are bitwise deterministic for any thread count AND sequence-exact;-Dvector-scan(§2.2) vectorizes both (non-last axes stay bitwise identical; the last axis reassociates likesum's SIMD lanes).max(ctx, tag)/min(ctx, tag)— extremum values with the tag removed (indices come fromargmax/topK, §4.16). The gradient flows only to the first occurrence of the extremum along the axis (strict-comparison tie-break, matchingtorch.maxover a dim).prod(ctx, tag)— product with the tag removed (torch.prod over a dim), atsum's kernel tier: rank-1 reduces through the pooled SIMDprodInto, a last-axis reduction runs one vectorizedprodSliceper row (likesum, the SIMD lane order fixes the multiplication order per backend). Differentiable with torch's zero-handling: zero-free rows getg·(Πx)/x_i, exactly one zero routes the whole gradient to the zero slot, two or more kill the row's gradient.cumprod(ctx, tag)— inclusive running product, shape-preserving (torch.cumprod); serial per row by default, vectorized under-Dvector-scan(§2.2, thecumsumgating). Differentiable: zero-free rows use the O(n) reverse-scan closed form; rows containing a zero fall back to an exact division-free O(n²) expansion (torch semantics).segmentSum(ctx, tag, offsets)— sums CONTIGUOUS index ranges of one axis (torch.segment_reduce("sum")over sorted segments):out[..., i, ...] = Σ_{j ∈ [offsets[i], offsets[i+1])} x[..., j, ...].offsetsmust be non-decreasing, start at0, and end at the axis size (offsets.len - 1segments partitioning the whole axis; empty segments produce zero rows). The reduced axis keeps its tag at the new sizeoffsets.len - 1. Differentiable: the gradient broadcasts each output row back over its segment. Rank-level entries onExecContext:segmentSumAxisRank(rank, x, axis, offsets)and its VJP helpersegmentBroadcastAxisRank(rank, gy, axis, offsets, n).linearRecurrence(ctx, time_tag, decay, options)— the first-order linear recurrenceh_t = a_t ⊙ h_{t-1} + b_talongtime_tag, shape-preserving:selfsuppliesb(and the result shape),decaysuppliesa, and every lane (each fixed choice of the non-time axes) scans independently — the associative-scan primitive of the SSM / linear-attention family (Mamba-2 / GDN-style diagonal decay states).decaybroadcasts BY TAG like the pointwise ops (tags a subset ofself's; a zero-stride view read in place, never materialized): no time tag = static per-lane decay,[time]only = shared schedule, state-shaped tags = per-state decay.optionsis.{}or.{ .initial = &h0 }withh0carryingself's tags minustime_tag(exact shape) —h_{-1}per lane, the streaming seam: feeding the previous chunk's last step continues a sequence bitwise-exactly. Determinism follows thecumsumcontract: serial per lane, multiply-then-add per step;-Dvector-scanvectorizes non-last time axes across lanes BITWISE identically, and the last axis always stays serial (an in-register form would reassociate the recurrence). Differentiable inself,decay, andinitial: the VJP is one reverse scan (gh_t = a_{t+1}·gh_{t+1} + gy_t;decaygetsgh_t·h_{t-1}reduced back over its broadcast axes,initialgetsa_0·gh_0). Withdecay == 1it degenerates tocumsumbitwise.norm(ctx, tag, order)/normAll(ctx, order)— vector norm alongtag/ over all elements (torch.linalg.vector_norm),orderinexec.NormOrder:.l1= Σ|x|,.l2= sqrt(Σx²),.inf= max|x|. Composed from existing differentiable ops (scope-required under gradients); like torch, the.l2gradient at an all-zero vector is NaN (sqrt'(0)).
Masked reductions¶
pub fn sumExt(self, ctx, comptime tag: Tag, opts: anytype) !Tensor(removeTag(tags, tag))
pub fn meanExt(self, ctx, comptime tag: Tag, opts: anytype) !Tensor(removeTag(tags, tag))
pub fn maxExt(self, ctx, comptime tag: Tag, opts: anytype) !Tensor(removeTag(tags, tag))
pub fn minExt(self, ctx, comptime tag: Tag, opts: anytype) !Tensor(removeTag(tags, tag))
The sum(a, dim, mask) / maxval(a, dim, mask) family: a reduction
restricted to the elements a mask selects. opts is .{} (identical to the
unmasked op), .{ .mask = &m }, or .{ .mask = &m, .empty = v }; any other
field is a compile error, never a silently-unmasked reduction (the
groupedAttention opts discipline, §4.13).
- Mask contract, shared with
where/maskedFill(§4.6): a.boolmask or a float read by truthiness (!= 0, NaN truthy), carryingself's exact tags and shape, non-grad, integer masks a compile error. ComposebroadcastTofor a mask over fewer axes. - Why not just compose. The spelling these replace is
maskedFillinto a full-size f32 temporary followed by an ordinary reduction: two passes and a whole extra tensor. These accumulate straight out of the source through the same SIMD row kernel. Measured on M1 Max, ReleaseFast,-Dblas=accelerate(zig build bench-masked-reduce): 2.3–2.4x faster than the composition at[1024, 4096]and[4096, 1024]on both the last-axis and non-last-axis arms. - Bitwise contract. An excluded element contributes the operation's
identity rather than being skipped, so a masked reduction performs exactly
the composition's arithmetic in exactly its order:
sumExtis bitwise equal tomaskedFill(¬mask, 0)thensum, and bitwise equal to the unmasked reduction when the mask is all-true. Substituting the identity is also what keeps an excluded NaN from poisoning its lane. - Empty lanes. A lane whose mask selects nothing takes the operation's
identity:
0forsumExt,-inf/+infformaxExt/minExt(Fortran'smaxvalof an empty selection is-HUGE). This is why a masked reduction needs noEmptySelectionerror the waymaskedSelectdoes (§3.7) — the answer is defined.meanhas no identity, so its empty lane isNaN(0/0)..empty = voverrides the value for any of them. - Gradients.
sumExt: the unmasked scatter, zeroed where the mask excluded the element.meanExt: divided by the count of SELECTED elements per lane (not the axis length), so an empty lane — which produced a constant, not a function of the data — contributes nothing.maxExt/minExt: the gradient goes to the first selected extremum, withmax's tie-break and NaN semantics applied to the selected elements only; an empty lane receives none. All four are pinned against finite differences byfucina.gradcheck(§5.7). - Counting a mask stays
mask.sum(tag)(i64, §4.19); there is no separatecountentry.
maxExt/minExt cover Fortran's maxval/minval; product and
all/any deliberately have no masked twin (no in-tree consumer, and
all/any compose with logicalAnd/logicalOr).
test "masked reductions restrict a reduction to the elements a mask selects" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const M = fucina.Tensor(.{ .dtype = .bool, .tags = .{ .row, .col } });
var x = try fucina.Tensor(.{ .row, .col })
.variableFromSlice(&ctx, .{ 2, 3 }, &.{ 1, 2, 3, 4, 5, 6 });
defer x.deinit();
// row 0 keeps {1, 3}; row 1 keeps {5}
var mask = try M.fromSlice(&ctx, .{ 2, 3 }, &.{ true, false, true, false, true, false });
defer mask.deinit();
var total = try x.sumExt(&ctx, .col, .{ .mask = &mask });
defer total.deinit();
try std.testing.expectEqualSlices(f32, &.{ 4, 5 }, try total.dataConst());
// The mean divides by the SELECTED count (2 and 1), not the axis length.
var avg = try x.meanExt(&ctx, .col, .{ .mask = &mask });
defer avg.deinit();
try std.testing.expectEqualSlices(f32, &.{ 2, 5 }, try avg.dataConst());
// Gradient reaches only the selected elements.
var loss = try total.sumAll(&ctx);
defer loss.deinit();
try loss.backward(&ctx);
var gx = (try x.grad(&ctx)).?;
defer gx.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 0, 1, 0, 1, 0 }, try gx.dataConst());
}
test "a masked lane that selects nothing takes the identity, not an error" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const M = fucina.Tensor(.{ .dtype = .bool, .tags = .{ .row, .col } });
var x = try fucina.Tensor(.{ .row, .col }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var mask = try M.fromSlice(&ctx, .{ 2, 2 }, &.{ false, false, true, false });
defer mask.deinit();
var total = try x.sumExt(&ctx, .col, .{ .mask = &mask });
defer total.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0, 3 }, try total.dataConst()); // identity, no error
// A mean has no identity: 0/0 unless the caller names a sentinel.
var avg = try x.meanExt(&ctx, .col, .{ .mask = &mask, .empty = 0 });
defer avg.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0, 3 }, try avg.dataConst());
}
compiled & run in CI ✓
Related (exec-level): ctx.kdaRecurrent (src/exec/delta_attention.zig)
is the fused, stateful counterpart of this scan family — the delta-rule
linear-attention recurrence the kimi3 model family mixes sequences with;
it lives with the attention entries, §4.13.
Dtype policy: on the f32 facade everything is f32 in and out. On the typed
constant tensors (§4.19) reductions widen per outputDType(.reduction, ·) —
f16/bf16 reduce into f32, f64 stays f64 — while pointwise and matmul keep
the input dtype; see §8 for the full dtype/storage matrix.
test "axis reductions and sumAll" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .row, .col }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var s = try x.sum(&ctx, .col); // Tensor(.{ .row })
defer s.deinit();
try std.testing.expectEqualSlices(f32, &.{ 3, 7 }, try s.dataConst());
var v = try x.variance(&ctx, .col, 0); // biased (ddof 0)
defer v.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0.25, 0.25 }, try v.dataConst());
var total = try x.sumAll(&ctx); // Tensor(.{}) scalar
defer total.deinit();
try std.testing.expectApproxEqAbs(@as(f32, 10), try total.item(), 1e-6);
}
test "cumsum keeps the axis" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{3}, &.{ 1, 2, 3 });
defer x.deinit();
var y = try x.cumsum(&ctx, .d);
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 3, 6 }, try y.dataConst());
}
test "linearRecurrence scans a decayed state along the time tag" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// h_t = a[d]·h_{t-1} + b_t per lane; decay broadcasts by tag.
var b = try fucina.Tensor(.{ .t, .d }).fromSlice(&ctx, .{ 3, 2 }, &.{ 1, 1, 1, 1, 1, 1 });
defer b.deinit();
var a = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{2}, &.{ 0.5, 0 });
defer a.deinit();
var h = try b.linearRecurrence(&ctx, .t, &a, .{});
defer h.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 1, 1.5, 1, 1.75, 1 }, try h.dataConst());
// .initial seeds h_{-1} per lane (the chunked-streaming seam).
var h0 = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{2}, &.{ 8, 8 });
defer h0.deinit();
var seeded = try b.linearRecurrence(&ctx, .t, &a, .{ .initial = &h0 });
defer seeded.deinit();
try std.testing.expectEqualSlices(f32, &.{ 5, 1, 3.5, 1, 2.75, 1 }, try seeded.dataConst());
}
compiled & run in CI ✓
4.8 dot: tag-directed contraction (src/ag/tensor.zig, src/tagged.zig)¶
pub fn dot(self: *const Self, ctx: *ExecContext, other: anytype, comptime contract_tag: Tag)
!Tensor(dotResultTags(tags, TensorObject(@TypeOf(other)).axis_tags, contract_tag))
// TensorObject unwraps pointer operands (`a.dot(&ctx, &b, .k)`)
dot is the workhorse contraction: it sums over the named tag
contract_tag, which must appear in both operands with equal dims. Tag
roles are decided at comptime:
- contract —
contract_tag; removed from the result. - batch — every other tag shared by both operands; dims must match exactly (batch tags do not broadcast).
- free — tags private to one operand.
The result tag order is batch ++ left-free ++ right-free (each group in
its operand's order). Because tags name axes, no transpose calls are ever
needed around dot — layout is handled by the lowering.
Lowering: dot is the single-contract-tag special case of einsum —
taggedDot delegates to taggedEinsum with the canonical dot result order
as the equation, so kernel selection is the einsum lowering's (§7.9): each
operand is aligned to the kernel layout as a zero-copy view, and at runtime
each side picks plain or transposed orientation by contiguity, so classic
layouts ([m,k]·[k,n], NT weights [out,in], batched [b..,m,k]·[b..,k,n]
and their trans permutations) dispatch straight to
matmul2D/matmulTransA/matmulTransB/bmm/bmmTransA/bmmTransB with
no data movement, vector operands ride along as size-1 GEMM axes, and a
full contraction runs ctx.dot. At most one of transA/transB is available
per call, so a layout where BOTH operands want their transposed orientation
(and no output-order swap covers it) materializes the smaller operand;
layouts no orientation can express materialize at most once per operand.
Mixed-precision and quantized weights. other's dtype is
comptime-dispatched:
- f32 RHS: full two-operand backward (
DotBackward). - f16 / bf16 RHS (
ConstRhsDotBackward): a CONSTANT RHS is a frozen weight — gradient flows toselfonly; a grad-requiring 16-bit RHS variable (§5.1) also receives its own gradient, as f32 (gradients are always f32; dW is the plain f32 einsum of the upstream gradient with the saved f32 LHS). With no batch tags, one RHS free axis, and RHS storage[free, contract](weight tags{.out, .in}-style) the forward hits the dedicated trans-B mixed kernels that widen in-register; otherwise the RHS is cast to f32 once and the f32 path runs. - block-quantized RHS (q8_0, q4_k, ... — §10): the quantized-RHS GEMM;
gradient to
selfonly. Requires RHS storage[free, contract], one RHS free axis, no batch tags (compile errors otherwise). When a GPU backend is active andselfneeds no gradient the GEMM may be offloaded (§9).
test "dot with a shared batch tag lowers to bmm" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var a = try fucina.Tensor(.{ .b, .m, .k }).fromSlice(&ctx, .{ 2, 1, 2 }, &.{ 1, 2, 5, 6 });
defer a.deinit();
var b = try fucina.Tensor(.{ .b, .k, .n }).fromSlice(&ctx, .{ 2, 2, 1 }, &.{ 3, 4, 7, 8 });
defer b.deinit();
var y = try a.dot(&ctx, &b, .k); // .b is shared (batch), result .{ .b, .m, .n }
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 11, 83 }, try y.dataConst());
}
test "dot with an f16 constant RHS stays mixed-precision" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .t, .in }).fromSlice(&ctx, .{ 1, 2 }, &.{ 1, 1 });
defer x.deinit();
var w = try fucina.Tensor(.{ .dtype = .f16, .tags = .{ .out, .in } })
.fromSlice(&ctx, .{ 1, 2 }, &.{ 2, 3 });
defer w.deinit();
var y = try x.dot(&ctx, &w, .in); // f32 result, [free, contract] fast path
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{5}, try y.dataConst());
}
compiled & run in CI ✓
addDot: fused residual/projection accumulate (addmm)¶
pub fn addDot(self: *const Self, ctx: *ExecContext, a: anytype, b: anytype, comptime contract_tag: Tag) !Self
self + a·b in one op — torch's addmm. The product accumulates directly
into a copy of self (the BLAS beta=1 route, or the vector accumulate
kernels elsewhere), so the residual pattern costs one GEMM with no
intermediate product tensor and no separate add pass, and backward is one
node (the base gradient is the output gradient itself). On the vector
kernels the result is bit-identical to a.dot(b) then add; the BLAS
route may differ in the last ulp, as any two BLAS entry points may.
The form is compile-checked: f32 operands, a tagged [rows, contract],
b tagged [contract, cols], self tagged exactly [rows, cols].
Differentiable in all three operands; other RHS dtypes compose dot +
add instead.
test "addDot fuses the residual add into the GEMM" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var residual = try fucina.Tensor(.{ .t, .out }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 1, 2, 2 });
defer residual.deinit();
var a = try fucina.Tensor(.{ .t, .in }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer a.deinit();
var w = try fucina.Tensor(.{ .in, .out }).fromSlice(&ctx, .{ 2, 2 }, &.{ 5, 6, 7, 8 });
defer w.deinit();
var y = try residual.addDot(&ctx, a, w, .in); // residual + a·w, one GEMM
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 20, 23, 45, 52 }, try y.dataConst());
}
compiled & run in CI ✓
einsum and einsumMany: multi-index contraction¶
pub fn einsum(self: *const Self, ctx: *ExecContext, other: anytype, comptime out_tags: anytype)
!Tensor(normalizeTags(out_tags))
pub fn einsumMany(ctx: *ExecContext, comptime out_tags: anytype, operands: anytype)
!Tensor(normalizeTags(out_tags)) // free function at the fucina root
einsum generalizes dot from one contraction tag to a whole Einstein
equation. Because both operands already carry named axes, the output tag
tuple is the equation:
result[out_tags] = Σ over every tag not in out_tags of self ⊙ other
Tag roles are decided at comptime purely from membership:
- batch — shared tags kept in
out_tags; dims must match exactly. - contract — shared tags dropped from
out_tags; dims must match (ShapeMismatchotherwise). Any number of contraction tags. - free — operand-private tags kept in
out_tags. - summed — operand-private tags dropped from
out_tags; summed away before the contraction (their gradient is a broadcast).
The result axis order is exactly out_tags (unlike dot, whose result
order is fixed); every output tag must exist in an operand (compile error
einsum output tag not found in any operand). self is f32; an f16/bf16 other
is widened to f32 once per call (forward and backward) — a constant RHS
routes gradient to self only, and a grad-requiring 16-bit RHS variable
also receives its own f32 gradient, exactly dot's widened fallback
contract — and
a quantized other is a compile error directing to dot, whose packed
kernels require the [free, contract] weight layout. Duplicate tags within
one operand remain impossible, so there are no trace/diagonal semantics. dot(other, .k) and
einsum(other, dotResultTags(...)) compute the same thing; einsum is the
one to reach for the moment an equation has several contraction axes,
several free axes on one side, or a specific output order.
Lowering (taggedEinsum, §7.9): summed-private axes are pre-reduced,
then both operands are aligned (zero-copy permute views) to an
out_tags-derived group-nested order and each side independently picks the
plain or transposed kernel layout at runtime — the orientation whose aligned
view is already contiguous wins, because a trans GEMM is free while
materializing costs a copy pass. Classic layouts therefore dispatch to
matmul2D/bmm (and trans variants) with zero copies; at most one of
transA/transB is available per call (both-want-trans materializes the
smaller operand), and layouts no orientation can express materialize at
most once per operand. When
out_tags nests as [batch][right free][left free], the operands swap
kernel roles, so "double-transposed" layouts (e.g. x[k,m] · y[n,k] ->
[n,m]) run as one plain GEMM with zero copies. An out_tags order that
interleaves the three groups costs one extra output materialization —
prefer group-nested orders.
Backward. Contractions are closed under differentiation: each operand's
gradient is another einsum (the output gradient contracted with the other
operand), broadcast over any forward-summed axes — so both VJP branches
stay on GEMM kernels for every tag structure (EinsumBackward, which
DotBackward delegates to, and its const-RHS variant
ConstRhsEinsumBackward, which ConstRhsDotBackward delegates to). This
retired the old
broadcast-multiply backward fallback for dots with more than one free tag
on the opposite side (zig build bench-einsum measured that case at two
orders of magnitude).
einsumMany folds two or more operands left-to-right through binary
einsum, keeping at each step exactly the tags still needed by the
remaining operands or the output. Contraction order is the operand order —
order the tuple so early intermediates stay small. As with other composed
facade ops, tracking gradients through it requires an active exec scope
(§6.3; error.ActiveExecScopeRequired).
test "einsum: one equation for a grouped-attention-style contraction" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// q[g, i, d] x k[j, d] -> scores[g, i, j]: two free axes on the left and
// an NT-layout right operand, contracted over .d in one equation.
var q = try fucina.Tensor(.{ .g, .i, .d }).fromSlice(&ctx, .{ 2, 1, 2 }, &.{ 1, 2, 3, 4 });
defer q.deinit();
var k = try fucina.Tensor(.{ .j, .d }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 0, 0, 1 });
defer k.deinit();
var scores = try q.einsum(&ctx, &k, .{ .g, .i, .j });
defer scores.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 2, 3, 4 }, try scores.dataConst());
}
test "einsum: dropped tags are summed — shared become contractions, private are pre-summed" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// y[n] = sum over s and k of a[s,k] * b[k,n]: .k is shared (contraction),
// .s is private to `a` and simply summed away.
var a = try fucina.Tensor(.{ .s, .k }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 2, 3, 4 });
defer a.deinit();
var b = try fucina.Tensor(.{ .k, .n }).fromSlice(&ctx, .{ 2, 2 }, &.{ 5, 6, 7, 8 });
defer b.deinit();
var y = try a.einsum(&ctx, &b, .{.n});
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 62, 72 }, try y.dataConst());
}
test "einsumMany: a LoRA delta as one three-operand equation" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .s, .i }).fromSlice(&ctx, .{ 1, 2 }, &.{ 1, 1 });
defer x.deinit();
var a = try fucina.Tensor(.{ .r, .i }).fromSlice(&ctx, .{ 1, 2 }, &.{ 2, 3 });
defer a.deinit();
var b = try fucina.Tensor(.{ .o, .r }).fromSlice(&ctx, .{ 2, 1 }, &.{ 1, -1 });
defer b.deinit();
// x[s,i] · A[r,i] · B[o,r] -> [s,o], contraction order = operand order.
var y = try fucina.einsumMany(&ctx, .{ .s, .o }, .{ &x, &a, &b });
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 5, -5 }, try y.dataConst());
}
compiled & run in CI ✓
4.9 Explicit matmul, ternary STE, and packed-RHS GEMMs (src/ag/tensor.zig)¶
pub fn matmul(self, ctx, other: anytype, comptime kind: exec.MatmulKind, comptime out_tags: anytype)
!Tensor(out_tags)
matmul bypasses the tag algebra: the caller names the result axes and
picks exec.MatmulKind (.plain, .trans_a, .trans_b). Routing is
comptime on rank: both operands rank-2 → the 2-D GEMM entries (.plain:
[m,k]·[k,n]; .trans_b: [m,k]·[n,k]ᵀ); anything else → the batched bmm
entries with stride-0 broadcast leading batch axes (mixed-rank operands
broadcast rather than error). .trans_a exists only on the batched path —
rank-2 .trans_a is a compile error directing to dot, whose tag algebra
reaches the 2-D trans-A kernel. f32 only, full two-operand gradients. Unlike
dot there is no materialize fallback: the operands' storage order is
the kernel layout.
test "explicit matmul with a transposed RHS" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .m, .k }).fromSlice(&ctx, .{ 1, 2 }, &.{ 1, 2 });
defer x.deinit();
var w = try fucina.Tensor(.{ .n, .k }).fromSlice(&ctx, .{ 2, 2 }, &.{ 3, 4, 5, 6 });
defer w.deinit();
var y = try x.matmul(&ctx, &w, .trans_b, .{ .m, .n }); // [m,k] · [n,k]ᵀ
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 11, 17 }, try y.dataConst());
}
compiled & run in CI ✓
dotTernarySte(ctx, weight, contract_tag) — trainable ternary linear
(BitNet b1.58 straight-through estimator). Every forward encodes the f32
latent weight (tags {.out, .in}, per-tensor absmean scale, round-clip to
{−1, 0, +1}) to TQ2_0 and contracts with the mul-free kernel. Backward: dx
flows through the quantized weight; dW is the straight-through
estimate (plain matmul VJP against the latent weight). The contract dim must
be a multiple of 256 (error.TernaryContractDimNotBlockAligned otherwise);
no shared batch tags, one weight free axis, weight storage
[free, contract], and lhs storage [..., contract] (contract tag last) —
all four are compile errors. See TERNARY.md and §10/§11.
test "dotTernarySte encodes the latent weight per call" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const k = 256; // contract dim must be a multiple of the TQ2_0 block size
const x_values = [_]f32{1} ** k;
var x = try fucina.Tensor(.{ .t, .in }).fromSlice(&ctx, .{ 1, k }, &x_values);
defer x.deinit();
var w = try fucina.Tensor(.{ .out, .in }).fromSlice(&ctx, .{ 1, k }, &x_values);
defer w.deinit();
var y = try x.dotTernarySte(&ctx, &w, .in); // absmean scale 1, all-ones encode
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{k}, try y.dataConst());
}
compiled & run in CI ✓
Packed-RHS entries (layout and quantization detail in §10):
dotPacked(ctx, rhs, contract_tag, out_tag)— 2-D[free, contract]lhs against a pre-packed dense or quantized RHS container (comptime-dispatched from the pointer type). Dense f32/f16/bf16 packs fail witherror.GradientPackedMatmulUnsupportedwhen the lhs requires grad; quantized packs fail witherror.GradientQuantizedMatmulUnsupported.rmsNormMulDotPacked(ctx, norm_weight, eps, rhs, contract_tag, out_tag)— fusedrmsNormMul(self, norm_weight) · rhsᵀwithout materializing the normalized tensor (selfis the pre-norm[free, contract]input,norm_weightthe[contract]scale row); matchesrmsNormMul+dotPackedto ≤ 1 ulp (q8_0x4 / q4_kx8 / q5_kx8 / q6_kx4; q4_kx2mmla is a deliberate compile error — MMLA targets use the unfused path).splitSwiGluDotPacked(ctx, rhs, split_tag, out_tag)— fused split-SwiGLU- packed down-projection GEMM without materializing the gated tensor
(q8_0x4 / q4_kx8 / q5_kx8 / q6_kx4; q4_kx2mmla is a deliberate compile
error — MMLA targets use the unfused
splitGated+dotPacked). gegluQuantDotPacked(ctx, up, rhs, in_tag, out_tag)— fused(up · geluQuant(self)) @ rhs; q8_0x4 only.- On block-quantized tensors:
packRhs(ctx)packs a rank-2 weight into the ISA-best layout for its dtype — q8_0→x4, q6_k→x4, q5_k→x8, q4_k→x2mmla on aarch64+i8mm targets else x8 (the return type isfucina.PackedRhs(dtype));packRhsLayout(ctx, layout)forces a specificfucina.PackedRhsLayoutinstead. - On f32/f16/bf16 tensors:
packRhs(ctx)snapshots a rank-2[out, contract]weight into the shared f32 output-row-panel layout. f16/bf16 values widen once while packing; the caller owns anddeinit()s the returnedfucina.PackedRhs(dtype). The source tensor may be released immediately.
test "load-time dense packed RHS" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const w_values = [_]f32{
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 1,
};
var packed_rhs = blk: {
var w = try fucina.Tensor(.{ .out, .in }).fromSlice(&ctx, .{ 3, 4 }, &w_values);
defer w.deinit();
break :blk try w.packRhs(&ctx); // independent snapshot of w
};
defer packed_rhs.deinit();
const x_values = [_]f32{ 2, 3, 4, 5, 7, 11, 13, 17 };
var x = try fucina.Tensor(.{ .seq, .in }).fromSlice(&ctx, .{ 2, 4 }, &x_values);
defer x.deinit();
var y = try x.dotPacked(&ctx, &packed_rhs, .in, .out);
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 2, 3, 9, 7, 11, 30 }, try y.dataConst());
}
compiled & run in CI ✓
4.10 Softmax family (src/ag/tensor.zig, src/exec/softmax.zig)¶
pub fn softmax(self: *const Self, ctx: *ExecContext, comptime tag: Tag, options: anytype) !Self
Softmax over tag, shape-preserving. options is a comptime-validated
struct literal (unknown fields are compile errors); an empty .{} routes to
the lean plain kernel. The fused extensions mirror ggml's soft_max_ext
(the exec-level option struct is exec.SoftmaxExtOptions); the effective
pre-softmax logit is x·scale + slope·mask:
.scale = s— logit multiplier (attention1/sqrt(d)without a separate pass)..mask = &m— additive tag-broadcast tensor (not −inf masking): the mask is aligned toself's tags and expanded by zero-stride broadcast, so a[q, k]mask serves every head of a[head, q, k]score tensor. The mask must not require grad (error.UnsupportedGradient)..max_bias = bwith.head_tag— ALiBi: a per-head slope multiplies the mask, following the ggml slope schedule (powers of2^(−b/h)withhthe head count rounded down to a power of two;src/exec/shape.zigalibiSlope). Requires.maskand.head_tag(InvalidShapeotherwise)..sinks = slice— per-head attention sinks: one extra logit per head that joins the running max and the denominator only, so row probabilities sum to less than 1 (the sink absorbs the remaining mass). Needs.head_tag(one sink per head; a single-element slice is accepted without one)..causal = .{ .query_tag, .source_offset }— fused causal masking: query rowqnormalizes over sources[0, source_offset + q]; positions beyond are exactly 0. Validatessource_offset + query_dim <= source_dim.
Backward: SoftmaxBackward/SoftmaxExtBackward (§5) — the unified backward
re-derives from the output and scale (mask/sinks/ALiBi contribute no
gradient).
Two log-domain companions (max-shifted for stability, torch semantics), FUSED single-node kernels sharing softmax's row machinery — SIMD max scan + vexpf sum per row, task-parallel over rows, streaming inner-lane kernels on non-last axes (lane-split across tasks, identical semantics — §9.4); no materialized intermediates and no exec-scope requirement:
logsumexp(ctx, tag)—log(Σ exp(x))with the tag removed (torch.logsumexp). Rows whose max is ±inf are shifted by 0 instead, so an all(-inf) row yields -inf and a +inf entry yields +inf rather than NaN (the torch convention). Backward is the saved-output identityexp(x − lse)·g(the row softmax).logSoftmax(ctx, tag)—x − logsumexp(x)broadcast, shape-preserving (torch.log_softmax), same non-finite-max handling. PrefercrossEntropywhen the next step is an NLL loss (fused with the loss, saved-stats backward). Backward is the saved-output identityg − exp(y)·Σg.
test "softmax and the fused causal extension" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .seq, .src }).zeros(&ctx, .{ 2, 2 });
defer x.deinit();
var p = try x.softmax(&ctx, .src, .{});
defer p.deinit();
try std.testing.expectEqualSlices(f32, &.{ 0.5, 0.5, 0.5, 0.5 }, try p.dataConst());
var c = try x.softmax(&ctx, .src, .{ .causal = .{ .query_tag = .seq } });
defer c.deinit();
// query 0 attends source 0 only; masked-out tail is exactly 0
try std.testing.expectEqualSlices(f32, &.{ 1, 0, 0.5, 0.5 }, try c.dataConst());
}
compiled & run in CI ✓
4.11 Normalization family (src/ag/tensor.zig)¶
All norms normalize over one named tag and preserve shape; all are differentiable in every tensor operand (statistics are recomputed in the backward — nothing extra is saved from the forward).
rmsNorm(ctx, tag, eps)—x / sqrt(mean(x²) + eps).rmsNormMul(ctx, tag, weight, eps)— fusedrmsNorm(x)·weight;weight: *const Tensor(.{tag}).rmsNormMulAdd(ctx, tag, weight, residual, eps)— fusedrmsNorm(x)·weight + residual(residualsame tags asself).rmsNormMulRopeHalfPrepared(ctx, position_tag, feature_tag, weight, eps, table)— fused rmsNorm·weight followed by half-mode RoPE from a prepared*const exec.RopeTable(the QK-norm + RoPE step of the model families' attention blocks, §14). The inference-onlyrmsNormMulDotPacked(rmsNormMul fused into a packed quantized GEMM) is documented with the packed-RHS entries in §4.9.layerNorm(ctx, tag, eps, options)— PyTorch semantics:(x − μ)/sqrt(σ² + eps)with biased variance.optionsis.{}for the plain form or.{ .weight = &w, .bias = &b }for the fused affine — the fused kernel requires both together (compile error otherwise). Weight/bias are rank-1[tag_dim]tensors, either tagged.{tag}(comptime-checked) or numeric-tagTensor(1)values (._0; runtime length check).groupNorm(ctx, channel_tag, groups, eps, weight, bias)— ggml GroupNorm over rank-2[time, channel]storage: per channel group, f64-accumulated mean and biased variance over alltime × (C/groups)elements, eps inside the sqrt, then the optional per-channel affine (?*const Tensor(.{channel_tag}), independently optional) applied after normalization.standardizeAxis(ctx, tag, options)—(x − mean)/denomovertag.optionsaccepts everyexec.StandardizeOptionsfield plus an optional.valid_len(unknown fields are compile errors): standardize only the firstvalid_lenelements; the suffix is returned as zeros and receives zero gradient.StandardizeOptions:ddof: u1 = 0,eps: f32 = 0,eps_mode: StandardizeEpsMode = .outside_sqrt(sqrt(var) + eps, Parakeet's frontend convention) or.inside_sqrt(sqrt(var + eps), LayerNorm placement),accumulation: StandardizeAccumulation = .f32or.f64.l2Normalize(ctx, tag, eps)—x·rsqrt(Σx² + eps). The eps is added to the squared norm (the rmsNorm convention) — deliberately not torchF.normalize'sx/max(‖x‖₂, eps). Composed op: requires an active exec scope when gradients are tracked (§4.1).snake(ctx, channel_tag, alpha, inv_b)— per-channel Snake activation (DAC codec):y = x + inv_b[c]·sin(alpha[c]·x)²over[time, channel]storage.alphaandinv_bare independent operands at this level; no gradient flows through the loader'sinv_b = 1/(alpha + 1e-9)relation.
test "layerNorm and rmsNorm" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .t, .d }).fromSlice(&ctx, .{ 1, 2 }, &.{ 1, 3 });
defer x.deinit();
var ln = try x.layerNorm(&ctx, .d, 0, .{}); // (x - mean) / sqrt(biased var + eps)
defer ln.deinit();
try std.testing.expectEqualSlices(f32, &.{ -1, 1 }, try ln.dataConst());
var rn = try x.rmsNorm(&ctx, .d, 0); // x / sqrt(mean(x²) + eps)
defer rn.deinit();
const rms = @sqrt((1.0 + 9.0) / 2.0);
try std.testing.expectApproxEqAbs(@as(f32, 3.0 / rms), (try rn.dataConst())[1], 1e-6);
}
test "standardizeAxis zero-mean unit-variance" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .t, .d }).fromSlice(&ctx, .{ 1, 4 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var y = try x.standardizeAxis(&ctx, .d, .{}); // ddof 0, eps 0
defer y.deinit();
const out = try y.dataConst();
try std.testing.expectApproxEqAbs(@as(f32, 0), out[0] + out[1] + out[2] + out[3], 1e-6);
try std.testing.expectApproxEqAbs(@as(f32, 1.5 / @sqrt(1.25)), out[3], 1e-6);
}
compiled & run in CI ✓
4.12 Rotary position embedding (src/ag/tensor.zig, src/exec/rope.zig)¶
pub fn rope(self, ctx, comptime position_tag: Tag, comptime feature_tag: Tag,
source: anytype, comptime mode: RopeMode) !Self
Rotates feature pairs by position-dependent angles over
(position_tag, feature_tag). mode is comptime exec.RopeMode:
.half pairs feature i with i + d/2 (NEOX/Llama layout);
.interleaved pairs adjacent features; .interleaved_tail pairs adjacent
features within the TRAILING table.feature_dim features — a partial
rotary aligned to the end of the feature axis with the leading features
passed through (DeepSeek V4's tail-64 rotary; identical to .interleaved
when the table spans the whole axis). source selects the factor source
at comptime (a closed set; anything else is a compile error):
*const exec.RopeTable— prepared factors, the production path. Build withctx.prepareRopeTable(positions, feature_dim, theta_base, inverse)orctx.prepareRopeTableFactors(..., freq_factors)— the latter is ggml'srope_extfrequency scaling (Llama-3 long-context, Gemma global layers). For custom frequency schedules the core cannot rebuild, hand-fill viactx.prepareRopeTableInvFreqsF64(pos0, count, inv_freq, inverse)— per-pair f64 inverse frequencies, angles accumulated in f64 before the f32 cast (theprepareRopeTable*pair compute f32 angles) — andctx.yarnBlendInvFreqsF64(dim, base, factor, orig_ctx)builds the DeepSeek-family YaRN blend (beta 32/1 correction ramp;factor <= 1returns the plain pow schedule). The table'sfeature_dimis the authoritative rotary span: equal todim(feature_tag)rotates fully; smaller rotates the leadingfeature_dimfeatures (.half,.interleaved) or the trailing ones (.interleaved_tail) and passes the rest through unchanged (partial RoPE).RopeTableowns its buffers;table.deinit()releases them.exec.RopeTheta/.{ .positions = p, .theta_base = t }— on-the-fly factors (positions: []const i32), full rotation only. Pairiat positionprotates byp / theta_base^(2i/d).
Differentiable in self; the backward applies the inverse rotation (§5).
Negative positions rotate backwards, so re-roping cached values to a new
offset is a valid pattern.
Position runs: AxisRange. A tensor axis here is 0-origin: Shape
records how long an axis is, never where it starts. A positional axis has an
origin anyway — the absolute token position of its first row — and with
nowhere to put it, callers materialized it: allocate n integers, fill them
with origin + i, pass them down, free them, all to express origin.
fucina.AxisRange is that origin as a value (Fortran's array lower bound,
which NumPy and torch have no equivalent of):
pub const AxisRange = struct {
origin: i64 = 0, // absolute index of the axis's first element
len: usize, // the axis spans [origin, origin + len)
pub fn at(self, i: usize) i64
pub fn end(self) i64
pub fn narrowed(self, start: usize, count: usize) AxisRange // keeps the absolute origin
pub fn shifted(self, delta: i64) AxisRange
pub fn rebased(self, new_origin: i64) AxisRange
pub fn contains(self, absolute: i64) bool
pub fn writeInto(self, out: []i32) !void // the materialization it avoids
};
The rope-table constructors take one directly:
pub fn prepareRopeTableRange(range: AxisRange, feature_dim, theta_base, inverse) !RopeTable
pub fn prepareRopeTableFactorsRange(range: AxisRange, feature_dim, theta_base, inverse, freq_factors) !RopeTable
.{ .len = n } is a prefill over 0..n; .{ .origin = pos0, .len = n } is a
decode step. Both share one arithmetic body with the array form, so a run
expressed as a range and the same run expressed as an array produce
bitwise identical tables (pinned in src/exec_tests.zig across several
origins, both inverse arms, and the freq_factors arm). The explicit
positions: []const i32 form stays for the genuinely ragged case — a
multi-stream batch whose positions are several runs, not one.
Band masks already carry an origin. An offset causal mask needs no new
API: a row origin shifts a bandMask/bandPart band by folding into the
bounds. Queries at absolute p0..p0+n over keys 0..k keep
absolute_q >= absolute_k, which is bandMask(shape, null, p0) — the
upper bound absorbs the origin, since (i + p0) - j >= 0 is j - i <= p0.
Generally, an origin difference o = row_origin - col_origin turns bounds
(lower, upper) into (lower - o, upper + o).
test "rope rotates feature pairs by position" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .seq, .d }).fromSlice(&ctx, .{ 2, 2 }, &.{ 1, 0, 1, 0 });
defer x.deinit();
var y = try x.rope(&ctx, .seq, .d, .{ .positions = &.{ 0, 1 }, .theta_base = 10000 }, .half);
defer y.deinit();
const out = try y.dataConst();
try std.testing.expectApproxEqAbs(@as(f32, 1), out[0], 1e-6); // position 0: identity
try std.testing.expectApproxEqAbs(@cos(@as(f32, 1)), out[2], 1e-6); // position 1: angle 1 rad
try std.testing.expectApproxEqAbs(@sin(@as(f32, 1)), out[3], 1e-6);
}
compiled & run in CI ✓
4.13 Attention (src/ag/tensor.zig)¶
pub fn groupedAttention(self, ctx, k: anytype, v: anytype, kv_head_for_head: []const usize,
comptime out_tag: Tag, scale_value: f32, opts: anytype)
!Tensor(.{ .seq, out_tag })
Grouped-query (GQA) flash-style attention. self is the query and must
be tagged .{ .seq, .head, .d } (compile error otherwise); the result is
[seq, head·d] tagged .{ .seq, out_tag }. kv_head_for_head[h] maps each
query head to its KV head. scale_value multiplies the scores. The KV
representation is comptime-dispatched from @TypeOf(k) (k and v must
match):
k/v type |
Use case | Gradients |
|---|---|---|
*Tensor(.{ .seq, .kv_head, .d }) (f32) |
training / f32 caches | full q/k/v backward (windowed re-masks to the window) |
| same tags, f16 | decode KV cache | q-grad only (K/V widened to f32 once for the backward) |
[]const BlockQ8_0 |
q8_0 raw-block cache, layout [kv_seq, kv_heads, d/32] |
q-grad only, causal only |
[]const []const f16 |
ragged multi-stream decode | inference-only |
[]const []const BlockQ8_0 |
ragged multi-stream decode (q8_0) | inference-only |
opts is a comptime-validated struct literal with a per-representation
field whitelist (a misspelled option is a compile error, never
silently-full-causal attention):
.mask = .causal(default) |.bidirectional— f32/f16 KV only. Bidirectional has no windowed kernel by design (realize SWA reach by narrowing the K/V views)..window = w— runtime sliding window, 0 = full causal (querypattends[max(0, p−w+1), p]); causal only..bias = &b— rank-2[q_seq, kv_seq]additive f32 bias on the scaled scores pre-softmax (ggmlsoft_max_extsemantics); bidirectional + f32 KV only; inference-only — any grad-requiring operand returnserror.UnsupportedGradient..kv_seq = n, .kv_heads = h— required for the q8_0-block representation (raw blocks carry no shape)..lens = lens, .kv_heads = h— required for the multi-stream representations; q's.seqtag is reinterpreted as the stream axis (one query row per stream, rowsattendinglens[s]cached positions); per-stream results are bit-identical to N single-stream calls.
Related: relposShift(ctx, t_k, out_tags) — Transformer-XL relative-shift
("skew") of a rank-3 [H, Tq, P] score tensor to [H, Tq, Tk] with
out[h,q,j] = self[h, q, j+(Tq−1)−q] (P >= Tk+Tq−1); differentiable
(scatter VJP).
Below the tag facade, ExecContext.kdaRecurrent
(src/exec/delta_attention.zig) is the fused delta-rule linear-attention
sequence recurrence (Kimi Delta Attention, the kimi3 family's sequence
mixer). Inputs are the post-convolution projections — q/k
[seq, heads, k_dim], v [seq, heads, v_dim], g_raw
[seq, heads, k_dim] (pre-gate low-rank decay), beta_raw [seq, heads]
(pre-sigmoid) — plus a_log (length heads for per-head or k_dim for
per-channel decay), dt_bias (heads·k_dim), an optional
[heads, k_dim, v_dim] initial state, and a scale (<= 0 selects the
default k_dim^(−1/2)). Per token, q/k are l2-normalized, each head's
[k_dim, v_dim] state decays along K by
exp(−exp(A_log)·softplus(g + dt_bias)), the sigmoid(beta)-weighted
delta-rule update lands, and the output row is read out. Returns
exec.delta_attention.KdaResult — o [seq, heads, v_dim] plus the
final state (the decode-resume seed), one deinit() releasing both. Work
splits by whole heads (single writer per output row — bitwise identical
for any task count); inference-only, no backward record.
test "groupedAttention over a single cached position returns v" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var q = try fucina.Tensor(.{ .seq, .head, .d }).fromSlice(&ctx, .{ 1, 1, 2 }, &.{ 1, 0 });
defer q.deinit();
var k = try fucina.Tensor(.{ .seq, .kv_head, .d }).fromSlice(&ctx, .{ 1, 1, 2 }, &.{ 1, 1 });
defer k.deinit();
var v = try fucina.Tensor(.{ .seq, .kv_head, .d }).fromSlice(&ctx, .{ 1, 1, 2 }, &.{ 5, 7 });
defer v.deinit();
var y = try q.groupedAttention(&ctx, &k, &v, &.{0}, .out, 1.0, .{});
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 5, 7 }, try y.dataConst());
}
compiled & run in CI ✓
4.14 Convolution and channel-last vision ops (src/ag/tensor.zig)¶
1-D family (input storage [time, channel], enforced at comptime; bias
is deliberately not fused — compose it with broadcast add):
conv1d(ctx, time_tag, in_tag, tap_tag, out_tag, weight, stride, padding, dilation, groups)— PyTorchConv1dsemantics (cross-correlation);weightis*const Tensor(.{ tap_tag, in_tag, out_tag })stored[tap, in/groups, out]; result[t_out, out]witht_out = (T + 2·pad − dilation·(taps−1) − 1)/stride + 1. Differentiable in input and weight.causalConv1d(ctx, time_tag, in_tag, tap_tag, out_tag, weight, dilation, state)— causal orientation (taptaps−1is the newest sample);state, when given, supplies thedilation·(taps−1)input rows precedingx(oldest first,[row, in]); absent rows read as zeros; no gradient intostate.groupedCausalConv1d(ctx, time_tag, in_tag, tap_tag, in_per_group_tag, out_tag, weight, dilation, groups, state)— grouped variant, weight[tap, in_per_group, out].causalDepthwiseConv1d(ctx, time_tag, channel_tag, tap_tag, kernel, dilation, state)— depthwise;kernel: *const Tensor(.{ channel_tag, tap_tag })(taptaps−1= the newest sample).dilationspaces the taps (y[t,c] = Σ_k x[t − dilation·(taps−1−k), c]·w[c,k]— Engram's ShortConv isdilation = max_ngram_size);state, when given, supplies thedilation·(taps−1)rows precedingx, oldest first.convTranspose1d(ctx, time_tag, in_tag, kout_tag, out_tag, weight2, bias, out_channels, taps, stride, padding, output_pad)— GEMM + col2im_1d (ggml decomposition);weight2is the load-time repacked[K·OC, IC]matrix (k fastest within each oc block),biasis?*const Tensor(.{out_tag}). Theoutput_padtrailing rows are bias-only (ggml/omnivoice convention, not exact PyTorch when pad > 0). Differentiable in input, weight2, and bias; the weight gradient is with respect to the packed layout.
Channel-last 2-D family (rank-3 [H, W, C] inputs; used by §14's face
detection stack):
conv2d(ctx, weight, bias, stride, padding, groups, out_tags)—weightrank-4[Cout, kH, kW, Cin/groups],biasisnullor rank-1[Cout]; result[oH, oW, Cout]taggedout_tags. Differentiable in all tensor operands.conv2dRelu(...)— conv2d with the relu fused into the epilogue on the no-grad path (identical values toconv2dthenrelu; on the Winograd route it folds into the output transform). Falls back to the differentiable composition when any operand requires gradients.prepareConv2dWeights(ctx)— on the rank-4 weight: load-time Winograd F2/F4 weight-transform planes, built once so the prepared entries below skip the per-call weight transform. Returnsfucina.PreparedConvWeights(callerdeinits); a weight that can never take the Winograd route returns.empty, which is inert on every conv route. No gradient support (thedotPackedpolicy — prepared planes live outside the graph): fails witherror.GradientPreparedConv2dUnsupportedon a grad-requiring weight.conv2dPrepared(ctx, weight, prepared, bias, stride, padding, groups, out_tags)— no-grad conv2d against the prepared planes: bitwise-identical values toconv2d, minus the per-call weight transform on the Winograd route (every other route ignoresprepared). Fails witherror.GradientPreparedConv2dUnsupportedwhen any operand requires grad.conv2dPreparedRelu(...)—conv2dPreparedwith the relu fused into the conv epilogue; same no-grad contract.maxPool2d(ctx, kernel, stride, padding)—[h, w]-ordered params; the zero-pad border reads as −inf.avgPool2d(...)averages valid taps only (ONNXcount_include_pad=0). Both differentiable.upsample2xNearest(ctx)—[H, W, C] → [2H, 2W, C]; VJP = 2×2 stride-2 sum-pool.unfold(ctx, kernel, stride, padding, out_tags)— patch extraction (torch.nn.Unfold in this repo's channel-last layout): rank-3[H, W, C]→ rank-2[oH·oW, kH·kW·C]taggedout_tags(patch axis, then patch-element axis). Patch rowoy·oW + oxholds output position (oy, ox)'s window taps ordered(ky, kx, c)with the channel fastest — exactly the im2col layout the groups == 1 conv2d GEMM route consumes, sounfold→dotover the element axis IS a dense conv2d, and a stride = kernel, pad 0 unfold is ViT patchify. Out-of-range (pad) taps read 0. Shares the threaded im2col kernel (bit-identical to serial); differentiable — the VJP is the exact adjointfold.fold(ctx, output_size, kernel, stride, padding, out_tags)— the adjoint (torch.nn.Fold): scatter-ADD rank-2[oH·oW, kH·kW·C]patch rows back into a[H, W, C]image (three out tags). Overlapping taps ACCUMULATE —fold(unfold(x))multiplies each position by its window count — and pad taps drop; the channel count is derived from the patch-element width, and a patch row count that contradicts the geometry isShapeMismatch. Shares the threaded col2im kernel; differentiable — the VJP is the exact adjointunfold.prelu(ctx, alpha)— learnable per-channel slope,alpharank-1[C](channel innermost); differentiable inxandalpha.channelAffine(ctx, scale_t, shift_t)— fused per-channelx·scale[c] + shift[c](frozen-stats inference BatchNorm); differentiable in all three.
test "conv1d computes a moving weighted sum" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .time, .in }).fromSlice(&ctx, .{ 3, 1 }, &.{ 1, 2, 3 });
defer x.deinit();
var w = try fucina.Tensor(.{ .tap, .in, .out }).fromSlice(&ctx, .{ 2, 1, 1 }, &.{ 1, 1 });
defer w.deinit();
var y = try x.conv1d(&ctx, .time, .in, .tap, .out, &w, 1, 0, 1, 1);
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 3, 5 }, try y.dataConst());
}
test "conv2d channel-last and maxPool2d" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .h, .w, .c }).fromSlice(&ctx, .{ 2, 2, 1 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
var weight = try fucina.Tensor(.{ .oc, .kh, .kw, .ic }).fromSlice(&ctx, .{ 1, 1, 1, 1 }, &.{2});
defer weight.deinit();
var y = try x.conv2d(&ctx, &weight, null, .{ 1, 1 }, .{ 0, 0 }, 1, .{ .oh, .ow, .oc });
defer y.deinit();
try std.testing.expectEqualSlices(f32, &.{ 2, 4, 6, 8 }, try y.dataConst());
var pooled = try x.maxPool2d(&ctx, .{ 2, 2 }, .{ 2, 2 }, .{ 0, 0 });
defer pooled.deinit();
try std.testing.expectEqualSlices(f32, &.{4}, try pooled.dataConst());
}
test "unfold extracts patches and fold accumulates them back" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .h, .w, .c }).fromSlice(&ctx, .{ 2, 2, 1 }, &.{ 1, 2, 3, 4 });
defer x.deinit();
// stride = kernel, pad 0: non-overlapping patchify (one 2x2 patch).
var col = try x.unfold(&ctx, .{ 2, 2 }, .{ 2, 2 }, .{ 0, 0 }, .{ .patch, .elem });
defer col.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 2, 3, 4 }, try col.dataConst());
// fold scatter-adds the patches back (no overlap here → identity).
var img = try col.fold(&ctx, .{ 2, 2 }, .{ 2, 2 }, .{ 2, 2 }, .{ 0, 0 }, .{ .h, .w, .c });
defer img.deinit();
try std.testing.expectEqualSlices(f32, &.{ 1, 2, 3, 4 }, try img.dataConst());
}
compiled & run in CI ✓
4.15 Losses and similarity (src/ag/tensor.zig, src/exec/loss.zig)¶
Cross-entropy (fused log-softmax + NLL over a named class tag; labels
are []const usize, one per position, positions ordered with the class axis
removed, remaining axes row-major):
pub fn crossEntropy(self, ctx, comptime class_tag: Tag, labels: []const usize) !Tensor(.{})
pub fn crossEntropyExt(self, ctx, comptime class_tag: Tag, labels: []const usize,
comptime options: exec.CrossEntropyOptions)
!Tensor(if (options.reduction == .none) removeTag(tags, class_tag) else .{})
exec.Reduction is { .mean, .sum, .none }. exec.CrossEntropyOptions
(PyTorch parity): ignore_index: ?usize = null (matching positions
contribute zero loss/gradient and leave the .mean denominator; when
every position is ignored the loss is 0, a deliberate divergence from
PyTorch's NaN), reduction: Reduction = .mean (.none returns per-position
losses with the class tag removed), label_smoothing: f32 = 0 (in [0,1),
PyTorch semantics). Labels must be < class_count or equal to
ignore_index (IndexOutOfBounds otherwise). Differentiable in the logits.
Fused linear + cross-entropy — crossEntropyExt(self·weightᵀ) as ONE
differentiable op:
pub fn linearCrossEntropyExt(self, ctx, weight: anytype, labels: []const usize,
comptime options: exec.CrossEntropyOptions)
!Tensor(if (options.reduction == .none) removeTag(tags, tags[1]) else .{})
self is rank-2 [row, shared] and weight rank-2 [class, shared]
(both f32, shared tag last on both, the class tag absent from self —
all comptime-checked). The logits exist only inside the op: they are
computed once and saved on the backward record with the forward's per-row
softmax statistics, and the VJP folds block-built probability panels
straight into dx and dweight, so the [rows, classes] logit gradient
is never materialized. Differentiable in both operands; same
options/reduction contract as crossEntropyExt (.none returns per-row
losses tagged by the row tag).
Fused linear + sparse-soft-target distillation — cross-entropy of
self·weightᵀ against per-entry (row, class, prob) soft targets as ONE
differentiable op (a teacher's top-k in the distillation use, §13.10):
pub fn linearDistillExt(self, ctx, weight: anytype, rows: []const usize,
classes: []const usize, probs: []const f32,
options: exec.LinearDistillOptions) !Tensor(.{})
loss = reduce_i probs[i]·(LSE(logits[rows[i]]) − logits[rows[i], classes[i]])
with the same operand contract as linearCrossEntropyExt (rank-2 f32,
shared tag last, comptime-checked). Two structural properties on top of
the fused CE: only the UNIQUE rows named by rows are ever projected
(rows without entries produce no logits at all), and the backward consumes
the saved selected-row logits in place, so neither the [row, class]
block nor its gradient ever exists outside the record.
exec.LinearDistillOptions{ .reduction = .mean|.sum, .loss_scale }
reduces over ENTRIES (.mean divides by the entry count; probs are used
as given — a truncated teacher tail is deliberately NOT renormalized) and
loss_scale multiplies the scalar result (the gradient-accumulation
knob). Differentiable in both operands; the record is single-use like
linearCrossEntropyExt (a repeat backward errors with
LinearDistillBackwardConsumed).
Elementwise losses vs a same-tagged target, all differentiable in
both operands, all sharing the reduction/result-type contract above
(.mean divides by the total element count):
mseLoss(ctx, target, options)—exec.MseOptions{ .reduction }.huberLoss(ctx, target, options)—exec.HuberOptions{ .delta = 1.0, .reduction }; quadratic for|x−t| <= delta, linear beyond.bceLoss(ctx, target, options)—exec.BceOptions{ .reduction, .from_logits = false }. Withfrom_logitsthe input is a raw logit and the stablemax(x,0) − x·y + log1p(exp(−|x|))form is used; otherwise a probability clamped to[bce_eps, 1−bce_eps](exec/loss.zig'sbce_eps = 1e-7; gradient defined as 0 outside the open interval — deliberate divergence from torch's huge boundary gradients).klDivLoss(ctx, target, options)—exec.KlDivOptions{ .reduction, .log_target = false }.selfholds log-probabilities; no.batchmeanexists —.meanis torch's total-element mean,.sumthe mathematical divergence.
Composed (require an active exec scope when gradients are tracked, §4.1):
nllLoss(ctx, class_tag, labels, comptime reduction)— NLL over log-probabilities (one-hot → mul → sum → negate). PrefercrossEntropy/crossEntropyExtwhen starting from logits.cosineSimilarity(ctx, other, tag, eps)— torchF.cosine_similarity:Σxy / max(‖x‖·‖y‖, eps)withtagreduced away; differentiable in both.
test "crossEntropy on uniform logits is ln(K)" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var logits = try fucina.Tensor(.{ .batch, .class }).zeros(&ctx, .{ 1, 4 });
defer logits.deinit();
var loss = try logits.crossEntropy(&ctx, .class, &.{2});
defer loss.deinit();
try std.testing.expectApproxEqAbs(@log(@as(f32, 4)), try loss.item(), 1e-6);
var per_pos = try logits.crossEntropyExt(&ctx, .class, &.{2}, .{ .reduction = .none });
defer per_pos.deinit(); // Tensor(.{ .batch }): class tag removed
try std.testing.expectApproxEqAbs(@log(@as(f32, 4)), (try per_pos.dataConst())[0], 1e-6);
}
test "mseLoss mean over all elements" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{2}, &.{ 1, 2 });
defer x.deinit();
var t = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{2}, &.{ 3, 2 });
defer t.deinit();
var loss = try x.mseLoss(&ctx, &t, .{});
defer loss.deinit();
try std.testing.expectApproxEqAbs(@as(f32, 2), try loss.item(), 1e-6); // ((-2)² + 0) / 2
}
compiled & run in CI ✓
4.16 Selection: argmax, topK, sort, routerTopK (src/ag/tensor.zig, src/exec/topk.zig)¶
Index outputs across the library are constant i64 tensors (the
repo-wide index convention — torch's index dtype, exact for any axis
length) and carry no gradient. As typed constants they are CALLER-owned
even under an exec scope (§6.3): pair them with deinit — an f32
values arm of the same call remains a scope-owned borrow.
argmax(ctx, tag)— indices of the per-row maximum, tag removed, asTensor(.{ .dtype = .i64, .tags = ... }). NaN never wins (comparisons drop NaN): the winner is the maximum over the non-NaN elements, and an all-NaN row falls back to index 0 — documented divergence fromtorch.argmax, which propagates NaN. No-grad by design (like sampling).topK(ctx, tag, k, out_tag)— returnsTopKResult(replaceTag(tags, tag, out_tag)), a struct ofvaluesandindiceswith a singledeinit()releasing both. NaN never places (it fails the descending-slot admission test); a row with fewer thanknon-NaN elements leaves its unfilled tail slots at value −inf, index 0 — documented divergence fromtorch.topk, which treats NaN as greater than every number.valuesis differentiable (the gradient scatters back through the saved indices);indicesis a constant i64 tensor.sort(ctx, tag, descending)— full sort (TopKResult(tags)): values + source index per output position. Unstable sort; NaN sorts last regardless of direction (documented divergence fromtorch.sort, which puts NaN first when descending). Values differentiable, indices constant.argsort(ctx, tag, descending)— the indices arm alone (i64); no-grad.multinomial(ctx, class_tag, out_tag, num_samples, seed, replacement)— categorical sampling from UNNORMALIZED non-negative weight rows (torch.multinomial):num_samplesi64 draws per row alongclass_tag(last axis, likerouterTopK; rank 1 or 2 — the torch surface),class_tagreplaced byout_tagin the result. Draw(row, s)reads the deterministic counter-based stream at(seed, row·num_samples + s)(§6.8) — reproducible and batching-independent; pass a fresh seed per call. Rows must hold finite weights>= 0with a positive sum (InvalidShapeotherwise); zero-weight classes are never drawn. Without replacement each draw removes the chosen class's mass, andnum_samplesbeyond the class count — or beyond a row's nonzero classes — isInvalidShape. No-grad by design (an i64 sampling output, likeargmax).routerTopK(ctx, expert_tag, k, options, selected, weights)— the MoE router primitive: fills caller-providedselected: []usize/weights: []f32(bothrows·klong) with the per-row top-k experts and their softmax probabilities computed over the full expert axis.exec.RouterTopKOptions{ .normalize_selected: bool = true }renormalizes the selected mass to sum to 1. Requires rank-2[row, expert]logits with the expert tag last (compile errors) and a no-grad input (error.UnsupportedGradient).
test "argmax, topK, and routerTopK" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var logits = try fucina.Tensor(.{ .row, .expert }).fromSlice(&ctx, .{ 1, 4 }, &.{ 1, 3, 2, 0 });
defer logits.deinit();
var best = try logits.argmax(&ctx, .expert); // i64 indices, no grad
defer best.deinit();
try std.testing.expectEqualSlices(i64, &.{1}, try best.dataConst());
var top = try logits.topK(&ctx, .expert, 2, .k);
defer top.deinit();
try std.testing.expectEqualSlices(f32, &.{ 3, 2 }, try top.values.dataConst());
var selected: [2]usize = undefined;
var weights: [2]f32 = undefined;
try logits.routerTopK(&ctx, .expert, 2, .{}, &selected, &weights);
try std.testing.expectEqual(@as(usize, 1), selected[0]);
// normalize_selected renormalizes the top-k softmax mass to 1
try std.testing.expectApproxEqAbs(@as(f32, 0.7310586), weights[0], 1e-6);
// multinomial: seed-deterministic categorical draws; zero-weight
// classes are never selected.
var probs = try fucina.Tensor(.{ .row, .class }).fromSlice(&ctx, .{ 1, 3 }, &.{ 0, 2, 0 });
defer probs.deinit();
var picks = try probs.multinomial(&ctx, .class, .sample, 3, 42, true);
defer picks.deinit();
try std.testing.expectEqualSlices(i64, &.{ 1, 1, 1 }, try picks.dataConst());
}
compiled & run in CI ✓
4.17 Indexing, assembly, and functional updates (src/ag/tensor.zig)¶
These produce owned tensors, differentiable in every tensor operand unless
noted; gradients route exactly through the index maps (gather scatters-adds,
slices narrow, pads drop the border). All materialize copies except
narrow, select, slice on step-1 ranges, unbindInto's entries, and
repeatAxis with n == 1, which alias the source storage (see their
bullets).
gather(ctx, tag, indices: []const usize, out_tag)— select rows alongtag; the axis is retaggedout_tag(which may equaltag). This IS torch.index_select with host-side indices (indices live host-side by design — ARCHITECTURE.md);indexSelectbelow is the tensor-valued spelling and lowers to this. For per-ELEMENT index tensors usetakeAlongAxisbelow.indexSelect(ctx, tag, indices, out_tag)— torch.index_select:gatherwith a rank-1 i64 index tensor (the argmax/topK/sort index convention — their outputs feed it directly; any other dtype is a compile error), read host-side into the same[]usizepath. Entries outside[0, dim(tag))error withIndexOutOfBounds(negatives are not wrapped); duplicate indices accumulate their gradients (the scatter-add adjoint); the index tensor is control data outside the graph and can be released after the call.getRows(ctx, tag, indices, out_tag)— block-quantized tensors only: fused gather + dequantize of rows from a rank-2 weight into an f32 tensor (the embedding-lookup path; §10). No-grad (quantized tensors are constants).narrow(ctx, tag, start, length)— contiguous sub-range as a zero-copy view: it retains the source buffer and aliases its memory, so a mutation of the source throughdata()is visible through the result.select(ctx, tag, index: isize)— torch.select /x[i]: one position oftagwith the axis removed (composed narrow → squeeze — a zero-copy view; scope-required under gradients, §4.1). Negativeindexcounts from the end; out of range errors withIndexOutOfBounds. The gradient is the exact scatter — unselected positions receive zero.slice(ctx, spec)— multi-axis basic slicing (torch/numpyx[1:-1, ::2], positive steps):specnames the tags to slice, each field afucina.SliceRange-shaped range — §3.7 has the full bounds contract (negatives from the end,end = null= dim, clamping, no negative steps — composeflip). Composed per-axisnarrow/sliceStepin tag order; scope-required under gradients when more than one axis is sliced.pad(ctx, tag, before, after, fill)— constant padding on one axis; pad positions holdfilland drop their gradient.concat(ctx, tag, others: []const *const Self)— concatenation along an existing tag; one multi-parent node, differentiable in all inputs.stack(ctx, new_tag, axis_index, others)— stack along a new axis (composed insertAxis + concat; scope-required under gradients, §4.1).unbindInto(ctx, tag, out: []Tensor(removeTag(tags, tag)))— fill a caller-provided slice with thedim(tag)slices, each withtagremoved (composed narrow + squeeze; scope-required under gradients). The caller owns and deinits every filled entry; on error, already-filled entries have been released.repeatAxis(ctx, tag, n)— tile the axis n times (n == 1is a zero-copy identity view;n == 0isInvalidShape); gradients from all copies accumulate.flip(ctx, tag)/roll(ctx, tag, shift)— reverse / rotate one axis (gather with a permutation; exact gradient).setSlice(ctx, tag, start, update)/setRows(ctx, tag, indices, update)— functional overwrite of a range / of specific rows; differentiable in bothself(gradient zeroed where overwritten) andupdate.zeroSlice(ctx, tag, start, length)/zeroRows(ctx, tag, indices)— copy with a range/rows zeroed; the zeroed positions receive zero gradient.maskedSelect(ctx, mask, out_tag)—torch.masked_select: rank-1 tensor of the elements wheremaskis nonzero, row-major. The mask must be input-shaped, contiguous, and non-grad;selfis differentiable (composed flatten + gather, scope-required under gradients). Selecting nothing is the dedicatedEmptySelection(zero-size tensors are not representable), distinct from the shape errors so the no-match case is recoverable with a targetedcatch; pre-counting with a mask sum avoids the error path (see the guard snippet in §3.7).indexAdd(ctx, tag, indices: []const usize, update)— torch.index_add:setRows's accumulating sibling — duplicates allowed, each occurrence adds. Differentiable in both (selfidentity,updaterow-gather).takeAlongAxis(ctx, tag, indices)— torch.gather / np.take_along_axis: per-element row selection alongtag.indicesis a same-tagged i64 tensor (the argmax/topK/sort index convention — their outputs feed it directly; any other dtype is a compile error), matchingselfon every other axis; the result takesindices' shape. Parallel over outer slices (disjoint writes — bitwise identical for any thread count); differentiable inself(exact scatter-add adjoint, duplicate reads accumulate).scatterAdd(ctx, tag, indices, src)/scatter(ctx, tag, indices, src)— torch.scatter_add / torch.scatter: functional per-element accumulate/overwrite atindicesalongtag(indicesshaped exactly likesrc). Duplicates accumulate inscatterAdd; inscatterthey resolve deterministically to the LAST row-major write (torch leaves the order unspecified; this pins it). Parallel over outer slices — duplicates only collide within a slice, where serial row-major order is preserved, so accumulation order and last-write-wins stay bitwise identical for any thread count. Differentiable in both operands (overwrite zeroesself's gradient at every written slot; on duplicates every writer receives the winning slot's gradient — the torch formula).nonzero(allocator)— host-side[]usizeof row-major nonzero flat indices (§3.7); pairs withgather/indexAdd/oneHot.
test "takeAlongAxis pairs with argsort indices" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const M = fucina.Tensor(.{ .row, .col });
var x = try M.fromSlice(&ctx, .{ 2, 3 }, &.{ 30, 10, 20, 5, 15, 0 });
defer x.deinit();
// argsort emits i64 indices — takeAlongAxis consumes them directly,
// reordering each row (here: torch.gather(x, 1, x.argsort(1))).
var order = try x.argsort(&ctx, .col, false);
defer order.deinit();
var sorted = try x.takeAlongAxis(&ctx, .col, &order);
defer sorted.deinit();
try std.testing.expectEqualSlices(f32, &.{ 10, 20, 30, 0, 5, 15 }, try sorted.dataConst());
}
compiled & run in CI ✓
test "gather, flip, and narrow copy with exact gradients" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{.d}).fromSlice(&ctx, .{3}, &.{ 10, 20, 30 });
defer x.deinit();
var picked = try x.gather(&ctx, .d, &.{ 2, 0 }, .g); // tag .d becomes .g
defer picked.deinit();
try std.testing.expectEqualSlices(f32, &.{ 30, 10 }, try picked.dataConst());
var reversed = try x.flip(&ctx, .d);
defer reversed.deinit();
try std.testing.expectEqualSlices(f32, &.{ 30, 20, 10 }, try reversed.dataConst());
var mid = try x.narrow(&ctx, .d, 1, 2);
defer mid.deinit();
try std.testing.expectEqualSlices(f32, &.{ 20, 30 }, try mid.dataConst());
}
compiled & run in CI ✓
test "select, multi-axis slice, and tensor-valued indexSelect" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const M = fucina.Tensor(.{ .row, .col });
var x = try M.fromSlice(&ctx, .{ 3, 4 }, &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 });
defer x.deinit();
var last_row = try x.select(&ctx, .row, -1); // Tensor(.{ .col }), torch x[-1]
defer last_row.deinit();
try std.testing.expectEqualSlices(f32, &.{ 9, 10, 11, 12 }, try last_row.dataConst());
// torch x[1:, 1:-1] — omitted bounds default, negatives count from the end.
var inner = try x.slice(&ctx, .{ .row = .{ .start = 1 }, .col = .{ .start = 1, .end = -1 } });
defer inner.deinit();
var inner_mat = try inner.materialize(&ctx);
defer inner_mat.deinit();
try std.testing.expectEqualSlices(f32, &.{ 6, 7, 10, 11 }, try inner_mat.dataConst());
// index_select with an i64 tensor — argmax/topK/sort outputs feed it directly.
const I = fucina.Tensor(.{ .dtype = .i64, .tags = .{.pick} });
var idx = try I.fromSlice(&ctx, .{2}, &.{ 2, 0 });
defer idx.deinit();
var rows = try x.indexSelect(&ctx, .row, &idx, .pick); // Tensor(.{ .pick, .col })
defer rows.deinit();
try std.testing.expectEqualSlices(f32, &.{ 9, 10, 11, 12, 1, 2, 3, 4 }, try rows.dataConst());
}
compiled & run in CI ✓
4.18 MoE facade entries (src/exec/moe.zig, src/exec.zig)¶
Routed expert FFNs run below the tag facade, directly on ExecContext
(inference-only; §10 covers the quantized layouts, §13 the LLM integration):
pub const MoeRhs = union(enum) { q4_k: ..., q5_k: ..., q6_k: ..., q8_0: ...,
tq2_0: ..., ptqtp: ..., q2_k: ..., iq2_xxs: ..., iq3_xxs: ..., iq2_s: ...,
iq4_xs: ..., q3_k: ..., streamed: ... }; // fucina.MoeRhs
pub fn moeExpertFfn(self: *ExecContext, x: *const Tensor,
gate: *const MoeRhs, up: *const MoeRhs, down: *const MoeRhs,
selected: []const usize, weights: []const f32,
out_pe: usize, act: GatedOp, io: ?std.Io, profile: ?*MoeBatchProfile) !Tensor
pub fn moeExpertFfnBatch(..., top_k: usize, ...) !Tensor
fucina.MoeRhs stacks all experts of one layer's gate/up/down projection
into a single compact-block RHS (experts are row-contiguous zero-copy
sub-views; the resident arms cover q4_k/q5_k/q6_k/q8_0/tq2_0/q2_k/
iq2_xxs/iq3_xxs/iq2_s/iq4_xs/q3_k, plus a streamed arm whose expert blocks resolve
through the disk-backed expert store (src/exec/expert_store.zig)
instead of one resident buffer). The ptqtp arm holds K ∈ {1..3} tq2_0
plane stacks (PTQTP experts, §10.9): the fused op runs the ternary tile
once per plane and sums per element in fixed plane order before the gated
nonlinearity — bitwise the dense fused PTQTP linear on the same weights.
Tie-fitted K = 2 stacks (§10.9, --ptqtp-tie) additionally carry the
4-bit folded pack (MoePtqtpRhs.folded, expert-major); when present, the
expert dot runs the one-pass folded kernel instead of two plane passes.
The streamed tier gathers a ProjSpec with plane_count/plane_offsets
pointing at the persisted <name>.ptqtpK sibling tensors (§13.2.1), and
ProjSpec.fold folds the two plane row-blocks into the pack on the way
into the slab (disk layout unchanged).
moeExpertFfn computes the route-weighted sum over the selected experts of
down(act(gate(x), up(x))) for a single token; moeExpertFfnBatch is the
batched-prefill variant taking the per-token selected/weights produced
by routerTopK (§4.16). fucina.MoeBatchProfile is an optional wall-clock
breakdown the caller can pass to profile a run.
ExecContext.moe_chain re-exports the shared batched-MoE scheduling
scaffolding of src/exec/moe_chain.zig — the expert-grouped route plan,
the phase-chain machinery, chunk helpers, and the profile timer pair — so
in-tree LLM-band MoE engines (§13) reach the exact same types through the
fucina root.
fucina.expert_store (src/exec/expert_store.zig) is the out-of-core
expert tier behind the streamed arm: fucina.ExpertStore
(create/destroy, addLayer/finalize) opens the GGUF part files and
resolves expert blocks through a pinned → LRU → pread hierarchy inside
a caller-driven acquire/release window, so MoE models larger than RAM
decode against the same kernels as the resident arms. Demand-miss reads
fan out across a persistent I/O worker pool (Options.io_workers, default
8; the acquiring thread drains the same batch; 0 = sequential on the
calling thread), and addMirror attaches additional full copies of the
split GGUF before finalize — typically on other drives, with a per-copy
weight setting its read share relative to the primary's 1 — so expert
reads split across every copy. Options.uncached opens the store and
mirror fds uncached (macOS F_NOCACHE; no-op on Linux): a streamed giant
reads more than RAM per token, so page-caching those reads only evicts
the mmapped dense weights. streamedRhs hands out the per-projection
StreamedMoeRhs handle the streamed arm wraps; pilotHint enqueues
router-lookahead readahead on a dedicated I/O thread; cacheRouteTopK —
active only when Options.cache_route: ?CacheRouteOptions was set — is
opt-in cache-aware top-k selection (max-rank): the true top-sacred
ranks are always taken and the remaining slots prefer already-resident
experts among the top-window ranks (it changes routing, so callers opt
in explicitly, and Stats reports the swap rate); saveUsage persists
the expert-usage histogram to a <gguf>.experts sidecar that auto-pin
turns into a pinned hot tier at the next startup, and repinPass adapts
that tier live at generation boundaries.
4.19 Math on non-f32 tensors (src/ag/tensor.zig)¶
Tensor(.{ .dtype = dt, .tags = ... }) instantiates typed constant tensors
(§3, §8). Their math surface is forward-only, always no-grad:
- f16 / bf16 / f64 (
supportsForwardFloatMath) — native typed kernels:to(cast),add,sub,mul,div(same dtype both sides — cast explicitly),sum,mean,sumAll,dot,scale,divScalar, plus the full structural set (split,merge,flatten,reshape,sliceStep,flip,roll,stack,repeatAxis, and the indexing subsetgather,narrow,concat,setSlice,setRows,broadcastTo, views). Pointwise anddotkeep the input dtype; reductions widen f16/bf16 results to f32 (§4.7, §8). - f16 / bf16 only — the widened forward set. Ops with no native typed
kernel lower to widen → f32 kernel → narrow-once (f32 arithmetic and
accumulation with a single final round, the §8.3 policy; on f64 they are
a compile error — f64 math must not round through f32). Shape-preserving
ops keep the input dtype: the unary family (
unary(op)and every named alias listed in §3.10),leakyRelu,clamp,addScalar,subScalar,powScalar,maximum,minimum,gated/glu/swiglu/geglu,softmaxandlayerNorm(plain.{}options only — cast to f32 for the ext/affine paths),logSoftmax,rmsNorm,rmsNormMul(same-dtype weight),cumsum,cumprod,where/maskedFill(.boolor same-dtype masks),compare(.boolresult),pad, andeinsum(same-dtype operands, f32 GEMM lowering — the typeddotcontract). The widened reductionsmax,min,prod,variance,logsumexpreturn f32 like the native typedsum/mean(§8.3);argmaxreturns i64 (§4.16). - Block-quantized (q8_0, q4_k, ...): no arithmetic —
to(.f32)(dequantize),getRows(§4.17), row-axisconcat,packRhs/packRhsLayout(§4.9), and constructors/views (§3, §10). Their main math role is as the constant RHS ofdot(§4.8) anddotPacked(§4.9). - Integer dtypes (e.g. token-id tensors) — ordinary integer forward
math, plain exec loops (integers are never the hot path): wrapping
two's-complement
add/sub/mul(torch's narrowing behavior),maximum/minimum, and EXPLICIT division —divTrunc(toward zero) anddivFloor(toward −inf),error.DivisionByZeroon a zero divisor, minInt/−1 wrapping to minInt. There is deliberately no integerdiv: torch's/silently promotes integers to float, and Fucina keeps promotion explicit (documented divergence). Remainders pair the divisions:rempairsdivTrunc(sign of the dividend, C%/ Zig@rem) andmodpairsdivFloor(sign of the divisor, Python/numpy%/ Zig@mod— the n-gram-hash op); both error on a zero divisor and define minInt % −1 as 0. Bitwise combinatorsbitAnd/bitOr/bitXoroperate on the two's-complement bit patterns (distinct from the.booltruthinesslogicalAnd/Or/Xor;bitXorwith a wrappingmulis exactly the multiplicative-XOR hash family, e.g. Engram §13). All of these follow the standard tag-broadcast rule (§4.2).sum/sumAllaccumulate in i64 and RETURN.i64(torch's integer-sum dtype).tocasts to any scalar dtype (§3.8). .bool: no pointwise arithmetic (compile error — cast first);toand the countingsum/sumAll(i64) apply, plus the structural subset.
The typed forward ops are no-grad by design: an operand that requires
gradients is REJECTED with error.UnsupportedGradient instead of silently
dropping its graph. The differentiable ways into and out of the 16-bit
world are to (§3.8) and the mixed-RHS dot/einsum (§4.8); widen with
to(.f32) for everything else in a trained path.
Because the widened ops run the identical f32 kernels and round once on
store, their results are bit-identical to "cast up, run the f32 op, cast
down" — pinned by parity tests in src/ag/tensor_tests.zig:
test "bf16 forward ops compute through f32 and narrow once" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .seq, .d }).fromSlice(&ctx, .{ 2, 2 }, &.{ 0.5, -1.25, 2.0, 3.5 });
defer x.deinit();
var half = try x.to(&ctx, .bf16);
defer half.deinit();
var activated = try half.gelu(&ctx); // widen -> f32 gelu -> narrow
defer activated.deinit();
comptime std.debug.assert(@TypeOf(activated).dtype == .bf16);
var reference_f32 = try x.gelu(&ctx);
defer reference_f32.deinit();
var reference = try reference_f32.to(&ctx, .bf16);
defer reference.deinit();
try std.testing.expectEqualSlices(u16, try reference.dataConst(), try activated.dataConst());
// Widened reductions keep the f32 accumulator dtype, like sum/mean.
var spread = try half.variance(&ctx, .d, 0);
defer spread.deinit();
comptime std.debug.assert(@TypeOf(spread).dtype == .f32);
}
compiled & run in CI ✓
test "integer math wraps, divides explicitly, and reduces to i64" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const Ids = fucina.Tensor(.{ .dtype = .i8, .tags = .{.d} });
var a = try Ids.fromSlice(&ctx, .{2}, &.{ 127, -7 });
defer a.deinit();
var b = try Ids.fromSlice(&ctx, .{2}, &.{ 1, 2 });
defer b.deinit();
var wrapped = try a.add(&ctx, &b); // two's-complement wrap
defer wrapped.deinit();
try std.testing.expectEqualSlices(i8, &.{ -128, -5 }, try wrapped.dataConst());
var quotient = try a.divFloor(&ctx, &b); // explicit: no integer `div`
defer quotient.deinit();
try std.testing.expectEqualSlices(i8, &.{ 127, -4 }, try quotient.dataConst());
var remainder = try a.mod(&ctx, &b); // floored: pairs divFloor
defer remainder.deinit();
try std.testing.expectEqualSlices(i8, &.{ 0, 1 }, try remainder.dataConst());
var mixed = try a.bitXor(&ctx, &b); // two's-complement bit patterns
defer mixed.deinit();
try std.testing.expectEqualSlices(i8, &.{ 126, -5 }, try mixed.dataConst());
var total = try a.sumAll(&ctx); // integer reductions return i64
defer total.deinit();
comptime std.debug.assert(@TypeOf(total).dtype == .i64);
try std.testing.expectEqual(@as(i64, 120), try total.item());
}
compiled & run in CI ✓
The f32 to(ctx, target_dtype) cast is differentiable for .f32 → .f32
and for the mixed-precision narrows .f32 → .f16/.bf16 (§3.8, §5.1 —
the backward is the identity on the f32 upstream gradient); casting a
grad-requiring tensor to any other dtype fails with
error.GradientCastUnsupported.