Skip to content

Commit d7bf680

Browse files
committed
[AutoDiff] Adstack heap: clip reducer count by per-task loop trip count (compile-time and SizeExpr-evaluated)
1 parent 4e06748 commit d7bf680

12 files changed

Lines changed: 205 additions & 13 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/ir/statements.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,15 @@ class OffloadedStmt : public Stmt {
13531353
std::size_t bls_size{0};
13541354
MemoryAccessOptions mem_access_opt;
13551355

1356+
// Pre-chunking loop trip-count `SizeExpr` captured by `determine_ad_stack_size`. Set on adstack-bearing
1357+
// range-for tasks before `make_cpu_multithreaded_range_for` rewrites the loop into per-thread chunks, so the
1358+
// SizeExpr still describes the original user-loop bound (handles both compile-time constants and
1359+
// runtime-bounded shapes like `for j in range(field[i])` via the same `FieldLoad` / `ExternalTensorRead` /
1360+
// `MaxOverRange` grammar `compute_bounded_adstack_size` already uses for per-thread stack sizing). Read by
1361+
// `analyze_adstack_static_bounds` at codegen time, serialised into `StaticAdStackBoundExpr::loop_iter_size_expr`,
1362+
// and evaluated at launch time as the per-task row-claim upper bound for the float-heap clip.
1363+
std::shared_ptr<SizeExpr> pre_chunk_loop_trip_count_expr;
1364+
13561365
OffloadedStmt(TaskType task_type, Arch arch, Kernel *kernel);
13571366

13581367
std::string task_name() const;

quadrants/runtime/amdgpu/kernel_launcher.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ void KernelLauncher::launch_offloaded_tasks(LaunchContextBuilder &ctx,
103103
context_pointer);
104104
// Size the float heap from the published gate-passing count (DtoH'd per task). Mirrors the CUDA / CPU
105105
// launcher post-reducer sizing.
106-
executor->ensure_per_task_float_heap_post_reducer(task_index, task.ad_stack, n_threads_amdgpu);
106+
executor->ensure_per_task_float_heap_post_reducer(task_index, task.ad_stack, n_threads_amdgpu, &ctx);
107107
}
108108
}
109109
++task_index;

quadrants/runtime/cpu/kernel_launcher.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ void KernelLauncher::launch_offloaded_tasks(LaunchContextBuilder &ctx,
7171
// allocas (in tasks with a captured `bound_expr`) address through `heap_float + row_id_var * stride_float +
7272
// float_offset`; sizing the heap at `count * stride_float` instead of the dispatched-threads worst case is
7373
// where the actual memory savings on sparse-grid workloads come from.
74-
executor->ensure_per_task_float_heap_post_reducer(i, ad_stacks[i], num_threads_per_task[i]);
74+
executor->ensure_per_task_float_heap_post_reducer(i, ad_stacks[i], num_threads_per_task[i], &ctx);
7575
}
7676
}
7777
task_funcs[i](&ctx.get_context());

quadrants/runtime/cuda/kernel_launcher.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ void KernelLauncher::launch_offloaded_tasks(LaunchContextBuilder &ctx,
129129
// Size the float heap from the published gate-passing count (DtoH'd per task). Mirrors the CPU launcher's
130130
// post-reducer sizing call - this is what shrinks the float slab to `count * stride_float` instead of the
131131
// dispatched-threads worst case on sparse-grid workloads.
132-
executor->ensure_per_task_float_heap_post_reducer(task_index, task.ad_stack, n);
132+
executor->ensure_per_task_float_heap_post_reducer(task_index, task.ad_stack, n, &ctx);
133133
}
134134
}
135135
++task_index;

quadrants/runtime/gfx/runtime.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,45 @@ 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 loop trip count. Each loop iteration claims at most one row at the LCA-block
689+
// (one `atomic_add` per gating iteration), so the heap needs at most `trip_count` rows regardless of
690+
// how many cells of the gating SNode the reducer counted; without this, an oversized SNode (1024-cell
691+
// `selector` paired with a 64-iter loop) inflates the float heap to the SNode's cell count even
692+
// though the loop can only claim 64 rows. Two trip-count sources, both gated by the
693+
// `dispatched_threads` ceiling so a `dynamic_gpu_range_for` that exceeds the SPIR-V dispatch cap and
694+
// serialises iterations across threads (each thread reaches the LCA-block multiple times) does not
695+
// accidentally undersize the heap:
696+
// * `loop_iter_static`: compile-time-known trip count (`for i in range(N)` with N a constant).
697+
// Cheap integer compare.
698+
// * `loop_iter_size_expr`: runtime-evaluated `SizeExpr` for shapes the static field cannot cover
699+
// (`for j in range(field[i])`, `for k in range(arr.shape[axis])`, two-arg ranges over either,
700+
// plus the multi-axis ndrange and bound-cast variants the SizeExpr grammar already supports).
701+
// Evaluator cost is the same per-launch host walk the per-thread `ad_stack_size` already runs;
702+
// returns -1 on shapes that aren't host-resolvable, in which case we skip the clip from this
703+
// source.
704+
if (attribs.ad_stack.bound_expr.has_value()) {
705+
const auto &bound_expr = *attribs.ad_stack.bound_expr;
706+
if (bound_expr.loop_iter_static > 0) {
707+
// Compile-time trip count: integer compare, no per-launch eval cost. Constant `SizeExpr` shapes
708+
// are already collapsed into this field by the analyzer so they short-circuit the runtime eval
709+
// below.
710+
const size_t loop_iter_static = static_cast<size_t>(bound_expr.loop_iter_static);
711+
if (loop_iter_static <= dispatched_threads) {
712+
effective_rows = std::min<size_t>(effective_rows, loop_iter_static);
713+
}
714+
} else if (!bound_expr.loop_iter_size_expr.nodes.empty() && program_impl_ != nullptr &&
715+
program_impl_->program != nullptr) {
716+
// Runtime-bounded clip: only evaluated when the static field is unset (the analyzer leaves it
717+
// 0 for shapes the compile-time path cannot cover, e.g. `for j in range(field[i])` /
718+
// `for k in range(arr.shape[axis])`). Cost = one tree walk per launch, same path the
719+
// per-thread `ad_stack_size` host-eval already runs.
720+
const int64_t evaluated =
721+
evaluate_adstack_size_expr(bound_expr.loop_iter_size_expr, program_impl_->program, &host_ctx);
722+
if (evaluated > 0 && static_cast<size_t>(evaluated) <= dispatched_threads) {
723+
effective_rows = std::min<size_t>(effective_rows, static_cast<size_t>(evaluated));
724+
}
725+
}
726+
}
688727
} else if (attribs.ad_stack.bound_expr.has_value()) {
689728
// Reaching here means the bound reducer skipped this `bound_expr`-captured task and `per_task_bound_count`
690729
// 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: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@ void LlvmRuntimeExecutor::ensure_adstack_heap_int(std::size_t needed_bytes) {
404404

405405
void LlvmRuntimeExecutor::ensure_per_task_float_heap_post_reducer(std::size_t task_index,
406406
const AdStackSizingInfo &ad_stack,
407-
std::size_t num_threads) {
407+
std::size_t num_threads,
408+
LaunchContextBuilder *ctx) {
408409
// Skip when the task has no float heap need (no f32 allocas, or analysis didn't capture a gate so we wouldn't have
409410
// routed it through the lazy float path on the codegen side).
410411
if (!ad_stack.bound_expr.has_value() || ad_stack.per_thread_stride_float == 0) {
@@ -436,9 +437,37 @@ void LlvmRuntimeExecutor::ensure_per_task_float_heap_post_reducer(std::size_t ta
436437

437438
// Floor at 1 row when the captured count is zero (no thread passed the gate this launch). The codegen-emitted bounds
438439
// 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 =
440+
// row stays unused. A 1-row allocation is cheap and keeps the heap pointer non-null. Clip by the captured
441+
// compile-time loop trip count when known: each iteration claims at most one row at the LCA-block (one `atomic_add`
442+
// per gating iteration), so the heap needs at most `loop_iter_static` rows regardless of how many cells of an
443+
// oversized gating SNode the reducer counted. The analyzer leaves `loop_iter_static == 0` for runtime-bounded loops
444+
// and for CPU LLVM tasks whose `[begin_value, end_value)` is a post-chunking subrange (the unclipped reducer count is
445+
// the right upper bound there).
446+
std::size_t effective_rows =
441447
(count == std::numeric_limits<uint32_t>::max()) ? num_threads : std::max<std::size_t>(count, 1);
448+
if (count != std::numeric_limits<uint32_t>::max() && ad_stack.bound_expr.has_value()) {
449+
if (ad_stack.bound_expr->loop_iter_static > 0) {
450+
// Compile-time trip count: integer compare, no per-launch eval cost. Constant `SizeExpr` shapes are
451+
// already collapsed into this field by the analyzer so they short-circuit the runtime eval below.
452+
effective_rows =
453+
std::min<std::size_t>(effective_rows, static_cast<std::size_t>(ad_stack.bound_expr->loop_iter_static));
454+
} else if (!ad_stack.bound_expr->loop_iter_size_expr.nodes.empty()) {
455+
// Runtime-bounded clip: evaluate the captured trip-count `SizeExpr` only when the static field is unset
456+
// (the analyzer leaves `loop_iter_static == 0` for shapes the compile-time path cannot cover, e.g.
457+
// `for j in range(field[i])` / `for k in range(arr.shape[axis])`). Cost = one tree walk per launch,
458+
// dominated by host scalar reads through `SNodeRwAccessorsBank` on `FieldLoad` / `ExternalTensorRead`
459+
// nodes (CPU: a memory load; CUDA / AMDGPU: a 4-8 byte DtoH). The evaluator returns -1 when the tree
460+
// references state that is not host-resolvable from `ctx`; in that case we leave `effective_rows`
461+
// unclipped from this source.
462+
Program *prog = (program_impl_ != nullptr) ? program_impl_->program : nullptr;
463+
if (ctx != nullptr && prog != nullptr) {
464+
const int64_t evaluated = evaluate_adstack_size_expr(ad_stack.bound_expr->loop_iter_size_expr, prog, ctx);
465+
if (evaluated > 0) {
466+
effective_rows = std::min<std::size_t>(effective_rows, static_cast<std::size_t>(evaluated));
467+
}
468+
}
469+
}
470+
}
442471
// Read back the per-thread float stride (in bytes) that `publish_adstack_metadata` published into
443472
// `runtime->adstack_per_thread_stride_float`. `AdStackSizingInfo::per_thread_stride_float` from the analysis pre-pass
444473
// is in entry-count units (`2 * max_size`), not bytes, and would massively undersize the heap.

quadrants/runtime/llvm/llvm_runtime_executor.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ class LlvmRuntimeExecutor {
170170
// is sized exactly to the reducer's count instead of the dispatched-threads worst case.
171171
void ensure_per_task_float_heap_post_reducer(std::size_t task_index,
172172
const AdStackSizingInfo &ad_stack,
173-
std::size_t num_threads);
173+
std::size_t num_threads,
174+
LaunchContextBuilder *ctx);
174175

175176
// Return (and lazily cache) the device pointer to `runtime->temporaries`, the global temporary buffer backing
176177
// `GlobalTemporaryStmt` loads and stores. GPU kernel launchers use this to read back dynamic range_for bounds (begin

quadrants/transforms/determine_ad_stack_size.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,47 @@ bool determine_ad_stack_size(IRNode *root, const CompileConfig &config) {
13931393
" or a shape the pre-pass already recognises, or extend the `SizeExpr` grammar to cover this shape.",
13941394
alloca->get_last_tb(), dump_hint);
13951395
}
1396+
// Build a per-task `SizeExpr` for each adstack-bearing parallel range-for offload's loop trip count, captured
1397+
// here BEFORE `make_cpu_multithreaded_range_for` rewrites the user loop into chunks. The same `SizeExpr`
1398+
// grammar `compute_bounded_adstack_size` uses for per-thread stack sizing applies: integer constants, scalar
1399+
// i32 / i64 field reads, ndarray shape axes, ndarray element reads at constant or loop indices, plus the
1400+
// arithmetic / max-over-range combinators. The runtime float-heap clip evaluates this expression at launch
1401+
// and uses it as an upper bound on row claims (each loop iteration claims at most one row at the LCA-block,
1402+
// so the heap needs at most `evaluate(loop_trip_count_expr)` rows regardless of how many cells of an
1403+
// oversized gating SNode the reducer counted). Skipped for non-range-for offloads (struct-for / mesh-for /
1404+
// serial / listgen / gc) because their iteration model does not fit the simple `[begin, end)` walk the
1405+
// expression captures; those tasks fall back to the unclipped reducer count.
1406+
auto offloaded_tasks = irpass::analysis::gather_statements(root, [&](Stmt *s) { return s->is<OffloadedStmt>(); });
1407+
for (Stmt *s : offloaded_tasks) {
1408+
auto *task = s->as<OffloadedStmt>();
1409+
if (task->task_type != OffloadedTaskType::range_for) {
1410+
continue;
1411+
}
1412+
bool has_adstack = false;
1413+
irpass::analysis::gather_statements(task, [&](Stmt *inner) {
1414+
if (inner->is<AdStackAllocaStmt>()) {
1415+
has_adstack = true;
1416+
}
1417+
return false;
1418+
});
1419+
if (!has_adstack) {
1420+
continue;
1421+
}
1422+
int32_t var_id_counter = 0;
1423+
auto end_e = resolve_loop_end(task, root, &var_id_counter);
1424+
if (!end_e) {
1425+
continue;
1426+
}
1427+
auto begin_e = resolve_loop_begin_lower_bound(task);
1428+
auto trip = expr_sub(std::move(end_e), std::move(begin_e));
1429+
if (!trip) {
1430+
continue;
1431+
}
1432+
if (trip->kind == SizeExpr::Kind::Const && trip->const_value < 0) {
1433+
trip = SizeExpr::make_const(0);
1434+
}
1435+
task->pre_chunk_loop_trip_count_expr = std::shared_ptr<SizeExpr>(std::move(trip));
1436+
}
13961437
return true;
13971438
}
13981439

0 commit comments

Comments
 (0)