[AutoDiff] SSA-promote AdStack count per task on LLVM backends to shrink ptxas RA cost#583
[AutoDiff] SSA-promote AdStack count per task on LLVM backends to shrink ptxas RA cost#583duburcqa wants to merge 1 commit into
Conversation
…unrolled loop of N pushes folds to a chain of integer adds with no per-push memory traffic on the count side: replace the runtime stack_init / stack_push / stack_pop / stack_top_primal / stack_top_adjoint helper-call sequence (each of which read / wrote the u64 count header in heap memory through aliasing-conservative paths LLVM AA could not fold) with inline LLVM IR that tracks the count in a per-stack alloca i64 created in the entry block, and hoist the per-stack max_sizes[stack_id] load to the entry block as well; mem2reg promotes both to SSA registers and GVN folds consecutive count++ chains across straight-line unrolled bodies, the dominant cost driver for ptxas register allocation on adstack-heavy reverse-mode kernels - new test_adstack_unrolled_many_pushes_promotes_count_to_ssa cross-checks gradients against PyTorch autograd through the new path with up to 64 pushes per stack across 1 / 3 stacks; the overflow branch keeps its relaxed-atomic adstack_overflow_flag write via a new set_adstack_overflow_flag runtime helper, and the unused 8-byte u64 header in heap memory is preserved to keep ad_stack_per_thread_stride_ slab-sizing arithmetic backwards compatible
…kends - replace runtime stack_init / push / pop / top_primal / top_adjoint helper-call sequence with inline LLVM IR using a per-stack alloca i64 in the entry block plus a hoisted max_sizes[stack_id] load; mem2reg promotes both to SSA and GVN folds consecutive count++ chains across straight-line unrolled bodies, cutting the per-push memory traffic that was bloating PTX basic-block size and dominating ptxas register-allocator cost on adstack-heavy reverse-mode kernels
…kends - replace runtime stack_init / push / pop / top_primal / top_adjoint helper-call sequence with inline LLVM IR using a per-stack alloca i64 in the entry block plus a hoisted max_sizes[stack_id] load
|
Closing - the optimization is a regression on the adstack-heavy reverse-mode workload it targeted. Cold GPU compile profile of the Genesis Ant kernel shows ptxas top-3 functions going from ~270s to ~461s (70% worse) after this change. The mistake: I optimized the count-side memory traffic (which mem2reg + GVN do fold cleanly via the alloca) but did not account for the basic-block count explosion from inline-expanding the runtime helpers. Each |
Coverage Report (
|
| File | Coverage | Missing |
|---|---|---|
🟢 tests/python/test_adstack.py |
81% | 2445-2452 |
Diff coverage: 81% · Overall: 73% · 42 lines, 8 missing
SSA-promote AdStack count per task on LLVM backends
TL;DR
alloca i64replaces the heap-resident u64 count header. The alloca is created once in the task entry block and init-stored to zero at theAdStackAllocaStmtvisit site (so an alloca nested inside a loop body restarts the count every iteration, matching the previousstack_initsemantics).LLVMRuntime.adstack_max_sizes[stack_id]is hoisted to the entry block per stack on first use. Each push otherwise re-issued the GEP+load through the runtime metadata pointer; the hoist makes the value SSA-stable for the whole task.AdStack*visit methods (Alloca,Push,Pop,LoadTop,LoadTopAdj,AccAdjoint) are reimplemented as inline LLVM IR using the alloca instead of runtime helper calls. The slot-address math (stack + sizeof(u64) + idx * 2 * element_size) is now exposed to GVN, so consecutive top-of-stack accesses fold across the runtime call boundary that used to block them.adstack_overflow_flagwrite via a newset_adstack_overflow_flag(LLVMRuntime*)runtime helper; the inlineAdStackPushStmtcodegen branches to the helper whencount + 1 > max_sizeand otherwise increments the alloca, zeros the new slot's primal+adjoint pair, and stores the pushed value into the primal half - identical observable behaviour to the previousstack_push+stack_top_primaltwo-call sequence.ad_stack_per_thread_stride_and the host-sideLlvmRuntimeExecutor::ensure_adstack_heapslab sizing are unchanged. The kernel just never reads or writes those bytes.Why
Profiles of cold GPU compile of the Genesis Ant kernel showed
ptxasaccounting for ~80% of total CUDA compile time, with a single hot function (presumably register allocation or live-range analysis) at ~161s. Two independent escape hatches (CU_JIT_OPTIMIZATION_LEVEL=2exposed in #581 andCUDA_DISABLE_PTXAS_OPT=1env var) had no measurable effect, indicating the cost is in mandatory phases (RA / liveness) that scale superlinearly with PTX basic-block size, not in optimization passes. The dominant size driver is the per-push count load / store / bounds-check sequence that the runtime helper path emits per AD variable per unrolled iteration. SSA-promoting the count collapses that sequence to a chain of integer adds, shrinking the flat block that ptxas's RA has to chew on.Surface API
No public API change.
qd.initand theCompileConfigknobs are untouched. The Python frontend, AD-stack sizing pipeline, host-side metadata publication, and SizeExpr machinery are all unaffected. The only internal shape change is to the fourAdStack*visit methods inTaskCodeGenLLVMand one new runtime helper.Mechanism
Before (one helper call per op)
GVN cannot fold consecutive
stack_pushcalls because they have function-call memory effects + the__atomic_store_noverflow path acts as a memory barrier; it cannot fold consecutivestack_top_primalcalls either because the helper reads through%stackwhich AA cannot prove disjoint from the runtime metadata. Result: each unrolled iteration emits the full sequence again.After (inline IR, per-stack alloca)
After mem2reg promotes
count_0to SSA and GVN folds theadd 1chain across N unrolled pushes, the only remaining memory ops in the unrolled body are the slot stores themselves. The count side has zero memory traffic.Heap layout (unchanged)
The 8-byte header gap is preserved so
ad_stack_per_thread_stride_(sum ofalign_up_8(size_in_bytes)per stack) and the host'sensure_adstack_heap(stride * num_threads)allocation are unchanged. Slot offsets stay atsizeof(u64) + idx * 2 * element_size. A future PR can drop the gap and shrink the slab by 8 bytes per stack per thread.Per-backend matrix
Tests
tests/python/test_adstack.pytest_adstack_unrolled_many_pushes_promotes_count_to_ssaparametrized overn_iterin{4, 16, 64}andn_stacksin{1, 3}. Each variant builds a kernel with one outerfor i in xper element + aqd.static(range(n_stacks))xqd.static(range(n_iter))straight-line unrolled body that pushesqd.sin(x[i] + j*step + s_offset)onto independent adstacks pers. Reverse-mode gradients are cross-checked against PyTorch autograd. The 64-iteration variant is well above the historicalmax_size=32floor and pins the bounds-check hoist: a regression that miscalculates the slot offset under the new codegen, or short-circuits / silently drops pushes via a wrongcount > max_sizeclamp, produces wrong gradients here long before it surfaces as an overflow at runtime.test_adstack_unary_loop_carried/test_adstack_unary_loop_carried_f64(every supported unary op x severaln_iterandx_val) and the broader AD suite (test_ad_basics,test_ad_for,test_ad_if,test_ad_atomic,test_ad_offload,test_ad_demote_dense,test_ad_grad_check,test_ad_dynamic_index) continue to pass on the LLVM CPU backend - 488 tests across the wider AD suite, 346 ontest_adstack.pyalone.Side-effect audit
mem2regrequires the alloca to be in the entry block;ensure_ad_stack_count_allocauses anInsertPointGuardto emit there regardless of where the AdStackAllocaStmt visit site sits. Same pattern asensure_ad_stack_heap_base_llvm/ensure_ad_stack_metadata_llvm.AdStackAllocaStmtis nested inside a loop body the alloca is still created once at the entry block, but the init store runs every iteration, matching the previousstack_init(stack_ptr)semantics where the heap u64 header was zeroed on each entry.runtime->adstack_overflow_flag, no increment of the count, no slot zero-init, no value store. The legacy helper additionally stored the value to slot[max(0, n-1)] (i.e. clobbered the previous top); the new path skips that store. This is a strict improvement: the old path corrupted user data on overflow, the new one leaves it intact.runtime->adstack_overflow_flagis still set soqd.sync()raises before any garbage value reaches user code.set_adstack_overflow_flaglives in the runtime module and is linked into kernel modules; the prefix matches the existingruntime_*symbols that surviveeliminate_unused_functions.ad_stack_per_thread_stride_,ad_stack_offsets_,ad_stack_allocas_info_,ad_stack_size_exprs_), no change to host-side metadata publication, no change to SizeExpr machinery.