Skip to content
Closed
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a40ed4c
Add qd.stream_parallel() context manager for implicit stream parallelism
hughperkins Mar 11, 2026
aa2fa2a
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Mar 12, 2026
be7ad92
Clear stream_parallel_group_id in ForLoopDecoratorRecorder::reset()
hughperkins Mar 12, 2026
ce83281
Reject nested stream_parallel blocks
hughperkins Mar 12, 2026
880abc7
Document stream_parallel launcher design: per-launch streams, shared …
hughperkins Mar 12, 2026
065a3b7
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Mar 12, 2026
cfc6f39
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins Apr 20, 2026
e9ce144
Apply clang-format
hughperkins Apr 20, 2026
007b050
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins Apr 24, 2026
91ca883
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Apr 28, 2026
8cd793c
[Doc] Add stream_parallel() section to streams user guide
hughperkins Apr 28, 2026
e880d07
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Apr 28, 2026
ad720bb
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Apr 28, 2026
6351215
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins Apr 28, 2026
470912f
Merge hp/streams-quadrantsic-2-amdgpu-cpu into hp/streams-quadrantsic…
hughperkins May 1, 2026
3b0ba29
Restore deleted comments, fix docstring wrapping, fix per-task adstac…
hughperkins May 1, 2026
1c62eae
Fix clang-format line break in AMDGPU kernel launcher
hughperkins May 1, 2026
e55c84f
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
216f7d5
Address Claude review: reject stream_parallel in @qd.func, use non-bl…
hughperkins May 1, 2026
49dc5af
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
d7836e3
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
74604f2
Allow docstrings in stream_parallel kernels, merge base branch updates
hughperkins May 1, 2026
b83b65d
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
0c552cd
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
212aeb9
Merge hp/streams-quadrantsic-2-amdgpu-cpu into hp/streams-quadrantsic…
hughperkins May 1, 2026
226c7c5
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
1f471b3
Fix AMDGPU stream flag comment: HIP_STREAM_NON_BLOCKING not CU_STREAM…
hughperkins May 1, 2026
6919fee
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
7b4e2a4
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
88f1bf7
Add stream_parallel_group_id to QD_STMT_DEF_FIELDS for cache key corr…
hughperkins May 1, 2026
ca560b6
Fix clang-format: multi-line QD_STMT_DEF_FIELDS for RangeForStmt and …
hughperkins May 1, 2026
158c8fb
Merge hp/streams-quadrantsic-2-amdgpu-cpu into hp/streams-quadrantsic…
hughperkins May 1, 2026
388a797
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 1, 2026
df0b03a
Fix stream_parallel identity check failing on dual-import-path builds
hughperkins May 1, 2026
acff351
Remove unused ASTResolver import from ast_transformer.py
hughperkins May 1, 2026
70eb471
Fix import sorting in ast_transformer.py
hughperkins May 1, 2026
ebd5e11
Add AST-level fallback for stream_parallel detection
hughperkins May 1, 2026
a6c3852
Add diagnostic info to stream_parallel exclusivity error message
hughperkins May 1, 2026
04e18ba
Merge hp/streams-quadrantsic-2-amdgpu-cpu: resolve streams.md conflict
hughperkins May 1, 2026
3af5bc8
Apply black formatting to function_def_transformer.py
hughperkins May 1, 2026
55b71fb
Merge hp/streams-quadrantsic-2-amdgpu-cpu: integrate adstack bound_ex…
hughperkins May 2, 2026
dbb055c
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins May 2, 2026
af4a306
Skip coverage probes in stream_parallel exclusivity check; restore de…
hughperkins May 2, 2026
c50d034
Merge remote-tracking branch 'origin/hp/streams-quadrantsic-2-amdgpu-…
hughperkins May 2, 2026
824cabf
Merge branch 'hp/streams-quadrantsic-2-amdgpu-cpu' into hp/streams-qu…
hughperkins May 2, 2026
24bc67d
Merge hp/streams-quadrantsic-2-amdgpu-cpu: integrate adstack post-red…
hughperkins May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 46 additions & 21 deletions docs/source/user_guide/streams.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Streams

Streams allow concurrent execution of GPU operations. By default, all Quadrants kernels launch on the default stream, which serializes everything. By creating explicit streams, you can run independent kernels concurrently and control synchronization with events.
Streams allow concurrent execution of GPU operations. By default, all Quadrants kernels launch on the default stream, which serializes everything. With streams, you can run multiple top-level for loops in parallel.

## Supported platforms

| Backend | Streams | Events | Notes |
|---------|---------|--------|-------|
| CUDA | Yes | Yes | Full concurrent execution |
| AMDGPU | Yes | Yes | Full concurrent execution (requires ROCm >= 5.4) |
| CPU | No-op | No-op | `qd_stream` is silently ignored, kernels run serially |
| Metal | No-op | No-op | `qd_stream` is silently ignored, kernels run serially |
| Vulkan | No-op | No-op | `qd_stream` is silently ignored, kernels run serially |
| Backend | Supported |
|---------|-----------|
| CUDA | Yes |
| AMDGPU | Yes |
| CPU | No-op |
| Metal | No-op |
| Vulkan | No-op |

On backends without native stream support, `create_stream()` and `create_event()` return objects with handle `0`. All stream/event operations become no-ops and kernels run serially. Code written with streams is portable across all backends in the sense that it will run without modifications, but serially.
On backends without native stream support, stream operations are no-ops and for loops run serially. Code using streams is portable across all backends it will run without modifications, but serially.

## Creating and using streams
## Stream parallelism

Inside a `@qd.kernel`, each `with qd.stream_parallel():` block runs on its own GPU stream.

```python
import quadrants as qd
Expand All @@ -24,17 +26,40 @@ qd.init(arch=qd.cuda)
N = 1024
a = qd.field(qd.f32, shape=(N,))
b = qd.field(qd.f32, shape=(N,))
c = qd.field(qd.f32, shape=(N,))

@qd.kernel
def fill_a():
for i in range(N):
a[i] = 1.0
def compute_ab():
with qd.stream_parallel():
for i in range(N):
a[i] = compute_a(i)
with qd.stream_parallel():
for j in range(N):
b[j] = compute_b(j)

@qd.kernel
def fill_b():
def combine():
for i in range(N):
b[i] = 2.0
c[i] = a[i] + b[i]

compute_ab() # the two stream_parallel blocks run concurrently
combine() # runs after compute_ab() returns — a[] and b[] are ready
```

Consecutive `with qd.stream_parallel():` blocks run concurrently. Multiple for loops within a single block share a stream and run serially on it. All streams are synchronized before the kernel returns.

### Restrictions

- All top-level statements in a kernel must be either all `stream_parallel` blocks or all regular statements. Mixing the two at the top level is a compile-time error.
- Nesting `stream_parallel` blocks is not supported.

## Explicit streams

For cases that require manual control — such as launching separate kernels on different streams or interoperating with PyTorch — you can create and manage streams directly.

### Creating and using streams

```python
s1 = qd.create_stream()
s2 = qd.create_stream()

Expand All @@ -50,7 +75,7 @@ s2.destroy()

Pass `qd_stream=` to any kernel call to launch it on that stream. Kernels on different streams may execute concurrently. Call `synchronize()` to block until all work on a stream completes.

## Events
### Events

Events let you express dependencies between streams without full synchronization.

Expand Down Expand Up @@ -84,7 +109,7 @@ s2.destroy()

`e.record(stream)` captures the point in `stream`'s execution. `e.wait(qd_stream=stream)` makes `stream` wait until the recorded point is reached. If `qd_stream` is omitted, the default stream waits.

## Context managers
### Context managers

Streams and events support `with` blocks for automatic cleanup:

Expand All @@ -95,11 +120,11 @@ with qd.create_stream() as s:
# s.destroy() called automatically
```

## PyTorch interop (CUDA)
### PyTorch interop (CUDA)

When mixing Quadrants kernels with PyTorch operations on CUDA, both frameworks must use the same stream to avoid race conditions. Without explicit stream management, Quadrants and PyTorch may launch work on different streams with no ordering guarantees, leading to intermittent data corruption.

### Running Quadrants kernels on PyTorch's stream
#### Running Quadrants kernels on PyTorch's stream

```python
import torch
Expand All @@ -115,7 +140,7 @@ apply_actions_kernel(qd_stream=stream)

Wrap PyTorch's raw `CUstream` pointer in a Quadrants `Stream` object. Do **not** call `destroy()` on this wrapper — PyTorch owns the underlying stream.

### Running PyTorch operations on a Quadrants stream
#### Running PyTorch operations on a Quadrants stream

```python
qd_stream = qd.create_stream()
Expand All @@ -135,4 +160,4 @@ qd_stream.destroy()

- **Not compatible with graphs.** Do not pass `qd_stream` to a kernel decorated with `graph=True`.
- **Not compatible with autograd Tape.** Do not pass `qd_stream` to a kernel inside a `qd.ad.Tape` context. The backward replay does not preserve the stream, so gradient kernels would run on the default stream with no ordering guarantee.
- **No automatic synchronization.** You are responsible for inserting events or `synchronize()` calls when one stream's output is another stream's input.
- **No automatic synchronization with explicit streams.** When using explicit streams, you are responsible for inserting events or `synchronize()` calls when one stream's output is another stream's input. `stream_parallel` handles this automatically.
34 changes: 31 additions & 3 deletions python/quadrants/lang/ast/ast_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from quadrants.lang.ast.ast_transformers.function_def_transformer import (
FunctionDefTransformer,
)
from quadrants.lang.ast.symbol_resolver import ASTResolver
from quadrants.lang.exception import (
QuadrantsIndexError,
QuadrantsRuntimeTypeError,
Expand All @@ -39,6 +40,7 @@
from quadrants.lang.field import Field
from quadrants.lang.matrix import Matrix, MatrixType
from quadrants.lang.snode import append, deactivate, length
from quadrants.lang.stream import stream_parallel
from quadrants.lang.struct import Struct, StructType
from quadrants.lang.util import (
is_from_quadrants_module as _is_from_quadrants_module,
Expand Down Expand Up @@ -119,7 +121,11 @@ def build_AnnAssign(ctx: ASTTransformerFuncContext, node: ast.AnnAssign):

@staticmethod
def build_assign_annotated(
ctx: ASTTransformerFuncContext, target: ast.Name, value, is_static_assign: bool, annotation: Type
ctx: ASTTransformerFuncContext,
target: ast.Name,
value,
is_static_assign: bool,
annotation: Type,
):
"""Build an annotated assignment like this: target: annotation = value.

Expand Down Expand Up @@ -165,7 +171,10 @@ def build_Assign(ctx: ASTTransformerFuncContext, node: ast.Assign) -> None:

@staticmethod
def build_assign_unpack(
ctx: ASTTransformerFuncContext, node_target: list | ast.Tuple, values, is_static_assign: bool
ctx: ASTTransformerFuncContext,
node_target: list | ast.Tuple,
values,
is_static_assign: bool,
):
"""Build the unpack assignments like this: (target1, target2) = (value1, value2).
The function should be called only if the node target is a tuple.
Expand Down Expand Up @@ -591,7 +600,8 @@ def build_Return(ctx: ASTTransformerFuncContext, node: ast.Return) -> None:
else:
raise QuadrantsSyntaxError("The return type is not supported now!")
ctx.ast_builder.create_kernel_exprgroup_return(
expr.make_expr_group(return_exprs), _qd_core.DebugInfo(ctx.get_pos_info(node))
expr.make_expr_group(return_exprs),
_qd_core.DebugInfo(ctx.get_pos_info(node)),
)
else:
ctx.return_data = node.value.ptr
Expand Down Expand Up @@ -1520,6 +1530,24 @@ def build_Continue(ctx: ASTTransformerFuncContext, node: ast.Continue) -> None:
ctx.ast_builder.insert_continue_stmt(_qd_core.DebugInfo(ctx.get_pos_info(node)))
return None

@staticmethod
def build_With(ctx: ASTTransformerFuncContext, node: ast.With) -> None:
if len(node.items) != 1:
raise QuadrantsSyntaxError("'with' in Quadrants kernels only supports a single context manager")
item = node.items[0]
Comment on lines +1533 to +1535

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. Not clear what items is.

if item.optional_vars is not None:
raise QuadrantsSyntaxError("'with ... as ...' is not supported in Quadrants kernels")
if not isinstance(item.context_expr, ast.Call):
raise QuadrantsSyntaxError("'with' in Quadrants kernels requires a call expression")
if not ASTResolver.resolve_to(item.context_expr.func, stream_parallel, ctx.global_vars):
raise QuadrantsSyntaxError("'with' in Quadrants kernels only supports qd.stream_parallel()")
Comment on lines +1538 to +1541

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 qd.stream_parallel() context manager accepts but silently drops user-supplied args/kwargs. with qd.stream_parallel(123, num_streams=4): compiles and runs the same as the no-arg form because build_With (ast_transformer.py:1540-1543) checks the call resolves to stream_parallel but never inspects item.context_expr.args / .keywords, and the runtime contextmanager is never invoked (so the TypeError its zero-parameter signature would raise never fires). Fix: after the resolve_to check, raise QuadrantsSyntaxError if item.context_expr.args or item.context_expr.keywords is non-empty — a one-line addition mirroring the other validation checks already in build_With.

Extended reasoning...

What the bug is

build_With at python/quadrants/lang/ast/ast_transformer.py:1533-1547 performs five structural checks on a with statement: single context manager, no as binding, the context expression must be an ast.Call, the call must resolve to qd.stream_parallel, and the enclosing function must be a kernel. After those pass it dispatches begin_stream_parallel() / end_stream_parallel() on the C++ ASTBuilder. It never inspects item.context_expr.args or item.context_expr.keywords.

Meanwhile stream_parallel in python/quadrants/lang/stream.py:131-138 is declared as a zero-parameter @contextmanager:

@contextmanager
def stream_parallel():
    yield

So the only legal call shape is qd.stream_parallel(). Anywhere else in Python, qd.stream_parallel(123, num_streams=4) would raise TypeError: stream_parallel() takes 0 positional arguments but 1 was given at runtime — but inside a kernel the AST transformer intercepts the call at compile time, replaces it with begin_stream_parallel() / end_stream_parallel() IR calls, and the contextmanager body never executes. The arguments are simply never evaluated.

Step-by-step proof

@qd.kernel
def k():
    with qd.stream_parallel(123, num_streams=4):   # silently accepted
        for i in range(N):
            a[i] = 1.0
    with qd.stream_parallel():                     # also accepted
        for j in range(N):
            b[j] = 2.0
  1. build_FunctionDef runs _validate_stream_parallel_exclusivity. _is_stream_parallel_with returns True for both with statements (it only checks single context manager, that context_expr is an ast.Call, and that the func resolves to stream_parallel — args/keywords are not inspected). Validation passes.
  2. build_stmts walks each ast.With. build_With checks len(node.items) == 1 ✓, optional_vars is None ✓, isinstance(item.context_expr, ast.Call) ✓, ASTResolver.resolve_to(item.context_expr.func, stream_parallel, ...) ✓, ctx.is_kernel ✓. It then calls ctx.ast_builder.begin_stream_parallel(), recurses into the body, and calls ctx.ast_builder.end_stream_parallel().
  3. item.context_expr.args == [ast.Constant(123)] and item.context_expr.keywords == [ast.keyword(arg='num_streams', value=ast.Constant(4))] are present in the AST but never read.
  4. The kernel compiles cleanly and runs identically to the no-arg form. The user gets stream-parallel execution as if they had written qd.stream_parallel()num_streams=4 did nothing.

Why nothing else catches it

The contextmanager body never executes at Python runtime (the AST transformer rewrites the call before Func.__call__ would otherwise dispatch into Python land), so the natural TypeError Python would raise for too-many-arguments never fires. _validate_stream_parallel_exclusivity shares the _is_stream_parallel_with helper with build_With and inherits the same blind spot. There is no test in tests/python/test_streams.py that exercises a misshapen stream_parallel(...) call.

Impact

User-facing UX papercut. A user who reasonably guesses at an API like num_streams=N, priority=..., or a positional stream-count argument gets no compile-time error and no runtime error — their argument is silently a no-op. They may well believe their argument is in effect and chase a phantom bug elsewhere. No silent miscompilation, no incorrect runtime behavior, no perf regression — the kernel still does what stream_parallel() with no args would do.

Fix

One-line addition in build_With after the resolve_to check, consistent with the surrounding validation style:

if item.context_expr.args or item.context_expr.keywords:
    raise QuadrantsSyntaxError("qd.stream_parallel() takes no arguments")

Three independent verifiers reviewed this and confirmed it is correct, real, and a real (if minor) UX gap; all three suggested nit severity given there is no correctness or performance impact.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still applies — build_With (ast_transformer.py:1533-1549) still does not inspect item.context_expr.args or item.context_expr.keywords, so with qd.stream_parallel(123, num_streams=4): continues to compile and run identically to the no-arg form. One-line fix after the resolve_to check, mirroring the surrounding validation style: if item.context_expr.args or item.context_expr.keywords: raise QuadrantsSyntaxError("qd.stream_parallel() takes no arguments").

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still applies — build_With (ast_transformer.py:1531-1547) still does not inspect item.context_expr.args or item.context_expr.keywords. with qd.stream_parallel(123, num_streams=4): continues to compile and run identically to the no-arg form. One-line fix after the _is_stream_parallel_with check, mirroring the surrounding validation style: if item.context_expr.args or item.context_expr.keywords: raise QuadrantsSyntaxError("qd.stream_parallel() takes no arguments").

if not ctx.is_kernel:
raise QuadrantsSyntaxError("qd.stream_parallel() can only be used inside @qd.kernel, not @qd.func")
ctx.ast_builder.begin_stream_parallel()
build_stmts(ctx, node.body)
Comment on lines +1531 to +1545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 with qd.stream_parallel(): placed inside an if/while/for body inside a kernel compiles cleanly and silently runs serially on the default stream — _validate_stream_parallel_exclusivity only walks top-level node.body and never recurses into nested container statements, while build_With accepts the with-block at any nesting level. The for-loops inside become part of an enclosing IfStmt/WhileStmt/RangeForStmt that the offloader bundles into a serial OffloadedStmt with stream_parallel_group_id=0, so the user gets no concurrency and no error. Fix: either have build_With reject non-root nesting, or extend _validate_stream_parallel_exclusivity to recursively reject any nested stream_parallel it finds — both are one-helper changes consistent with the validators stated 'all top-level statements' contract.

Extended reasoning...

What the bug is

FunctionDefTransformer._validate_stream_parallel_exclusivity (function_def_transformer.py:467-477) iterates only node.body (the top-level kernel statements). _is_stream_parallel_with matches isinstance(stmt, ast.With) only — ast.If / ast.For / ast.While / ast.With (other than the stream_parallel one itself) all return False and are not descended into. Meanwhile build_With (ast_transformer.py:1533-1547) only checks (a) single context manager, (b) no as, (c) call expression, (d) resolves to stream_parallel, (e) ctx.is_kernel. There is no check that the with-block sits at the kernel root.

Result: a with qd.stream_parallel(): placed inside any non-root container body is silently accepted, stamps non-zero stream_parallel_group_id on its inner for-loops, and then loses all concurrency at offload time.

Step-by-step proof

@qd.kernel
def k(cond: qd.i32):
    if cond:
        with qd.stream_parallel():
            for i in range(N):
                a[i] = 1.0
            for j in range(N):
                b[j] = 2.0
  1. build_FunctionDef runs _validate_stream_parallel_exclusivity(node.body, ...). node.body = [ast.If]. _is_stream_parallel_with(ast.If) returns False. has_sp = False → validation returns OK.
  2. build_If opens begin_frontend_if, then build_stmts walks the if-body.
  3. The if-body contains an ast.With. build_With runs unconditionally:
    • begin_stream_parallel()current_stream_parallel_group_id_ = 1
    • build_stmts builds the two for-loops; begin_frontend_range_for (frontend_ir.cpp:1395) writes for_loop_dec_.config.stream_parallel_group_id = 1 onto the FrontendForStmt.
    • end_stream_parallel() resets to 0.
  4. Lowered IR: kernel root contains a single IfStmt; inside its true_statements are two RangeForStmts carrying stream_parallel_group_id=1 (lower_ast.cpp:294 propagates).
  5. Offloader::run (offload.cpp:90-158) walks root-block statements. The IfStmt fails the RangeForStmt / StructForStmt / MeshForStmt casts at lines 93/162/186, falls into the else at line 156, and the entire IfStmt (with its embedded RangeForStmts) is moved into pending_serial_statements — a serial OffloadedStmt with stream_parallel_group_id=0 (default-init at statements.h:1357).
  6. Codegen at codegen_cuda.cpp:641 / codegen_amdgpu.cpp:354 copies stmt->stream_parallel_group_id (= 0) onto current_task->stream_parallel_group_id.
  7. Launcher at runtime/cuda/kernel_launcher.cpp:60 sees task.stream_parallel_group_id == 0 and takes the default-stream branch.

The two for-loops execute serially on the default stream — exactly the behavior stream_parallel was used to avoid. No error, no warning.

Same trigger on other containers

Identical silent-serialization manifests for any non-root container:

  • while cond: with qd.stream_parallel(): for i in range(N): ... — the WhileStmt is the top-level statement and gets bundled serial.
  • for i in range(N): with qd.stream_parallel(): for j in range(M): ... — outer RangeForStmt is at root; the inner stream_parallel for-loops are part of the outer's body and never become their own offloaded tasks.

Why nothing else catches it

  • _validate_stream_parallel_exclusivity only walks node.body (top-level). It never recurses into ast.If/ast.For/ast.While/ast.With bodies.
  • build_With checks the call site is stream_parallel and ctx.is_kernel, but not the structural location.
  • begin_stream_parallel only rejects the nested-stream_parallel-within-stream_parallel case (frontend_ir.h:1027-1029).

The validator's stated contract ('all top-level statements must be stream_parallel blocks if any are') was designed to catch this class of misuse, but its implementation only enforces the rule at depth 0.

Severity rationale

Marking normal. The validator was specifically introduced in this PR to give clear compile-time errors for misuse patterns that would otherwise be silently confusing. A new user who reasonably writes if use_parallel: with qd.stream_parallel(): ... or for outer in range(M): with qd.stream_parallel(): ... (a natural attempt at conditional parallelism) gets no error and no parallelism — the exact UX failure mode the validator was supposed to prevent. One could note that for-loops nested in non-static control flow would never be parallel offloaded tasks anyway, so the user's underlying intent is doomed regardless; but the validator's job is to catch the misuse with a clear error, and it doesn't.

Fix

Two reasonable options, both one helper function:

(a) Have build_With raise QuadrantsSyntaxError when called from anything other than the kernel-root build_stmts invocation. Could be tracked with a depth counter on the AST context, or by passing a 'top-level' flag from FunctionDefTransformer.build_FunctionDef.

(b) Strengthen _validate_stream_parallel_exclusivity to recursively walk container statements (ast.If / ast.For / ast.While / ast.With bodies) and reject any non-root stream_parallel it finds.

Either brings the runtime behavior into line with the validator's documented contract.

ctx.ast_builder.end_stream_parallel()
Comment on lines +1544 to +1546

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Non-for statements (e.g. a[0] = 1.0, qd.deactivate(snode, [k]), counter[None] = 0) placed directly inside with qd.stream_parallel(): silently race with sibling for-loops. The non-for statement is bundled into a serial OffloadedStmt with default stream_parallel_group_id=0 and dispatched on active_stream, while the sibling for-loop runs on a fresh CU_STREAM_NON_BLOCKING per-group stream that has no event handoff back to the active stream — so the for-loop can begin reading what the assignment is supposed to write before the assignment retires. Fix: in build_With, walk node.body and reject anything that is not a for-loop (mirroring the existing kernel-body validator), or stamp the active group_id onto every emitted root_block statement while inside a stream_parallel block.

Extended reasoning...

Bug\n\nbuild_With (python/quadrants/lang/ast/ast_transformer.py:1546-1548) does not introspect node.body and does not open a new IR scope — begin_stream_parallel only flips a counter on the ASTBuilder. So statements inside the with body are inserted directly into the kernel root_block. The frontend group-id stamping the PR added lives only in the four begin_frontend_*_for methods (frontend_ir.cpp:1395/1409/1423/1439), which means non-for statements (FrontendAssignStmt, FrontendSNodeOpStmt, etc.) never carry a stream_parallel_group_id — that field does not exist on those statement types.\n\nIn Offloader::run (quadrants/transforms/offload.cpp:90-158), only RangeForStmt / StructForStmt / MeshForStmt become standalone OffloadedStmts; everything else falls into the else at line 155-157 and is moved into pending_serial_statements — a serial OffloadedStmt constructed with default-initialized stream_parallel_group_id=0 (statements.h:1370). Sibling RangeForStmt at line 129 propagates group_id correctly. Codegen at codegen_cuda.cpp:641 / codegen_amdgpu.cpp:354 copies that 0 onto the OffloadedTask, and the launcher at runtime/cuda/kernel_launcher.cpp:55-95 (AMDGPU twin) takes the default-stream branch for group_id=0 and the per-group stream branch for the for-loop.\n\nThe two streams are independent: the per-group stream is created with CU_STREAM_NON_BLOCKING (line 80), which does NOT implicit-sync with the legacy NULL stream nor with arbitrary user-created streams. The launcher records no event from active_stream and inserts no stream_wait_event on the new stream — so the for-loop on s_K can begin executing before the serial assignment on active_stream finishes.\n\n## Distinct from existing PR-timeline bugs\n\n- Bug #11 (for-with-break): trigger requires a break inside the for-loop, which causes lower_ast to emit AllocaStmt+WhileStmt at root_block. No assignment-style trigger.\n- Bug #6 (strictly_serialized): trigger is a top-level RangeForStmt with strictly_serialized=true that fails the !s->strictly_serialized predicate at offload.cpp:93. The cast succeeds; the predicate fails. Different upstream path.\n- Bug #13 (non-static if/while wrapping for-loop): trigger is an IfStmt/WhileStmt at root_block whose body contains a for-loop. The cast at line 93 fails because of TYPE (IfStmt/WhileStmt), and the BUNDLE drops the inner for's group_id. Bug 13's proposed fix is to recursively scan the bundle for an inner for-loop and propagate that for-loop's group_id onto the bundle. That fix does not help here: the offending statement is itself the bundle entry (Assignment, SNodeOp, etc.) — there IS NO inner for-loop to read group_id from.\n- Bug #16 (qd.deactivate gc tasks): trigger is qd.deactivate INSIDE a for-loop, producing gc auxiliary tasks via insert_gc. This bug is qd.deactivate (or any non-for statement) at the with-body level, NOT inside a for-loop.\n\nThe shared root cause across these bugs is that pending_serial_statements always defaults to stream_parallel_group_id=0, but the user-reachable trigger here (plain non-for statement directly in the with-body) is not covered by any of those bugs' fix proposals.\n\n## Step-by-step proof\n\npython\n@qd.kernel\ndef k():\n with qd.stream_parallel():\n a[0] = 1.0 # FrontendAssignmentStmt at root_block, NO group_id\n for i in range(N):\n b[i] = a[0] * 2 # range_for, group_id=1, reads a[0]\n\n\n1. _validate_stream_parallel_exclusivity (function_def_transformer.py:472) walks node.body == [ast.With] — single with qd.stream_parallel():, all top-level entries match. Validation passes.\n2. build_With (ast_transformer.py:1533-1548) calls begin_stream_parallel() (counter→1), then build_stmts(ctx, node.body) which walks [ast.Assign, ast.For] at the SAME scope as kernel root. build_Assign emits a FrontendAssignmentStmt directly into root_block — no group_id stamping. build_For reaches begin_frontend_range_for which DOES stamp stream_parallel_group_id=1 onto the FrontendForStmt.\n3. After lowering: root_block = [FrontendAssignment, RangeForStmt(group_id=1)]. The FrontendAssignment has no stream_parallel_group_id field at all.\n4. Offloader::run iterates root_block. The FrontendAssignment fails every for-loop cast → falls into the else at offload.cpp:155-157 → moved into pending_serial_statements. RangeForStmt hits offload.cpp:93assemble_serial_statements flushes the serial OffloadedStmt (group_id=0) into root_block, then constructs a fresh range_for OffloadedStmt with group_id=1.\n5. Final OffloadedTask list: [serial(group=0, [Assignment]), range_for(group=1, [for-body])].\n6. Launcher walk:\n - i=0, group=0 → default-stream branch, launches serial on active_stream (async).\n - i=1, group=1 → enters else branch. Creates s_1 with CU_STREAM_NON_BLOCKING (line 80). Sets stream to s_1, launches range_for on s_1, syncs, destroys.\n7. Race: s_1 has no implicit dependency on active_stream (NON_BLOCKING semantics), and the launcher inserts no event handoff. The range_for on s_1 can begin reading a[0] before the serial task on active_stream finishes writing 1.0.\n\n## Reachable user patterns\n\n- a[0] = some_value before a for-loop (initialize accumulator, then iterate)\n- counter[None] = 0 (global atomic-store followed by parallel reduction)\n- qd.deactivate(snode, [k]) (plain snode op without a wrapping if/while)\n- qd.append(snode, [...], val)\n- A function call like qd.sync() or any custom @qd.func that performs a global store\n\nIn each case the user is inside with qd.stream_parallel(): and reasonably expects safe sequencing. The validator's contract ('all top-level statements... must be stream_parallel blocks') is enforced at the kernel-body level but not at the with-body level. tests/python/test_streams.py only exercises with-bodies that contain only for-loops, so this regression is untested.\n\n## Fix\n\n**(a) Conservative, one-liner**: in build_With, after the stream_parallel call resolves, walk node.body and reject anything that is not an ast.For. Symmetric with the kernel-body validator already introduced in this PR; the docs already imply only for-loops are intended inside with qd.stream_parallel():.\n\n**(b) Structural**: when emitting any root_block statement while current_stream_parallel_group_id_ != 0, propagate the group_id onto a new field on the statement (or split bundles in the offloader by surrounding group_id). More invasive, but preserves the legality of mixing assignments and for-loops in a single with block.

return None

@staticmethod
def build_Pass(ctx: ASTTransformerFuncContext, node: ast.Pass) -> None:
return None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
from quadrants.lang.ast.ast_transformer_utils import (
ASTTransformerFuncContext,
)
from quadrants.lang.ast.symbol_resolver import ASTResolver
from quadrants.lang.buffer_view import BufferView
from quadrants.lang.exception import (
QuadrantsSyntaxError,
)
from quadrants.lang.matrix import MatrixType
from quadrants.lang.stream import stream_parallel
from quadrants.lang.struct import StructType
from quadrants.lang.util import to_quadrants_type
from quadrants.types import annotations, buffer_view_type, ndarray_type, primitive_types
Expand Down Expand Up @@ -443,7 +445,39 @@
else:
FunctionDefTransformer._transform_as_func(ctx, node, args)

if ctx.is_kernel:
FunctionDefTransformer._validate_stream_parallel_exclusivity(node.body, ctx.global_vars)
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

with ctx.variable_scope_guard():
build_stmts(ctx, node.body)

return None

@staticmethod
def _is_stream_parallel_with(stmt: ast.stmt, global_vars: dict[str, Any]) -> bool:
if not isinstance(stmt, ast.With):
return False
if len(stmt.items) != 1:
return False
item = stmt.items[0]
Comment on lines +511 to +513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is items ? Could you document here or somewhere else why the length can be 1 or more, and what does it means in this context?

if not isinstance(item.context_expr, ast.Call):
return False
return ASTResolver.resolve_to(item.context_expr.func, stream_parallel, global_vars)

@staticmethod
def _is_docstring(stmt: ast.stmt, index: int) -> bool:
return index == 0 and isinstance(stmt, ast.Expr) and isinstance(stmt.value, (ast.Constant, ast.Str))

@staticmethod
def _validate_stream_parallel_exclusivity(body: list[ast.stmt], global_vars: dict[str, Any]) -> None:
if not any(FunctionDefTransformer._is_stream_parallel_with(s, global_vars) for s in body):
return
for i, stmt in enumerate(body):
if FunctionDefTransformer._is_docstring(stmt, i):
continue
if not FunctionDefTransformer._is_stream_parallel_with(stmt, global_vars):
raise QuadrantsSyntaxError(
"When using qd.stream_parallel(), all top-level statements "
"in the kernel must be 'with qd.stream_parallel():' blocks. "
"Move non-parallel code to a separate kernel."
)

Check warning on line 483 in python/quadrants/lang/ast/ast_transformers/function_def_transformer.py

View check run for this annotation

Claude / Claude Code Review

Top-level validator rejects qd.static_assert / qd.static_print / ast.Pass / assert with misleading error

The new _validate_stream_parallel_exclusivity check (function_def_transformer.py:467-477) only carves out docstrings (index==0 ast.Expr(Constant)), but ast.Pass and `qd.static_assert(...)` / `qd.static_print(...)` at the kernel top-level — both compile-time directives that emit no IR — also trip the validator with the misleading "Move non-parallel code to a separate kernel" error. A kernel that writes `qd.static_assert(N > 0)` (the idiomatic pattern shown in tests/python/test_assert.py:138/150)
Comment on lines +572 to +576

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't understand why you are moving to the next line before you have to. This is weird to me. But I don't care much.

Comment thread
claude[bot] marked this conversation as resolved.
Comment on lines +530 to +576

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new _validate_stream_parallel_exclusivity check (function_def_transformer.py:467-477) only carves out docstrings (index==0 ast.Expr(Constant)), but ast.Pass and qd.static_assert(...) / qd.static_print(...) at the kernel top-level — both compile-time directives that emit no IR — also trip the validator with the misleading "Move non-parallel code to a separate kernel" error. A kernel that writes qd.static_assert(N > 0) (the idiomatic pattern shown in tests/python/test_assert.py:138/150) followed by with qd.stream_parallel(): blocks fails compilation; the workaround is to delete or relocate the directive. Fix is a one-helper extension that also skips ast.Pass and ast.Expr(Call) whose call resolves to known compile-time directives.

Extended reasoning...

What the bug is

_validate_stream_parallel_exclusivity (function_def_transformer.py:467-477) iterates node.body and raises QuadrantsSyntaxError("...all top-level statements must be with qd.stream_parallel(): blocks. Move non-parallel code to a separate kernel.") for any statement that is neither _is_stream_parallel_with nor _is_docstring. The new _is_docstring carve-out only matches index == 0 and isinstance(stmt, ast.Expr) and isinstance(stmt.value, (ast.Constant, ast.Str)) — i.e. PEP 257 docstrings at body[0]. Three other harmless top-level constructs are not handled:

  1. ast.Passpass placeholder, lowered by build_Pass (ast_transformer.py) to a no-op (return None). Emits no IR.
  2. qd.static_assert(...) at top level — pure-Python compile-time check (impl.py:615-638), uses Python assert against a static value. Emits no IR. Idiomatic kernel-top directive, exercised at tests/python/test_assert.py:138, 150, 161, 171 and tests/python/test_lexical_scope.py:13, 17. Parses to ast.Expr(value=ast.Call(func=Attribute(...static_assert)))ast.Call, not ast.Constant, so _is_docstring returns False even at index 0.
  3. qd.static_print(...) at top level — same shape, same compile-time-only semantics, same incorrect rejection.

Note: the original synthesis also lists Python assert as a harmless construct, but build_Assert (ast_transformer.py:1475) does emit real runtime-checked IR that becomes part of the offloaded task graph. Rejecting it at top level is correct (it would race with sibling stream_parallel for-loops the same way any other store would). I am narrowing the bug to ast.Pass + qd.static_assert + qd.static_print and leaving assert out.

How the failure manifests

@qd.kernel
def k():
    qd.static_assert(N > 0)              # ast.Expr(Call(static_assert)) — no IR
    with qd.stream_parallel():
        for i in range(N): a[i] = 1.0
    with qd.stream_parallel():
        for j in range(N): b[j] = 2.0

Step-by-step trace:

  1. build_FunctionDef calls _validate_stream_parallel_exclusivity(node.body, ctx.global_vars).
  2. The body has [ast.Expr(Call(static_assert)), ast.With, ast.With]. _is_stream_parallel_with returns True for the two ast.With nodes → has_sp = True.
  3. The walk iterates body. At i=0, stmt=ast.Expr(Call): _is_docstring(stmt, 0) checks isinstance(stmt.value, (ast.Constant, ast.Str))stmt.value is ast.Call, returns False. _is_stream_parallel_with(stmt) returns False (not ast.With). Validator raises QuadrantsSyntaxError("...Move non-parallel code to a separate kernel.").

The user sees an error telling them to "move non-parallel code", but there is no non-parallel runtime code to move — qd.static_assert only emits a Python-level assertion at kernel compile time. The workaround is to delete the invariant check or to relocate it inside one of the with bodies (where it still runs at compile time and so still works), but neither preserves the kernel-wide intent. Same story for pass and static_print.

Why no existing safeguard catches it

  • _is_docstring only matches index 0 + ast.Expr + ast.Constant/ast.Strast.Call is neither.
  • _is_stream_parallel_with only matches ast.With.
  • The frontend group-id stamping is irrelevant here; the rejection happens during the AST walk before any IR is emitted.

Impact and severity

User-facing impact is a confusing compile-time error. No silent miscompilation, no perf regression, no data race. Workaround is trivial (delete or relocate the directive). All four verifiers independently confirmed at nit severity:

  • Verifier 1: "narrow papercut, not a correctness issue, low frequency of occurrence."
  • Verifier 2: explicitly excluded assert from the carve-out (see narrowing above).
  • Verifier 3: "Worth folding into the existing carve-out as a one-helper change."
  • Verifier 4: "no correctness/perf impact and the workaround is trivial; the papercut is most acute for users following defensive-programming patterns or PEP 257-style annotations."

Fix

Extend the existing _is_docstring carve-out in function_def_transformer.py:

@staticmethod
def _is_skippable(stmt: ast.stmt, index: int, global_vars: dict[str, Any]) -> bool:
    if FunctionDefTransformer._is_docstring(stmt, index):
        return True
    if isinstance(stmt, ast.Pass):
        return True
    if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
        for target in (static_assert, static_print):
            if ASTResolver.resolve_to(stmt.value.func, target, global_vars):
                return True
    return False

Then call _is_skippable in place of _is_docstring inside _validate_stream_parallel_exclusivity. The principled alternative noted in the original Bug #9 — only reject statements that actually emit offloaded tasks — would catch this class of constructs (and any future no-op directive) for free, but it is a larger refactor.

13 changes: 12 additions & 1 deletion python/quadrants/lang/stream.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import weakref
from contextlib import contextmanager

from quadrants.lang import impl

Expand Down Expand Up @@ -127,4 +128,14 @@ def create_event() -> Event:
return Event(handle, _get_prog_weakref())


__all__ = ["Stream", "Event", "create_stream", "create_event"]
@contextmanager
def stream_parallel():
"""Run top-level for loops in this block on separate GPU streams.

Used inside @qd.kernel. At Python runtime (outside kernels), this is a no-op. During kernel compilation, the AST
transformer calls into the C++ ASTBuilder to tag loops with a stream-parallel group ID.
"""
Comment on lines +178 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The docstring on stream_parallel says 'Run top-level for loops in this block on separate GPU streams' (plural), but per the same PR's user_guide/streams.md ('Multiple for loops within a single block share a stream and run serially on it') and the actual implementation, all for-loops within ONE with qd.stream_parallel(): block share ONE stream — it is consecutive blocks that get separate streams. Suggest clarifying to e.g. 'Run this block on its own GPU stream (separate from sibling stream_parallel blocks). Multiple for-loops inside one block share that stream and execute serially on it.'

Extended reasoning...

What the docstring says vs. what the code does

python/quadrants/lang/stream.py:131-138:

@contextmanager
def stream_parallel():
    """Run top-level for loops in this block on separate GPU streams.

    Used inside @qd.kernel. ...
    """
    yield

The plural 'separate GPU streams' for the for-loops within a single block reads naturally as 'each for-loop here gets its own stream'. That contradicts the actual semantics established by this same PR.

Why it is wrong

ASTBuilder::begin_stream_parallel (quadrants/ir/frontend_ir.h:1027-1029) increments stream_parallel_group_counter_ once per with-block and assigns the new value to current_stream_parallel_group_id_:

void begin_stream_parallel() {
  QD_ERROR_IF(current_stream_parallel_group_id_ != 0, ...);
  current_stream_parallel_group_id_ = ++stream_parallel_group_counter_;
}

Every for-loop inside the with-body then reads that single value. begin_frontend_range_for at frontend_ir.cpp:1395 stamps it onto for_loop_dec_.config, and the same pattern fires in begin_frontend_struct_for_on_snode (1409), begin_frontend_struct_for_on_external_tensor (1423), and begin_frontend_mesh_for (1439). So all for-loops in one block carry the same group id.

The launcher at quadrants/runtime/cuda/kernel_launcher.cpp:75-83 (and the byte-identical AMDGPU twin) creates one stream per unique group id — stream_by_id is keyed by stream_parallel_group_id, so a single block always maps to a single stream.

Why I'm certain — corroborated by the PR's own docs

The user-guide rewrite in this same PR (docs/source/user_guide/streams.md) spells out the correct behavior verbatim:

Consecutive with qd.stream_parallel(): blocks run concurrently. Multiple for loops within a single block share a stream and run serially on it.

So the docstring directly contradicts the prose docs the same PR ships.

Step-by-step proof — two for-loops in one block

@qd.kernel
def k():
    with qd.stream_parallel():
        for i in range(N): a[i] = 1.0   # for-A
        for j in range(N): b[j] = 2.0   # for-B
  1. build_With calls begin_stream_parallel() → counter goes 0→1, current_stream_parallel_group_id_=1.
  2. begin_frontend_range_for for for-A stamps stream_parallel_group_id=1 onto its FrontendForStmt.
  3. begin_frontend_range_for for for-B reads the same current_stream_parallel_group_id_=1 and stamps 1 onto its FrontendForStmt — the counter is not incremented again.
  4. end_stream_parallel resets to 0 (counter retains the high-water mark).
  5. After lowering and offloading both tasks carry stream_parallel_group_id=1.
  6. Launcher walks offloaded_tasks: enters the stream-parallel branch, builds stream_by_id keyed by group id → exactly one entry for id=1, so exactly one stream is created. for-A and for-B are launched on that same stream sequentially.

So the user-visible behavior for the docstring's described scenario ('top-level for loops in this block') is one stream, not multiple. The plural is wrong.

Fix

Replace the docstring with text that matches the implementation and the .md docs, e.g.:

@contextmanager
def stream_parallel():
    """Run this block on its own GPU stream, separate from sibling stream_parallel blocks.

    Used inside @qd.kernel. Consecutive `with qd.stream_parallel():` blocks execute concurrently on
    different streams; multiple for-loops inside one block share that block's stream and execute
    serially on it. At Python runtime (outside kernels), this is a no-op — the AST transformer
    interprets the with-statement at compile time and tags loops with a stream-parallel group ID.
    """
    yield

Severity

All four verifiers rated this nit: pure documentation papercut, no behavioral or correctness impact, the help() output for stream_parallel is the only user-facing surface affected. Worth folding in because the contradiction is right next to the PR's own .md prose.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment is unchanged at python/quadrants/lang/stream.py:133-137 — still reads "Run top-level for loops in this block on separate GPU streams" (plural). The contradiction with user_guide/streams.md ("Multiple for loops within a single block share a stream and run serially on it") still stands.

yield


__all__ = ["Stream", "Event", "create_stream", "create_event", "stream_parallel"]
1 change: 1 addition & 0 deletions quadrants/analysis/gen_offline_cache_key.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ class ASTSerializer : public IRVisitor, public ExpressionVisitor {
emit(stmt->strictly_serialized);
emit(stmt->mem_access_opt);
emit(stmt->block_dim);
emit(stmt->stream_parallel_group_id);
emit(stmt->body.get());
}

Expand Down
1 change: 1 addition & 0 deletions quadrants/codegen/amdgpu/codegen_amdgpu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ class TaskCodeGenAMDGPU : public TaskCodeGenLLVM {
current_task->grid_dim = num_SMs * query_max_block_per_sm;
}
current_task->block_dim = stmt->block_dim;
current_task->stream_parallel_group_id = stmt->stream_parallel_group_id;
QD_ASSERT(current_task->grid_dim != 0);
QD_ASSERT(current_task->block_dim != 0);
// Host-side adstack sizing, same scheme as codegen_cuda: tight `grid_dim * block_dim` for
Expand Down
1 change: 1 addition & 0 deletions quadrants/codegen/cuda/codegen_cuda.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@ class TaskCodeGenCUDA : public TaskCodeGenLLVM {
}
current_task->block_dim = stmt->block_dim;
current_task->dynamic_shared_array_bytes = dynamic_shared_array_bytes;
current_task->stream_parallel_group_id = stmt->stream_parallel_group_id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 qd.deactivate() inside with qd.stream_parallel(): silently runs serially. Two complementary sites strip stream_parallel_group_id from the gc tasks the offloader inserts: (1) offload.cpp::insert_gc constructs the gc OffloadedStmt without propagating the preceding for-loop's group id (defaults to 0), and (2) emit_cuda_gc / emit_amdgpu_gc emit 3 sub-tasks via init_offloaded_task_function and never copy stmt->stream_parallel_group_id onto current_task — bypassing the codegen_cuda.cpp:641 / codegen_amdgpu.cpp:354 propagation that exists in the else branch. Both fixes are required: the IR-level fix alone has no observable effect because codegen still strips the id; the codegen fix alone never sees a non-zero id to propagate.

Extended reasoning...

What the bug is

This PR threads stream_parallel_group_id through every for-loop branch in lowering, offloading and codegen — but two complementary sites along the gc-task path drop it, with the result that any kernel using qd.deactivate() (a public API) on a sparse SNode inside with qd.stream_parallel(): silently loses concurrency.

Site 1 — IR-level: offload.cpp::insert_gc (lines 630-651). insert_gc is a post-pass that runs after Offloader::run (called from offload() at line 734). It walks the root block, finds OffloadedStmts that deactivate sparse SNodes (via gather_deactivations), and inserts a gc OffloadedStmt right after each one. The construction at line 645:

auto gc_task = Stmt::make_typed<OffloadedStmt>(OffloadedStmt::TaskType::gc, config.arch, kernel);
gc_task->snode = snode;          // stream_parallel_group_id default-initialized to 0
b->insert(std::move(gc_task), i + 1);

OffloadedStmt::stream_parallel_group_id defaults to 0 (statements.h:1357). Nothing reads the preceding OffloadedStmt's group id; the inserted gc task always lands with group_id=0.

Site 2 — Codegen: emit_cuda_gc (codegen_cuda.cpp:522-551) and emit_amdgpu_gc (codegen_amdgpu.cpp:238-267). The propagation line at codegen_cuda.cpp:641 (current_task->stream_parallel_group_id = stmt->stream_parallel_group_id;) lives inside the else branch of visit(OffloadedStmt*). The if branch at line 602-604 short-circuits the gc case to emit_cuda_gc(stmt) and bypasses line 641 entirely. emit_cuda_gc emits 3 sub-tasks (gather_list, reinit_lists, zero_fill); for each it calls init_offloaded_task_function (codegen_llvm.cpp:1810: current_task = std::make_unique<OffloadedTask>(task_kernel_name)), which uses the OffloadedTask ctor that default-initializes stream_parallel_group_id to 0 (llvm_compiled_data.h:78-83). Neither emit_cuda_gc nor emit_amdgpu_gc reads stmt->stream_parallel_group_id or writes it onto current_task before offloaded_tasks.push_back(*current_task).

Why both fixes are required

A developer who applies only the site-1 fix sets a non-zero group_id on the gc OffloadedStmt — but codegen (site 2) immediately discards it when constructing the 3 OffloadedTasks. The launcher reads the OffloadedTask vector, so the user-observable batch-splitting symptom is unchanged. Conversely, the site-2 fix alone would faithfully propagate stmt->stream_parallel_group_id onto current_task — but that field is always 0 because site 1 never sets it. Both fixes are needed for the propagation chain to reach the runtime launcher.

Step-by-step proof

User code:

block = qd.root.pointer(qd.i, 64).dynamic(qd.i, 1024)
a = qd.field(qd.f32); block.place(a)

@qd.kernel
def k():
    with qd.stream_parallel():
        for i in range(64):
            qd.deactivate(block, [i])
    with qd.stream_parallel():
        for j in range(N):
            b[j] = compute_b(j)
  1. Frontend stamps stream_parallel_group_id 1 and 2 onto the two FrontendForStmts (frontend_ir.cpp:1395 + init_config).
  2. Lowering propagates the ids onto the two RangeForStmts (lower_ast.cpp:294).
  3. Offloader::run constructs two range_for OffloadedStmts, propagating group ids 1 and 2 (offload.cpp:129).
  4. insert_gc runs: gather_deactivations finds the deactivate inside the first range_for (since SNodeOpType::deactivate is what qd.deactivate() lowers to via FrontendSNodeOpStmt at frontend_ir.cpp:1480). is_gc_able(SNodeType::pointer) is true (snode_types.cpp:21-23), so a gc OffloadedStmt is inserted at index i+1. Site 1: the gc task gets default stream_parallel_group_id=0.
  5. Root block now contains: [range_for(g=1), gc(g=0), range_for(g=2)].
  6. Codegen visits each:
    • range_for(g=1) → else branch → line 641 sets group_id=1. ✓
    • gc(g=0) → if branch at line 602 → emit_cuda_gc. Site 2: 3 sub-tasks pushed, all with group_id=0 (init_offloaded_task_function default-constructs).
    • range_for(g=2) → else branch → line 641 sets group_id=2. ✓
  7. Final OffloadedTask vector: [range_for(1), gc_0(0), gc_1(0), gc_2(0), range_for(2)].
  8. Launcher walk (cuda/kernel_launcher.cpp:55-95, AMDGPU twin):
    • i=0, group=1: enters batch. Inner-while exits at i=1 (gc_0 is g=0). Batch=[range_for(1)]. Creates s_1, launches, host-syncs, destroys.
    • i=1,2,3, group=0: default-stream branch each, launches gc_0/1/2 on active_stream.
    • i=4, group=2: batch=[range_for(2)]. Creates s_2, launches, host-syncs, destroys.

The two stream_parallel blocks run strictly sequentially with host syncs between them. Zero concurrency, no error, no warning.

Why nothing upstream catches this

_validate_stream_parallel_exclusivity (function_def_transformer.py:467-477) only walks top-level With nodes; qd.deactivate() is inside the with-body, not at top level. build_With only restricts the context manager call site to qd.stream_parallel() and does not introspect the for-loop body. insert_gc runs as a host-side analysis pass after offloading, with no user-facing knob. tests/python/test_streams.py does not exercise sparse SNodes + deactivate inside stream_parallel.

Distinct from existing bugs in this PR

  • Bug [Doc] Check markup links #7 (clear_list / listgen for struct_for): hits the auxiliary tasks emitted in emit_struct_for (offload.cpp:192-205) before the struct_for. This bug is in the post-offload insert_gc pass and triggers on plain range_for + qd.deactivate (no struct_for needed).
  • Bug [Build] Add linters #6 (strictly_serialized) and [Build] Add clang-tidy linter, and fix lint errors #11 (for-with-break): caused by the for-loop falling into offload.cpp's else fallback. Here the for-loop reaches the range_for branch correctly with non-zero group_id; the gc task is appended after it as a separate post-pass.

Fix

Two minimal one-line changes:

(a) IR-level, in insert_gc:

auto gc_task = Stmt::make_typed<OffloadedStmt>(OffloadedStmt::TaskType::gc, config.arch, kernel);
gc_task->snode = snode;
auto *prev = b->statements[i]->cast<OffloadedStmt>();
if (prev) gc_task->stream_parallel_group_id = prev->stream_parallel_group_id;
b->insert(std::move(gc_task), i + 1);

(b) Codegen-level, in each of the 3 sub-blocks of emit_cuda_gc and emit_amdgpu_gc:

current_task->grid_dim = compile_config.saturating_grid_dim;
current_task->block_dim = 64;
current_task->stream_parallel_group_id = stmt->stream_parallel_group_id;  // add
offloaded_tasks.push_back(*current_task);

With both in place, [range_for(1), gc_0(1), gc_1(1), gc_2(1), range_for(2)] becomes a single 5-task batch with two per-group streams; the gc tasks naturally serialize after their triggering for-loop on stream s_1, and the second range_for runs concurrently on s_2.

QD_ASSERT(current_task->grid_dim != 0);
QD_ASSERT(current_task->block_dim != 0);
// Host-side adstack sizing. For non-range_for and for const-bound range_for the launcher uses
Expand Down
12 changes: 9 additions & 3 deletions quadrants/codegen/llvm/llvm_compiled_data.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,20 @@ class OffloadedTask {
int block_dim{0};
int grid_dim{0};
int dynamic_shared_array_bytes{0};
int stream_parallel_group_id{0};
AdStackSizingInfo ad_stack{};

explicit OffloadedTask(const std::string &name = "",
int block_dim = 0,
int grid_dim = 0,
int dynamic_shared_array_bytes = 0)
: name(name), block_dim(block_dim), grid_dim(grid_dim), dynamic_shared_array_bytes(dynamic_shared_array_bytes) {};
QD_IO_DEF(name, block_dim, grid_dim, dynamic_shared_array_bytes, ad_stack);
int dynamic_shared_array_bytes = 0,
int stream_parallel_group_id = 0)
: name(name),
block_dim(block_dim),
grid_dim(grid_dim),
dynamic_shared_array_bytes(dynamic_shared_array_bytes),
stream_parallel_group_id(stream_parallel_group_id) {};
QD_IO_DEF(name, block_dim, grid_dim, dynamic_shared_array_bytes, stream_parallel_group_id, ad_stack);
};

struct LLVMCompiledTask {
Expand Down
6 changes: 6 additions & 0 deletions quadrants/ir/frontend_ir.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ FrontendForStmt::FrontendForStmt(const FrontendForStmt &o)
strictly_serialized(o.strictly_serialized),
mem_access_opt(o.mem_access_opt),
block_dim(o.block_dim),
stream_parallel_group_id(o.stream_parallel_group_id),
loop_name(o.loop_name) {
}

Expand All @@ -118,6 +119,7 @@ void FrontendForStmt::init_config(Arch arch, const ForLoopConfig &config) {
strictly_serialized = config.strictly_serialized;
mem_access_opt = config.mem_access_opt;
block_dim = config.block_dim;
stream_parallel_group_id = config.stream_parallel_group_id;
loop_name = config.loop_name;
if (arch == Arch::cuda || arch == Arch::amdgpu) {
num_cpu_threads = 1;
Expand Down Expand Up @@ -1390,6 +1392,7 @@ void ASTBuilder::create_assert_stmt(const Expr &cond,
}

void ASTBuilder::begin_frontend_range_for(const Expr &i, const Expr &s, const Expr &e, const DebugInfo &dbg_info) {
for_loop_dec_.config.stream_parallel_group_id = current_stream_parallel_group_id_;
auto stmt_unique = std::make_unique<FrontendForStmt>(i, s, e, arch_, for_loop_dec_.config, dbg_info);
auto stmt = stmt_unique.get();
this->insert(std::move(stmt_unique));
Expand All @@ -1403,6 +1406,7 @@ void ASTBuilder::begin_frontend_struct_for_on_snode(const ExprGroup &loop_vars,
QD_WARN_IF(for_loop_dec_.config.strictly_serialized,
"ti.loop_config(serialize=True) does not have effect on the struct for. "
"The execution order is not guaranteed.");
for_loop_dec_.config.stream_parallel_group_id = current_stream_parallel_group_id_;
auto stmt_unique = std::make_unique<FrontendForStmt>(loop_vars, snode, arch_, for_loop_dec_.config, dbg_info);
for_loop_dec_.reset();
auto stmt = stmt_unique.get();
Expand All @@ -1416,6 +1420,7 @@ void ASTBuilder::begin_frontend_struct_for_on_external_tensor(const ExprGroup &l
QD_WARN_IF(for_loop_dec_.config.strictly_serialized,
"ti.loop_config(serialize=True) does not have effect on the struct for. "
"The execution order is not guaranteed.");
for_loop_dec_.config.stream_parallel_group_id = current_stream_parallel_group_id_;
auto stmt_unique =
std::make_unique<FrontendForStmt>(loop_vars, external_tensor, arch_, for_loop_dec_.config, dbg_info);
for_loop_dec_.reset();
Expand All @@ -1431,6 +1436,7 @@ void ASTBuilder::begin_frontend_mesh_for(const Expr &i,
QD_WARN_IF(for_loop_dec_.config.strictly_serialized,
"ti.loop_config(serialize=True) does not have effect on the mesh for. "
"The execution order is not guaranteed.");
for_loop_dec_.config.stream_parallel_group_id = current_stream_parallel_group_id_;
auto stmt_unique =
std::make_unique<FrontendForStmt>(ExprGroup(i), mesh_ptr, element_type, arch_, for_loop_dec_.config, dbg_info);
for_loop_dec_.reset();
Expand Down
Loading
Loading