Skip to content

Add #[nounroll] (or #[unroll(1)]) so ptxas keeps a loop rolled #1468

Open

Description

@praveenperera

What problem does this solve?

There's no way to keep a loop rolled through ptxas. ptxas fully unrolls small constant-trip-count loops on its own, even when the PTX keeps them rolled, and then schedules every iteration's loads at once. In speakrs's tensor-core convolution kernels (crates/speakrs-cuda-kernels/src/wideconv/tensor.rs, https://github.com/avencera/speakrs), that raised register pressure and hoisted later taps' weight loads, so we keep those loops rolled on purpose.

#[unroll(N)] only accepts factors in 2..=1024, so #[unroll(1)] isn't available. And per #1375, #[unroll(N)] doesn't emit loop metadata.

Proposed API / behavior

#[nounroll]          // or #[unroll(1)]
while tap < TAPS { /* … */ tap += 1; }

This would lower to llvm.loop.unroll.disable metadata on the loop. The NVPTX backend then emits .pragma "nounroll"; at the loop header, which ptxas honors. The libNVVM route should do the same.

Alternatives considered

We emit the pragma by hand at the top of the loop body:

#[inline(always)]
pub(crate) fn nounroll() {
    unsafe { ptx_asm!(".pragma \"nounroll\";"); }
}

This works, but it depends on where the asm lands relative to the loop header. dialect-ptx/src/scopes.rs already has to reason about the scope of a block-level .pragma "nounroll" that comes from inline asm.

Additional context

Related: #1375 (#[unroll(N)] emits no loop metadata, so the NVVM backend unrolls a second time).

Activity

  1. nihalpasham commented on Oct 10, 2026

    @nihalpasham
    Collaborator

    Agreed, having an explicit #[nounroll] would be useful. I checked the metadata through LLVM and libNVVM, including LTO, and ptxas honors the resulting pragma.

    A PR should preserve that loop policy through transformations and lowering, including debug builds.

    For this one - we should cover nested loops and the main/remainder loops from partial unrolling; that’s also relevant to #1375.

    There’s already a range-based for loop unrolling PR from @midagedev, #1346, which we’ll review. We should check how the two interact as well.

  2. added
    codegenDevice code-generation pipeline (Rust MIR to IR to PTX)
    cuda-oxideSIMT programming model: rustc backend, cargo oxide, cuda-device, cuda-host, book
    device-apisUser-facing device-side and kernel-authoring APIs (cuda-device, cuda-macros)
    enhancementNew feature or request
    perfPerformance of generated code or of the compiler itself
    on Oct 10, 2026
  3. praveenperera commented on Oct 10, 2026

    @praveenperera
    Author

    Thanks. One correction to the issue text: I said dialect-ptx/src/scopes.rs already has to reason about a block-level .pragma "nounroll" that comes from inline asm. That isn't right. ScopeFlattenPlan is only used by the PTX raising in dialect-ptx/src/raising/plan.rs, which ptx-schedule uses for cargo oxide fuzz-schedule. It isn't on the kernel compile path, so it has no say in where a hand-written ptx_asm! pragma ends up.

  4. hhdri commented on Oct 11, 2026

    @hhdri

    A data point for the nested-loop case. In this register-MMA kernel, ptxas fully unrolls a 4-trip K loop even when its trip count is hidden behind inline asm. A hand-placed .pragma "nounroll" keeps it rolled and removes most spills. This looks like the same failure that motivates the second part of #1235.

    Setup: cuda-oxide bafffe45 (current main), LLVM NVPTX route, ptxas 13.4 (V13.4.92) -O3, sm_89 (RTX 4060).

    The kernel is the 128×128 int8 mma.sync.m16n8k32 Q4_K prefill tile from my comment on #1346 (#1346 (comment)), with 64 f32 accumulators per thread and a 128-register cap from launch_bounds(256, 2). The shared-memory K loop while base < 8 { …; base += 2 } runs 4 trips. Each trip contains two 2-trip #[unroll] loops, one staging and one MMA. The MMA loop contains #[unroll] loops of 4 trips and of 16 trips, and the 16-trip loop has another 4-trip loop inside. Only the outer K loop's control changes between builds:

    Outer K loop control PTX mma.sync SASS IMMA SASS BAR SASS instrs stack spill st / ld
    constant 8 32 128 9 3784 144 B 212 / 228 B
    opaque trip count, ptx_asm!("mov.u32 %0, 8;") 32 128 9 3792 72 B 104 / 96 B
    constant + ptx_asm!(".pragma \"nounroll\";") first in the body 32 32 3 1528 16 B 20 / 12 B
    opaque count, and the inner 2-trip MMA loop also kept rolled with an opaque count 16 16 3 1136 0 0 / 0

    In the last row ptxas leaves the K loop rolled as well (3 barriers), presumably because it now contains a loop.

    Timing. Each measurement covers the tile plus its activation quantization, run as a CUDA graph and compared with a native CUDA kernel in the same process. The GPU was shared with another job, so I kept only runs with no overlapping GPU process: 4 samples per shape per build. Compared with the opaque-count build:

    • The plain constant is 6–7% slower on all four shapes (M×N×K = 128×{1024, 4096, 12288}×4096 and 512×12288×4096).
    • The pragma build is 1–4% faster on three shapes and about 1% slower on 128×1024×4096.

    In this kernel the measurable effect comes mostly from the visible-constant case doubling the spills. Removing the remaining spills gives only a small further gain.

    Observations:

    1. LLVM never unrolls the K loop here; ptxas does. No PTX file holds more than one K step (32 mma.sync). In SASS, the first two rows are fully unrolled (128 IMMA, 9 barriers). That matches the issue description.
    2. An opaque trip count doesn't stop ptxas. Inline asm reaches ptxas as an ordinary mov.u32 %r, 8, so ptxas still unrolls 4×. We had used this pattern in our kernel, believing it kept the loop rolled. It changes the generated code enough to halve the spills compared with a visible constant, but it doesn't keep the loop rolled, so it can't replace #[nounroll].
    3. The hand-placed pragma works in this nested case. It landed first in the K loop's header block (// => This Inner Loop Header: Depth=2), and ptxas honored it. Correct placement still depends on where LLVM puts the asm after loop rotation, so loop metadata would make it reliable. All rows produce bitwise-identical output: our checks compare against a native CUDA kernel and CPU references.
    4. Interaction with feat(unroll): recognize range for loops #1346: with that PR cherry-picked onto bafffe45, I wrote all ten #[unroll] loops in the kernel as for x in 0..Nusize and kept the pragma on the outer K loop. The build emits no warnings, and its PTX is identical to the counted-while + pragma build apart from a shared-memory symbol hash, with the pragma still first in the K loop header. So feat(unroll): recognize range for loops #1346's canonicalization of inner loops doesn't disturb an outer nounroll. This is only the hand-written pragma, though; an attribute-based #[nounroll] would need its own check against feat(unroll): recognize range for loops #1346.

    Relation to #1235. Its proposed counted register-MMA carrier is motivated partly by "libNVVM expanded all four K-steps before ptxas and introduced 168-216 bytes of spill traffic". That is the same failure as rows 1 and 2, happening in ptxas instead of libNVVM. A #[nounroll] that reaches LLVM/libNVVM as llvm.loop.unroll.disable and ptxas as .pragma "nounroll" would likely cover the loop-expansion and spill part on both routes without the carrier. The u32 shared-address recurrence part of #1235 is separate and would still be needed.

    Prepared with help from an AI assistant (Claude Code).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    codegenDevice code-generation pipeline (Rust MIR to IR to PTX)cuda-oxideSIMT programming model: rustc backend, cargo oxide, cuda-device, cuda-host, bookdevice-apisUser-facing device-side and kernel-authoring APIs (cuda-device, cuda-macros)enhancementNew feature or requestperfPerformance of generated code or of the compiler itself

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions