Skip to content

ctpop/ctlz/cttz results are never cast back to the operand width — u64::count_ones() returns 0 on device #401

Closed

Description

@nh13

rustc_codegen_nvvm omits the result cast that rustc_codegen_llvm performs on the bit-counting intrinsics. Every width except 32 is silently wrong on device: u64::count_ones() returns 0 for all inputs, and the same source is correct on the host. It compiles with no error or warning.

Reproducer: https://github.com/nh13/rust-cuda-nvvm-repros (01-ctpop/)

Mechanism

core::intrinsics::ctpop returns u32 for every operand width — library/core/src/intrinsics/mod.rs:1646 at rustc f34ba774c (= nightly-2025-08-04, the nightly this backend pins). Same for ctlz (:1687), ctlz_nonzero (:1709), cttz (:1750), cttz_nonzero (:1772). (bswap/bitreverse return T and are unaffected.)

rustc_codegen_llvm therefore casts the result back:

// compiler/rustc_codegen_llvm/src/intrinsic.rs:397-400
sym::ctpop => {
    let ret = self.call_intrinsic("llvm.ctpop", &[llty], ...);
    self.intcast(ret, result.layout.llvm_type(self), false)
}

rustc_codegen_nvvm does not:

// crates/rustc_codegen_nvvm/src/intrinsic.rs:481-484  @ 7fa76f3d
sym::ctpop => self.call_intrinsic(&format!("llvm.ctpop.i{width}"), &[args[0].immediate()]),

Same omission for ctlz/cttz/ctlz_nonzero/cttz_nonzero at :471-479. Nothing casts downstream either — the value is stored via OperandRef::from_immediate_or_packed_pair(...).val.store(...) (:633-635), and check_store (builder.rs:1415-1427) bitcasts the pointer, never truncates the value.

Where the oversized value lands depends on the destination (rustc_codegen_ssa/src/mir/block.rs:935-956):

  • Common case — a wrong answer. The destination is a fresh alloca of the intrinsic's return layout, i.e. 4 bytes (:948; :942 reuses an existing alloca of the same size). An i64 result is then an 8-byte store into a 4-byte alloca, which is deleted outright (below), so the load reads undef → 0.
  • Destination is a place projection (:955) — potential memory corruption. For a struct field, array element, or Deref of a device pointer, the 8-byte store is in bounds of the enclosing object. SROA does not delete it; it instead clobbers 4 bytes of adjacent memory — for a *mut u32 destination, a straight 4-byte device-buffer overrun.

I have only observed the first case: count_ones is an #[inline] wrapper, so the destination is normally a temp, and reaching the second would require destination propagation to forward it into a place. I flag it because if it is reachable the impact is memory corruption rather than a wrong answer, which changes the severity of this bug considerably.

In the common case that out-of-bounds store is deleted outright by SROA (LLVM 7, lib/Transforms/Scalar/SROA.cpp:809-816 — "If this memory access can be shown to statically extend outside the bounds of the allocation, it's behavior is undefined, so simply ignore it"). The subsequent load reads undef → 0, and the llvm.ctpop.i64 call becomes dead. This is what makes the failure a clean 0 rather than a truncation, and it independently explains the PTX evidence below. (I read LLVM 7's SROA but could not confirm which pass instance in libNVVM's pipeline fires; the PTX makes that moot.)

PTX evidence: the emitted PTX contains zero popc.b64 instructions — the call is eliminated, not lowered. After switching to a u32-halves workaround, popc.b32 appears as expected.

Width 32 is the only width that works, because its cast is a no-op: llvm.ctpop.i32 returns i32, store size equals alloca size, nothing overruns.

Scope

  • count_ones, leading_zeros, trailing_zeros on u8/u16/u64/u128 (and signed equivalents).
  • ctlz_nonzero/cttz_nonzero too, so NonZero::leading_zeros, ilog2, etc.
  • u128 goes through emulate_i128.rs:626-640, which zexts to i128 — a 16-byte store into the same 4-byte slot.
  • u8/u16 fail differently: their store is in-bounds (1–2 bytes into 4), so SROA keeps it and the upper bytes of the i32 load are undef — garbage rather than a clean 0.

Still present on master

Unchanged at crates/rustc_codegen_nvvm/src/intrinsic.rs:630-633 on master (103a8d5). git log -S 'intcast(ret' on that file returns nothing — the cast has never been there. The backend predates the rustc signature change: ctpop<T> -> T in Rust 1.75, -> u32 by 1.80.

Suggested fix

Mirror rustc_codegen_llvm. Rather than wrapping each of the five arms (and handle_128_bit_intrinsic, a free fn with no result in scope, so its cast has to land at the call site anyway), a single intcast around the whole if width == 128 { … } else { match name { … } } result is strictly better:

let llval = self.intcast(llval, result.layout.llvm_type(self), false);

An intcast to an identical type is a no-op (LLVMRustBuildIntCast → CreateIntCast), so the intrinsics in that arm which correctly return T — bswap, bitreverse, rotate_*, saturating_* — are unaffected, and any intrinsic added later is covered for free. result: PlaceRef is already the parameter at intrinsic.rs:219 and LayoutLlvmExt is imported at :23, so this resolves in scope as written.

Worth considering alongside it: a debug assertion in builder.rs's store_with_flags that the stored value's store-size does not exceed the destination place's size. This bug class — a silent oversized store into an intrinsic result slot, then silently deleted — then becomes a codegen-time error for every intrinsic rather than a per-symbol fix. A regression test asserting popc.b64 appears in the emitted PTX would pin this specific case.

Environment

Rust-CUDA 7fa76f3d717038a92c90bf4a482b0b8dd3259344, cuda_builder 0.3.0, nightly-2025-08-04, CUDA 13.2 libNVVM, driver 595.71.05. Wrong answers observed on Tesla T4 (sm_75) and A10G (sm_86). Kernel crates no_std, -Z saturating_float_casts=false, --override-libm on (default).

Note on provenance: the wrong answers and the PTX evidence come from the original project where this was found. The standalone reproducer in 01-ctpop/ was written afterwards on a machine without a CUDA host and has not itself been built — it mirrors the same three-way comparison (u64 intrinsic vs u32-halves split vs SWAR, all reading the same value) that isolated the defect originally.


Prepared with the assistance of Claude (Anthropic). Every source citation above was checked against the referenced revisions — please verify them directly. The one claim I could not confirm from source, the place-projection corruption case, is marked as unobserved in the text.

Activity

  1. added a commit that references this issue on Jul 22, 2026
    4aa57f2
  2. added a commit that references this issue on Sep 16, 2026
    7c07c61
  3. brandonros commented on Sep 16, 2026

    @brandonros
    Contributor

    Confirmed independently with a built and GPU-executed minimal reproducer, including an LLVM-IR-only correction that restores the expected results. This also explains an RSA miner's host/device factor mismatch that led us here.

    Minimal Rust-CUDA kernel (no RSA or bigint dependency)

    #![no_std]
    use cuda_std::prelude::*;
    
    #[kernel]
    pub unsafe extern "C" fn zero_counts(input: *const u64, output: *mut u32) {
        let x = unsafe { *input };
        unsafe {
            *output = x.leading_zeros();
            *output.add(1) = x.trailing_zeros();
            *output.add(2) = x.count_ones();
        }
    }

    Build the device crate as cdylib/rlib, with panic = "abort". We used cuda_std and cuda_builder pinned to fork commit f554f74a78a1ee30b2668f0ec2e5b1fd54b7b588, cuda_builder features rustc_codegen_nvvm,llvm21, and this builder configuration (paths relative to the host build script's package directory):

    CudaBuilder::new(package.join("device"))
        .arch(NvvmArch::Compute100)
        .copy_to(package.join("diagnostic.ptx"))
        .final_module_path(package.join("diagnostic.ll"))
        .emit_llvm_ir(true)
        .build()
        .unwrap();

    Environment: nightly-2026-04-02, LLVM 21.1.8, CUDA 13.3.33 / NVVM 23.0.0, PTX 9.3 targeting sm_100; JIT execution on an RTX 5090 with driver 610.43.02. The host runtime uses the CUDA driver API, one block and one thread, an 8-byte input allocation and a 12-byte output allocation initialized to three 0xA5A5A5A5 sentinels.

    This is a fork-build observation. We also inspected upstream main 6a836d9236fc38e0fa7a71f7bdeda7a8f82bc8d5: these lowering arms still omit the result cast. We did not rebuild upstream main independently.

    Evidence before NVVM

    The emitted final LLVM IR contains:

    %2 = alloca [4 x i8], align 4
    %x = load i64, ptr %input, align 8
    call void @llvm.lifetime.start.p0(i64 4, ptr %2)
    %3 = call i64 @llvm.ctlz.i64(i64 %x, i1 false)
    store i64 %3, ptr %2, align 4
    %_4 = load i32, ptr %2, align 4
    call void @llvm.lifetime.end.p0(i64 4, ptr %2)
    store i32 %_4, ptr %output, align 4

    The same 8-byte-store/4-byte-allocation mismatch occurs for cttz.i64 and ctpop.i64. This localizes the defect to result lowering before NVVM, rather than RSA arithmetic or GPU launch geometry.

    PTX and runtime result

    In this reduction, NVVM removes all the kernel work, rather than emitting stores of zero:

    .visible .entry zero_counts(
        .param .u64 .ptr .align 1 zero_counts_param_0,
        .param .u64 .ptr .align 1 zero_counts_param_1
    )
    {
        ret;
    }
    

    The driver launch succeeds, but every input leaves all three sentinels unchanged.

    Input Expected [leading_zeros, trailing_zeros, count_ones] Unmodified PTX Corrected-IR PTX
    0 [64,64,0] all sentinels untouched pass
    1 [63,0,1] all sentinels untouched pass
    2 [62,1,1] all sentinels untouched pass
    1 << 63 [0,63,1] all sentinels untouched pass
    u64::MAX [0,0,64] all sentinels untouched pass

    Causal A/B check

    For a diagnostic-only experiment, we changed only three result stores in the emitted LLVM IR to truncate the LLVM i64 result to Rust's i32 result layout:

    %3 = call i64 @llvm.ctlz.i64(i64 %x, i1 false)
    %narrow_count_0 = trunc i64 %3 to i32
    store i32 %narrow_count_0, ptr %2, align 4

    We recompiled that IR directly with the same libNVVM and reran it on the same GPU. All five inputs passed. This validates the missing-cast mechanism; it is not a tested Rust-CUDA source patch or a production PTX workaround.

    Downstream symptom

    A separate crypto-bigint::U1024 diagnostic, built with the same compiler, reported bits(2^1022 - 1) = 1024, trailing_zeros(2^1022) = 0, and (2^1022) % 2 = 2. Correcting the same result-store widths in that diagnostic's IR restored 1022, 1022, and 0, respectively.

    In the miner this made rejection sampling skip candidates the CPU accepted, because the calculated leading-bit mask was wrong. Nine of thirteen retained GPU factors in a deterministic public-seed probe matched later HMAC retries for the correct task ID, rather than the CPU's first accepted candidate. Fixed known-factor round trips passed 128/128, helping distinguish derivation from result transport. Compute Sanitizer also located the full RSA self-test's first trap in prime_filter.

    This matches the source fix in #403, which we have cherry-picked onto our fork as 7c07c618dd7c1c08af4d040a5fc422d9ebf8caff. The runtime/IR A/B results above directly validate the u64 case only; they are not GPU validation of that cherry-picked compiler build. Full RSA mining still needs freshly rebuilt-kernel validation.

  4. added a commit that references this issue on Sep 29, 2026
    4f47d30
  5. added a commit that references this issue on Sep 30, 2026
    c976c7d
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions