Skip to main content

tagged_index_stack/
lib.rs

1//! `tagged-index-stack` — a lock-free LIFO free-list of small **indices** (a
2//! *slot recycler*) whose head is a single atomic word packing an
3//! `(index | tag)` pair, where a STRICTLY MONOTONIC generation **tag** in the
4//! high bits eliminates the ABA problem outright — it never wraps; a push
5//! that would need to wrap is refused instead (`Err(`[`TagExhausted`]`)`) —
6//! see "The tag is strictly monotonic" below for the full mechanism and
7//! "Tag-width budget" for the pushes-until-sealed lifetime (at least
8//! `2^48 - 1` at every legal `INDEX_BITS`). Lock-freedom here describes the
9//! stack's own CAS loops; end-to-end it additionally requires a
10//! non-blocking [`StackStorage`] implementation.
11//!
12//! # The tag is strictly monotonic — it never wraps
13//!
14//! Every successful push installs a tag exactly one greater than the one it
15//! observed, and a push that observes [`TaggedIndex::TAG_MAX`] is refused
16//! (`Err(`[`TagExhausted`]`)`) instead of wrapping to 0. Consequently every
17//! `(index, tag)` head word occurs in at most one contiguous interval of the
18//! head's history — from the push that installed it until the pop that
19//! removes `index` — so a popper's CAS expecting `(index, tag)` can succeed
20//! only while `index` is still the head it observed, and the link it read is
21//! the link that push wrote. ABA is eliminated, not mitigated. The price is
22//! a finite lifetime of `2^TAG_BITS - 1` successful pushes per head — at
23//! least `2^48 - 1` at every legal width — after which the stack is sealed
24//! (pops continue; pushes are refused). See [`StackHead`]'s "Sealing is
25//! permanent" section: there is no reset API, by design.
26//!
27//! Allocation-free, `no_std`; the production library source (`src/`) is
28//! `#![deny(unsafe_code)]`, with its `unsafe` surface confined to an audited
29//! set of item-scoped `#[allow(unsafe_code)]` lint-exception regions, all in
30//! `src/imp.rs` — see ["Where unsafe lives"](#where-unsafe-lives) below for
31//! the audited region count, the full region-by-region inventory, and the
32//! unsafe-operation count those regions contain.
33//!
34//! Slab allocators, object pools, entity-component stores, and connection
35//! tables all need to recycle small integer ids, and commonly get two details
36//! wrong (documented below): **empty-transition tag preservation** and the
37//! **lazy link discipline**; both are structurally enforced here.
38//!
39//! # The packed word — [`TaggedIndex`]
40//!
41//! The stack head is one `AtomicU64` holding a [`TaggedIndex`]`<INDEX_BITS>`:
42//! the low `INDEX_BITS` bits carry a slot index, the high `64 - INDEX_BITS`
43//! bits carry a strictly monotonic generation **tag** bumped on every
44//! successful push and preserved on every pop. The all-ones value
45//! ([`empty_index`](TaggedIndex::empty_index)) is reserved as the "stack
46//! empty" sentinel, so the usable index range is `0 .. (1 << INDEX_BITS) - 1`.
47//! The classic ABA scenario — a stale CAS on `(X, old_tag)` after X is popped
48//! and re-pushed — fails because the re-push bumps the tag.
49//! [`TaggedIndex::pack`]/[`unpack`](TaggedIndex::unpack) convert between an
50//! `(index, tag)` pair and the packed word; `pack` is checked, returning
51//! `None` for an out-of-range half instead of silently truncating it.
52//!
53//! # Storage — one implementor owns the head AND the links
54//!
55//! Each pushed index's "next" link lives in the implementor's storage, reached
56//! through the [`StackStorage`] trait ([`load_next`](StackStorage::load_next) /
57//! [`store_next`](StackStorage::store_next)), alongside the head it exposes via
58//! [`head`](StackStorage::head). This is what lets a production allocator
59//! keep its links **slot-resident** (an `AtomicU32` field inside each slot it
60//! already owns) instead of paying for a second array; the crate provides
61//! [`ArrayIndexStack`]`<INDEX_BITS, N>` for standalone use. The trait is
62//! `unsafe` to implement — see its `# Safety` section. Slot-resident does
63//! not mean payload-aliased — see the [`StackStorage`] trait's `# Safety`
64//! contract (violating it defeats
65//! [`pop_index`](StackOps::pop_index)'s corruption-detection guard; see its
66//! `# Panics`).
67//!
68//! The head↔links binding is established once by the implementor's single
69//! [`StackStorage`] impl. [`StackOps`] owns the operation side through its
70//! blanket implementation, so a caller cannot supply different backing for
71//! the same head on a later call. The value-level obligations are one live
72//! binding per head for its whole life and disjoint reachable-index
73//! populations when link cells are shared; cell sharing itself is harmless.
74//! The [`StackStorage`] trait's `# Safety` contract is the source of truth
75//! for those binding obligations.
76//!
77//! [`store_next`](StackStorage::store_next) is the only write the stack ever
78//! makes to a link, and it happens during
79//! [`push_index`](StackOps::push_index), immediately before the CAS that
80//! publishes the index as the new head — see "The lazy link discipline"
81//! below. [`StackHead::is_empty`] is an advisory, `Relaxed`
82//! emptiness check for diagnostics/monitoring; a concurrent push or pop can
83//! make it stale the instant it returns, so
84//! [`pop_index`](StackOps::pop_index)'s `None` remains the only authoritative
85//! empty check.
86//! The unsafe implementation must ensure that no other storage, payload, or
87//! binding without authority writes a link cell: only the stack algorithm may
88//! mutate it during a push through the binding currently receiving valid,
89//! unique publish/recycle authority. A successful pop through one binding may
90//! transfer that authority to another binding sharing the cells; reachable
91//! populations must remain disjoint before and after the transfer. Direct or
92//! forged writes remain forbidden.
93//!
94//! # Two correctness-critical subtleties
95//!
96//! ## Empty-transition tag preservation
97//!
98//! When a [`pop_index`](StackOps::pop_index) drains the last element, the head
99//! transitions to "empty". A naive implementation packs the empty sentinel
100//! with tag 0 (the bootstrap word). That is a bug:
101//! resetting the tag to 0 reopens the ABA window — a popper parked mid-`pop`
102//! holding a stale `(idx, tag)` snapshot from before the drain sees its stale
103//! tag recur once the stack drains (→ tag 0) and is refilled by a push of the
104//! same index (→ tag 1); if the parked snapshot's tag was 1, the head word
105//! recurs exactly and the stale CAS succeeds, corrupting the free-list. The
106//! fix (in [`pop_index`](StackOps::pop_index)) packs the empty sentinel's
107//! index half with the RUNNING tag the draining pop just observed, so the tag
108//! keeps climbing across the empty transition. [`is_empty`](TaggedIndex::is_empty)
109//! inspects only the index half, so a non-zero tag on the empty word is still
110//! unambiguously "empty"; [`push_index`](StackOps::push_index) already reads
111//! the tag out of the current head and bumps it, so it composes unchanged.
112//! The shipped loom counterfactual
113//! `counterfactual_empty_transition_tag_reset_lets_aba_recur` proves this is
114//! load-bearing: with tag-reset restored, loom finds the collision.
115//!
116//! ## The lazy link discipline
117//!
118//! The stack writes a slot's link only inside
119//! [`push_index`](StackOps::push_index) (the
120//! [`store_next`](StackStorage::store_next) immediately before publishing that
121//! index as head) and performs no bulk/eager initialisation of the link
122//! storage at construction. A caller whose link backing is OS-zeroed memory
123//! (a fresh mmap, a zeroed slot array) therefore never first-touches those
124//! pages merely to set up the free-list; [`ArrayLinks::new`] likewise starts
125//! every link at `0`, matching OS-zeroed backing, rather than eagerly chaining
126//! a full free-list. Consequently a freshly-constructed stack is empty — the
127//! caller pushes indices in as they become free. This crate offers no "start
128//! with `0..N` all pushed" constructor precisely because that would require an
129//! eager link-chaining pass. (A caller that wants every index
130//! free from the start pushes `0..N` itself, or mints fresh indices via a
131//! separate monotonic counter and pushes only recycled ones here.)
132//!
133//! # Tag-width budget — the pushes-until-sealed lifetime
134//!
135//! Because the tag is strictly monotonic, it does not wrap — it SEALS: a
136//! head accepts successful pushes until its tag reaches
137//! [`TaggedIndex::TAG_MAX`] (`2^TAG_BITS - 1`), and the next push is refused
138//! (`Err(`[`TagExhausted`]`)`) rather than wrapping the tag back to 0. This
139//! is a LIFETIME bound, not a risk bound: once a head seals, pushes stop —
140//! loudly, via `Err`, never silently — because the tag never recurs, so
141//! there is no collision to reason about. This section derives how many
142//! successful pushes, and how much wall time at a hardware-bounded rate
143//! ceiling, a head's tag budget affords before that seal is reached:
144//!
145//! ```text
146//! seal_time = (2^TAG_BITS - 1) / aggregate_successful_push_rate
147//! ```
148//!
149//! The concrete `2^48 / rate`, `2^40 / rate`, and `2^32 / rate` forms below
150//! are approximation-only shorthand; the exact numerator is one less in each
151//! case.
152//!
153//! The rate term is bounded above by the fastest regime, not by the workload.
154//! An uncontended head line resident in one core's L1 makes the successful
155//! push rate roughly a `10^8`/sec hardware ceiling; contention on that one
156//! cache line only lowers the aggregate. The cited sweep's 8-16-thread rows
157//! measure roughly `1.1–1.4 × 10^7` pop+push pairs/sec, versus about
158//! `1.8 × 10^7` pairs/sec single-threaded. The deliberately generous
159//! `2 × 10^8` working ceiling below is therefore an upper bound for both
160//! regimes, not a contended-rate estimate.
161//!
162//! Taking a generous `2 × 10^8` successful pushes/sec as the working ceiling:
163//! at `INDEX_BITS = 16` — the widest permitted index half, 65535 usable
164//! indices with the `0xFFFF` empty sentinel reserved above them — the tag
165//! gets the other **48 bits**, sealing after
166//! `2^48 - 1 ≈ 2.8 × 10^14` successful pushes, which takes
167//! `2^48 / (2 × 10^8) ≈ 16` days at the deliberately generous ceiling —
168//! at which point pushes are refused (not corrupted), never silently. This
169//! bound is why `INDEX_BITS > 16` is
170//! rejected at compile time (`TaggedIndex::_CHECK_BITS`) rather than merely
171//! discouraged: at `INDEX_BITS = 24` the tag would be 40 bits,
172//! `2^40 / (2 × 10^8) ≈ 92` minutes at the same ceiling — sealing a hot
173//! free-list within a single long-running process's ordinary lifetime is a
174//! real availability concern, not merely a debugger-pause hazard — and the
175//! pre-cap `INDEX_BITS = 32` maximum gave only `2^32 / (2 × 10^8) ≈ 21`
176//! seconds, well within reach of a single benchmark run. Within the
177//! permitted range a caller still trades index range against tag headroom,
178//! but never below the 48-bit floor.
179//!
180//! The rate assumption's order of magnitude is confirmed by this repository's
181//! own bench receipt
182//! ([`docs/perf/_raw_tis_backoff_cap_sweep_run1.log`](https://github.com/PHPCraftdream/sefer-alloc/blob/main/docs/perf/_raw_tis_backoff_cap_sweep_run1.log)).
183//! For a fresh sample, run `cargo bench -p tagged-index-stack
184//! --bench tagged_index_stack_bench`; the bound needs only the order of
185//! magnitude, not the exact figure.
186//!
187//! Read this section as what it is: a bound on how long — in pushes, and in
188//! wall time at a hardware-bounded rate ceiling — a head's tag budget lasts
189//! before [`push_index`](StackOps::push_index) starts refusing with
190//! `Err(`[`TagExhausted`]`)`. It is NOT a bound on a residual ABA risk: the
191//! seal makes tag recurrence impossible regardless of how long any thread
192//! stays parked (see "The tag is strictly monotonic" above) — a caller does
193//! not need its own hazard/epoch-style protection on top for correctness.
194//! What it DOES need, for AVAILABILITY, is either enough tag headroom for
195//! its expected process lifetime at this rate ceiling, or a plan for what
196//! happens once a head seals: drain and replace it with a distinct
197//! [`StackHead`] object (see [`StackHead`]'s "Sealing is permanent" section
198//! — there is no reset). A caller needing a longer lifetime trades index
199//! range for tag headroom via a narrower `INDEX_BITS` (see
200//! [`TaggedIndex::TAG_BITS`]).
201//!
202//! # Lock-freedom and starvation
203//!
204//! [`push_index`](StackOps::push_index)/[`pop_index`](StackOps::pop_index)
205//! never block on a lock — a losing CAS retries — but lock-freedom is not
206//! starvation-freedom: a call can lose arbitrarily many CASes, and capped
207//! exponential backoff can make an unlucky call wait longer between retries.
208//! The shipped cap trades a small number of extreme outliers for better
209//! latency through p99.9. A historical repository contention sweep reported a
210//! roughly 4-5x aggregate-throughput difference on its measured host; that is
211//! historical evidence, not a current or portable performance guarantee. A
212//! latency-sensitive consumer should size its tolerance at its own thread count; the trade is host- and
213//! microarchitecture-dependent because the cap counts `spin_loop` hints, not
214//! portable time units. Full measurements and the derivation are in
215//! [`docs/perf/TIS_BACKOFF_CAP_SWEEP_GATE.md` §3.4](https://github.com/PHPCraftdream/sefer-alloc/blob/main/docs/perf/TIS_BACKOFF_CAP_SWEEP_GATE.md).
216//!
217//! # loom — the tests run against THIS type
218//!
219//! Under `--cfg loom` the stack's atomics alias to `loom::sync::atomic`, so
220//! the loom model suite model-checks the real [`ArrayIndexStack`] /
221//! [`StackHead`] / [`TaggedIndex`] code exhaustively —
222//! no `preemption_bound`, so loom explores every interleaving these small
223//! models admit. Several models run end-to-end through the shipped
224//! [`push`](StackOps::push_index)/[`pop`](StackOps::pop_index); most of the
225//! rest drive the real head atomic and real packing through
226//! `cas_head_for_test` — the one exception is the untagged-ABA counterfactual,
227//! which drives a locally-defined buggy stand-in stack. `#[should_panic]`
228//! counterfactuals prove the harness is non-vacuous.
229//!
230//! # Where unsafe lives
231//!
232//! The production library source (`src/`) contains exactly ten audited
233//! `#[allow(unsafe_code)]` regions, all in `src/imp.rs`:
234//!
235//! 1. `StackStorage`'s unsafe-trait declaration;
236//! 2. `SealedStorage`'s three unsafe-hook declarations;
237//! 3. the `StackOps::push_index` unsafe-method declaration;
238//! 4. the `StackOps` blanket implementation;
239//! 5. the shared `push_index_impl` body;
240//! 6. the shared `pop_index_impl` body;
241//! 7. the `SealedStorage` blanket bridge;
242//! 8. `ArrayIndexStack::push`;
243//! 9. `ArrayIndexStack`'s `SealedStorage` implementation.
244//! 10. the loom-only `ArrayIndexStack::store_next_for_test` probe.
245//!
246//! The production contents are exactly one unsafe trait, seventeen unsafe
247//! function declarations, zero unsafe impls, and nine local `unsafe {}`
248//! blocks. The inventory covers only the published library source.
249//!
250//! ```text
251//! rg -n '^\s*#\[allow\(unsafe_code\)\]' src/imp.rs
252//! rg -n '^\s*(?:pub(?:\([^)]*\))?\s+)?unsafe (?:trait|fn|impl)|^\s*unsafe \{|=\s*unsafe \{' src/imp.rs
253//! ```
254//!
255//! The first command checks region boundaries; the second checks the unsafe
256//! contents inside them, so neither count substitutes for the other.
257//!
258//! WHY: because allocator consumers rely on [`StackStorage`]'s exclusive-issuance
259//! contract for their own memory safety — an allocator's registry free-list
260//! today, and any third-party unsafe allocator built on this crate after
261//! publication. The moment unsafe code depends on a trait's contract, that
262//! trait is in the same category as
263//! [`core::alloc::GlobalAlloc`](https://doc.rust-lang.org/core/alloc/trait.GlobalAlloc.html)
264//! and `std::alloc::Allocator` (unstable) — both `unsafe trait` for the
265//! identical reason. Marking the trait `unsafe` does not make the compiler
266//! verify the value-level binding invariant (unobservable to the type
267//! system); it moves the unchecked promise into Rust's unsafe-contract
268//! system, where responsibility for a violation is formally assigned to
269//! whichever `unsafe impl` asserted a contract it did not uphold. The three
270//! implementor hooks AND the caller-facing push surface are `unsafe fn` — a
271//! bare call from safe code is E0133, and an `unsafe`-block call takes on the
272//! callee's own caller-side `# Safety` contract (`push_index`'s is the
273//! three-clause link-domain + liveness + exclusive-ownership contract); `pop_index` deliberately
274//! stays safe, because an unauthorized pop can only LEAK an index, never
275//! double-issue one. See the [`StackStorage`] trait's unsafe-fn hooks,
276//! `# Safety`, and `# Stability` sections.
277//!
278//! # Portability limit — requires 64-bit atomics
279//!
280//! The stack head is a single `AtomicU64` (the packed `(index | tag)` word —
281//! see above); packing both halves into one atomic word is the entire
282//! mechanism that makes the CAS in
283//! [`push_index`](StackOps::push_index)/[`pop_index`](StackOps::pop_index)
284//! atomic across index-and-tag together, so this is not an incidental
285//! implementation choice. That means this crate needs `target_has_atomic =
286//! "64"` and will **not compile** on a target without native 64-bit atomic
287//! support — notably `thumbv6m-none-eabi`, `thumbv7em-none-eabi`,
288//! `riscv32imc-unknown-none-elf`, and `armv5te-unknown-linux-gnueabi`. This
289//! crate is `no_std`-compatible, but `no_std` alone does not imply 64-bit
290//! atomic support: many Cortex-M and RISC-V-without-A-extension targets are
291//! `no_std` yet lack `AtomicU64` entirely. A build on an unsupported target
292//! fails fast with an explicit [`compile_error!`] naming the requirement,
293//! rather than the more cryptic "cannot find function/no `AtomicU64` in
294//! `core::sync::atomic`" error a bare unresolved import would otherwise
295//! produce.
296
297#![no_std]
298// `deny`, not `forbid`: the library target (`src/`) holds audited,
299// item-scoped `#[allow(unsafe_code)]` regions (tier 2 of this workspace's
300// two-tier unsafe-inventory convention) that a `forbid` lint could not
301// locally relax; `deny` keeps every OTHER `unsafe` token a hard compile
302// error. Integration tests are separate crate targets that do not inherit
303// this attribute and intentionally carry additional `unsafe impl` test
304// fixtures. See the crate docs' "Where unsafe lives" section (above) for
305// the audited region count, the full region inventory, the self-verifying
306// grep command, and the unsafe-operation count those regions hold.
307#![deny(unsafe_code)]
308// Edition 2021 gives an `unsafe fn` body ambient permission to call another
309// `unsafe fn` with no local `unsafe {}` — a real gap the tier-2 allow-region
310// grep (see "Where unsafe lives" above) cannot see, because it counts
311// `#[allow(unsafe_code)]` REGIONS, not unsafe OPERATIONS inside them. This
312// closes that gap: every unsafe call inside an `unsafe fn` body now needs
313// its own local `unsafe {}` + `// SAFETY:`, same as safe-code call sites.
314#![deny(unsafe_op_in_unsafe_fn)]
315#![deny(missing_docs)]
316
317// The stack head is one AtomicU64 (see the crate-doc "Portability limit"
318// section above), which requires native 64-bit atomic support from the target.
319// Fail fast with an explicit, named reason instead of the cryptic "no
320// `AtomicU64` in `core::sync::atomic`" unresolved-import error.
321#[cfg(not(target_has_atomic = "64"))]
322compile_error!(
323    "tagged-index-stack requires a target with native 64-bit atomics \
324     (target_has_atomic = \"64\") because its head is a single AtomicU64 \
325     packing the (index | tag) word atomically. This target does not have \
326     them (e.g. thumbv6m-none-eabi, thumbv7em-none-eabi, \
327     riscv32imc-unknown-none-elf, and armv5te-unknown-linux-gnueabi are all \
328     known-unsupported) — see the crate-root doc comment's \"Portability \
329     limit\" section."
330);
331
332// Loom is an optional `cfg(loom)`-gated dependency (feature `loom`), but Cargo
333// only resolves and links it when the implicit `loom` feature is also enabled.
334// Fail fast with a named reason instead of the cryptic "unresolved import
335// `loom`" error a cfg-without-feature build would otherwise produce.
336#[cfg(all(loom, not(feature = "loom")))]
337compile_error!(
338    "building with --cfg loom requires --features loom (loom is now an \
339     optional dependency)"
340);
341
342// The entire implementation lives in one module gated on the exact complement
343// of the two `compile_error!` conditions above: `compile_error!` does not stop
344// rustc from parsing and name-resolving sibling items, so under an invalid
345// configuration the module below is cfg'd out entirely and the build fails
346// with only the named error — no secondary name-resolution error from the
347// loom-aliasing `use` (nor from `AtomicU64` on a target without native 64-bit
348// atomics). Under a valid configuration the module compiles and its public
349// items are re-exported here.
350#[cfg(all(target_has_atomic = "64", any(not(loom), feature = "loom")))]
351mod imp;
352
353#[cfg(all(target_has_atomic = "64", any(not(loom), feature = "loom")))]
354pub use imp::*;