Skip to content

Commit 087d2bf

Browse files
committed
[AutoDiff] Adstack heap: clip reducer count by static loop trip count to avoid oversize on small-loop large-snode shapes
1 parent 4e06748 commit 087d2bf

6 files changed

Lines changed: 80 additions & 8 deletions

File tree

docs/source/user_guide/autodiff.md

Lines changed: 1 addition & 1 deletion
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-
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, so a workload whose gate matches 5% of iterations pays 5% of the float-adstack cost. See [Appendix B: gate-index shapes that capture vs fall back to the worst-case heap](#appendix-b-gate-index-shapes-that-capture-vs-fall-back-to-the-worst-case-heap) for the authoritative list of supported shapes.
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. 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. So a workload whose gate matches 5% of iterations pays 5% of the float-adstack cost. See [Appendix B: gate-index shapes that capture vs fall back to the worst-case heap](#appendix-b-gate-index-shapes-that-capture-vs-fall-back-to-the-worst-case-heap) for the authoritative list of supported shapes.
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

quadrants/codegen/llvm/codegen_llvm.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,8 +1850,16 @@ std::string TaskCodeGenLLVM::init_offloaded_task_function(OffloadedStmt *stmt, s
18501850
desc.iter_count = static_cast<uint32_t>(iter_count);
18511851
return desc;
18521852
};
1853-
auto adstack_analysis =
1854-
analyze_adstack_static_bounds(stmt, snode_resolver, compile_config.ad_stack_sparse_threshold_bytes);
1853+
// CPU LLVM goes through `make_cpu_multithreaded_range_for` in `offload_to_executable`, which rewrites the user
1854+
// loop's `[begin_value, end_value)` into per-thread chunks before codegen runs. The atomic row counter the
1855+
// codegen emits is shared across every chunk of the same task, so the total claim count is the original
1856+
// pre-chunk loop trip count, not the per-chunk subrange. Signal that to the analyzer so it skips filling
1857+
// `bound_expr.loop_iter_static` on CPU and the runtime falls back to the unclipped reducer count there. CUDA
1858+
// and AMDGPU dispatch one thread per iteration without chunking, so their per-task `[begin_value, end_value)`
1859+
// matches the user loop and the analyzer can fill the field.
1860+
const bool task_range_is_original_loop = !arch_is_cpu(compile_config.arch);
1861+
auto adstack_analysis = analyze_adstack_static_bounds(
1862+
stmt, snode_resolver, compile_config.ad_stack_sparse_threshold_bytes, task_range_is_original_loop);
18551863
ad_stack_bootstrap_pushes_ = std::move(adstack_analysis.bootstrap_pushes);
18561864
ad_stack_lca_block_float_ir_ = adstack_analysis.lca_block_float;
18571865
ad_stack_static_bound_expr_ = adstack_analysis.bound_expr;

quadrants/runtime/gfx/runtime.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,22 @@ void GfxRuntime::launch_kernel(KernelHandle handle, LaunchContextBuilder &host_c
685685
auto bound_count_it = per_task_bound_count.find(i);
686686
if (bound_count_it != per_task_bound_count.end()) {
687687
effective_rows = bound_count_it->second;
688+
// Clip by the captured compile-time loop trip count. Each loop iteration claims at most one row at the
689+
// LCA-block (one `atomic_add` per gating iteration), so the heap needs at most `loop_iter_static` rows
690+
// regardless of how many cells of the gating SNode the reducer counted; without this, an oversized SNode
691+
// (1024-cell `selector` paired with a 64-iter loop) inflates the float heap to the SNode's cell count
692+
// even though the loop can only claim 64 rows. Skipped when `loop_iter_static == 0` (runtime-bounded
693+
// loops, where the analyzer cannot resolve the trip count from IR alone) - the unclipped reducer count
694+
// is already a valid upper bound there. Skipped on `dynamic_gpu_range_for` shapes that exceed the GPU
695+
// dispatch cap and serialize iterations across threads (each thread reaches the LCA-block multiple
696+
// times, so `loop_iter_static` does not bound row claims) by capping to `dispatched_threads` whenever
697+
// it is smaller, falling back cleanly to the unclipped reducer count.
698+
if (attribs.ad_stack.bound_expr.has_value() && attribs.ad_stack.bound_expr->loop_iter_static > 0) {
699+
const size_t loop_iter_static = static_cast<size_t>(attribs.ad_stack.bound_expr->loop_iter_static);
700+
if (loop_iter_static <= dispatched_threads) {
701+
effective_rows = std::min<size_t>(effective_rows, loop_iter_static);
702+
}
703+
}
688704
} else if (attribs.ad_stack.bound_expr.has_value()) {
689705
// Reaching here means the bound reducer skipped this `bound_expr`-captured task and `per_task_bound_count`
690706
// has no entry for slot `i`. The reducer's skip paths in `dispatch_adstack_bound_reducers` are: PSB

quadrants/runtime/llvm/llvm_adstack_lazy_claim.cpp

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,19 @@ void LlvmRuntimeExecutor::ensure_per_task_float_heap_post_reducer(std::size_t ta
436436

437437
// Floor at 1 row when the captured count is zero (no thread passed the gate this launch). The codegen-emitted bounds
438438
// clamp keeps `claimed_row` in [0, count-1] so threads that miss the gate never reach the LCA-block claim - the heap
439-
// row stays unused. A 1-row allocation is cheap and keeps the heap pointer non-null.
440-
const std::size_t effective_rows =
439+
// row stays unused. A 1-row allocation is cheap and keeps the heap pointer non-null. Clip by the captured
440+
// compile-time loop trip count when known: each iteration claims at most one row at the LCA-block (one `atomic_add`
441+
// per gating iteration), so the heap needs at most `loop_iter_static` rows regardless of how many cells of an
442+
// oversized gating SNode the reducer counted. The analyzer leaves `loop_iter_static == 0` for runtime-bounded loops
443+
// and for CPU LLVM tasks whose `[begin_value, end_value)` is a post-chunking subrange (the unclipped reducer count is
444+
// the right upper bound there).
445+
std::size_t effective_rows =
441446
(count == std::numeric_limits<uint32_t>::max()) ? num_threads : std::max<std::size_t>(count, 1);
447+
if (count != std::numeric_limits<uint32_t>::max() && ad_stack.bound_expr.has_value() &&
448+
ad_stack.bound_expr->loop_iter_static > 0) {
449+
effective_rows =
450+
std::min<std::size_t>(effective_rows, static_cast<std::size_t>(ad_stack.bound_expr->loop_iter_static));
451+
}
442452
// Read back the per-thread float stride (in bytes) that `publish_adstack_metadata` published into
443453
// `runtime->adstack_per_thread_stride_float`. `AdStackSizingInfo::per_thread_stride_float` from the analysis pre-pass
444454
// is in entry-count units (`2 * max_size`), not bytes, and would massively undersize the heap.

quadrants/transforms/static_adstack_analysis.cpp

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ void walk_ir(IRNode *node, Fn &&visit) {
140140

141141
StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir,
142142
const SNodeDescriptorResolver &snode_descriptor_resolver,
143-
std::size_t sparse_heap_threshold_bytes) {
143+
std::size_t sparse_heap_threshold_bytes,
144+
bool task_range_is_original_loop) {
144145
StaticAdStackAnalysisResult result;
145146
if (task_ir == nullptr || task_ir->body == nullptr) {
146147
return result;
@@ -228,6 +229,26 @@ StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir
228229
}
229230
});
230231

232+
// Compile-time loop trip count of the analyzed task, used by the runtime to clip the reducer's gate-passing-cell
233+
// count down to the actual maximum row claim count: each iteration claims at most one row at the LCA-block, so
234+
// `loop_iter` is a sound upper bound on heap rows regardless of how oversized the gating SNode / ndarray is.
235+
// Zero when:
236+
// * the task's loop is runtime-bounded (`task_ir->end_stmt != nullptr` or non-const begin / end - the analyzer
237+
// cannot resolve the trip count from IR alone), or
238+
// * `task_range_is_original_loop` is false (the caller signals that the task's `[begin_value, end_value)` is a
239+
// post-chunking subrange, e.g. CPU LLVM after `make_cpu_multithreaded_range_for` rewrote a 256-iteration
240+
// range-for into 16 parallel chunks of 16; the chunked subrange would massively undersize the heap clip
241+
// because the atomic row counter is shared across all chunks of the same task).
242+
// In both cases the runtime falls back to the unclipped reducer count.
243+
const bool task_static_bound = task_ir->const_begin && task_ir->const_end && task_ir->end_stmt == nullptr;
244+
const int64_t task_loop_iter =
245+
task_static_bound ? (static_cast<int64_t>(task_ir->end_value) - static_cast<int64_t>(task_ir->begin_value)) : 0;
246+
const uint32_t loop_iter_static_for_bound_expr =
247+
(task_range_is_original_loop && task_loop_iter > 0 &&
248+
static_cast<uint64_t>(task_loop_iter) <= std::numeric_limits<uint32_t>::max())
249+
? static_cast<uint32_t>(task_loop_iter)
250+
: 0;
251+
231252
// Resolve a `GlobalLoadStmt::src` chain to a captured field source. Returns true on a recognized shape (ndarray
232253
// ext-ptr or SNode root->dense->place(scalar)); on success populates the source-kind-specific fields of `out`.
233254
auto match_field_source = [&](Stmt *load_src, StaticAdStackBoundExpr &out) -> bool {
@@ -245,6 +266,7 @@ StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir
245266
}
246267
}
247268
out.field_source_kind = StaticAdStackBoundExpr::FieldSourceKind::NdArray;
269+
out.loop_iter_static = loop_iter_static_for_bound_expr;
248270
out.ndarray_arg_id = base_arg->arg_id;
249271
// Capture the gating ndarray's ndim so the host launcher can walk shape[0..ndim) at dispatch time and product
250272
// them into the reducer's flat-element walk bound. Without this the launcher would have to fall back to
@@ -498,6 +520,7 @@ StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir
498520
out.snode_byte_base_offset = desc_opt->byte_base_offset;
499521
out.snode_byte_cell_stride = desc_opt->byte_cell_stride;
500522
out.snode_iter_count = desc_opt->iter_count;
523+
out.loop_iter_static = loop_iter_static_for_bound_expr;
501524
return true;
502525
}
503526
return false;

quadrants/transforms/static_adstack_analysis.h

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ struct StaticAdStackBoundExpr {
9494
uint32_t snode_byte_cell_stride{0};
9595
uint32_t snode_iter_count{0};
9696

97+
// Compile-time loop trip count of the task whose pushes this gate dominates. Set when the task's
98+
// `range_for` has a constant `[begin, end)` (the same `static_bound` predicate the iteration-count check
99+
// uses). Zero means runtime-bound (analyzer cannot resolve the trip count from IR alone). The runtime
100+
// uses this as an upper bound when sizing the float adstack heap from the reducer's gate-passing-cell
101+
// count: each loop iteration claims at most one row at the LCA-block, so the heap needs at most
102+
// `loop_iter_static` rows regardless of how many cells of an oversized SNode the reducer counted.
103+
uint32_t loop_iter_static{0};
104+
97105
QD_IO_DEF(cmp_op,
98106
field_dtype_is_float,
99107
field_dtype_is_double,
@@ -107,7 +115,8 @@ struct StaticAdStackBoundExpr {
107115
snode_root_id,
108116
snode_byte_base_offset,
109117
snode_byte_cell_stride,
110-
snode_iter_count);
118+
snode_iter_count,
119+
loop_iter_static);
111120
};
112121

113122
// SNode descriptor info the analysis needs to capture an SNode-backed gate. The resolver returns `std::nullopt` when
@@ -154,8 +163,14 @@ struct StaticAdStackAnalysisResult {
154163
// addressing and the launchers skip the per-launch reducer dispatch + DtoH; see `CompileConfig::
155164
// ad_stack_sparse_threshold_bytes` for the user-facing knob (default 100 MiB; 0 forces capture, useful for tests
156165
// that pin the reducer-backed sizing path).
166+
// `task_range_is_original_loop`: true when `task_ir->{begin_value, end_value}` is the original user-loop trip
167+
// count, false when an upstream pass (e.g. `make_cpu_multithreaded_range_for` on CPU LLVM) has rewritten it
168+
// into a per-chunk subrange. The analyzer only fills `bound_expr.loop_iter_static` when this is true; on
169+
// rewritten loops the original trip count is no longer recoverable from `task_ir` and the runtime falls back to
170+
// the unclipped reducer count for that task.
157171
StaticAdStackAnalysisResult analyze_adstack_static_bounds(OffloadedStmt *task_ir,
158172
const SNodeDescriptorResolver &snode_descriptor_resolver,
159-
std::size_t sparse_heap_threshold_bytes);
173+
std::size_t sparse_heap_threshold_bytes,
174+
bool task_range_is_original_loop = true);
160175

161176
} // namespace quadrants::lang

0 commit comments

Comments
 (0)