Skip to content

spv/lift: canonicalize loop shortcut before phi materialization#30

Draft
niklasha wants to merge 7 commits intoRust-GPU:mainfrom
niklasha:bughunt-loop-shortcut-0.4.0
Draft

spv/lift: canonicalize loop shortcut before phi materialization#30
niklasha wants to merge 7 commits intoRust-GPU:mainfrom
niklasha:bughunt-loop-shortcut-0.4.0

Conversation

@niklasha
Copy link

@niklasha niklasha commented Mar 2, 2026

While playing around with rust-gpu I stepped upon a miscompilation, which I debuggged to be a problem in spirt where loops could clobber values in non-deterministic ways. After quite some bughunt, spirt's "lift" logic seems to be at error. The included commit-set adds regression tests matching the problem I faced, and a patch for spirt, that fixes them (and my original miscompilation problem).

Disclosure: This change was developed with LLM assistance (gpt-5.3-codex); I reviewed, tested, and take responsibility for the final patch.

spv/lift: canonicalize loop shortcut to preserve loop edge value mapping

Summary

This MR fixes a spv::lift loop-shape issue where a specific structured loop encoding can preserve a fragile shortcut CFG pattern through lifting.

The fix adds a guarded canonicalization in FuncLifting::from_func_decl (after linear branch fusion, before dead-block pruning and phi case collection) and adds fixture-backed structural + semantic regression tests.

Problem

In a narrow loop pattern:

  • loop header branches to a body SelectBranch(BoolCond)
  • one body arm is an empty pass-through to the body merge
  • body merge branches to loop_continue
  • loop_continue conditionally branches to {loop_header, loop_merge} on the same condition (or negation)

this preserved shape can mis-handle edge-carried values in later phi materialization-sensitive paths.

Changes

1) spv::lift canonicalization

File: src/spv/lift.rs

Added helper methods in FuncLifting:

  • recompute_use_counts
  • is_passthrough_branch_to
  • is_const_opcode
  • continue_cond_relation
  • rewrite_continue_as_unconditional_backedge
  • canonicalize_loop_continue_shortcuts

Canonicalization behavior (only when strict guards match):

  • retarget the pass-through body arm directly to loop_merge
  • remove the body Selection merge marker for that case
  • rewrite loop_continue into unconditional backedge to loop header
  • keep only header-target phi payloads on rewritten continue edge
  • recompute predecessor/use counts before dead-block pruning

2) Regression coverage

Files:

  • tests/regression_loop_continue_shortcut.rs
  • tests/data/loop-continue-shortcut*.spvbin
  • tests/data/basic.frag.glsl.dbg.spvbin

Test strategy:

  • Run lower -> structurize -> link -> lift on fixed SPIR-V fixtures
  • Parse lifted blocks and check both:
    • structural shortcut shape detector
    • semantic loop-carried OpUndef detector on the shortcut path
  • Assert:
    • repro fixture set does not retain the shortcut shape
    • repro fixture set does not contain loop-carried OpUndef through shortcut phis
    • control fixture set does not produce false positives

Named tests:

  • no_loop_continue_shortcut_shape_after_lift
  • no_loop_carried_undef_from_shortcut_after_lift
  • detector_does_not_trigger_on_non_loop_fixture

Fixture sets include:

  • pre-test while variants (const, len)
  • nested pre-test while variants (const, len)
  • controls (post-test loop, no-loop, basic fragment)

Safety/Correctness guards

The canonicalization is conservative and only fires when all of the following hold:

  • exact terminator kinds/attrs for header, body, body-merge, and continue blocks
  • expected merge topology (Loop + body Selection)
  • continue target ordering and uniqueness constraints
  • predecessor count constraints for body-merge and continue
  • phi payload arity compatibility for both body-merge and loop-header edges
  • loop-merge has no phis in rewritten shape

No rewrite occurs when checks fail.

Validation

Executed locally:

  • cargo test --test regression_loop_continue_shortcut
  • cargo test -q
  • cargo test --release --all-targets
  • cargo build -q --examples

All pass.

Scope

Semantics-affecting code changes are isolated to:

  • src/spv/lift.rs

Test-only additions are isolated to:

  • tests/regression_loop_continue_shortcut.rs
  • tests/data/*.spvbin fixture files

Tracking

niklas added 5 commits March 2, 2026 08:58
Add a regression test with a fixed SPIR-V fixture that exercises a loop CFG containing a pass-through body-select arm feeding a body merge and conditional continue.

The test lowers, structurizes, links, and lifts the module, then inspects lifted blocks and fails if the shortcut shape remains, because that shape can violate intended edge-local value flow through the loop backedge/exit region.
Extend the regression to check two independent shortcut-shape fixtures (base + nested-loop variant) and report all offending fixtures in one failure.

Also add a non-loop SPIR-V fixture sanity check so the matcher is exercised on unrelated control flow.
Expand the loop-continue shortcut regression to a wider fixture set: pre-test while (const/len), nested pre-test while (const/len), and two control variants (post-test loop and no-loop).

The reproducer assertion now reports all offending fixtures, while control fixtures are asserted non-matching to guard detector specificity.
Add a guarded CFG canonicalization pass in FuncLifting that rewrites a strict loop shortcut shape before dead-block pruning and OpPhi case collection.

Matched shape: loop header branches to a body select with one empty pass-through arm into body merge, body merge branches to loop_continue, and loop_continue conditionally branches to loop header or loop merge on the same boolean condition (or its negation).

Rewrite: retarget pass-through arm directly to loop merge, drop the body selection merge marker, and canonicalize loop_continue to an unconditional backedge while preserving only header-target phi payloads. Then recompute predecessor/use counts before block retention.

This preserves CFG/phi invariants through explicit guards on terminator kinds, merge topology, predecessor counts, and payload arity, while eliminating a fragile structured-loop encoding that can mis-handle edge-carried values.
@eddyb
Copy link
Member

eddyb commented Mar 2, 2026

Problem

In a narrow loop pattern:

  • loop header branches to a body SelectBranch(BoolCond)
  • one body arm is an empty pass-through to the body merge
  • body merge branches to loop_continue
  • loop_continue conditionally branches to {loop_header, loop_merge} on the same condition (or negation)

It would help a lot to have a GLSL/WGSL example, I believe you mean that:

while(f()) {
    g();
}

ends up encoded like this in SPIR-T (for reasons that go back to RVSDG):

do {
    bool cond = f();
    if(cond) {
        g();
    }
} while(cond);

And if you were to use SPIRV-Cross or Naga to get GLSL/WGSL after SPIR-V -> SPIR-T -> SPIR-V, you'd see the latter do-while with the bool value being passed through.


Whether or not we improve this aspect, could you first open an issue about it?
(You should also try to see if spirv-opt cleans it up, and what passes do that in it)

Any amount of cleverness comes at a cost, and SPIR-T may be suboptimal in a few places, to maximize the likelihood of correctness (as we don't have the tools for formally verified translation etc.).

@niklasha niklasha force-pushed the bughunt-loop-shortcut-0.4.0 branch from 75a375b to 1f62277 Compare March 2, 2026 08:19
@niklasha
Copy link
Author

niklasha commented Mar 2, 2026

Thanks, this is a helpful framing.

Yes, your understanding is exactly the family I was targeting, with one extra detail: the problematic case is when a value carried around the loop gets a merge input from the pass-through arm that is OpUndef in SPIR-V.

A clear source-level shape (GLSL/WGSL) is:

#version 450
layout(local_size_x = 1) in;
layout(set = 0, binding = 0, std430) buffer OutBuf { uint out_data[]; } out_buf;

void main() {
    uint i = 0u;
    while (i < 1u) {
        if (i < 1u) {
            i = i + 1u;
        }
    }
    out_buf.out_data[0] = i; // expected: 1
}
@group(0) @binding(0)
var<storage, read_write> out_data: array<u32>;

@compute @workgroup_size(1)
fn main() {
  var i: u32 = 0u;
  while (i < 1u) {
    if (i < 1u) {
      i = i + 1u;
    }
  }
  out_data[0] = i; // expected: 1u
}

The problematic lowered form (in pseudo-GLSL) is:

uint i = 0u;
do {
    uint next;              // corresponds to OpUndef on one incoming path
    if (i < 1u) {
        next = i + 1u;
    }
    i = next;               // value merge can carry undef from pass-through arm
} while (i < 1u);

In the concrete failing fixture, the key pattern is:

%16 = OpUndef %uint
...
%25 = OpPhi %uint %32 %53 %16 %54
...
OpBranchConditional %31 %53 %54      ; body cond
...
OpBranchConditional %31 %51 %57      ; continue cond (same condition)

So the issue is not just while -> do/while structurally, but that this specific shortcut shape can preserve an undef-fed loop-carried value in a way that later phi materialization paths mishandle.

Also per your suggestion: I tested spirv-opt -O and spirv-opt -Os on the concrete failing .link.spv; in this case both retained the shortcut shape and the undef-fed phi (i.e. they did not clean it up).

I can open a dedicated issue with:

  • the failing fixture,
  • a minimal script to reproduce pre-fix vs post-fix behavior,
  • and the spirv-opt observations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants