Compiler Optimizations#
cuda-oxide already benefits from optimizations in rustc and LLVM. The
mir-transforms crate is where cuda-oxide adds its own passes while a program
is still represented as dialect-mir. Loop unrolling is the first
cuda-oxide-owned optimization pass in this layer.
This page starts with the user-facing idea, then follows one loop through the compiler. You do not need compiler experience to read it.
What is a MIR transform?#
After rustc lowers Rust source to MIR, cuda-oxide imports it into dialect-mir,
where typed operations and control-flow branches are explicit.
There are three related jobs in this part of the compiler:
Job |
What it does |
|---|---|
Analysis |
Reads the IR and records facts, such as which blocks form a loop |
Transform |
Rewrites the IR without changing what the program computes |
Lowering |
Converts the program from one IR dialect into a lower-level dialect |
Loop analysis can identify a loop’s counter, step, and bound. The unroll transform uses those facts to rewrite the loop so that each trip performs several original iterations. Lowering later converts the result to LLVM.
Note
In this chapter, MIR transforms means transforms over cuda-oxide’s imported
dialect-mir. They do not modify rustc’s internal MIR directly.
Where transforms run#
The optimization layer sits in the middle of the device compilation pipeline:
Rust source
→ rustc MIR
→ mir-importer
→ dialect-mir
→ mem2reg
→ mir-transforms ← cuda-oxide optimizations
→ mir-lower
→ LLVM dialect
→ LLVM IR
→ PTX
The MIR importer initially represents local variables with
loads and stores. mem2reg promotes eligible locals to SSA values, where
each value has one definition and block arguments carry values between blocks.
That makes loop counters and their updates explicit enough for an optimization
pass to reason about.
Transforms run here because dialect-mir retains Rust-level types and
operations, making targeted cuda-oxide rewrites easier to express. The
lowering pipeline then converts the optimized result.
Full variable-debug builds skip mem2reg and loop unrolling. Keeping source
variables in stable memory locations gives cuda-gdb a better debugging
experience.
Diagnosing locals that remain stack-backed#
An optimized verbose build also checks the exact LLVM IR that will be passed to
llc. If a Rust local’s stack allocation survives both cuda-oxide mem2reg and
LLVM’s -O2 scalar-replacement pipeline, cuda-oxide reports it before PTX
generation. For example:
warning: local `scratch` (`[u32; 4]`, 16 bytes) in `kernel` could not be promoted to registers
= note: indexed by a non-constant value; the allocation survives LLVM scalar replacement and remains stack-backed
= help: use constant indexing or a small match/select when the index range is bounded
Dynamic indexing is one common blocker because LLVM cannot replace a small aggregate with independent SSA values when the selected element is not known. An address passed to another function, stored as a pointer, returned, or otherwise made observable can also prevent promotion. When the surviving use pattern is not one of the conservative cases cuda-oxide recognizes, the warning still identifies the local but gives a generic scalar-replacement explanation rather than guessing at the cause.
The check runs only after a successful LLVM optimization pass and only in verbose mode:
cargo oxide build <example> --verbose
This placement matters. Reporting immediately after cuda-oxide mem2reg would
produce false positives for allocations that LLVM SROA removes later. The check
is skipped for no-optimization builds and full variable-debug builds because
those modes intentionally retain storage that an optimized build would normally
try to promote.
The first pass: loop unrolling#
A loop normally tests its condition and branches back after every iteration. Unrolling copies the body so that one trip through the new loop performs several iterations’ work.
Consider this small counted loop inside a kernel or device function:
let mut i = 0u32;
#[unroll]
while i < 4 {
work(i);
i += 1;
}
Conceptually, full unrolling turns it into:
work(0);
work(1);
work(2);
work(3);
The condition and branch back to the loop header disappear. More importantly,
each copy now has a constant value for i, exposing further simplifications.
The tradeoff is larger generated code. A larger body may also need more registers, so unrolling is a request to the compiler, not a promise of better performance.
Full and partial unrolling#
cuda-oxide provides two forms:
Annotation |
Use it when |
What the pass produces |
|---|---|---|
|
The trip count is known at compile time |
One body copy per iteration; the loop disappears |
|
The trip count may be known only at runtime |
|
Full unrolling#
Bare #[unroll] needs a compile-time-known trip count:
let mut i = 0u32;
#[unroll]
while i < 8 {
sum += i;
i += 1;
}
The pass gives the eight copies counter values 0 through 7. Constant
propagation can then fold expressions derived from those literals.
The bound does not have to be a literal. The pass folds integer arithmetic on
constants before it reads the trip count, so while i < N / 8 unrolls in each
instantiation of a function with a const generic N. Casts and
overflow-checked arithmetic in the bound are not yet folded.
Partial unrolling#
#[unroll(4)] also works when the limit is a runtime value:
let mut i = 0u32;
#[unroll(4)]
while i < n {
process(i);
i += 1;
}
If n is 10, the main loop handles two groups of four iterations. The
remainder loop handles iterations 8 and 9. The result is still correct
when n is not divisible by four.
This removes three out of every four main-loop tests and gives the compiler a larger straight-line region to optimize.
A useful peephole: folding pipeline stages#
Partial unrolling has one wrinkle: the main loop still has a runtime counter. cuda-oxide includes a small peephole optimization for a common software-pipeline pattern:
let mut k_idx = 0u32;
#[unroll(4)]
while k_idx < k_iters {
let stage = k_idx & 3;
match stage {
0 => consume_stage_0(),
1 => consume_stage_1(),
2 => consume_stage_2(),
3 => consume_stage_3(),
_ => {}
}
k_idx += 1;
}
The unrolled main-loop counter advances by four. Its low two bits therefore do not change between groups, so the four copies have fixed stage values:
copy 0: (k_idx + 0) & 3 → 0
copy 1: (k_idx + 1) & 3 → 1
copy 2: (k_idx + 2) & 3 → 2
copy 3: (k_idx + 3) & 3 → 3
The peephole replaces those expressions with literals. Constant propagation
then removes the dead match arms, leaving four straight-line,
stage-specific bodies. The remainder loop stays intact for leftover
iterations.
k_idx % 4 works too when k_idx is unsigned, and constant offsets such as
(k_idx + 1) & 3 are also recognized. The pass folds these forms only when
their power-of-two period divides the unrolled step.
Why a runtime base does not fold#
The expression must be based directly on the loop counter:
let stage = k_idx & 3; // can fold
let global_k = tile_k_base + k_idx;
let stage = global_k & 3; // tile_k_base is runtime: cannot fold
Because the low bits of runtime tile_k_base are unknown at compile time, the
compiler cannot choose one literal stage for each copy. Rewriting it to the
first form is correct only when the algorithm separately guarantees that
tile_k_base is a multiple of four. The pass deliberately refuses to guess.
Note
Full unrolling does not need this peephole. A fully unrolled counter is already a literal in every copy, so ordinary constant propagation can do the work.
How the annotation reaches the pass#
The source attribute travels through the compiler in six stages:
The
#[kernel]or#[device]macro consumes#[unroll]or#[unroll(N)]and inserts an internal marker call at the start of that loop.mir-importerrecognizes the call and emits amir.unroll_hintoperation. The marker produces no runtime code.Constant propagation folds constant expressions, such as a bound written
N / 8.LoopInfothen finds the enclosing loop, while induction analysis finds its counter, start, step, bound, and—when possible—trip count.The pass normalizes supported control flow. It joins multiple
continuepaths into one path back to the loop header and routes supported loop-carried values out of a fully unrolled loop.The transform clones and reconnects the body for full or partial unrolling.
A cleanup phase folds constants, simplifies branches, and removes dead or unreachable code.
The unroll pass leaves functions without an unroll hint untouched. If it cannot prove a requested rewrite is safe, it prints a warning and does not apply the unroll; the loop continues with the same behavior.
Supported loops and safe fallback#
The current pass intentionally recognizes a conservative set of loop shapes.
A counted while loop and a range for loop (for i in a..b and
(a..b).rev(), over any integer counter including isize) need a counter the
pass can follow: a compile-time-constant starting value, the same fixed step
on every path back to the header, and a simple, side-effect-free test that
directly compares the counter with its bound. The loop must also have one
ordinary entry before its header. For unusual or irreducible control flow, the
compiler warns and does not unroll the loop.
Shape |
Current behavior |
|---|---|
Explicit counted |
Supported, over any integer counter including |
Range |
Supported, over any integer counter including |
Other iterator |
Not yet recognized; warns |
Constant trip count with |
Fully unrolled |
Runtime, loop-invariant limit with |
Partially unrolled |
Several |
Joined and unrolled |
Early |
Supported |
Early |
Warns; partial unroll is skipped |
Nested loops |
Only the annotated loop is unrolled |
Partial unrolling also requires N >= 2, a positive counter step, a < or
<= comparison, and a limit that does not change inside the loop.
When an annotated outer loop contains an unannotated inner loop, each outer copy receives an intact clone of that inner loop; the inner loop remains a loop. When both loops are annotated, the compiler handles the inner loop first.
To bound compile time and memory, one annotation may create at most 1,024 body
copies, 8,192 cloned basic blocks, and 65,536 cloned operations. A request over
one of those limits warns and is not unrolled. Every warning about a loop that
was not unrolled names the #[unroll] attribute it refers to as
file:line:column on a --> line, so a kernel with several annotated loops
shows which one was left alone.
For the complete kernel-author reference, see Loop unrolling.
When should you unroll?#
Good candidates are:
small loops with a fixed trip count;
hot counted loops where branch overhead matters;
software pipelines whose stage selection becomes constant after unrolling;
loops where several independent copies expose instruction-level parallelism.
Be cautious when the body is large or already uses many registers. Extra code can increase register pressure, reduce occupancy, or put more pressure on the instruction cache.
Choose a factor that matches the structure of the algorithm—often a pipeline
depth or another small natural group—and benchmark the result. Removing
#[unroll] should always remain an easy comparison.
Keeping a loop rolled with #[nounroll]#
The unroll pass only acts on #[unroll] requests, but LLVM, libNVVM, and ptxas
have their own unrollers. ptxas in particular fully unrolls small
constant-trip-count loops even when the PTX keeps them rolled. #[nounroll]
asks all three to leave a loop alone. It does not go through the unroll pass:
The macro inserts a different marker, and
mir-importeremits amir.nounroll_hintoperation inside the loop.The hint survives MIR preparation. The unroll pass ignores it, so unrolling an enclosing loop copies the hint along with the inner loop.
Just before lowering, after the last transform that can reshape loops, each hint is mapped to its innermost loop. The loop’s back-edges are joined through one latch block, and the hint is removed.
Lowering marks that latch’s branch, and the LLVM IR exporter attaches
!llvm.loopmetadata withllvm.loop.unroll.disableto it. The NVPTX backend prints.pragma "nounroll";at the loop header; libNVVM reads the same metadata.
Step 3 also runs in full-debug builds, which skip MIR preparation. A hint that
is not inside any loop is dropped with a warning that names the attribute’s
file:line:column. A hint in unreachable code is dropped silently. WGMMA
counted-loop fusion replaces a K loop with one operation that generates its own
PTX loop. It moves a #[nounroll] request onto that operation, whose loop
header then starts with .pragma "nounroll";, and leaves the original blocks
unreachable.
Like #[unroll], the hint is mapped to the innermost loop around it. A loop
whose body never repeats, such as loop { ...; break; }, produces no loop in
MIR. If it sits inside another loop, the request applies to that enclosing
loop.
To inspect the pass’s analysis, build verbosely:
cargo oxide build <example> --verbose
Verbose mode prints the requested factor and the loop facts the pass found. If a request is skipped, a warning explains why. Loop numbers and analysis fields are internal details and may change.
A home for future transforms#
The mir-transforms crate keeps reusable analysis separate from IR mutation:
cuda-oxide/crates/mir-transforms/src/
├── analyses/
│ ├── loop_info.rs # which blocks belong to each loop?
│ └── induction.rs # what are the counter, step, bound, and trip count?
├── canonicalize.rs # put supported control flow into a simpler shape
└── unroll.rs # prove, clone, reconnect, and clean up loops
This separation gives future optimizations a predictable place to live:
analyses gather facts, canonicalization normalizes equivalent shapes, and
transforms perform behavior-preserving rewrites. mir-importer/src/pipeline.rs
controls when each pass runs.
Every transform should follow the same rule as the unroller: prove the rewrite is safe, bound its resource use, and fall back without changing behavior when a program is outside the supported shape.
The focused test suite runs with:
cargo test -p mir-transforms --all-targets
From here, The Lowering Pipeline explains how the
optimized dialect-mir becomes the LLVM dialect and, eventually, PTX.