Skip to content

Commit 33bb554

Browse files
committed
[Lang] SNode-arm bound-expr capture rejects fold-attack gate indices via iter-count + axis-classification checks
1 parent cda2944 commit 33bb554

3 files changed

Lines changed: 422 additions & 38 deletions

File tree

docs/source/user_guide/autodiff.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,19 @@ where each quantity means:
317317
| `bytes_per_slot` | Depends on `T` and on the backend (see table below). |
318318
| `num_buffers` | Number of adstacks the kernel allocates - one per loop-carried variable plus one per dependent branch flag (see [One adstack per variable](#one-adstack-per-variable)). |
319319

320-
Kernels of the shape `for i in range(...): if field[i] cmp literal: <adstack work>` (a runtime gate directly above the adstack-using body, comparing one field entry to a constant) shrink further: the compiler counts gate-passing iterations at launch time and sizes the float adstack to that count instead of `num_threads * stack_size`. A workload whose gate matches 5% of iterations pays 5% of the float-adstack cost; the float heap grows on demand if a later launch matches more. Integer / boolean adstacks stay at `num_threads * stack_size` - their pushes fire unconditionally for control-flow replay. The shrinking is exact only when the gate's per-axis index is a bare loop variable (`field[i]`, `field[I, J, K]`); see [What can go wrong](#what-can-go-wrong) for a known limitation on `qd.field`-backed gates indexed by compound expressions.
320+
The float heap is by far the main reverse-mode memory bottleneck because a typical kernel allocates many float-typed adstacks - one per floating-point loop-carried scalar, each storing both primal and adjoint - and the total scales as `num_threads * stack_size * num_float_buffers * 8` bytes, dominating the integer / boolean heap. Advanced static IR analysis is used to further shrink the float adstack in some common gated-kernel shapes. When a runtime gate sits directly above the adstack-using body and compares a single field entry to a constant, the compiler counts the gate-passing iterations at launch time and sizes the float adstack to that count instead of `num_threads * stack_size`, so a workload whose gate matches 5% of iterations pays 5% of the float-adstack cost; the float heap grows on demand if a later launch matches more, and integer / boolean adstacks stay at `num_threads * stack_size` since their pushes fire unconditionally for control-flow replay. The shrinking kicks in only when the compiler can statically prove that each loop iteration touches a different field cell; patterns where uniqueness cannot be proven statically fall back to worst-case heap `num_threads * stack_size`.
321+
322+
Patterns that capture:
323+
- **Linear range loop**: `for i in range(n): if field[i] > eps: ...`
324+
- **Multi-axis StructFor**: `for I, J, K in field3d: if field3d[I, J, K] > eps: ...`
325+
- **Multi-axis ndrange**: `for ii, jj, kk in qd.ndrange(*shape): if grid[ii, jj, kk] > eps: ...`
326+
- **Iterating axes plus a slice**: a kernel argument or constant on an extra axis, e.g. `grid[i, 0]`, `grid[arg, ii, jj, kk]`.
327+
328+
Patterns that fall back to the worst-case heap:
329+
- **Single-axis arithmetic on the loop variable**: `field[i % K]`, `field[i / 2]`, `field[i + 5]`, `field[2 * i]`, and similar.
330+
- **Constant-index gate**: `field[42]`, or any axis that is a literal constant.
331+
- **Kernel-argument index, no iterating axis**: `field[arg]` where every axis is launch-constant.
332+
- **Indirect index via runtime load**: `field[other_field[i]]`; the compiler cannot prove `other_field` is injective.
321333

322334
Every adstack slot always stores a *primal* value - the forward-pass value the reverse pass pops to recover the chain-rule step. Floating-point adstacks additionally store an *adjoint* slot where the reverse pass accumulates chain-rule contributions. Integer / boolean adstacks do not need an adjoint slot.
323335

@@ -354,9 +366,6 @@ A large `ndrange` combined with several loop-carried variables multiplies quickl
354366
- pass `ad_stack_size=N` to `qd.init()` with `N` large enough to cover the real push count (bypasses the sizer).
355367
- **Out-of-memory before the kernel even runs.** A reverse pass through many loop-carried variables at a large ndrange can ask the runtime for more adstack memory than the device can physically back, even when the sizer's number is correct. Surfaces as an allocator OOM at launch time. Remedies are the ones listed under *Avoiding OOM on GPU* above: fewer loop-carried variables, a smaller ndrange, manual checkpointing, or more device-memory headroom.
356368
- **Loop bounds backed by a mutated ndarray.** A reverse-mode kernel with `for i in range(n[j])` requires `n[j]` to hold the same value at the forward call and at `.grad()`. If anything writes to `n[j]` between those two points - the differentiable kernel itself, or any other kernel call - the computed gradient may come out wrong, sometimes as an `Adstack overflow` exception at `qd.sync()`, sometimes silently. The safe rule: populate loop-bound ndarrays before the forward call and leave them untouched until `.grad()` returns. The reason for that is Quadrants' adstack sizer design: it reads the loop bound separately at each dispatch, which includes forward and backward calls. Tape-based eager AD like [PyTorch's autograd](https://pytorch.org/docs/stable/notes/autograd.html) is not affected, since the trip count is recorded as the forward runs and reused at backward time.
357-
- :warning: **Gate on a `qd.field` indexed by an expression that is not a plain loop variable.** A reverse-mode kernel of the shape `for i in range(n): if field[i % K] > eps: <adstack work>` (or any gate whose index is not a plain loop variable - `field[2 * i]`, `field[42]`, `field[other_field[i]]`) may produce silently wrong gradients. Workarounds:
358-
- raise `ad_stack_sparse_threshold_bytes` in `qd.init()` past the kernel's conservative-heap byte size;
359-
- use a `qd.ndarray` for the gating field instead of a `qd.field`.
360369

361370
## Performance characteristics
362371

quadrants/transforms/static_adstack_analysis.cpp

Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -274,20 +274,134 @@ StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir
274274
if (!desc_opt.has_value()) {
275275
return false;
276276
}
277-
// KNOWN LIMITATION: the SNode arm trusts whatever index expression the codegen passed to `SNodeLookupStmt` and
278-
// does not verify that it is a bijection with the kernel's loop iteration space. A pathological pattern like
279-
// `for i in range(n): if field[i % K] > eps: <push f32>` (with `K < n`, `field` an SNode-backed `qd.field`)
280-
// captures `iter_count = K` while the main pass walks `[0, n)` and claims a heap row for every iteration whose
281-
// `i % K` cell passes - aliasing the n - K excess gated iterations onto the K-row heap and corrupting
282-
// gradients (silent on LLVM, hard "reducer count diverged" overflow on SPIR-V). The ndarray arm above DOES
283-
// validate that each axis is a `LoopIndexStmt` because at analysis time the ndarray's per-axis indices are
284-
// still individual statements; the SNode case has no such per-axis information once `LinearizeStmt` is
285-
// lowered into raw `add` / `mul` arithmetic, and any narrower walker we tried would also reject legitimate
286-
// multi-axis kernels (e.g. `for I, J, K in grid: if grid[I, J, K].mass > eps: ...`, the canonical MPM-grid
287-
// shape), where the lowered offset is `add(mul(I, sx), add(mul(J, sy), mul(K, sz)))` - the same affine shape
288-
// a malicious manual linearisation can fake. Until the analysis runs at an earlier IR stage where
289-
// `LinearizeStmt` is preserved (or a different bijection-witness is identified), this gap is documented
290-
// rather than gated. Working assumption: production kernels don't use `field[i % K]` as a gate.
277+
// Iteration-count check (statically-bounded loops only): reject when the task's loop iterates more times
278+
// than the SNode has cells. The reducer walks `desc_opt->iter_count` cells and counts gate-passing ones,
279+
// the main pass claims a heap row per gated iteration, so a mismatch under-allocates the heap and aliases
280+
// excess iterations onto a shared row. Structural signature of `for i in range(n): if field[i % K] > eps:
281+
// <push f32>` (K < n, field on a K-cell SNode): `loop_iter = n > K = snode_iter_count`. Legitimate
282+
// multi-axis kernels of the shape `for ii, jj, kk, ib in qd.ndrange(...): if grid[f, ii, jj, kk, ib] >
283+
// eps:` keep `loop_iter <= snode_iter_count` (slice axes like the kernel-arg `f` make the reducer
284+
// over-count by the slice factor, which is benign over-allocation).
285+
const bool static_bound = task_ir->const_begin && task_ir->const_end && task_ir->end_stmt == nullptr;
286+
if (static_bound) {
287+
const int64_t loop_iter = static_cast<int64_t>(task_ir->end_value) - static_cast<int64_t>(task_ir->begin_value);
288+
if (loop_iter <= 0 || static_cast<uint64_t>(loop_iter) > static_cast<uint64_t>(desc_opt->iter_count)) {
289+
return false;
290+
}
291+
}
292+
// Per-axis classification on the gate's `LinearizeStmt::inputs`. After `lower_access` the input_index is
293+
// either a `LinearizeStmt` (StructFor path preserves it) or an `add`/`mul` arithmetic tree (ndrange path
294+
// expanded form); a recursive collector recovers per-axis components from either shape. For each axis,
295+
// walk through `BinaryOpStmt` / `UnaryOpStmt` looking for a `LoopIndexStmt` to classify as iterating; pure
296+
// `ConstStmt` / `ArgLoadStmt` axes are slices. Reject when:
297+
// * `n_iterating == 0`: every axis is loop-invariant (`field[42]`, `field[arg]`, `field[other_field[i]]`).
298+
// * `n_iterating == 1 && n_bare_iterating == 0`: single non-bare iterating axis (`field[i / 2]`,
299+
// `field[i % K]`, `field[i + 5]`). Conservatively rejects bijective shifts too; the worst-case heap
300+
// stays correct, just larger.
301+
// Multi-axis cases with `n_iterating >= 2` are accepted: the canonical `qd.ndrange(*shape)` decomposes a
302+
// single linear loop index into multiple axes via floordiv / mod chains whose joint mapping is bijective.
303+
auto *lookup = getch->input_ptr ? getch->input_ptr->cast<SNodeLookupStmt>() : nullptr;
304+
if (lookup == nullptr || lookup->input_index == nullptr) {
305+
return false;
306+
}
307+
std::vector<Stmt *> axes;
308+
if (auto *lin = lookup->input_index->cast<LinearizeStmt>()) {
309+
axes.assign(lin->inputs.begin(), lin->inputs.end());
310+
} else {
311+
std::function<void(Stmt *)> collect_axes = [&](Stmt *s) {
312+
if (auto *bin = s->cast<BinaryOpStmt>()) {
313+
if (bin->op_type == BinaryOpType::add) {
314+
collect_axes(bin->lhs);
315+
collect_axes(bin->rhs);
316+
return;
317+
}
318+
if (bin->op_type == BinaryOpType::mul) {
319+
if (bin->rhs && bin->rhs->is<ConstStmt>()) {
320+
axes.push_back(bin->lhs);
321+
return;
322+
}
323+
if (bin->lhs && bin->lhs->is<ConstStmt>()) {
324+
axes.push_back(bin->rhs);
325+
return;
326+
}
327+
}
328+
}
329+
axes.push_back(s);
330+
};
331+
collect_axes(lookup->input_index);
332+
}
333+
std::function<bool(Stmt *, int)> contains_loop_index = [&](Stmt *s, int depth) -> bool {
334+
if (s == nullptr || depth > 8) {
335+
return false;
336+
}
337+
if (s->is<LoopIndexStmt>()) {
338+
return true;
339+
}
340+
if (auto *bin = s->cast<BinaryOpStmt>()) {
341+
return contains_loop_index(bin->lhs, depth + 1) || contains_loop_index(bin->rhs, depth + 1);
342+
}
343+
if (auto *un = s->cast<UnaryOpStmt>()) {
344+
return contains_loop_index(un->operand, depth + 1);
345+
}
346+
// The reverse task replays multi-axis loop indices by spilling them onto per-axis adstacks during the forward
347+
// pass and loading via `AdStackLoadTopStmt` in the reverse pass. The push (in the forward `OffloadedStmt`) and
348+
// the load (in the reverse `OffloadedStmt`) sit in different tasks, so the per-task `per_stack_pushed_values`
349+
// map cannot trace from this load back to the `LoopIndexStmt` source. Treat the load as iterating directly:
350+
// autodiff only spills runtime-varying values onto adstacks, and per-axis adstacks - the only ones reaching a
351+
// `LinearizeStmt::input` - carry replayed loop indices by construction.
352+
if (s->is<AdStackLoadTopStmt>()) {
353+
return true;
354+
}
355+
return false;
356+
};
357+
// For each iterating axis, walk back to its root source: the underlying `LoopIndexStmt` (StructFor case)
358+
// or the `AdStackAllocaStmt` whose `AdStackLoadTopStmt` replays it (autodiff reverse task). Multi-axis
359+
// gates whose iterating axes all derive from the SAME source (`field[i % 2, i % 2]`) are non-injective
360+
// even though they have multiple iterating axes; requiring `distinct_iterating_sources == n_iterating`
361+
// rejects those while admitting the canonical sparse-grid shape where each ndrange axis is replayed via
362+
// a distinct per-axis adstack. `BinaryOpStmt` / `UnaryOpStmt` operands are walked recursively to find the
363+
// leaf source.
364+
std::function<Stmt *(Stmt *, int)> root_source = [&](Stmt *s, int depth) -> Stmt * {
365+
if (s == nullptr || depth > 8)
366+
return nullptr;
367+
if (s->is<LoopIndexStmt>())
368+
return s;
369+
if (auto *load_top = s->cast<AdStackLoadTopStmt>()) {
370+
return load_top->stack;
371+
}
372+
if (auto *bin = s->cast<BinaryOpStmt>()) {
373+
if (Stmt *r = root_source(bin->lhs, depth + 1))
374+
return r;
375+
return root_source(bin->rhs, depth + 1);
376+
}
377+
if (auto *un = s->cast<UnaryOpStmt>()) {
378+
return root_source(un->operand, depth + 1);
379+
}
380+
return nullptr;
381+
};
382+
int n_iterating = 0;
383+
int n_bare_iterating = 0;
384+
std::unordered_set<Stmt *> distinct_iterating_sources;
385+
for (Stmt *axis : axes) {
386+
if (contains_loop_index(axis, 0)) {
387+
n_iterating++;
388+
if (axis->is<LoopIndexStmt>()) {
389+
n_bare_iterating++;
390+
}
391+
if (Stmt *src = root_source(axis, 0)) {
392+
distinct_iterating_sources.insert(src);
393+
}
394+
}
395+
}
396+
if (n_iterating == 0) {
397+
return false;
398+
}
399+
if (n_iterating == 1 && n_bare_iterating == 0) {
400+
return false;
401+
}
402+
if (static_cast<int>(distinct_iterating_sources.size()) < n_iterating) {
403+
return false;
404+
}
291405
out.field_source_kind = StaticAdStackBoundExpr::FieldSourceKind::SNode;
292406
out.snode_root_id = desc_opt->root_id;
293407
out.snode_byte_base_offset = desc_opt->byte_base_offset;

0 commit comments

Comments
 (0)