Skip to content

Make ClockCache hits lock-free - #9

Closed
lkdvos wants to merge 1 commit into
mainfrom
lockfree-clock
Closed

lkdvos wants to merge 1 commit into
mainfrom
lockfree-clock

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 2, 2026

Copy link
Copy Markdown
Member

With this change, ClockCache hits take no lock, so a cache shared between threads scales on hits. Misses, insertions, evictions and every other change still take the lock. ClockCache keeps its name and stays the default container.

Design

  • The index is a fixed-size open-addressing table of immutable entries. A table is never resized in place: growing builds a new one and publishes it atomically. Readers load slots with acquire ordering, so they never read out of bounds and never pair a key with the wrong value.
  • The reference bit lives on each entry as @atomic ref::Bool, and is only written when it is clear. Hot entries cause no writes at all.
  • Hit and miss counts are kept per thread, in separate cache lines.
  • A benign race: a lookup that races with a removal may return the removed entry, and one that races with an insertion may miss it. The value always belongs to its key.
  • Invariants: three, documented in clock.jl. Rationale and alternatives are in research/lockfree-clock.md:
    • reading Julia's Dict without the lock is memory-unsafe;
    • sharded locks don't help hits;
    • copy-on-write snapshots cost 0.2–0.35 ms per insert.
  • Task-local caches keep the previous locked code, as the internal LocalClockCache, so they don't carry the per-thread counters (64 B per thread per cache).

Also fixed

  • collect(c) could throw under threads, for every container, because it sized its result from one snapshot and filled it from another.
  • get! hashes the key once and specializes on default. Without that, Julia 1.10 allocated on hits.

Benchmarks (Julia 1.13, ns per lookup per task)

Threads Before After
@cached hits, Int keys 1 37 20
@cached hits, Int keys 8 529 28
@cached hits, 4-tuple keys 8 853 41
Zipf mixed hits and misses 8 2874 2086

Shared hits still allocate 0 bytes. The benchmarks are in benchmark/contention.jl.

Tests

  • The full suite passes on Julia 1.13.
  • Container, concurrency and macro tests pass at -t 8 on 1.10, 1.11, 1.12 and 1.13. That includes the new stress test, in which many tasks run every operation concurrently and every returned value is checked against its key.

Known risk

The release half of publishing a slot relies on setindex! compiling to a release store. That is codegen behaviour, checked on 1.10–1.13, not documented API. AtomicMemory would make it explicit once 1.10 is dropped.

🤖 Generated with Claude Code

ClockCache is now an open-addressing table of immutable entries, published
atomically and read with acquire loads, so hits take no lock and write no
shared cache line (hits and misses are counted per thread). Misses and all
other mutations still take the lock. Shared hits stay at ~25 ns at 8
threads (was ~500 ns) and are 2x faster single-threaded.

Task-local caches keep the previous locked implementation, now the internal
LocalClockCache, to avoid the per-thread counters in every task's cache.

Also: iteration has unknown length (collect could throw under threads), and
get! forwards to _get! with a specialized default (no allocation on 1.10).
Adds concurrency stress tests, benchmark/contention.jl and
research/lockfree-clock.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lkdvos

lkdvos commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Closing for now. The branch lockfree-clock is kept, and this can be reopened.

Why: the design works and passes every check, but it adds about 200 lines with memory-ordering invariants, and one of them relies on undocumented codegen behaviour. The evidence that contention matters in practice is thin:

  • TensorKit's own benchmarks showed no difference between the old cache, ClockCache and LRU at 8 threads, because hashing its keys dominates.
  • The large slowdowns (62 ns → ~12 µs for SU(3) Fsymbol) come from microbenchmarks of hits alone.
  • In typical memoized workloads the function itself is expensive enough to hide this, and TaskLocalCache already covers the cheap, very hot cases.

State:

  • lockfree-clock at 103c622, on top of 3983984.
  • It replaces ClockCache with the lock-free design and keeps the old locked code as the internal LocalClockCache for task-local caches.
  • The full suite passes on Julia 1.13. Container, concurrency and macro tests pass at -t 8 on 1.10, 1.11, 1.12 and 1.13. CI was green.
  • Design, alternatives and numbers are in research/lockfree-clock.md on the branch:
    • why reading the Dict without the lock is unsafe;
    • why sharded locks and copy-on-write don't fit;
    • follow-ups: AtomicMemory once 1.10 is dropped, sharded lock-free caches for miss-heavy workloads, and a single implementation for task-local caches.

Already split out: the collect race, which affects every container, is fixed separately in #10. The get! change on this branch (specializing on default) was only needed for the new type's hits on 1.10, so it stays here.

When to revisit: if the re-run of the TensorKit/SUNRepresentations integration with a threaded workload shows contention on main, or if TaskLocalCache turns out to be awkward as a workaround.

@lkdvos lkdvos closed this Oct 2, 2026
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