Axle v0.17.0

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

ClassMethodsCell
AtomicI32get · set · incrementAndGet · decrementAndGet · addAndGet · compareAndSet · swap4 bytes
AtomicI64the same seven8 bytes
AtomicBooleanget · set · compareAndSet1 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

MethodEmitted instructionAnswers
get()load atomic i32, ptr %cell seq_cstthe current value
set(v)store atomic i32 v, ptr %cell seq_cstnothing
incrementAndGet()atomicrmw add ptr %cell, i32 1 seq_cstthe new value
decrementAndGet()atomicrmw sub ptr %cell, i32 1 seq_cstthe new value
addAndGet(d)atomicrmw add ptr %cell, i32 d seq_cstthe new value
swap(v)atomicrmw xchg ptr %cell, i32 v seq_cstthe old value
compareAndSet(e, u)cmpxchg ptr %cell, i32 e, i32 u seq_cst seq_cstwhether 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:

  1. The analysis classifies the stub. A stdlib atomic method is declared as an extern "C" fn with a mangled link symbol (__axle_stdlib_concurrent_AtomicI32_incrementAndGet). Sema’s atomic_lowering pass reads that symbol against a table and stamps the match on the function — which cell width, which operation.

  2. 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’s incrementAndGet” — 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.

  3. 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:

BehaviourWhy it is observableHow the instruction reproduces it
Everything is sequentially consistentA 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 valuefetch_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 valueThe whole point of an exchange is knowing what you replaced.atomicrmw xchg answers exactly that, with no follow-up arithmetic.
compareAndSet answers a boolCallers 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 nothingA 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

optimisationatomicsconcurrencycodegen