Skip to content

[BUG]: DeviceReduce::ReduceByKey is not run-to-run deterministic for fp64 sums over multi-tile runs #9995

Description

@guillaume-michel

Is this a duplicate?

Type of Bug

Something else

Component

CUB

Describe the bug

Summary

cub::DeviceReduce::ReduceByKey documents:

  • Provides "run-to-run" determinism for pseudo-associative reduction
    (e.g., addition of floating point types) on the same GPU device.
    However, results for pseudo-associative reduction may be inconsistent
    from one device to a another device of a different compute-capability
    because CUB can employ different tile-sizing for different architectures.

(v3.1.4,
main @ 0d919ed
— same sentence in the ReduceByKey doc block.)

Empirically this does not hold when a run of equal keys spans several
tiles: repeated invocations of the same ReduceByKey call on the
same device with byte-identical device input and the same
pre-allocated temp storage
return the long run's float64 aggregate
with 5–6 distinct bit patterns across 200 repetitions, spread over the
last ~3 ulps. Short (single-tile) runs are bit-stable, and the same
experiment with integer-valued doubles (whose sums are exact under any
association order) is bit-stable — so the variation is a run-to-run
change in the floating-point combination order (presumably the
cross-tile partial combination in the decoupled look-back), not memory
corruption or a harness artifact.

Reproduced on two architectures with the same CUB version, so it is a
property of the implementation, not of a particular GPU:

GPU SM Driver Runtime CUB_VERSION Result (experiment A)
NVIDIA T600 Laptop GPU sm_75 13010 13010 300104 (3.1.4) 6 distinct bit patterns / 200 reps
NVIDIA H100 80GB HBM3 sm_90 13020 12080¹ 300104 (3.1.4) 5 distinct bit patterns / 200 reps

¹ the H100 host links a CUDA 12.8 runtime while compiling against a
CCCL 3.1.4 include tree (NVHPC toolchain); the T600 datapoint is a pure
CUDA 13.1 stack, so the finding does not depend on the mixed toolkit.

How to Reproduce

Steps to reproduce

Compile the self-contained program below (no dependencies beyond the
toolkit's bundled CCCL) and run it:

nvcc -O2 -arch=native \
     -I$CUDA_HOME/targets/x86_64-linux/include/cccl \
     repro.cu -o repro
./repro

The program runs three experiments over 65536 (uint64 key, double value)
pairs, 200 repetitions each, and prints a census of the bit patterns
observed for the first run's aggregate:

  • A — one 50 000-item run of key 0 (values: mixed fp64 and zeros),
    followed by 64-item runs. Expected: 1 bit pattern. Actual: 5–6.
  • B — identical keys, values replaced by small integers stored as
    fp64. Integer sums below 2^53 are exact under any association order, so
    any flip here would indicate corruption rather than reordering.
    Actual: 1 bit pattern, equal to the sequential host sum.
  • C — the same fp64 values with every run 64 items (single tile).
    Actual: 1 bit pattern.

Input buffers are uploaded once and checksummed before/after (reported
UNMODIFIED); the output buffer is poisoned before every repetition;
temp storage is allocated once and its size printed.

repro.cu
// Reproducer: cub::DeviceReduce::ReduceByKey is not run-to-run
// deterministic for float64 addition over a key run spanning many tiles,
// despite the documented guarantee ("Provides 'run-to-run' determinism for
// pseudo-associative reduction (e.g., addition of floating point types) on
// the same GPU device" -- cub/device/device_reduce.cuh, ReduceByKey doc
// block; present in v3.1.4 and on main).
//
// Observed (NVIDIA T600 Laptop GPU, sm_75, CUDA 13.1, CUB 3.1.4 / 300104):
// experiment A returns 5 distinct bit patterns for the long run's aggregate
// across 200 identical invocations, spread over the last ~3 ulps.
//
// Protocol:
//   - keys/values are uploaded to the device ONCE and never touched again
//     (verified by checksumming the input buffers before and after);
//   - temp storage is allocated ONCE (constant size, printed);
//   - the SAME ReduceByKey call runs REPS times on the default stream; the
//     first (long) run's aggregate is compared bit-for-bit across reps.
//
// Controls (same code path, only the data changes):
//   A) fp64 values, one 50k-item key run  -> flips if the internal
//      combination order varies between invocations;
//   B) same keys, values = small integers stored as fp64. Integer sums
//      below 2^53 are EXACT in any association order, so if A's flips came
//      from memory corruption or a harness bug, B would flip too; if they
//      come from reordered floating-point combination, B is bit-stable;
//   C) fp64 values but every run fits one tile (64 items) -> bit-stable,
//      isolating the nondeterminism to the cross-tile combination.
//
// Build (no dependencies beyond the CUDA toolkit's bundled CCCL):
//   nvcc -O2 -arch=native \
//        -I$CUDA_HOME/targets/x86_64-linux/include/cccl \
//        cub_reducebykey_determinism.cu -o cub_reducebykey_determinism
//   ./cub_reducebykey_determinism

#include <cub/device/device_reduce.cuh>
#include <cub/version.cuh>

#include <cstdint>
#include <cstdio>
#include <cstring>
#include <map>
#include <random>
#include <vector>

#define CK(x)                                                            \
    if ((x) != cudaSuccess) {                                            \
        printf("cuda error %s @%d\n", cudaGetErrorString(x), __LINE__);  \
        return 1;                                                        \
    }

struct Sum {
    __host__ __device__ double operator()(double a, double b) const {
        return a + b;
    }
};

static uint64_t fnv1a(const void* p, size_t n) {
    const auto* b = static_cast<const unsigned char*>(p);
    uint64_t h = 1469598103934665603ULL;
    for (size_t i = 0; i < n; ++i) h = (h ^ b[i]) * 1099511628211ULL;
    return h;
}

static int experiment(const char* name, const std::vector<uint64_t>& keys,
                      const std::vector<double>& vals, int reps) {
    const size_t n = keys.size();
    uint64_t *d_keys, *d_ukeys;
    double *d_vals, *d_aggs;
    size_t* d_nruns;
    CK(cudaMalloc(&d_keys, n * 8));
    CK(cudaMalloc(&d_ukeys, n * 8));
    CK(cudaMalloc(&d_vals, n * 8));
    CK(cudaMalloc(&d_aggs, n * 8));
    CK(cudaMalloc(&d_nruns, 8));
    CK(cudaMemcpy(d_keys, keys.data(), n * 8, cudaMemcpyHostToDevice));
    CK(cudaMemcpy(d_vals, vals.data(), n * 8, cudaMemcpyHostToDevice));
    const uint64_t in_hash =
        fnv1a(keys.data(), n * 8) ^ fnv1a(vals.data(), n * 8);

    size_t temp_bytes = 0;
    cub::DeviceReduce::ReduceByKey(nullptr, temp_bytes, d_keys, d_ukeys,
                                   d_vals, d_aggs, d_nruns, Sum{}, n);
    void* d_temp;
    CK(cudaMalloc(&d_temp, temp_bytes));

    std::map<uint64_t, int> observed;  // aggregate[0] bit pattern -> count
    size_t nruns_first = 0;
    for (int rep = 0; rep < reps; ++rep) {
        CK(cudaMemset(d_aggs, 0xEE, n * 8));  // poison: must be overwritten
        cub::DeviceReduce::ReduceByKey(d_temp, temp_bytes, d_keys, d_ukeys,
                                       d_vals, d_aggs, d_nruns, Sum{}, n);
        uint64_t bits = 0;
        size_t nruns = 0;
        CK(cudaMemcpy(&bits, d_aggs, 8, cudaMemcpyDeviceToHost));
        CK(cudaMemcpy(&nruns, d_nruns, 8, cudaMemcpyDeviceToHost));
        if (rep == 0) nruns_first = nruns;
        if (nruns != nruns_first) printf("  !! run count changed\n");
        ++observed[bits];
    }

    // Re-download the input to prove it was never modified.
    std::vector<uint64_t> keys2(n);
    std::vector<double> vals2(n);
    CK(cudaMemcpy(keys2.data(), d_keys, n * 8, cudaMemcpyDeviceToHost));
    CK(cudaMemcpy(vals2.data(), d_vals, n * 8, cudaMemcpyDeviceToHost));
    const uint64_t out_hash =
        fnv1a(keys2.data(), n * 8) ^ fnv1a(vals2.data(), n * 8);

    // Host reference: strict left-to-right sum of run 0.
    double seq = 0;
    for (size_t i = 0; i < n && keys[i] == keys[0]; ++i) seq += vals[i];

    printf("%s: n=%zu runs=%zu temp=%zu B, input %s, %d reps -> %zu "
           "distinct bit patterns for aggregate[0]\n",
           name, n, nruns_first, temp_bytes,
           in_hash == out_hash ? "UNMODIFIED" : "MODIFIED(!)", reps,
           observed.size());
    for (const auto& [bits, count] : observed) {
        double v;
        memcpy(&v, &bits, 8);
        printf("    %.17g  (0x%016llx)  x%d%s\n", v,
               static_cast<unsigned long long>(bits), count,
               memcmp(&v, &seq, 8) == 0 ? "  == sequential host sum" : "");
    }
    cudaFree(d_keys);
    cudaFree(d_ukeys);
    cudaFree(d_vals);
    cudaFree(d_aggs);
    cudaFree(d_nruns);
    cudaFree(d_temp);
    return 0;
}

int main() {
    int dev = 0;
    cudaDeviceProp prop{};
    CK(cudaGetDeviceProperties(&prop, dev));
    int drv = 0, rt = 0;
    cudaDriverGetVersion(&drv);
    cudaRuntimeGetVersion(&rt);
    printf("GPU: %s (sm_%d%d), driver %d, runtime %d, CUB %d\n\n", prop.name,
           prop.major, prop.minor, drv, rt, CUB_VERSION);

    const size_t n = 65536;
    std::mt19937_64 rng(42);
    std::uniform_real_distribution<double> dist(-1.0, 1.0);

    // A: one 50k-item run of key 0 (fp64 values), then short runs.
    std::vector<uint64_t> keys(n);
    std::vector<double> vals_fp(n), vals_int(n);
    for (size_t i = 0; i < n; ++i) {
        keys[i] = (i < 50000) ? 0 : 1 + (i - 50000) / 64;
        vals_fp[i] = (i % 7 == 0) ? dist(rng) * 1e-2 : 0.0;
        vals_int[i] = static_cast<double>(static_cast<int>(rng() % 1000));
    }
    if (experiment("A) fp64, 50k-item run   ", keys, vals_fp, 200)) return 1;

    // B: identical keys, integer-valued doubles (any order sums exactly).
    if (experiment("B) int-as-fp64, same run", keys, vals_int, 200)) return 1;

    // C: fp64 values, all runs short (64 items, single-tile).
    std::vector<uint64_t> keys_short(n);
    for (size_t i = 0; i < n; ++i) keys_short[i] = i / 64;
    if (experiment("C) fp64, 64-item runs   ", keys_short, vals_fp, 200))
        return 1;
    return 0;
}

Expected behavior

Observed output

T600 (sm_75, CUDA 13.1):

GPU: NVIDIA T600 Laptop GPU (sm_75), driver 13010, runtime 13010, CUB 300104

A) fp64, 50k-item run   : n=65536 runs=244 temp=5119 B, input UNMODIFIED, 200 reps -> 6 distinct bit patterns for aggregate[0]
    -0.042204912555673282  (0xbfa59be1de5076d2)  x10
    -0.042204912555673296  (0xbfa59be1de5076d4)  x2
    -0.04220491255567331  (0xbfa59be1de5076d6)  x53
    -0.042204912555673324  (0xbfa59be1de5076d8)  x84
    -0.042204912555673338  (0xbfa59be1de5076da)  x5
    -0.042204912555673352  (0xbfa59be1de5076dc)  x46
B) int-as-fp64, same run: n=65536 runs=244 temp=5119 B, input UNMODIFIED, 200 reps -> 1 distinct bit patterns for aggregate[0]
    24857026  (0x4177b49c20000000)  x200  == sequential host sum
C) fp64, 64-item runs   : n=65536 runs=1024 temp=5119 B, input UNMODIFIED, 200 reps -> 1 distinct bit patterns for aggregate[0]
    0.024264975624164437  (0x3f98d8eaf2f9e148)  x200

H100 (sm_90):

GPU: NVIDIA H100 80GB HBM3 (sm_90), driver 13020, runtime 12080, CUB 300104

A) fp64, 50k-item run   : n=65536 runs=244 temp=5119 B, input UNMODIFIED, 200 reps -> 5 distinct bit patterns for aggregate[0]
    -0.042204912555673296  (0xbfa59be1de5076d4)  x6
    -0.04220491255567331  (0xbfa59be1de5076d6)  x85
    -0.042204912555673324  (0xbfa59be1de5076d8)  x26
    -0.042204912555673338  (0xbfa59be1de5076da)  x11
    -0.042204912555673352  (0xbfa59be1de5076dc)  x72
B) int-as-fp64, same run: n=65536 runs=244 temp=5119 B, input UNMODIFIED, 200 reps -> 1 distinct bit patterns for aggregate[0]
    24857026  (0x4177b49c20000000)  x200  == sequential host sum
C) fp64, 64-item runs   : n=65536 runs=1024 temp=5119 B, input UNMODIFIED, 200 reps -> 1 distinct bit patterns for aggregate[0]
    0.024264975624164437  (0x3f98d8eaf2f9e148)  x200

Expected behavior

Per the documentation, identical invocations on the same device should
return bit-identical aggregates, including for floating-point addition
("pseudo-associative reduction").

Reproduction link

No response

Operating System

No response

nvidia-smi output

No response

NVCC version

No response

Activity

  1. github-actions commented on Jul 17, 2026

    @github-actions
    Contributor

    Hi @guillaume-michel!

    Thanks for submitting this issue - the CCCL team has been notified and we'll get back to you as soon as we can!
    In the mean time, feel free to add any relevant information to this issue.

  2. gevtushenko commented on Jul 17, 2026

    @gevtushenko
    Collaborator

    Hello @guillaume-michel and thank you for filing the issue!

    It was identified before NVIDIA/cub#441. I forgot to change reduce docs when fixing this in NVIDIA/cub#432. Since then, @srinivasyadav18 implemented deterministic scan by using new scan technique (lookahead) that performs similarly to non-deterministic one. Instead of fixing docs, I'd like us to try and make reduce by key deterministic to evaluate the performance impact. If performance difference is tolerable, we could keep the doc.

    @nanan-nvidia is currently integrating lookahead into run length encode. It's very similar to reduce by key, so we should be able to re-use a lot of code there.

  3. ahmedtaha100 commented on Aug 26, 2026

    @ahmedtaha100
    Contributor

    Is anyone currently adapting the Run Length Encode lookahead implementation to ReduceByKey, or would an external contribution addressing this issue be welcome? @gevtushenko

  4. moved this from Todo to In Progress in CCCLon Aug 27, 2026
  5. moved this from In Progress to In Review in CCCLon Aug 27, 2026
  6. gevtushenko commented on Aug 27, 2026

    @gevtushenko
    Collaborator

    Is anyone currently adapting the Run Length Encode lookahead implementation to ReduceByKey

    @nanan-nvidia imlemented initial PoC in #10680. We haven't planned productization of his lookahead implementation yet.

    would an external contribution addressing this issue be welcome?

    External contributions are always welcome! The issue covers a couple of limitations. If you are interested in trying to address them, please, comment on #10680 and I'll assign to you.

  7. ahmedtaha100 commented on Aug 27, 2026

    @ahmedtaha100
    Contributor

    Just left a comment, thank you!

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

    needs triageIssues that require the team's attention

    Type

    Projects

    • Status
      In Review

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions