Atomic cells lower to one instruction
std/concurrent ships three atomic classes — AtomicI32, AtomicI64 and AtomicBoolean. On the Rust side each one is a boxed
atomic cell, and each method has a native shim that dereferences the
handle and performs the operation:
pub fn increment(&self) -> i32 {
self.0.fetch_add(1, AtomicOrdering::SeqCst).wrapping_add(1)
} So a call to hits.incrementAndGet() used to be an ordinary FFI
call: cross the boundary, null-test the handle, dereference it,
do one atomic, come back. The operation was already a single
instruction — everything around it was the cost.
Axle removes the call. When the compiler resolves a call to one of these methods it emits the hardware atomic at the call site, and the emitted program contains no call into the shim at all. This page is what that means, which of the shim’s behaviours had to be preserved to make it sound, and where it stops.
The surface
| Class | Methods | Cell |
|---|---|---|
AtomicI32 | get · set · incrementAndGet · decrementAndGet · addAndGet · compareAndSet · swap | 4 bytes |
AtomicI64 | the same seven | 8 bytes |
AtomicBoolean | get · set · compareAndSet | 1 byte |
The handle an Axle object carries is the address of the cell — new AtomicI32(0) boxes the cell and hands back its address. That is
what makes the lowering possible: there is no lookup table to
consult, so the pointer is already in the register the method would
have passed.
What each method compiles to
| Method | Emitted instruction | Answers |
|---|---|---|
get() | load atomic i32, ptr %cell seq_cst | the current value |
set(v) | store atomic i32 v, ptr %cell seq_cst | nothing |
incrementAndGet() | atomicrmw add ptr %cell, i32 1 seq_cst | the new value |
decrementAndGet() | atomicrmw sub ptr %cell, i32 1 seq_cst | the new value |
addAndGet(d) | atomicrmw add ptr %cell, i32 d seq_cst | the new value |
swap(v) | atomicrmw xchg ptr %cell, i32 v seq_cst | the old value |
compareAndSet(e, u) | cmpxchg ptr %cell, i32 e, i32 u seq_cst seq_cst | whether the swap happened |
Take a program that uses five of them:
use std::concurrent::AtomicI32;
fn main() : i32 {
let hits : AtomicI32 = new AtomicI32(0);
hits.incrementAndGet(); // atomicrmw add
hits.incrementAndGet();
let seen : i32 = hits.swap(100); // atomicrmw xchg — returns the OLD value
if (seen != 2) { return 1; }
if (!hits.compareAndSet(100, 7)) { return 2; }
if (hits.get() != 7) { return 3; }
hits.set(9); // atomic store
return hits.get() - 9; // atomic load
} Compiled at -O0 — deliberately unoptimised, so this is what the
compiler itself emitted and not what LLVM made of it — the whole of
it is:
%atomic_old = atomicrmw add ptr %atomic_cell, i32 1 seq_cst, align 4
%atomic_old6 = atomicrmw add ptr %atomic_cell, i32 1 seq_cst, align 4
%atomic_old14 = atomicrmw xchg ptr %atomic_cell, i32 100 seq_cst, align 4
%0 = cmpxchg ptr %atomic_cell, i32 100, i32 7 seq_cst seq_cst, align 4
%atomic_load = load atomic i32, ptr %atomic_cell seq_cst, align 4
store atomic i32 9, ptr %atomic_cell seq_cst, align 4
%atomic_load47 = load atomic i32, ptr %atomic_cell seq_cst, align 4 No call to __axle_stdlib_concurrent_AtomicI32_get — or to any of
the others — survives anywhere in the module.
Under the hood: how the compiler knows
The decision is taken once, in the analysis phase, and recorded on the program. Three steps, in three different parts of the compiler:
The analysis classifies the stub. A stdlib atomic method is declared as an
extern "C" fnwith a mangled link symbol (__axle_stdlib_concurrent_AtomicI32_incrementAndGet). Sema’satomic_loweringpass reads that symbol against a table and stamps the match on the function — which cell width, which operation.Code generation reads the stamp. At a call site the lowerer resolves the callee and asks for its stamp. It never looks at a symbol’s spelling; the fact — “this is
AtomicI32’sincrementAndGet” — was settled by the analysis and is all it reads. That separation is a hard rule of the codebase: no pass downstream of the analysis is allowed to re-derive a fact it could have been told.The instruction is emitted, guarded. The handle is used as the cell pointer, the operation goes out, and the access sits behind the same null test the shim performed.
Nothing about this is specific to a class name. A new atomic class added to the standard library joins the lowering the day its symbols are listed in the table — no change to the lowerer.
What had to be preserved exactly
Inlining a call is only sound if the instruction reproduces everything the call did. Five of the shim’s behaviours are observable and all five are kept:
| Behaviour | Why it is observable | How the instruction reproduces it |
|---|---|---|
| Everything is sequentially consistent | A reader and a writer on different threads order their accesses through the same total order. Relaxing it would be a real, silent change to your program’s meaning. | Every emitted access carries seq_cst, matching the shim’s Ordering::SeqCst on every path — including the failure half of cmpxchg. |
| A counter wraps, and hands back the new value | fetch_add answers the old value; the shim then applied the step again with a wrapping add. At the top of the range the result must wrap, not trap. | atomicrmw also answers the old value, and the lowerer applies the step again with a plain (non-trapping, two’s-complement) add or sub. |
swap hands back the old value | The whole point of an exchange is knowing what you replaced. | atomicrmw xchg answers exactly that, with no follow-up arithmetic. |
compareAndSet answers a bool | Callers branch on it. | cmpxchg yields a { value, i1 } pair; the second member is the answer. |
A null handle answers 0 / false, and a store does nothing | A default-constructed or otherwise empty object carries handle 0. The shim’s accessors return a default for it rather than dereferencing null. | Every access is emitted inside a null guard: on the null edge the result is the constant 0 (or false), and a set simply does nothing. |
The last row is the one worth pausing on. It is why the inlined form is not literally “one instruction and nothing else” — the guard remains, because removing it would turn a program that asks a disarmed object for its value from “answers zero” into a segfault.
A flag is a byte, not a bit
LLVM has no atomic i1. AtomicBoolean is therefore laid out as one
byte — #[repr(transparent)] over Rust’s AtomicBool, with a
compile-time assertion on its size and alignment — and the lowering
widens the boolean to i8 on the way in and narrows it back on the
way out. A get() is an i8 atomic load followed by a truncation;
a set(true) is a zero-extension followed by an i8 atomic store.
What is not lowered
Two methods keep their call, on purpose:
- the constructor.
new AtomicI32(0)allocates the cell and returns its address. There is nothing to inline that isn’t an allocation. - the compiler-owned release. It reconstructs and drops the box. It runs exactly once, at the owner’s scope exit, and is not callable from Axle source.
Both are visible in the emitted module, and they are the only calls into the atomic shims that remain.
That the release appears more than once is worth knowing about: a function with several exit paths materialises the same release sequence on each of them. It is a code-size effect on cold paths, not a behavioural one — exactly one of them runs.
Where it stops — honestly
- Only the listed methods. The lowering is a table, not a rule
about “methods named
get”. An atomic class method outside the table keeps its call. - Only a direct call. The stamp lives on the resolved callee. A call the compiler cannot resolve to that function — through a function value, or any route that hides the target — takes the ordinary call path. The program is still correct; it just pays for the call.
- The layout is a contract, not an accident. The inline path
treats the handle as a bare
i32/i64/ byte at its natural alignment. The standard library’s atomic wrappers declare that with#[repr(transparent)]and assert their size and alignment at compile time, so a change to the wrapper’s shape is a build error rather than a miscompile.
See also
- Concurrency primitives — the user-facing surface of
std/concurrent, including when to reach for an atomic rather than a lock. - Inlining, tail calls, and whole-program merge — the general machinery behind “a call that isn’t a wall”.
- What the compiler tells LLVM — the other half of the story: the facts stamped on ordinary calls.
- Optimisations overview — the full map.