Skip to content

perf(cute): speed up the CuTe verifier's provenance analysis - #1648

Open
juliankang4 wants to merge 4 commits into
NVIDIA:mainfrom
juliankang4:perf/cute-verifier-linear
Open

juliankang4 wants to merge 4 commits into
NVIDIA:mainfrom
juliankang4:perf/cute-verifier-linear

Conversation

@juliankang4

@juliankang4 juliankang4 commented Oct 26, 2026 •

Copy link
Copy Markdown

Summary

Fixes #1647. The CuTe semantic verifier added in #1598 takes time that grows roughly with the cube of a function's size, and it runs on every module, so large kernel crates no longer finish building. This change indexes the provenance facts by carrier, skips a story's analysis in scopes that have none of its operations, stops opaque origins from piling up in fact sets, and checks each local cell's aliases once per analysis. The accept and reject rules and the diagnostic each consumer reports stay the same. On the inputs measured below, verification time now grows linearly.

Changes

  • OrdinaryFacts keys its facts by SSA value first and field path second, so entries, at_path and copy_entries touch one carrier instead of scanning the whole map. copy_entries still forwards a snapshot taken before the copy. The shared-MMA use closure walks the nested map and checks scope membership through a set.
  • The scheduler, pipeline and shared-MMA stories return before building facts when the scope holds no operation of their family (scope_has_any in cfg.rs). Every check in those stories starts from such an operation, so these scopes were already accepted after the analysis.
  • insert_path keeps at most two opaque origins (Unknown, and Scalar for shared MMA) per field path, through a small CarrierFact trait. Consumers read a set only through its size, its sole element, or whether it holds a given semantic origin, and they skip opaque origins when they walk one: scheduler_root, pipeline_state_root and one_mma_fact reject len() != 1, require_same_mma_scalar compares Scalar identity only when the set has one element, the advance and accumulator propagation and verify_mma_use_closure skip opaque facts, and the poison pass only asks whether a path exists. A capped set is a subset of the uncapped one with the same semantic origins, min(2, n) opaque origins and the same paths, so each of those reads gives the same answer.
  • LocalCells walks the alias graph of every mir.alloca in the scope once, because the verifier does not change the IR it reads. The fact builders, the shared-MMA use closure and the epilogue plumbing check look cells up there instead of calling local_cell_is_closed for every access on every sweep. A cell allocated outside the scope is still walked on demand. The plumbing and the poison pass also read each incoming edge's successor operands once per block instead of once per block argument.
  • New tests: the cap and the snapshot copy on OrdinaryFacts, the cell table against the alias walk for closed, aliased and escaped cells with and without their allocations in the scope, a scheduler state carried through 48 blocks and closed cells, and the exact rejection text for a scheduler cursor that has no origin or merges one to eight unrelated values. The two story tests also pass against the verifier at 9df9e7ce.

When an input has more than one defect, the verifier still reports whichever one it meets first in hash-map order, as it did before this change.

Testing

cargo test -p dialect-cute --all-targets on 9df9e7ce plus this branch, with the pinned 1.99.1 toolchain: 93 passed; 0 failed in the library tests, and the global_store, public_paths and sm100_attention integration tests pass. cargo clippy -p dialect-cute --all-targets -- -D warnings passes, and rustfmt --check passes on the changed files. Each of the first two commits also passes the existing 90 library tests on its own.

Verification time on one function with one scheduler story and N ordinary blocks (one scheduler story whose entry reaches its loop through N ordinary blocks, each with a block argument, a closed local cell, a store and a load; debug build, Apple M4 Pro):

ordinary blocks operations before after
25 113 0.102 s 0.003 s
50 213 0.670 s 0.005 s
100 413 4.819 s 0.009 s
200 813 37.643 s 0.018 s
1000 4013 not run 0.092 s
4000 16013 not run 0.375 s

Verification time with N store/load pairs on one closed cell (the same story with the N pairs in its entry block; same machine):

store/load pairs before after
200 0.493 s 0.008 s
400 1.964 s 0.015 s
800 7.624 s 0.029 s
1600 30.487 s 0.058 s

Cold cargo oxide build --arch sm_86 -- --release -j 12 of a 358-kernel crate without CuTe operations, RTX 5090, Ubuntu 26.04.1, CUDA 13.3:

revision build
0143af5c (parent of #1598) 220 s
9df9e7ce stopped at 1700 s, not finished
9df9e7ce with the first three commits 276 s

The last commit only changes code that runs after a story finds one of its operations, which this crate never reaches. The remaining difference from 0143af5c is outside the verifier and is not addressed here.

On this branch, RTX 5090 with driver 595.99.02, CUDA 13.3, LLVM 21.1.8 and the pinned 1.99.1 toolchain:

  • just -f cuda-oxide/Justfile check passes (exit 0)
  • cargo oxide run vecadd passes: ✓ SUCCESS: All 1024 elements correct!
  • New example added (not applicable)

Checklist

  • All commits signed off (git commit -s)
  • SPDX headers on new source files (not applicable; no new files)

`OrdinaryFacts` kept one flat map keyed by (value, field path), so
`entries(value)` scanned every key in the map. The ordinary plumbing
and the poison pass call it for every cast, aggregate, store, load and
block argument of a scope, which made each sweep quadratic in the
scope's size.

Key the facts by SSA value first and by field path second. `entries`,
`at_path` and `copy_entries` now touch only the named carrier, and
`copy_entries` still forwards a snapshot of the source taken before
the copy. The shared-MMA use closure walks the nested map and tests
scope membership through a set instead of a linear search.

Signed-off-by: juliankang4 <200967947+juliankang4@users.noreply.github.com>
…tions

The scheduler, pipeline and shared-MMA stories built their provenance
facts for every scope, including every function with no CuTe operation
at all. Each analysis seeds every integer value of the scope, so this
cost was paid for every ordinary kernel.

Return before building the facts when the scope holds no operation of
the story's family. Every check in those stories starts from such an
operation, so a scope without one was already accepted after the
analysis; consumer-only (malformed) stories still reach their checks
because their operations belong to the family.

Signed-off-by: juliankang4 <200967947+juliankang4@users.noreply.github.com>
Seeding gives every factless integer value an origin that names the
value itself (`Unknown`, and `Scalar` for shared MMA). Propagation
merges those origins through block arguments and closed local cells,
so a value's set grew with the length of every chain that reaches it,
and each sweep of the fixed point copied the grown sets: verification
time grew with the cube of the scope size.

Consumers read a set only through its size, its sole origin, or whether
it holds a given semantic origin, and they skip opaque origins when
walking a set. Two opaque origins therefore report a merge exactly as
any larger number does. `insert_path` now drops a third one, through a
`CarrierFact` trait that each family's fact type implements. Path
presence and semantic origins are unchanged, so every accept or reject
decision and the diagnostic each consumer reports stay the same.

Tests cover the cap, the snapshot copy, a scheduler state carried
through 48 blocks and cells, and the rejection wording for cursors that
merge one to eight unrelated values.

Signed-off-by: juliankang4 <200967947+juliankang4@users.noreply.github.com>
Every load and store that forwards provenance through a closed local
cell called `local_cell_is_closed`, which walks all uses of the cell
and its aliases. A cell with N accesses therefore cost N walks of N
uses on every fixed-point sweep, so verification time grew with the
square of the number of accesses to one cell.

The verifier does not change the IR it reads, so a cell's closedness
is fixed for the whole analysis. `LocalCells` walks the alias graph of
each `mir.alloca` in the scope once, and the scheduler, pipeline and
shared-MMA fact builders, the shared-MMA use closure and the epilogue
plumbing check look cells up there. A cell allocated outside the scope
is still walked on demand, so every answer is the one the walk gives.
The ordinary plumbing and the poison pass also read each incoming
edge's successor operands once per block instead of once per block
argument.

A new test compares the table with the alias walk for closed, aliased
and escaped cells, with and without their allocations in the scope.

Signed-off-by: juliankang4 <200967947+juliankang4@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 26, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@midagedev

Copy link
Copy Markdown
Contributor

Tested on bloomery, a crate of large SIMT kernels with no CuTe operation, on its own box. Cold release build of one binary: 9df9e7c alone did not finish in 1700 s; with this PR, 480 s (388 s at 0143af5, before #1598). The PTX of 809 kernel entries is identical to a build that skips the verifier in scopes without CuTe operations. A block-chain probe with no CuTe operation verifies in 0.006 s at 800 blocks and 0.050 s at 8000.

midagedev added a commit to midagedev/bloomery that referenced this pull request Oct 26, 2026
…used by name without it; host flags generated per triple

tools/oxide-backend.sh applies tools/oxide-backend/cuda-rust-1648.patch to a copy of the pin's checkout, builds the
backend and records the patch's sha256 in PROVENANCE; tools/oxide-host.sh checks it before every cargo oxide build and
writes .cargo/cuda-oxide.toml's flags from .cargo/config.toml's table for the target triple, with the llc major check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
midagedev added a commit to midagedev/bloomery that referenced this pull request Oct 26, 2026
…used by name without it; host flags generated per triple

tools/oxide-backend.sh applies tools/oxide-backend/cuda-rust-1648.patch to a copy of the pin's checkout, builds the
backend and records the patch's sha256 in PROVENANCE; tools/oxide-host.sh checks it before every cargo oxide build and
writes .cargo/cuda-oxide.toml's flags from .cargo/config.toml's table for the target triple, with the llc major check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

dialect-cute: verify_cute_semantics takes cubic time on a function with no CuTe operation

2 participants