Compiler transform_ad only. Not Phase 2 handler AD. Not a Phase 1 exit-criteria replacement.
These rules extend Section 9; they do not add a user-facing scatter primitive.
1. What this section commits to
Section 9 compiles log_softmax and take_along_axis and rejects them in
the backward build. StoryLM-0 NLL is the composition
- logits.log_softmax(axis).take_along_axis(targets, axis)
so a training-shaped AD graph needs three VJPs:
neg— already a specified unary; the restricted AD pass had no rule.log_softmax— standard log-domain softmax cotangent.take_along_axis— scatter the incoming cotangent back onto the class axis of the values tensor.
This section writes those rules for the existing compiler AD pass
(Ad_pass.transform_ad). It does not start Phase 2 AD-as-an-effect.
2. What this section does not commit to
- Phase 2 handler AD, tape AD, or differentiating an arbitrary program.
- A user-facing
scatter,one_hot, orarangeform. The scatter used here is IR-internal. - Device
take_along_axis(still Section 9 open issue 1). - The
reverseModeresult ABI (now Section 12). This section only builds the VJPs. - SGD (now Section 11), AdamW,
argmax, seeded sampling, or KV-cache semantics. - A user-facing
expVJP.expappears in thelog_softmaxbackward graph only; differentiating a primalexpremains unsupported.
3. Pass constraints (restated, not new)
The restricted pass is unchanged except for the new VJP cases:
- The differentiated function body is a
letchain that ends with aVar. - Each differentiated node’s children are
Vars (theletdiscipline). - The seed cotangent is
onesof the primal result shape (ConstantTensor (1.0, ret_tensor_ty)). - Every
reverseModefunction parameter must receive a cotangent. I64 index tensors are discrete and carry no adjoint, so they are innerlets, not function parameters, in this slice. Aletthat never receives a cotangent (discrete indices, unused literals) is skipped in the backward build; it is not an error.
A legal NLL AD program therefore looks like:
let nll = fn(logits: tensor<F32, [1, 4, 8], @host>) -> tensor<F32, [1, 4], @host> {
let targets = tensor<I64, [1, 4], @host> in
let log_probs = logits.log_softmax(axis=2) in
let picked = log_probs.take_along_axis(targets, axis=2) in
let loss = -picked in
loss
} in
let logits = tensor<F32, [1, 4, 8], @host> in
handle nll(logits) with reverseMode
4. neg VJP
neg is Section 5 / Phase 0 unary negation. This section only fills the
missing backward case.
v = -a
─────────────────────────
g_a = -g_v
Placement and dtype of g_a equal those of a. The backward node is the
existing Unary("neg", …) form.
5. log_softmax VJP
Let y = log_softmax(x) along a compile-time axis (Section 9 §3). The
incoming cotangent g has the same shape as y and x.
s = exp(y) (softmax(x); computed from the primal)
g_x = g - broadcast(sum(g, axis, keepdims=true), shape(x)) * s
sum and broadcast are the existing reduction / broadcast primitives.
exp is the existing unary; it is not given its own VJP in this
section.
This is the standard identity g - sum(g) * softmax. Using exp(y) avoids
recomputing the stable max-shift in the backward graph.
VJP placement follows x. Device log_softmax may therefore produce a
device backward graph composed of ops that already have device lowering.
No new FFI.
6. take_along_axis VJP
Write σ = α ++ [C] ++ β with |α| = axis, and
out[a ++ b] = values[a ++ [indices[a ++ b]] ++ b]
as in Section 9 §4.2. The incoming cotangent g has shape ρ = α ++ β.
indicesis discrete: no cotangent.valuescotangent has shapeσ:
g_values[a ++ [c] ++ b] =
g[a ++ b] if indices[a ++ b] = c
0 otherwise
Each destination point (a, c, b) is written by at most one source
(a, b). Duplicate class ids at different (a, b) land at different
outputs. A parallel compare/select over the destination shape is therefore
enough; this slice does not need atomics or a sequential scatter-add.
The compiler represents that cotangent with the IR-internal constructor in
§7. It does not extend 2D gather.
7. ScatterAlongAxis (IR-internal)
7.1 Typing
Not a surface form. The typed IR constructor is
ScatterAlongAxis of int * program * program * tensor_type
in constructor order (axis, updates, indices, dest_ty).
Γ ⊢ updates : Tensor[T, ρ]
Γ ⊢ indices : Tensor[I64, ρ]
0 ≤ axis < |σ|
|σ| ≥ 2
ρ ≡ σ with the dimension at `axis` removed
updates.placement = indices.placement = dest_ty.placement
dest_ty.dtype = T
dest_ty.shape = σ
placement = Host (this slice)
∀i. 0 ≤ indices[i] < σ[axis] (runtime-checkable bound)
─────────────────────────────────────────
Γ ⊢ ScatterAlongAxis(axis, updates, indices, dest_ty) : Tensor[T, σ]
dest_ty is the result type (the primal values type of the corresponding
take_along_axis). Structural shape equality, same as Section 9.
The StoryLM-0 instance:
updates : Tensor[F32, [B, T], @host]
indices : Tensor[I64, [B, T], @host]
axis = 2
dest_ty : Tensor[F32, [B, T, V], @host]
result : Tensor[F32, [B, T, V], @host]
The type checker does not accept a surface scatter_along_axis. The AD
pass constructs well-typed nodes. infer_type / typeof_program read
dest_ty. Device operands are rejected at emit, same stance as
take_along_axis.
7.2 Operational meaning
out[a ++ [c] ++ b] =
updates[a ++ b] if indices[a ++ b] = c
0 otherwise
Out-of-range indices trap on the host path (cf.assert), same policy as
checked take_along_axis.
7.3 Host lowering
- A bounds
linalg.genericoverindices(compare against0andC), assertion messagescatter_along_axis index out of bounds. - A
linalg.genericover the destination shapeσ. Affine maps project awayaxisforupdatesandindices. The body compareslinalg.index axis(cast to i64) with the index element andarith.selects the update versus a typed zero.
Device placement fails emit:
scatter_along_axis: checked device lowering is not available.
The forward AD walk rewrites children of ScatterAlongAxis if such a node
appears. Building a VJP for ScatterAlongAxis is unsupported in this
slice (it is only inserted in the backward graph).
7.4 Surface
None. No .kina method, no S-expression keyword.
8. Tests
ad_neg.kina—negVJP compiles; emitted MLIR contains a backwardlinalg.neg(or equivalent host unary neg).ad_log_softmax.kina— last-axislog_softmaxVJP compiles; emitted backward graph containsexpandsubafter a keepdimssum.ad_nll.kina— composed NLL (neg∘take_along_axis∘log_softmax) with inner-let I64 targets compiles; emitted graph contains the scatter compare/select (arith.cmpi eq+arith.select).mlir-optaccepts the goldens.- Structural IR check: after
transform_ad, the NLL backward graph containsScatterAlongAxis; a primal-onlylog_softmaxstill has no scatter. - Python
scripts/validate_loss_ad.py: the §4–§6 formulas match a finite-difference of the reference forward, and match PyTorch when present. Kina-executed.sograds for anegwrapper are Section 12.
9. Open issues
reverseModeresult ABI. Specified in Section 12. The language type is the flat tuple(primal, g₀, …);@main/fnuse MLIR multi-result returns. Tuple projection (loss → VJP → SGD in one program) remains open there.- Device
take_along_axis/ScatterAlongAxis. Still follow-on. ScatterAlongAxisVJP (would be atake_along_axisof the dest cotangent). Not needed while scatter exists only in the backward graph.- User-facing
expVJP. Not in this section. - Mean NLL. Optional
reduceof the per-site loss; existingsumVJP already covers a subsequent reduction.