LLVM · NVPTX backend PR #200732 → PR #206154 Technical assessment

Fast atomics or honest atomics:
what should NVPTX do by default?

PR #200732 made atomicrmw fadd respect the function's denormal mode — at the cost of turning most f32 atomic adds into CAS loops. PR #206154 proposes to make the old fast-but-unfaithful lowering the default again. This report weighs both defaults from the IR-contract, end-user, and deployment perspectives, and recommends a path.

Adopt the fast default (PR #206154) — but only as a documented transition, with a supported per-operation opt-out as the exit criterion.

The correct-by-default lowering is the right destination, but today it regresses the most common configuration (default IEEE mode → every global f32 atomicrmw fadd becomes a CAS loop), offers no denormal mode that yields native atomics across address spaces, and its only remedy is a hidden -mllvm flag that is not a supported interface and is nearly unusable in JIT pipelines. Meanwhile the correctness it buys has no demonstrated beneficiaries.

The deciding fact: clang already ships the right control surface — -fatomic-ignore-denormal-mode / [[clang::atomic(ignore_denormal_mode)]], lowered to per-instruction metadata that AMDGPU honors for exactly this problem. NVPTX should adopt that mechanism, and flipping the default back to FTZ-correct should be gated on it, on an announced schedule — not on hope.

01

What happened

PTX's native atom.add.f32 has a fixed, address-space-dependent flush-to-zero behavior that cannot be changed by the program[2]:

OperationDenormal behaviorConfigurable?
atom.add.f32, global memoryAlways flushes denormals (FTZ)No
atom.add.f32, shared memoryNever flushesNo
atom.add.f16 / .bf16Never flushesNo
atom.add.f64No f64 FTZ on NVIDIA GPUs—

For years the NVPTX backend lowered atomicrmw fadd to atom.add unconditionally — fast, single-instruction, and identical to what nvcc does for CUDA's atomicAdd(), but oblivious to the function's declared denormal mode. Under LLVM's default (IEEE) floating-point environment, a global-memory atomic add silently flushed denormals: a genuine miscompile with respect to the IR contract.

02

What the IR contract actually says

LangRef is unambiguous on three points[1]:

Mapping the hardware onto that contract:

Function modeGlobal atom.add.f32Shared atom.add.f32Generic ptr
ieee (default) violates flushes when it must preserve conforms unknowable
preservesign conforms violates ignores mandated input flush unknowable
Structural observation No single function-level denormal mode makes atom.add.f32 legal in both address spaces, and a generic pointer can never be proven legal at compile time. The conflict is not between “right mode” and “wrong mode” — it is between a per-function attribute and per-instruction, per-address-space hardware behavior. A function-level knob cannot resolve it; only a per-operation one can (§7).

So the pre-#200732 lowering was, strictly, a miscompile — and worse in one direction than the other: in IEEE mode it silently introduced flushing, which is never permitted; in FTZ mode failing to flush inputs is at least the kind of slop many FTZ users tolerate. #200732 fixed a real contract violation. The question is not whether it was a bug fix. It was. The question is what the default should be while the ecosystem cannot yet express the alternative.

03

The two defaults, side by side

OPTION A · #200732

FTZ-correct by default

  • Backend honors LangRef; atomicrmw fadd means what the IR says.
  • Numeric behavior is a function of the IR alone, not of which memory the pointer happens to target.
  • Latent races behind convergent atomics get flushed out rather than grandfathered.
  • Default IEEE mode → every global f32 atomic add becomes a CAS loop; the most common configuration regresses.
  • Measured, bisected regressions in production XLA workloads.
  • CAS loop serializes under contention and introduces divergence — a delayed-discovery failure mode (perf dashboards, heisenbugs), not a crash.
  • Only remedy is a hidden cl::opt — process-global, unsupported, hostile to JIT deployment.
  • Diverges from nvcc, which always emits atom.add for atomicAdd().
OPTION B · #206154

Fast by default (status quo ante)

  • Preserves decade-old performance and convergence behavior every existing binary was built against.
  • Matches nvcc semantics and CUDA-programmer expectations: atomicAdd = the hardware instruction.
  • Denormal-sensitive users (rare, and typically expert) can opt into correctness.
  • Atomic FP reductions are already order-nondeterministic; per-op flush noise is usually below run-to-run variance.
  • The backend knowingly violates LangRef by default — an unsound target default other passes can't reason about.
  • Wrong-by-default with a "we'll flip it later" plan historically becomes permanent (jlebar's flip-back skepticism is well founded).
  • Correct behavior is now opt-in via the same unsupported flag surface.
  • Users who do set denormal_fpenv(ieee) deliberately still get silent flushing — their explicit request is ignored.
CriterionA · CorrectB · Fast
IR contract fidelityDoes codegen mean what the IR says? soundviolated by default
Performance of common caseDefault-mode global f32 atomics CAS loopnative
Behavioral continuityExisting deployed code, incl. racy-but-stable patterns breaks silentlyunchanged
Ecosystem consistencynvcc / CUDA expectations divergesmatches
Failure-mode qualityHow victims discover the change delayed perf loss, divergence heisenbugsrare silent numeric drift
Beneficiaries todayUsers demonstrably helped by the default none identifiedmost GPU users
Long-term IR healthPrecedent for target defaults goodacceptable only if documented & time-boxed
04

The LLVM point of view

The principled position — jlebar's — is straightforward and largely right: LangRef is a contract, a backend that ignores denormal_fpenv on atomicrmw fadd miscompiles, and programs broken by CAS-loop divergence contain data races that LLVM was always entitled to expose (atomics are not convergent; the compiler may already duplicate or sink them in ways that break warp-convergence assumptions). Correctness fixes should not be held hostage to programs that "happened to work."

But LLVM practice tempers the principle in two relevant ways.

First, targets do make pragmatic speed/semantics trade-offs at defaults, and the project's real norm is that such trade-offs must be visible and controllable — not that they never happen. The NVPTX backend in this very file defaults UsePrecSqrtF32 one way and approximate-log2 the other, each with a rationale about matching CUDA-ecosystem expectations. The sin is not deviation; it is silent, uncontrollable deviation.

Second, the fix's benefit is asymmetric to its cost. Correctness fixes normally protect someone. Here, nobody has surfaced a workload harmed by denormal flushing in FP atomics across a decade of the old behavior — reductions fed by atomic fadd are already nondeterministically associated, so per-op flushing is typically noise beneath run-to-run variance. The costs, by contrast, are measured (XLA regressions), broad (all default-mode global f32 atomics), and of the worst discovery class: performance loss and divergence bugs found weeks later by bisection, not compile errors found in CI.

The uncomfortable middle A default that violates the IR contract is a liability. A default that regresses nearly every user of a common operation while helping no identified user is also a liability. LLVM's history with comparable situations (AMDGPU FP atomics, §7) resolved this by moving the decision to a per-instruction, frontend-visible control — which is the actual fix. Both PR defaults are stopgaps; the question is which stopgap to stand on while building the real thing.
05

The end-user point of view (CUDA and friends)

From a high-level language, the user's expressible intents are painfully coarse. A CUDA programmer writes atomicAdd(ptr, x) and controls denormal behavior only via -ftz=true/false (clang: -fcuda-flush-denormals-to-zero), which sets a per-function mode. Under Option A, the outcomes are:

User compiles withGlobal atomicAddShared atomicAddGeneric ptr
default (no FTZ)CAS loopnativeCAS loop
-ftz=truenativeCAS loopCAS loop

There is no spelling available to the user that yields native atomics everywhere — and kernels routinely mix shared-memory staging with global accumulation, so "pick the mode that matches your pointers" doesn't exist as advice. Worse, whether a given atomicAdd compiles to one instruction or a divergent loop is now invisible at the source level; the answer requires reading backend source. That destroys the user's ability to reason about both performance and synchronization behavior of a primitive they think of as "one hardware instruction." (Do I need __syncwarp() after this atomicAdd? Today: it depends on the address space, the denormal attribute, and the compiler version.)

Also worth naming plainly: nvcc unconditionally compiles atomicAdd(float*) to atom.add.f32, and CUDA's own documentation describes the global-memory flush behavior as a property of the operation. The CUDA ecosystem's de facto contract is "atomicAdd is the hardware instruction, with the hardware's quirks." A clang/LLVM default that quietly deviates from that puts the two CUDA compilers at odds on a performance cliff, which users experience as "clang is slow," not as "clang is correct."

The counterweight: a user who has deliberately requested IEEE semantics — a numerics-careful HPC code, say — is silently denied them under Option B, with no diagnostic. That user exists in principle; the discussion surfaced none in practice. Their remedy under Option B (flip one flag, or soon, per-block attribute §7) is far cheaper than the majority's remedy under Option A (audit every atomic for perf and convergence, in every framework in the stack).

06

Build & deployment realities

The escape hatch that makes Option A tolerable is -mllvm -nvptx-allow-ftz-atomics=true. As a deployment interface, it fails on every axis:

Option B's deployment story is the mirror image, and much smaller: the rare denormal-sensitive user must discover that FP atomics ignore their FTZ setting and opt into strict lowering. That discovery is also silent today — which is why the recommendation below makes documentation and a supported control mandatory, not optional. But asking few, expert users to opt in beats asking everyone else to opt out through an interface most of them cannot reach.

07

The AMDGPU precedent: this problem is already solved once

AMDGPU faced the identical dilemma years earlier — its global FP atomics also have fixed denormal behavior that can conflict with the function's mode — and the resolution that survived review was neither a wrong default nor a hidden flag. It was per-instruction metadata, emitted by the frontend from a supported user-facing control[3]:

; user said "denormal handling on this atomic is insignificant" —
; backend may emit the native instruction despite the mode mismatch
%r = atomicrmw fadd ptr addrspace(1) %p, float %v seq_cst,
        !amdgpu.ignore.denormal.mode !0

The full control stack already exists in-tree, today:

LayerMechanismStatus
Driver flag-fatomic-ignore-denormal-mode (alias: -munsafe-fp-atomics)shipping
Source, scoped[[clang::atomic(ignore_denormal_mode)]] on a compound statementshipping
IR!amdgpu.ignore.denormal.mode on atomicrmw faddshipping
Backend honorAMDGPU: yes (SIISelLowering) · SPIR-V AMDGCN: yes · NVPTX: nothe gap

This dissolves most of the dilemma. The reason #206154 exists is that Option A's victims have no reachable remedy; the reason jlebar distrusts Option B is that "temporary" wrong defaults have no exit. A per-operation control that clang's CUDA path emits (for atomicAdd, or under a flag, or per [[clang::atomic]] block) gives Option A a usable remedy — and thereby gives Option B a concrete, testable exit criterion. It also answers Artem-B's "the escape hatch is too indiscriminate" objection: metadata is per-instruction, composable across TUs, and JIT-friendly (frameworks control their own IR; no process-global flag plumbing).

Naming note The metadata should be generalized (e.g. !atomic.ignore.denormal.mode or a target-neutral spelling blessed in LangRef) rather than having NVPTX match on an amdgpu.-prefixed string — but that is a bikeshed, not a blocker; dropped metadata is semantically safe by design, so a transition is low-risk.
08

Recommendation

Land #206154: default nvptx-allow-ftz-atomics to true. Between two flawed stopgaps, prefer the one whose harms are small, opt-out-able by its few affected users, and consistent with what the ecosystem has always shipped — over the one whose harms are broad, latent, and unfixable through any supported interface. But land it as a transition with obligations, not as a resting state:

  1. Document the deviation, loudly.

    NVPTXUsage.rst and the release notes must state that by default atomicrmw fadd may not honor denormal_fpenv on f32, what the hardware does per address space, and how to get strict behavior. A known deviation is a policy; an undocumented one is a bug that ferments.

  2. Wire up the per-operation control for NVPTX.

    Honor ignore-denormal metadata in shouldExpandAtomicRMWInIR, and have clang's CUDA/OpenMP paths emit it via the existing -fatomic-ignore-denormal-mode / [[clang::atomic]] machinery. This replaces the process-global chicken switch with a supported, per-instruction, JIT-friendly surface, and gives XLA/Triton something they can emit directly.

  3. Flip the default back on an announced schedule, gated on (2) — not on PTX.

    Once frontends can express "fast atomic here, please," the strict default becomes livable and should return, with a release-note deprecation window. Do not gate on future PTX ftz/non-ftz atomic variants: as the discussion established, those arrive behind PTX-version floors and may well be SASS CAS loops themselves — jlebar's skepticism on this point deserves to be ratified as policy.

  4. Treat the divergence hazard as real but out of scope.

    Unsynchronized read-after-atomic is a data race whichever default wins, and LLVM may break it through unrelated transforms. The right venue is documentation and (if NVIDIA wants stronger guarantees) an explicit convergent atomic intrinsic — not the FTZ default. Neither PR should carry that weight.

In short: #200732 identified the right contract; #206154 identifies the right default for today; the AMDGPU mechanism is the right bridge between them. The failure mode to avoid is landing #206154 and stopping — that converts a correctness fix into a dead flag and validates every "temporary defaults are forever" prior. Conditions (1)–(3) are cheap, mostly-existing work; they should be part of the same conversation, ideally the same patch series.

09

Steelman of the other side

An honest report says what would change its verdict.

A

Appendix: original prompt

This assessment was produced in response to the following request, reproduced verbatim:

PR https://github.com/llvm/llvm-project/pull/200732 implemented correct denormals folding for atomic FP operations on NVIDIA GPUs, but it also introduced undesirable side effect in the code that happened to work acceptably well for the existing code. Details and the discussion are in https://github.com/llvm/llvm-project/pull/206154. I want an impartial expert opinion on what should be the default compiler behavior and why -- fast atomics that ignore specified denormals handling mode (pre-patch behavior) or correct denormals handling, but with side effects that will need to be addressed by all affected users. Consider this from both the LLVM's point of view, from the end-user's point of view (e.g. what can user do from a high-level language like CUDA), and from the build and deployment point of view where adoption may need build changes, and/or workarounds for the performance regressions. Supplementary material: LLVM IR reference is the canonical IR spec/contract and can be found in llvm/docs/LangRef.rst

Create your report as a HTML document served with a simple python web server and give me its URL.

Sources

  1. LLVM LangRef — default FP environment (“subnormals are assumed to be preserved”), denormal_fpenv attribute semantics (output flush permitted vs. input flush mandated), atomicrmw fadd semantics. llvm/docs/LangRef.rst (≈ lines 2745–2799, 4110–4122, 12582).
  2. NVPTX lowering logic and FTZ behavior matrix: llvm/lib/Target/NVPTX/NVPTXISelLowering.cpp:7439–7484; AllowFTZAtomics flag at lines 109–120.
  3. AMDGPU per-instruction control: !amdgpu.ignore.denormal.mode, llvm/docs/AMDGPUUsage.rst:2243; honored in llvm/lib/Target/AMDGPU/SIISelLowering.cpp:20139; emitted by clang in clang/lib/CodeGen/Targets/AMDGPU.cpp:575 from -fatomic-ignore-denormal-mode (alias -munsafe-fp-atomics, clang/include/clang/Options/Options.td:5926) and [[clang::atomic(...)]] (clang/include/clang/Basic/Attr.td:5424).
  4. PR #200732 — “[NVPTX] Respect FTZ flag when lowering atomicrmw fadd” (jlebar, merged 2026-06-03), including review discussion with AlexMaclean, gonzalobg, akshayrdeodhar, Artem-B.
  5. PR #206154 — “[NVPTX] Set default value of nvptx-allow-ftz-atomics to true” (Artem-B, open), including the XLA regression report, the legacy "denormal-fp-math"/autoupgrade analysis, and the thread-divergence discussion.