Skip to content

partition_ref costs 5-10x more since the epoch pin, and the recorded 0.71 ns is now wrong #81

Description

@pathscale

The epoch pin landed in beta.14 (7d0faaf, efdbe41). beta.14 was never adopted because of the multimap regression, so beta.15 is the first release anyone runs with it, and partition_ref is materially slower than the number the docs still quote.

Measured

partition_ref on 64 resident partitions, same keys, release, M4 Max:

threads beta.13 beta.15
1 1.61 ns 7.78 ns 4.8x
2 0.64 ns 4.10 ns 6.4x
4 0.60 ns 6.28 ns 10.5x
8 1.61 ns 10.06 ns 6.2x

Single-threaded in a tighter loop it is 1.17 ns to 3.45 ns. contains, which takes no pin, is unaffected (1.18 ns to 0.59 ns).

docs/partitioned-tables-implementation.md still says:

| partition_ref | 0.71 ns | 0.79 ns |

partition_ref returns a borrow, touches no refcount, and is flat across the same sweep. Use it on the tick path.

That is now off by roughly 10x, and it is the sentence pointing trading at this call.

Where the cost is

Not crossbeam. EpochDomain::pin runs on every read:

LOCALS.with(|locals| {
    let mut locals = locals.borrow_mut();          // RefCell write borrow
    if let Some(pos) = locals.iter().position(..)  // linear scan
    {
        if pos != 0 { locals.swap(0, pos); }       // LRU move-to-front, a write
        return locals[0].handle.pin();
    }
    ...

So a TLS access, a RefCell write borrow, a linear scan and a move-to-front write happen before the actual pin, on the hot path, per call.

Two cheap fixes, in order

  1. Drop the move-to-front. With a small cap the scan is already cheap, and the swap is the only reason the hot path needs borrow_mut rather than borrow. Removing it takes a write off every read.
  2. Add a one-entry fast path. A Cell<(domain_id, *const LocalHandle)> checked before touching the Vec covers the overwhelmingly common case of one table being read repeatedly, and falls through to the existing cache otherwise.

A third option, skipping the pin entirely when nothing has ever been retired, is tempting but not sound as stated: a reader that observes "no retirements" and then loads the slot can still race a remove that publishes afterwards, because a reader outside the domain is invisible to the reclaimer. It would need readers to remain observable some other way, which is what epochs already are.

Reproducer

worktable!(name: Price, persist: false, partition_by: symbol_id: u16,
           columns: { exchange_id: u8 primary_key, bid: f64 });

let set = Arc::new(PricePartitions::new());
for k in 0..64u16 { set.partition_or_create(k).unwrap(); }
// N threads, then: for i in 0..1_000_000 { black_box(set.partition_ref((i % 64) as u16)); }

Whatever the fix, the doc table needs correcting either way: right now it tells the tick path to use a call that is 10x its documented cost.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions