Skip to content

Commit fa4a713

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

3 files changed

Lines changed: 205 additions & 37 deletions

File tree

docs/source/user_guide/autodiff.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ 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+
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 applies when each loop iteration touches a different field cell (`field[i]`, `field[I, J, K]`, `grid[i, 0]`, `grid[arg, i, j]` where `arg` is a kernel argument that slices alongside iterating axes); shapes where more than one iteration may hit the same cell (`field[i % K]` with K < n, `field[i / 2]` and similar affine folds, `field[42]`, `field[arg]` with no iterating axis, `field[other_field[i]]`) automatically fall back to the `num_threads * stack_size` heap and stay correct.
321321

322322
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.
323323

@@ -354,9 +354,6 @@ A large `ndrange` combined with several loop-carried variables multiplies quickl
354354
- pass `ad_stack_size=N` to `qd.init()` with `N` large enough to cover the real push count (bypasses the sizer).
355355
- **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.
356356
- **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`.
360357

361358
## Performance characteristics
362359

quadrants/transforms/static_adstack_analysis.cpp

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -274,20 +274,112 @@ 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). Runtime-bounded loops cannot be checked
285+
// statically and fall through to the joint axis check below; the conservative reject for the unsafe
286+
// single-axis-bare runtime-bounded shape happens after `n_iterating` is known.
287+
const bool static_bound = task_ir->const_begin && task_ir->const_end && task_ir->end_stmt == nullptr;
288+
if (static_bound) {
289+
const int64_t loop_iter = static_cast<int64_t>(task_ir->end_value) - static_cast<int64_t>(task_ir->begin_value);
290+
if (loop_iter <= 0 || static_cast<uint64_t>(loop_iter) > static_cast<uint64_t>(desc_opt->iter_count)) {
291+
return false;
292+
}
293+
}
294+
// Reject when the loop-to-cell mapping is not provably injective.
295+
// * `n_iterating == 0`: every axis is loop-invariant (`field[42]`, `field[arg]`, `field[other_field[i]]`).
296+
// The gate evaluates to a launch-constant cell, so the reducer reports a launch-constant count while the
297+
// n iterations claim n rows - rows past that count alias.
298+
// * `n_iterating == 1 && n_bare_iterating == 0`: the single iterating axis goes through arithmetic on the
299+
// loop index (`field[i / 2]`, `field[i % K]`, `field[i + 5]`). Affine folds collapse iterations onto
300+
// fewer cells; bijective shifts like `i + 5` are caught here too as conservative fallback - they would
301+
// need range analysis we do not have at this IR stage to admit safely.
302+
// Multi-axis cases with `n_iterating >= 2` are accepted: the canonical sparse-grid shape decomposes a
303+
// single linear loop index into multiple axes via floordiv / mod chains whose joint mapping is bijective
304+
// when the divisors / moduli form a non-overlapping partition (the `qd.ndrange(*shape)` lowering); the cell
305+
// visited per iteration is unique. The axis walk recurses through `BinaryOpStmt` / `UnaryOpStmt` and stops
306+
// at load nodes so dynamic indices like `field[other_field[i]]` register as non-iterating.
307+
auto *lookup = getch->input_ptr ? getch->input_ptr->cast<SNodeLookupStmt>() : nullptr;
308+
if (lookup == nullptr || lookup->input_index == nullptr) {
309+
return false;
310+
}
311+
// Collect the per-axis components of `lookup->input_index`. After `lower_access`, the input_index can be
312+
// either a `LinearizeStmt` (whose `inputs` are the axes - the StructFor path preserves this) or an
313+
// `add`/`mul` arithmetic tree of the form `axis_0 * stride_0 + axis_1 * stride_1 + ...` (the ndrange path
314+
// expands the LinearizeStmt). Either shape encodes the same axis decomposition; the recursive collector
315+
// walks `add` nodes and extracts the non-const operand of each `mul(axis, stride_const)` leaf, falling
316+
// through to push any other shape as a single axis.
317+
std::vector<Stmt *> axes;
318+
if (auto *lin = lookup->input_index->cast<LinearizeStmt>()) {
319+
axes.assign(lin->inputs.begin(), lin->inputs.end());
320+
} else {
321+
std::function<void(Stmt *)> collect_axes = [&](Stmt *s) {
322+
if (auto *bin = s->cast<BinaryOpStmt>()) {
323+
if (bin->op_type == BinaryOpType::add) {
324+
collect_axes(bin->lhs);
325+
collect_axes(bin->rhs);
326+
return;
327+
}
328+
if (bin->op_type == BinaryOpType::mul) {
329+
if (bin->rhs && bin->rhs->is<ConstStmt>()) {
330+
axes.push_back(bin->lhs);
331+
return;
332+
}
333+
if (bin->lhs && bin->lhs->is<ConstStmt>()) {
334+
axes.push_back(bin->rhs);
335+
return;
336+
}
337+
}
338+
}
339+
axes.push_back(s);
340+
};
341+
collect_axes(lookup->input_index);
342+
}
343+
std::function<bool(Stmt *, int)> contains_loop_index = [&](Stmt *s, int depth) -> bool {
344+
if (s == nullptr || depth > 8) {
345+
return false;
346+
}
347+
if (s->is<LoopIndexStmt>()) {
348+
return true;
349+
}
350+
if (auto *bin = s->cast<BinaryOpStmt>()) {
351+
return contains_loop_index(bin->lhs, depth + 1) || contains_loop_index(bin->rhs, depth + 1);
352+
}
353+
if (auto *un = s->cast<UnaryOpStmt>()) {
354+
return contains_loop_index(un->operand, depth + 1);
355+
}
356+
return false;
357+
};
358+
int n_iterating = 0;
359+
int n_bare_iterating = 0;
360+
for (Stmt *axis : axes) {
361+
if (contains_loop_index(axis, 0)) {
362+
n_iterating++;
363+
if (axis->is<LoopIndexStmt>()) {
364+
n_bare_iterating++;
365+
}
366+
}
367+
}
368+
if (n_iterating == 0) {
369+
return false;
370+
}
371+
if (n_iterating == 1 && n_bare_iterating == 0) {
372+
return false;
373+
}
374+
// Runtime-bounded loops with a single iterating axis are unsafe: even when the axis is a bare `LoopIndexStmt`
375+
// (e.g. `for i in range(n_arg): if grid[i] > eps:`), the launch can dispatch `n_arg > snode_iter_count`
376+
// threads and undershoot the heap. Multi-axis runtime-bounded ndrange shapes (Genesis-style `for ii, jj, kk,
377+
// ib in qd.ndrange(*shape, batch)`) are accepted: the joint floordiv / mod decomposition from a single
378+
// linear index is structurally bijective even when the per-axis sizes are runtime values, so excess
379+
// iterations would manifest as out-of-bounds field access (a different error class) before fold aliasing.
380+
if (!static_bound && n_iterating == 1) {
381+
return false;
382+
}
291383
out.field_source_kind = StaticAdStackBoundExpr::FieldSourceKind::SNode;
292384
out.snode_root_id = desc_opt->root_id;
293385
out.snode_byte_base_offset = desc_opt->byte_base_offset;

0 commit comments

Comments
 (0)