Skip to content

Commit 3eaef5e

Browse files
committed
[Perf] Adstack max-reducer: skip recognizer on CPU; lift host-eval cap to UINT32_MAX since CPU max-reducer is serial; restrict GPU-only tests; layer doc
1 parent 5f99e0f commit 3eaef5e

4 files changed

Lines changed: 64 additions & 41 deletions

File tree

docs/source/user_guide/autodiff.md

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -465,27 +465,23 @@ Quadrants computes that worst case at launch time - in this example, the max of
465465

466466
### Evaluation paths
467467

468-
The compiler picks one of two evaluation paths to compute the maximum based on the bound expression's structure:
468+
On GPU backends the compiler picks one of two evaluation paths to compute the maximum based on the bound expression's structure; on CPU the sequential path is always taken since the runtime's CPU max-reducer is single-threaded and the parallel dispatch's per-launch setup would be pure overhead:
469469

470-
- **Parallel:** the maximum is computed with a tiny parallel reduction kernel for efficiency. The reducer accepts a common subset of bound expressions:
470+
- **Parallel:** the maximum is computed with a tiny parallel reduction kernel on the GPU for efficiency. The reducer accepts a common subset of bound expressions:
471471
- **Integer ndarray or field read** up to 32 bits wide, indexed by literal constants or outer-loop variables: `arr[i, j]`, `field[i]`.
472472
- **Shape term**: `arr.shape[k]`.
473473
- **Literal integer constant**: `42`.
474474
- **Arithmetic combinator**: any `+`, `-`, `*`, `max` of the above.
475-
- **Sequential:** the fallback path, used whenever the parallel path doesn't support the bound expression. Quadrants walks the bound expression one outer-loop iteration at a time on a single thread; the adstack is sized identically, only the upfront cost differs. This path accepts everything the parallel path does, plus:
475+
- **Sequential:** the fallback path, used whenever the parallel path doesn't support the bound expression. Quadrants walks the bound expression one outer-loop iteration at a time on a single thread (host-side on CPU, single-thread on-device kernel on GPU); the adstack is sized identically, only the upfront cost differs. This path accepts everything the parallel path does, plus:
476476
- **Arithmetic-indexed read**: `arr[i // 2]`, `arr[i % 4]`.
477477
- **Indirect / nested read**: `arr1[arr2[i]]`, `my_field[arr[i]]`.
478478

479479
### Nested loops
480480

481-
Quadrants supports arbitrarily nested loops. When the bound expression itself contains another enclosed loop whose own bound expression must be reduced first, the enclosing bound expression takes the parallel path only if every nested bound expression also fits the parallel-path grammar; otherwise it falls back to the sequential walk. This keeps the runtime from mixing parallel and sequential evaluators inside a single bound expression, which would otherwise force per-iteration kernel launches.
481+
Quadrants supports arbitrarily nested loops. When the bound expression itself contains another enclosed loop whose own bound expression must be reduced first, the enclosing bound expression takes the parallel path only if every nested bound expression is also supported by the parallel path; otherwise it falls back to the sequential walk. This keeps the runtime from mixing parallel and sequential evaluators inside a single bound expression, which would otherwise force per-iteration kernel launches.
482482

483483
### Sequential walk cap
484484

485-
The sequential walk's outer loop is artificially capped at 2^24 = 16 777 216 iterations to keep both the walk time and the read-tracking memory bounded; past that the kernel raises `RuntimeError: ... iteration count ... exceeds the 16777216 guard`.
485+
The sequential walk's outer loop is artificially capped at 2^24 = 16 777 216 iterations on GPU backends to keep both the walk time and the read-tracking memory bounded; past that the kernel raises `RuntimeError: ... iteration count ... exceeds the 16777216 guard`. In the example above, the iteration count of the enclosed loop takes the sequential path because of the `i // 2` index, so it would raise at launch on GPU backends if `arr.shape[0] > (1 << 24)`.
486486

487-
In the example above, the iteration count of the enclosed loop takes the sequential path because of the `i // 2` index. As such, it would raise at launch if `arr.shape[0] > (1 << 24)`.
488-
489-
### Workaround
490-
491-
Rewrite the bound expression to unlock the parallel path (e.g. precompute `bounds[i] = arr[i // 2]` into a persistent separate buffer, pass `bounds` in as an input, and use `for j in range(bounds[i]):`), or keep the outer loop count below 2^24.
487+
To circumvent this limitation, rewrite the bound expression to unlock the parallel path (e.g. precompute `bounds[i] = arr[i // 2]` into a persistent separate buffer, pass `bounds` in as an input, and use `for j in range(bounds[i]):`), or keep the outer loop count below 2^24.

quadrants/codegen/llvm/codegen_llvm.cpp

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,10 +1994,18 @@ void TaskCodeGenLLVM::finalize_offloaded_task_function() {
19941994
current_task->ad_stack.allocas = ad_stack_allocas_info_;
19951995
current_task->ad_stack.size_exprs = ad_stack_size_exprs_;
19961996
current_task->ad_stack.bound_expr = ad_stack_static_bound_expr_;
1997-
// recognize `MaxOverRange` nodes that the runtime can reduce in parallel via the dedicated max-reducer dispatch
1998-
// instead of letting the per-thread sizer enumerate. Indexing matches `ad_stack_size_exprs_` (same iteration order
1999-
// as the pre-scan above).
2000-
current_task->ad_stack.max_reducer_specs = recognize_adstack_max_reducer_specs(ad_stack_size_exprs_);
1997+
// Recognize `MaxOverRange` nodes the runtime can reduce in parallel via the dedicated max-reducer dispatch instead
1998+
// of letting the per-thread sizer enumerate. Indexing matches `ad_stack_size_exprs_` (same iteration order as the
1999+
// pre-scan above). Skip on CPU: `runtime_eval_adstack_max_reduce_serial` walks single-threaded just like the host
2000+
// evaluator's `MaxOverRange` loop in `program/adstack/eval.cpp`, so the dispatch's per-launch setup overhead
2001+
// (params blob encode, body bytecode encode, observation bookkeeping, JIT call) is pure cost without compute
2002+
// parallelism to offset it - measured ~28 % wallclock regression on the rigid-step CPU bench. The host evaluator
2003+
// handles every iteration count up to its own cap (raised to UINT32_MAX on CPU in `eval.cpp`) so above-cap shapes
2004+
// still resolve correctly. On CUDA / AMDGPU the parallel reducer is the whole point of the dispatch and the
2005+
// recognizer stays active.
2006+
if (!arch_is_cpu(compile_config.arch)) {
2007+
current_task->ad_stack.max_reducer_specs = recognize_adstack_max_reducer_specs(ad_stack_size_exprs_);
2008+
}
20012009
// Snodes the task body mutates. Persisted on `OffloadedTask::snode_writes` so the LLVM
20022010
// launcher can invalidate the per-task adstack metadata cache when a kernel that runs in
20032011
// between mutated a SNode an enclosing `size_expr::FieldLoad` reads. Mirrors the SPIR-V

quadrants/program/adstack/eval.cpp

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#include "quadrants/program/adstack/eval.h"
22

33
#include <algorithm>
4+
#include <climits>
5+
#include <cstdint>
46
#include <functional>
57
#include <limits>
68
#include <unordered_map>
@@ -10,6 +12,8 @@
1012
#include "quadrants/common/logging.h"
1113
#include "quadrants/ir/snode.h"
1214
#include "quadrants/ir/type.h"
15+
#include "quadrants/program/program.h"
16+
#include "quadrants/rhi/arch.h"
1317
#include "quadrants/ir/type_factory.h"
1418
#include "quadrants/ir/type_utils.h"
1519
#include "quadrants/program/launch_context_builder.h"
@@ -248,11 +252,18 @@ int64_t evaluate_node(const SerializedSizeExpr &expr,
248252
case SizeExpr::Kind::MaxOverRange: {
249253
int64_t begin = evaluate_node(expr, node.operand_a, bound_vars, prog, ctx, reads);
250254
int64_t end = evaluate_node(expr, node.operand_b, bound_vars, prog, ctx, reads);
251-
// Guard against pathological trip counts. The evaluator walks `[begin, end)` linearly and re-evaluates the
252-
// body at every i; a range of several million would stall the launch hot path for seconds. Real reverse-mode
253-
// trip counts sit well below this cap (a few hundred to a few thousand in practice); anything above is
254-
// almost certainly a pre-pass grammar bug the user should file, and a clear QD_ERROR beats a silent hang.
255-
constexpr int64_t kMaxOverRangeIterations = int64_t{1} << 24;
255+
// Guard against pathological trip counts. The evaluator walks `[begin, end)` linearly and re-evaluates the body
256+
// at every i; on Metal / Vulkan / CUDA / AMDGPU the recognizer-captured `MaxOverRange` shapes are dispatched in
257+
// parallel by the max-reducer and substituted to a `Const` before the host evaluator walks the tree, so any
258+
// shape that lands here above the `1<<24` cap is out-of-grammar and a clear QD_ERROR beats a silent hang. On
259+
// CPU the recognizer is intentionally skipped (see the matching `arch_is_cpu` gate in
260+
// `codegen/llvm/codegen_llvm.cpp::finalize_offloaded_task_function`: the CPU max-reducer walks single-threaded
261+
// just like this loop, so the dispatch's per-launch setup overhead is pure cost), and the CPU max-reducer
262+
// itself has no equivalent cap (`runtime_eval_adstack_max_reduce_serial` at
263+
// `runtime/llvm/runtime_module/adstack_runtime.cpp:622-636` iterates `total_length` unconditionally), so this
264+
// evaluator must match - lift the cap to `UINT32_MAX` on CPU so legitimate above-cap workloads still complete.
265+
const bool prog_is_cpu = (prog != nullptr) && arch_is_cpu(prog->compile_config().arch);
266+
const int64_t kMaxOverRangeIterations = prog_is_cpu ? int64_t{UINT32_MAX} : (int64_t{1} << 24);
256267
QD_ERROR_IF(end > begin && end - begin > kMaxOverRangeIterations,
257268
"SerializedSizeExpr MaxOverRange iteration count {} exceeds the {} guard; refusing to enumerate. "
258269
"Shrink the enclosing reverse-mode loop or restructure the `SizeExpr` source kernel.",

tests/python/test_adstack.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4735,21 +4735,25 @@ def compute(x: qd.types.NDArray, selector: qd.types.NDArray, out: qd.types.NDArr
47354735
"above_cap_arith_combine",
47364736
],
47374737
)
4738-
@test_utils.test(require=qd.extension.adstack, cfg_optimization=False)
4738+
@test_utils.test(arch=[qd.cuda, qd.amdgpu, qd.vulkan, qd.metal], require=qd.extension.adstack, cfg_optimization=False)
47394739
def test_max_reducer_pins_stride_for_oversized_axis(shape, body_kind):
4740-
# A reverse-mode kernel with a parallel-for over an arbitrarily large ndarray axis and an inner range-for bound to a
4741-
# recognizer-accepted trip-count expression sizes its adstack at launch time and computes the right gradient,
4742-
# without the per-task sizer's `1<<24` cap firing.
4740+
# A reverse-mode kernel with a parallel-for over an arbitrarily large ndarray axis and an inner range-for bound to
4741+
# a recognizer-accepted trip-count expression sizes its adstack at launch time and computes the right gradient,
4742+
# without the per-task sizer's `1<<24` cap firing. GPU only: the max-reducer dispatch is GPU-specific - the host
4743+
# evaluator handles equivalent shapes on CPU.
47434744
#
47444745
# Internal details: the kernel lowers to `MaxOverRange(0, a.shape[0], <body>)` in the per-stack `SizeExpr`.
47454746
# `recognize_adstack_max_reducer_specs` captures the spec; the launcher dispatches the parallel max-reducer before
4746-
# the per-task sizer walks the tree; `substitute_precomputed_max_over_range` rewrites the captured `MaxOverRange` to
4747-
# `Const`. The above-cap variants place the only non-zero cell at `arr_np[-1] = N_X` so heap-stride correctness
4747+
# the per-task sizer walks the tree; `substitute_precomputed_max_over_range` rewrites the captured `MaxOverRange`
4748+
# to `Const`. The above-cap variants place the only non-zero cell at `arr_np[-1] = N_X` so heap-stride correctness
47484749
# depends on the dispatch walking every element of the axis rather than relying on a partial host-eval walk. The
47494750
# `shape_in_body` / `field_in_body` variants additionally pin that closed leaves (`ExternalTensorShape`,
47504751
# `FieldLoad`) host-fold to `kConst` at encode time and never reach the device interpreter; `arith_combine`
47514752
# exercises every binary combinator (`Add`, `Sub`, `Mul`, `Max`) and `Const` leaf in a single body expression that
4752-
# algebraically reduces to `a[i_e]`.
4753+
# algebraically reduces to `a[i_e]`. The CPU codegen gate lives in
4754+
# `codegen/llvm/codegen_llvm.cpp::finalize_offloaded_task_function`; the lifted host-eval cap lives in
4755+
# `program/adstack/eval.cpp::evaluate_node`. On CPU `_get_max_reducer_dispatch_count` stays at 0 (no dispatch
4756+
# fires), which is why this test pins it on GPU arches only.
47534757
N_X = 4
47544758
arr_np = np.zeros(shape, dtype=np.int32)
47554759
arr_np[-1] = N_X
@@ -4813,17 +4817,18 @@ def compute(a: qd.types.ndarray(dtype=qd.i32, ndim=1)):
48134817
assert x.grad[k] == pytest.approx(2 * 0.1, rel=1e-5)
48144818

48154819

4816-
@test_utils.test(require=qd.extension.adstack, cfg_optimization=False)
4820+
@test_utils.test(arch=[qd.cuda, qd.amdgpu, qd.vulkan, qd.metal], require=qd.extension.adstack, cfg_optimization=False)
48174821
def test_max_reducer_dispatch_counts_advance_on_input_mutation():
4818-
# Pins the dispatch + cache invalidation pipeline. The first launch must fire at least one max-reducer dispatch (the
4819-
# kernel's `MaxOverRange(0, a.shape[0], a[var])` matches the recognizer grammar so the recognizer captures the spec;
4820-
# the launcher dispatches once and bumps `Program.max_reducer_dispatch_count`). A subsequent host mutation of the
4821-
# gating ndarray must bump `ndarray_data_gen` and force the next launch to re-dispatch, advancing the counter beyond
4822-
# its post-first-launch value. Steady-state cache short-circuit on an unchanged ndarray is backend-dependent (the
4823-
# CPU launcher's `set_host_accessible_ndarray_ptrs` path converts qd.ndarray reads to `kNone` semantics and
4824-
# `bump_writes_for_kernel_llvm` then bumps the gen on every read; the SPIR-V launchers preserve the qd.ndarray
4825-
# dev-alloc-type and only bump on writes), so this test asserts only the mutation-triggers-redispatch contract that
4826-
# holds uniformly.
4822+
# Pins the dispatch + cache invalidation pipeline. The first launch must fire at least one max-reducer dispatch
4823+
# (the kernel's `MaxOverRange(0, a.shape[0], a[var])` matches the recognizer grammar so the recognizer captures
4824+
# the spec; the launcher dispatches once and bumps `Program.max_reducer_dispatch_count`). A subsequent host
4825+
# mutation of the gating ndarray must bump `ndarray_data_gen` and force the next launch to re-dispatch, advancing
4826+
# the counter beyond its post-first-launch value. Steady-state cache short-circuit on an unchanged ndarray is
4827+
# backend-dependent (the CPU launcher's `set_host_accessible_ndarray_ptrs` path converts qd.ndarray reads to
4828+
# `kNone` semantics and `bump_writes_for_kernel_llvm` then bumps the gen on every read; the SPIR-V launchers
4829+
# preserve the qd.ndarray dev-alloc-type and only bump on writes), so this test asserts only the
4830+
# mutation-triggers-redispatch contract that holds uniformly. GPU only: the max-reducer dispatch is GPU-specific -
4831+
# the host evaluator handles equivalent shapes on CPU.
48274832
N = 4
48284833

48294834
x = qd.field(qd.f32, shape=(N,), needs_grad=True)
@@ -4921,13 +4926,14 @@ def compute():
49214926
"arr_bv_indexed_by_field_load",
49224927
],
49234928
)
4924-
@test_utils.test(require=qd.extension.adstack)
4929+
@test_utils.test(arch=[qd.cuda, qd.amdgpu, qd.vulkan, qd.metal], require=qd.extension.adstack)
49254930
def test_max_reducer_field_load_bound_var_dispatch(body_kind):
49264931
# A reverse-mode kernel whose inner range-for trip count reads a `qd.field` indexed by the outer chain variable
49274932
# captures via the parallel max-reducer dispatch and produces the analytical gradient. The body-shape
49284933
# parametrization exercises every supported composition: bound-var FieldLoad on its own, mixed with bound-var ETR
49294934
# via `Add` / `Max`, combined with `Const` / arithmetic, and the nested-load worst-case form (`field[field[i]]` /
4930-
# `arr[field[i]]`).
4935+
# `arr[field[i]]`). GPU only: the max-reducer dispatch is GPU-specific - the host evaluator handles equivalent
4936+
# shapes on CPU.
49314937
#
49324938
# Internal details: each variant lowers to `MaxOverRange(0, M, body)` where `body` is bound-var-indexed
49334939
# `FieldLoad(field_a, [bound_var])` or a recognizer-accepted composition that includes one. The relaxed
@@ -5028,10 +5034,11 @@ def compute(a: qd.types.ndarray(dtype=qd.i32, ndim=1)):
50285034
assert x.grad[k] == pytest.approx(expected, rel=1e-5)
50295035

50305036

5031-
@test_utils.test(require=qd.extension.adstack)
5037+
@test_utils.test(arch=[qd.cuda, qd.amdgpu, qd.vulkan, qd.metal], require=qd.extension.adstack)
50325038
def test_max_reducer_field_load_bound_var_cache_invalidates_on_snode_mutation():
50335039
# A reverse-mode kernel whose inner trip count reads a `qd.field` indexed by the outer chain variable redispatches
5034-
# the max-reducer when the gating field is mutated between launches.
5040+
# the max-reducer when the gating field is mutated between launches. GPU only: the max-reducer dispatch is
5041+
# GPU-specific - the host evaluator handles equivalent shapes on CPU.
50355042
#
50365043
# Internal details: the encoder emits a `kFieldLoad` device node and pushes a `FieldLoadObs` carrying the snode id
50375044
# and the live `snode_write_gen` snapshot. On the second launch's `try_max_reducer_cache_hit`,
@@ -5087,10 +5094,11 @@ def compute():
50875094
assert prog._get_max_reducer_dispatch_count() > pre_mutation
50885095

50895096

5090-
@test_utils.test(require=qd.extension.adstack, cfg_optimization=False)
5097+
@test_utils.test(arch=[qd.cuda, qd.amdgpu, qd.vulkan, qd.metal], require=qd.extension.adstack, cfg_optimization=False)
50915098
def test_above_cap_out_of_grammar_kernel_raises():
50925099
# A reverse-mode kernel whose inner `range(...)` trip count is bound to an out-of-grammar `MaxOverRange` body and
50935100
# whose iteration count exceeds the `1<<24` adstack-sizer cap surfaces a `QuadrantsAssertionError` at `qd.sync()`.
5101+
# GPU only: on CPU the host-eval cap is lifted to UINT32_MAX, so a shape of `(1<<24)+1` resolves without raising.
50945102
#
50955103
# Internal details: the recognizer's body grammar accepts only `Const / ExternalTensorRead / Add / Sub / Mul / Max
50965104
# / ExternalTensorShape / FieldLoad(literal-or-bound-var indices)`, and `max_reducer_body_is_recognizable` further

0 commit comments

Comments
 (0)