Skip to content

Offset Pointer

OffsetPtr<T> and SharedRegion<T>

Rust Edition Layout Lock-Free Cross-Process Memory

A 4-byte position-independent pointer to a typed slot inside a memory-mapped file. Resolves to the same logical value in any process that has the file mapped, regardless of where the OS places each process’s mapping in its address space.

The foundational primitive. Every other cross-process pointer in this crate composes on top of OffsetPtr<T> plus its host SharedRegion<T>. Once this pair is understood, the rest of the family is the same shape with extra metadata bytes bolted on.

Constraints (read first):

  • Cross-process / MMF-backed. Requires a writable backing file path. For in-process workloads that never cross a process boundary, prefer a typed-arena crate; the offset encoding buys nothing without a second address space.
  • T: Copy + 'static. No Drop glue runs on free. Types with destructors (String, Vec, Box) cannot live in a slot directly.
  • Capacity fixed at create. No auto-grow. Size the region for the workload upfront; allocation past capacity returns Err(Full).
  • Maximum u32::MAX - 1 slots per region (the 4-byte index plus the reserved NIL sentinel). For larger workloads, partition across regions.
  • clear() is not thread-safe. Intended for single-threaded bench setup and test teardown only; calling it concurrent with allocate / free corrupts the free list. All other ops are lock-free.
  • Pointer is meaningful only with the region that issued it. An OffsetPtr<T> from region A passed to region B silently resolves to wrong bytes (or returns Err(InvalidPtr) if the index exceeds B’s capacity).

Table of contents


What it is

OffsetPtr<T> is a #[repr(C)] struct with one field:

pub struct OffsetPtr<T> {
    pub index: u32,
    _phantom: PhantomData<T>,
}

Four bytes. Plain old data. Copy. Hash. Equality on the index field. A sentinel OffsetPtr::NIL whose index is u32::MAX.

SharedRegion<T> is the host. It owns the memory-mapped file, the allocator’s bump cursor, and the lock-free free list. An OffsetPtr is meaningful only when paired with the region that issued it. The region resolves the pointer by reading slot[index] out of its mmap.

The position-independent property is the whole point. A raw *const T in one process’s heap is a virtual address that the OS chose at mmap time; another process mapping the same file gets a different base address and the raw pointer dangles as a wild pointer. The 4-byte index does not have that problem: every process resolves index to base_ptr + slots_offset + index * size_of::<T>(), where base_ptr is that process’s own mapping.

When to reach for it

Reach for OffsetPtr<T> + SharedRegion<T> when all of these hold:

  • The pointer or the value it points to needs to be visible to more than one process, OR persisted to disk and reloaded later.
  • The payload T is Copy + 'static (no Drop glue, no lifetimes).
  • The slot count can be picked at allocation time (capacity is fixed at create; it does not auto-grow).
  • You want a typed arena with one slot type, not a heterogeneous bump allocator.

Reach for something else when:

  • Your data lives in one process and never crosses the boundary. A plain Vec<T> or a typed-arena crate is smaller and faster.
  • T has Drop glue. The region does not run drop on free.
  • The slot count is unknown and may exceed u32::MAX - 1. The 4-byte index caps total slots at u32::MAX - 1 (one value is reserved for NIL).

Memory layout

The MMF backing file is laid out in three contiguous regions:

    flowchart LR
    H[RegionHeader<br/>64 bytes aligned]
    N["next array<br/>capacity x u32 free-list links"]
    S["slots array<br/>capacity x T payloads"]
    H --> N
    N --> S

    classDef hdr fill:#1e3a8a,stroke:#1e40af,color:#ffffff
    classDef links fill:#7c3aed,stroke:#5b21b6,color:#ffffff
    classDef payload fill:#059669,stroke:#065f46,color:#ffffff
    class H hdr
    class N links
    class S payload
  

The header is a #[repr(C, align(64))] struct that fits in one cache line:

FieldTypePurpose
magicu320x4150_5247, sanity check on open
capacityu32Slot count, set at create, validated on open
slot_sizeu32size_of::<T>(), validated on open
bump_nextAtomicU64Bump cursor for fresh allocations
free_headAtomicU64Packed (counter, index) for the lock-free free list

The next[] array sits between header and payloads. Each entry is an AtomicU32 that holds the free-list link when its matching slot is on the free list (and is unused otherwise). Keeping the link out of the payload bytes means T is free to choose any alignment without colliding with allocator metadata.

The slots[] array is just capacity consecutive T values, byte- identical to a Vec<T> of the same length. Resolving an OffsetPtr is one pointer add and one read.

Allocation protocol

Two paths exist: a lock-free Treiber-stack pop from the free list, falling back to an atomic bump-pointer allocation.

    flowchart TD
    Start([allocate value]) --> CheckFree{free_head<br/>has slot?}
    CheckFree -->|yes| LoadHead[Load packed head<br/>counter and index]
    LoadHead --> ReadNext[Read next index<br/>from next array]
    ReadNext --> CASFree[CAS free_head to<br/>counter+1, next_index]
    CASFree -->|win| WriteSlotF[Write value to slot]
    WriteSlotF --> RetFree([Return OffsetPtr index])
    CASFree -->|lose| LoadHead

    CheckFree -->|no| Bump[bump_next.fetch_add 1]
    Bump --> CheckCap{idx less than<br/>capacity?}
    CheckCap -->|yes| WriteSlotB[Write value to slot]
    WriteSlotB --> RetBump([Return OffsetPtr index])
    CheckCap -->|no| Rollback[Rollback bump_next]
    Rollback --> ErrFull([Return Err Full])

    classDef startend fill:#0e7490,stroke:#0e7490,color:#ffffff
    classDef decision fill:#fbbf24,stroke:#92400e,color:#1f2937
    classDef action fill:#1e40af,stroke:#1e3a8a,color:#ffffff
    classDef errpath fill:#b91c1c,stroke:#7f1d1d,color:#ffffff
    class Start,RetFree,RetBump,ErrFull startend
    class CheckFree,CheckCap decision
    class LoadHead,ReadNext,CASFree,WriteSlotF,Bump,WriteSlotB action
    class Rollback errpath
  

The free-list path is a classic Treiber stack with one twist: a 32-bit counter is packed into the high half of free_head alongside the 32-bit index. Every push increments the counter, which makes the packed 64-bit word distinct even when the same index repeats. The counter exists to close the ABA race: without it, a thread that reads head = A, sleeps, then races on a CAS may see “A” again even though A has been popped, repushed with a different next link, and the link it remembered is stale. With the counter, the second view of A is a different (counter, A) pair and the CAS fails correctly. A 32-bit counter spans 4 billion pushes before it wraps, which exceeds any realistic concrete race window.

The bump path is fetch_add plus a capacity check. On overflow the bump cursor is rolled back with fetch_sub so a subsequent allocation does not see a stale bump_next > capacity.

API at a glance

OffsetPtr<T> (click to expand)
MethodSignatureReturns
OffsetPtr::NILconstsentinel pointer with index = u32::MAX
OffsetPtr::new(index: u32)constructortyped pointer
is_nil()&self -> booltrue when index equals NIL_INDEX
Copy + Clonederived4-byte copy
PartialEq + Eqderivedindex equality
Hashderivedhashes the u32 index
SharedRegion<T> (click to expand)
MethodSignatureBehavior
create(path, capacity)Result<Self, RegionError>creates the MMF, writes header, initializes free-list links
open(path, expected_capacity)Result<Self, RegionError>opens an existing MMF, validates magic / capacity / slot_size
allocate(value)Result<OffsetPtr<T>, RegionError>tries free-list pop, falls back to bump, returns Err(Full) on exhaustion
free(ptr)Result<T, RegionError>pushes the slot index back onto the Treiber stack, returns the previous value
get(ptr)Result<T, RegionError>reads the slot by value (T: Copy)
set(ptr, value)Result<(), RegionError>overwrites the slot in place
len()usizebump high-water minus free-list length (snapshot)
free_count()usizewalks the free list (O of N_free)
clear()&selfresets bump cursor to 0 and free list to NIL; not thread-safe
flush()Result<(), RegionError>msync (blocks until durable)
flush_async()Result<(), RegionError>schedules writeback; partially async on Windows
mmap_ptr()*const u8raw mmap base for primitives that build on top

Worked example

use subetha_cxc::{OffsetPtr, SharedRegion};

let region: SharedRegion<u64> = SharedRegion::create("/tmp/example.bin", 1024)?;

// Allocate two slots.
let a = region.allocate(42).expect("region full");
let b = region.allocate(99).expect("region full");

// Read them back.
assert_eq!(region.get(a)?, 42);
assert_eq!(region.get(b)?, 99);

// Free a, then allocate again. The free-list LIFO returns the same slot.
let v = region.free(a)?;
assert_eq!(v, 42);
let c = region.allocate(777).expect("region full");
assert_eq!(c.index, a.index);  // slot reused

// A second process can open the same file and resolve the same OffsetPtr.
// (run in a separate process)
// let reader: SharedRegion<u64> = SharedRegion::open("/tmp/example.bin", 1024)?;
// assert_eq!(reader.get(OffsetPtr::new(c.index))?, 777);

Benchmark results

Comparison baseline: MutexArena<T> = { Mutex<Vec<T>>, Mutex<Vec<usize>> }, the textbook in-process typed-arena + free-list pattern. The baseline takes one parking_lot::Mutex lock per allocate and per free. The SharedRegion path uses lock-free fetch_add on the bump cursor or a Treiber CAS on the free list.

Numbers are median of 10 samples, 2-second measurement window, on Windows x86-64. Each iteration is one operation. Lower is faster.

WorkloadSharedRegion (mmf)MutexArena (in-process)Ratio
allocate (bump path)60 ns91 ns1.5x faster
alloc + free cycle (free-list path)18 ns78 ns4.3x faster
get (resolve known pointer)1.6 ns17 ns10.6x faster
concurrent_alloc_4t (4 threads, 100 allocs each)416 us372 us0.89x (baseline wins)

Why each result lands where it does

allocate (bump): SharedRegion wins 1.5x

Both implementations bump-allocate to a slot. SharedRegion uses a single fetch_add on bump_next plus one std::ptr::write to the slot. MutexArena takes the slots Mutex, calls Vec::push, then releases. The Mutex acquire / release adds ~30 ns of atomic-op overhead the lock-free path skips. Vec::push also checks capacity and may reallocate (it does not in this bench because the Vec was pre-allocated to capacity, but the branch is still in the hot path).

alloc + free cycle: SharedRegion wins 4.3x

alloc + free is the Treiber stack’s home turf. SharedRegion does two compare_exchange operations (one push on free, one pop on alloc) and zero locks. MutexArena takes the free_list Mutex twice (push on free, pop on alloc) and the slots Mutex twice (write on alloc, read on free), so four mutex round-trips per cycle. At ~10 to 15 ns per uncontended parking_lot::Mutex cycle and one CAS at ~5 ns, the math works out to roughly the measured ratio.

get (resolve known pointer): SharedRegion wins 10.6x

SharedRegion::get is one bounds check, one pointer add, and one std::ptr::read. The whole call is a handful of instructions and the bench measures ~1.6 ns per resolve.

MutexArena::get has to take the slots Mutex first because the Vec might be growing concurrently. That single Mutex acquire is the entire 17 ns. A non-thread-safe baseline (just Vec<T>) resolves in the same ~1 to 2 ns the MMF version does.

The 10x gap here is not “MMF is fast”; it is “Mutex is unavoidable for safe shared access through Vec.” SharedRegion::get is safe without a mutex because the slot is never moved after allocation: slot indices are stable for the lifetime of the region.

concurrent_alloc_4t: MutexArena wins 0.89x (the honest loss)

Four threads each allocate 100 slots. Total work: 400 allocations in a tight loop, all contending on a single bump cursor.

MutexArena wins this by ~11%. The reason is that parking_lot::Mutex on a 400-allocation tight loop pays its lock cost once per allocation but the lock itself acts as a queueing mechanism: at most one thread is inside Vec::push at a time, so there is no cache-line ping-pong between threads beyond the lock word itself.

SharedRegion’s lock-free fetch_add on bump_next forces all four threads to contend on the same cache line for every allocate. At 4 threads on a 4-wide superscalar core that bounces the bump_next cache line via the MESI / MOESI coherence protocol once per allocate. The atomic op is faster than a lock acquire in isolation, but the cache-line ping-pong cost shows up in this specific high-contention pattern.

What you trade away to lose this bench: the MMF version provides cross-process visibility and disk persistence that the in-process baseline cannot match at any speed. For a workload that needs those properties, MutexArena is not a valid alternative. For a pure single-process workload with no cross-process or persistence needs, the typed-arena crates are the right choice and are faster than either of these.

For pointer-graph workloads that allocate once and read many times, the get ratio (10.6x) is the dominant cost: SharedRegion’s single-allocation overhead amortizes across thousands of reads.

Concurrency and safety

Thread-safety contract

SharedRegion<T> is Send when T: Send and Sync when T: Sync.

OperationThread-safe?
allocateyes (lock-free Treiber + atomic bump)
freeyes (lock-free Treiber push)
get (typed T: Copy)yes (slot indices are stable; payload writes are aligned for T)
setyes for T: Copy + Sized with aligned writes
clearNO (resets cursor and head without coordinating with concurrent ops)
flushyes (msync is atomic from the OS perspective)

clear is the explicit exception. It is intended for single- threaded bench setup and test teardown. Calling it concurrent with allocate or free will corrupt the free list.

Cross-process visibility

Two processes that both open the same backing file see the same slots. Writes are visible to other processes after the write completes (the mmap shares physical pages between processes). Acquire / Release ordering on bump_next and free_head provides the happens-before relationship needed for the reader process to see the slot bytes that the writer wrote before publishing the pointer.

For durability across a crash or reboot, call flush() explicitly. The OS may delay writeback indefinitely without it.

Use-after-free is the caller's problem

Slots are reused. If process A holds an OffsetPtr to a slot, and process B frees the slot and allocates into it again, A’s pointer now refers to a different value. The crate cannot distinguish “this pointer is still valid” from “this pointer was freed and the slot reallocated”, because the index is the entire pointer payload and the only signal of slot identity.

Composing primitives that need a “did this slot get reused?” check add a version or generation counter alongside the index. See TaggedOffsetPtr for one pattern.

Known limitations (verified)

These are limitations that exist in the shipped code as of this writing. Each is observable in the source.

  1. Capacity is fixed at create and never grows. The MMF file length is set once. An attempt to allocate beyond capacity returns Err(Full). Callers must size the region for the workload upfront.

  2. Maximum slot count is u32::MAX - 1. The 4-byte index plus the reserved NIL_INDEX = u32::MAX sentinel caps a single region at 4 294 967 294 slots. For workloads beyond this, partition across multiple regions.

  3. T: Copy + 'static. Drop glue is not run on free. Types with destructors (e.g. String, Vec, Box) cannot live in a SharedRegion directly. For variable-length data, store an OffsetPtr into a separate SharedRegion<u8> or SharedStringArena.

  4. clear() is not thread-safe. See the table above. Concurrent ops with clear will corrupt the free-list invariant.

  5. No drop integration with the file lifecycle. When the last SharedRegion is dropped, the file is unmapped but not deleted. Callers manage file cleanup explicitly.

  6. The free-list link array costs 4 * capacity extra bytes compared to a union’d-link allocator. The design trade is layout simplicity for any T alignment versus the extra storage.

  7. Re-opening a region does not validate the free-list. open checks magic, capacity, and slot_size from the header. If the next[] array is corrupt, allocation will still proceed but may return overlapping pointers. There is no integrity check on the free chain at open time.

  8. clear does not zero the slot payloads. Subsequent allocate writes overwrite each slot before returning the pointer, so callers never observe stale payload bytes via the normal allocate then get path. A stray reader holding a stale OffsetPtr after clear will read whatever bytes happened to be in that slot at clear time.

Common pitfalls

Pitfall 1: capacity mismatch on open

open(path, expected_capacity) validates that the on-disk header’s capacity matches the argument. Mismatch returns Err(LayoutMismatch). The capacity is not auto-detected from the file size because doing so lets the caller silently read arbitrary indices on a smaller file. Always pass the same capacity that the creating process used.

Pitfall 2: T changed between create and open

open validates size_of::<T>() against the header’s slot_size. A mismatch returns Err(LayoutMismatch). This catches the case where process A creates with SharedRegion<u64> and process B opens with SharedRegion<u32>: bytes are there but the typed view is wrong.

It does NOT catch the case where two different 8-byte types share the file accidentally. The crate has no Rust-type identity in the header.

Pitfall 3: free-then-get the same pointer
let ptr = region.allocate(42)?;
region.free(ptr)?;
// At this point ptr.index may be sitting on the free list. If
// another thread reallocates into the same slot before this thread
// calls get, the get sees the new value, not 42.
let _ = region.get(ptr);  // races with concurrent allocate

The crate does not invalidate the OffsetPtr value on free. The caller’s invariant is “do not use an OffsetPtr after passing it to free.”

Pitfall 4: counting on durability without flush

The OS may buffer mmap writes indefinitely. A crash before flush() loses any unflushed bytes. For an audit log or similar durability-critical workload, call flush() at the end of each batch. For the throughput cost of msync, see the audit-log bench documentation.

Composition with other primitives

OffsetPtr + SharedRegion is the base of these other primitives in this crate:

PrimitiveComposition
SharedHashMap<K, V>inline slots; not built on SharedRegion, but follows the same MMF + atomic-state pattern
SharedVec<T>inline slots in its own MMF, indexed positionally; conceptually a region with no free-list
SharedLinkedList<T>SharedRegion<LinkedListNode<T>> where each node carries OffsetPtr<LinkedListNode<T>> for prev / next
SharedTreiberStack<T>SharedRegion<StackNode<T>> with one OffsetPtr link per node
SharedGraph<N, E>one SharedRegion for nodes, one for edges; each edge is a node-to-node OffsetPtr pair
SharedVersionedChain<T>SharedRegion-like layout with its own header; chains MVCC versions via OffsetPtr next links
SharedStringArenabyte-region with intern-table indices

Every pointer-graph data structure in the crate uses some variant of this pattern. The OffsetPtr index is the universal way to talk about a slot, and the SharedRegion is the universal way to host slots that other processes can see.


back to subetha docs