Describe the bug
A dynamic range() bound inside qd.graph.do_while can retain a value loaded
before the loop, even after the loop body writes a new value. The failure is
silent: the range task launches but executes zero iterations.
To Reproduce
import os
import numpy as np
import quadrants as qd
os.environ["QD_SPLIT_MAX_COST_RATIO"] = "0" # exercise whole-kernel frontend
qd.init(qd.cuda, enable_fallback=False, offline_cache=False)
@qd.kernel(graph=True)
def k(n: qd.types.ndarray(qd.i32, 0),
c: qd.types.ndarray(qd.i32, 0),
out: qd.types.ndarray(qd.i32, 0)):
for _ in range(n[()]): # initial load: n == 0
qd.atomic_add(out[()], 10)
while qd.graph.do_while(c):
for _ in range(n[()]): # iteration 2 must see n == 1
qd.atomic_add(out[()], 1)
for _ in range(1):
n[()] = 1
c[()] -= 1
n, c, out = (qd.ndarray(qd.i32, shape=()) for _ in range(3))
n.from_numpy(np.array(0, np.int32))
c.from_numpy(np.array(2, np.int32))
out.from_numpy(np.array(0, np.int32))
k(n, c, out)
print(out.to_numpy()) # actual: 0; expected: 1
The environment override forces the whole-kernel frontend path. The production
QIPC kernel reaches that path naturally through the recompute-safety fallback.
With the normal per-construct split, this small example happens not to trigger
the bug because the two loads are optimized in separate constructs.
Log/Screenshots
[Quadrants] version 1.3.1, llvm 22.1.0, commit 33919bef, win, python 3.13.9
[Quadrants] Starting on arch=cuda
0
Setting advanced_optimization=False changes the output to the expected 1.
Setting opt_level=0 does not fix it.
Root cause
The graph do-while body is flattened into a linear top-level IR and its tasks
are marked with graph_do_while_level_id. Before simplify_I, the reproducer
contains two independent GlobalLoadStmts of n: one for the pre-loop range
and one for the range in the graph loop.
BasicBlockSimplify::visit(GlobalLoadStmt) merges the second load into the
first. Its store scan only examines statements linearly between the two loads.
The write to n is later in the flattened block, so the scan does not model
the graph backedge that makes this write happen before the second load on the
next iteration.
After offload, the resulting IR has one top-level serial prelude:
offloaded
load arg n
store global_tmp(offset=0)
offloaded range_for(0, tmp(offset=0))
offloaded range_for(0, tmp(offset=0)) gdw_level=0
offloaded range_for(0, 1) gdw_level=0 # writes n = 1
The prelude runs once before the graph loop, so the loop range keeps reading
the initial zero. The optimizer needs to treat graph-region/backedge boundaries
as control-flow and memory-dependence boundaries when eliminating global loads,
or otherwise materialize a bound prelude in the consuming graph region.
Real-world impact
In QIPC's two-cube contact scene, broadphase produces 48 PT and 109 EE pairs
during the first Newton iteration. CP1/CP2 in the next Newton iteration reuse a
bound temporary initialized from zero before the Newton loop, so contact
assembly silently emits no constraints. The frame reaches 1024 Newton
iterations instead of the cgq reference's 3 iterations.
Copying the counts to fresh scalar storage immediately before CP1 avoids the
incorrect load merge and restores parity, but this is only a downstream
workaround, not a compiler fix.
Environment
- Windows 10.0.26200
- Python 3.13.9
- CUDA backend
- Quadrants 1.3.1, LLVM 22.1.0
- Commit
33919bef (hp/po-6a-cross-process)
Describe the bug
A dynamic
range()bound insideqd.graph.do_whilecan retain a value loadedbefore the loop, even after the loop body writes a new value. The failure is
silent: the range task launches but executes zero iterations.
To Reproduce
The environment override forces the whole-kernel frontend path. The production
QIPC kernel reaches that path naturally through the recompute-safety fallback.
With the normal per-construct split, this small example happens not to trigger
the bug because the two loads are optimized in separate constructs.
Log/Screenshots
Setting
advanced_optimization=Falsechanges the output to the expected1.Setting
opt_level=0does not fix it.Root cause
The graph do-while body is flattened into a linear top-level IR and its tasks
are marked with
graph_do_while_level_id. Beforesimplify_I, the reproducercontains two independent
GlobalLoadStmts ofn: one for the pre-loop rangeand one for the range in the graph loop.
BasicBlockSimplify::visit(GlobalLoadStmt)merges the second load into thefirst. Its store scan only examines statements linearly between the two loads.
The write to
nis later in the flattened block, so the scan does not modelthe graph backedge that makes this write happen before the second load on the
next iteration.
After offload, the resulting IR has one top-level serial prelude:
The prelude runs once before the graph loop, so the loop range keeps reading
the initial zero. The optimizer needs to treat graph-region/backedge boundaries
as control-flow and memory-dependence boundaries when eliminating global loads,
or otherwise materialize a bound prelude in the consuming graph region.
Real-world impact
In QIPC's two-cube contact scene, broadphase produces 48 PT and 109 EE pairs
during the first Newton iteration. CP1/CP2 in the next Newton iteration reuse a
bound temporary initialized from zero before the Newton loop, so contact
assembly silently emits no constraints. The frame reaches 1024 Newton
iterations instead of the cgq reference's 3 iterations.
Copying the counts to fresh scalar storage immediately before CP1 avoids the
incorrect load merge and restores parity, but this is only a downstream
workaround, not a compiler fix.
Environment
33919bef(hp/po-6a-cross-process)