Skip to content

graph.do_while: dynamic range bound can retain a stale pre-loop value #899

Description

@alanray-tech

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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingqipcneeded by qipc

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions