Skip to content

perf: inline cell marker to eliminate heap allocation - #5

Draft
jahfer wants to merge 10 commits into
mainfrom
inline-cell-marker
Draft

perf: inline cell marker to eliminate heap allocation#5
jahfer wants to merge 10 commits into
mainfrom
inline-cell-marker

Conversation

@jahfer

@jahfer jahfer commented Jan 15, 2026

Copy link
Copy Markdown
Owner

Summary

This PR eliminates heap allocation for cell markers by inlining atomic fields directly into the Cell struct. Previously, each cell stored a pointer to a heap-allocated Marker<K, V> enum, requiring an indirection on every read/write operation. Now, the marker state is stored as inline atomic fields within the cell itself.

Motivation

Profiling revealed that the heap-allocated marker was a significant source of overhead:

  1. Pointer indirection: Every cell access required dereferencing an AtomicPtr to read the marker state
  2. Memory allocation: Creating/updating markers required Box::new() and Box::into_raw() calls
  3. Cache inefficiency: Marker data was scattered across the heap rather than co-located with cell data
  4. Memory overhead: Each marker allocation included allocator metadata overhead

Changes

Before

pub struct Cell<K: Clone, V: Clone> {
    pub version: AtomicU16,
    pub marker: Option<AtomicPtr<Marker<K, V>>>,  // Heap-allocated pointer
    pub key: UnsafeCell<Option<K>>,
    pub value: UnsafeCell<Option<V>>,
}

pub enum Marker<K: Clone, V: Clone> {
    Empty(u16),
    Move(u16, isize),
    InsertCell(u16, K, V),   // Stored K, V redundantly
    DeleteCell(u16, K),       // Stored K redundantly
}

After

pub struct Cell<K: Clone, V: Clone> {
    pub version: AtomicU16,
    pub marker_state: AtomicU8,    // Inline: Empty=0, Move=1, Inserting=2, Deleting=3
    pub move_dest: AtomicIsize,    // Inline: destination for Move operations
    pub key: UnsafeCell<Option<K>>,
    pub value: UnsafeCell<Option<V>>,
}

#[repr(u8)]
pub enum MarkerState {
    Empty = 0,
    Move = 1,
    Inserting = 2,
    Deleting = 3,
}

Key Insight

The InsertCell(version, K, V) and DeleteCell(version, K) marker variants stored key/value data that was never read by other threads—they only served as intent markers. Only the operation state and the move_dest index (for rebalancing) need to be atomically visible. This allows us to replace the polymorphic heap-allocated enum with fixed-size inline atomics.

Benchmark Results

All benchmarks run on the same hardware, comparing before/after this change.

Point Lookups (Read Performance)

Dataset Size Before After Improvement
100 81.7 ns 78.7 ns
500 92.5 ns 87.6 ns +6.1%
1000 91.9 ns 87.9 ns +5.3%
2000 100.2 ns 91.9 ns +9.0%
5000 113.9 ns 104.7 ns +8.9%

Sequential Inserts

Dataset Size Before After Improvement
100 3.70 ms 2.80 ms +32%
500 14.2 ms 13.4 ms +5.7%
1000 27.1 ms 26.5 ms +2.3%

Random Inserts (Most Dramatic Improvement)

Dataset Size Before After Improvement
100 1.59 ms 417 µs +281% 🚀
500 2.01 ms 725 µs +177% 🚀
1000 2.33 ms 888 µs +160% 🚀
2000 3.42 ms 1.71 ms +100% 🚀
5000 5.62 ms 3.44 ms +63%

Mixed Workload (80% Read / 20% Write)

Dataset Size Before After Improvement
500 104 µs 96 µs +8.5%
1000 107 µs 96.5 µs +11%
2000 116 µs 102 µs +14%

Strided Access

Structure Before After Improvement
COBTree 989 ns 929 ns +6.8%

Why Random Inserts Improved So Dramatically

Random inserts trigger frequent rebalancing operations. The old implementation:

  1. Allocated a new Marker::Move(version, dest_index) via Box::new()
  2. Performed CAS to swap the pointer
  3. On failure, deallocated the marker and retried
  4. On success, copied data, then allocated another marker for the destination cell

The new implementation:

  1. Performs a single compare_exchange on the AtomicU8 marker state
  2. Stores the destination index in the inline AtomicIsize
  3. No heap allocation, no deallocation on retry

This eliminates all heap allocations from the hot path of cell operations.

Testing

All 103 existing tests pass. New tests added for:

  • MarkerState enum conversion and methods
  • Cell::load_marker() reconstruction
  • CellGuard version change detection on cache access

Breaking Changes

  • Cell::new() no longer takes a marker pointer argument
  • CellGuard::update() replaced with update_marker_state(MarkerState, isize)
  • Marker<K, V> enum variants InsertCell and DeleteCell now use PhantomData instead of storing actual key/value data (backwards compatibility preserved for pattern matching)

jahfer added 10 commits January 14, 2026 23:13
Replace heap-allocated AtomicPtr<Marker<K,V>> with inline atomic fields
(AtomicU8 for state, AtomicIsize for move destination). The K,V data in
InsertCell/DeleteCell markers was never read by other threads - only the
operation state matters for synchronization.

This eliminates all heap allocations from the cell operation hot path,
particularly benefiting rebalance-heavy workloads.

Benchmark improvements:
- Random inserts: +63% to +281% throughput
- Point lookups: +5% to +9% at larger dataset sizes
- Mixed workloads: +8% to +14%
- Sequential inserts: +2% to +32%
Relax SeqCst atomic orderings to Acquire on read paths for improved
performance, especially on ARM/AArch64 where SeqCst requires full
memory barriers.

Changes:
- CellGuard::from_raw(): version, marker_state, move_dest loads → Acquire
- CellGuard::cache(): version load → Acquire
- btree_map rebalance: source/dest cell reads → Acquire

The seqlock validation re-read in from_raw() remains SeqCst to prevent
reordering of data reads past the validation check on weakly-ordered
architectures.

Acquire ordering is sufficient because we only need pairwise
synchronization between a writer and its readers for each cell, not
total ordering across unrelated cells.

Added 6 tests to verify Acquire semantics work correctly.
Replace expensive key.clone() in CellGuard::from_raw() with a simple
is_some() check. The actual key/value cloning is now deferred to
cache() when the data is actually needed.

This optimization reduces overhead in the hot iteration path where code
frequently checks is_empty() to find empty slots or skip cells without
needing their actual contents. The seqlock validation still protects
against torn reads since the is_some() check happens within the
version-validated window.

Before: clone key on every from_raw() call (~10% overhead)
After:  check is_some() in from_raw(), clone only in cache()

Added tests verifying deferred cloning behavior.
Change version.store() and marker_state.store() calls after UnsafeCell
writes from SeqCst to Release ordering in the insert paths of btree_map.rs.

Release ordering is sufficient because it ensures all preceding writes
(including UnsafeCell writes to key/value) are visible to any thread that
subsequently performs an Acquire load on these atomics. This is the
"publication" side of the Release/Acquire pattern - we don't need total
ordering with writes to other cells, only pairwise synchronization with
readers of this cell.

Changes:
- 6 stores relaxed: lines ~140, ~144, ~174, ~178, ~223, ~227
- Added 5 tests verifying Release/Acquire synchronization semantics

All 116 tests pass.

Step 2 of atomic ordering relaxation plan.
Change compare_exchange and compare_exchange_weak orderings from
SeqCst/SeqCst to AcqRel/Acquire for marker_state CAS operations.

Changes:
- cell.rs: update_marker_state() CAS now uses AcqRel/Acquire
- btree_map.rs: Move marker CAS uses AcqRel/Acquire
- btree_map.rs: Best-effort cleanup CAS (version + marker) uses AcqRel/Acquire

Rationale:
- Success (AcqRel): Acquire synchronizes with prior writer's Release,
  Release publishes our claim to subsequent readers
- Failure (Acquire): Observes the blocking value; no write on failure
  so Release adds nothing

Added 5 tests verifying AcqRel/Acquire CAS semantics:
- test_cas_acqrel_synchronizes_with_prior_writes
- test_cas_failure_observes_blocking_value
- test_rebalance_cas_publishes_move_marker
- test_best_effort_cleanup_cas_correctness
- test_cas_release_sequence_with_move_dest
Step 4 of atomic ordering relaxation plan: Change move_dest.store()
from SeqCst to Release in both btree_map.rs and cell.rs.

Release ordering is sufficient because move_dest is stored before
the CAS that sets marker_state to Move. The CAS uses AcqRel ordering,
creating a release sequence that readers synchronize with via Acquire
on marker_state.

Changes:
- btree_map.rs: move_dest.store() in rebalance loop
- cell.rs: move_dest.store() in update_marker_state()

Tests added:
- test_move_dest_release_visible_after_marker_acquire
- test_move_dest_release_prevents_reorder_after_cas
- test_update_marker_state_move_dest_release
- test_concurrent_readers_observe_move_dest
Change destination cell version.store() and marker_state.store() from
SeqCst to Release ordering in rebalance (lines ~502-509). Release
ensures preceding UnsafeCell writes (key/value) are visible to readers
who Acquire-load these atomics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant