Skip to content

Make ClockCache hits lock-free - #21

Merged
lkdvos merged 6 commits into
mainfrom
lockfree-clock
Oct 15, 2026
Merged

lkdvos merged 6 commits into
mainfrom
lockfree-clock

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Revives #9, rebased onto the current main (the SlotCache refactor and the rename).

Motivation: QuantumKitHub/SUNRepresentations.jl#57, where TensorKit reads the F- and R-symbol caches from many threads at once. The LRU there locks on every hit, so that PR adds a snapshot cache and a 64-shard LRU. With lock-free hits, a single cache per rank does the job without sharding.

Changes

  • ClockCache (src/containers/clock.jl) is now a hash table of immutable entries, replaced as a whole when it fills up and read with acquire loads.
    • On Julia 1.12+ the table is an AtomicMemory{Any}, read and written with @atomic :acquire/:release. Julia 1.10 and 1.11 lack atomic indexing, so they read a Vector{Any} through a pointer and write it with setindex!. A test checks that this store compiles to a release store with a GC write barrier there.
    • Hits take no lock and write no shared memory, except to set a clear reference bit. Hits and misses are counted per thread, in separate cache lines.
    • Misses, insertions, evictions and the other changes still take the lock, and computations still run outside it.
    • The three rules that make lock-free reads safe are listed at the top of the file.
    • Entries keep the cache's key type, so concretely typed caches (e.g. ClockCache{NTuple{6, I}, Array{Float64, 4}}) store keys unboxed. Their hits allocate nothing, like those of {Any, Any} caches.
  • Task-local caches use the previous locked clock, now the internal LocalClockCache (a SlotCache), since a single task gains nothing from lock-free hits and the per-thread counters would cost memory in every task.
  • Semantics: a lookup that races with a removal may still return the removed value, and one that races with an insertion may miss it. A returned value always belongs to its key.
  • Tests: test/test_concurrency.jl stress-tests every operation from many tasks on all three containers and checks that @cached hits are inferred and allocate nothing. The container suite also runs on LocalClockCache.
  • The docs no longer say that shared caches always lock.

Benchmarks

One task per thread, each doing 200k lookups on one shared cache (task-local: one cache per task), Julia 1.13.1, shared 32-core machine (load average about 14), ns per lookup. Measured with a contention benchmark that was not kept in the repository (see the first commit):

1 thread 8 threads
ClockCache hits 11–24 15–25
locked clock hits 29–37 330–620
LRU hits 29–44 330–1450
task-local clock hits 29–37 88–107
@cached hits, ClockCache 20–36 28–46
@cached hits, LRU 41–53 870–1840

The ranges span Int keys, 4-tuple keys and one hot key.

Limits

  • Misses still serialize on one lock. With a near-zero computation, 8 threads missing at once cost about 1.8 µs per lookup (Zipf workload; 2.7 µs for the locked clock). With realistic computations, such as F-symbols, this is negligible. Splitting the cache into several lock-free caches measured 2–4× better contended misses in Make ClockCache hits lock-free #9, but brings back per-shard size limits, so it is left out.
  • On 1.10 and 1.11, readers rely on setindex! into a Vector{Any} compiling to a release store, which is codegen behaviour rather than documented API. Those versions no longer change, and the test above guards it; 1.12+ use the documented AtomicMemory API.
  • Only measured on x86.

Tested on Julia 1.10.12, 1.11.9, 1.12.7 and 1.13.1 with 8 threads.

🤖 Generated with Claude Code

lkdvos and others added 2 commits October 14, 2026 18:13
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 ~15-25 ns at 8
threads (was ~330-620 ns) and are 2x faster single-threaded. Entries keep
their key type, so concretely typed caches store keys unboxed.

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

Adds concurrency stress tests and benchmark/contention.jl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Slots are read and written through `@atomic` indexing of an `AtomicMemory`
  on 1.12+. On 1.10 and 1.11 the pointer load and `setindex!` stay, with a
  test that the store compiles to a release store with a GC write barrier.
- Fewer single-use helpers and descriptive names without leading
  underscores; the fields of `Entry` read on a hit come first.
- The hand no longer errors when, after two sweeps of bits re-set by
  readers, it lands on a free position: it moves on to the next entry.
- Remove benchmark/contention.jl.

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

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/containers/clock.jl 97.03% 4 Missing ⚠️
src/containers/slots.jl 85.71% 2 Missing ⚠️
src/containers/localclock.jl 94.11% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/MemoizationKit.jl 100.00% <ø> (ø)
src/cachestyle.jl 100.00% <ø> (ø)
src/call.jl 96.55% <100.00%> (+0.25%) ⬆️
src/containers/localclock.jl 94.11% <94.11%> (ø)
src/containers/slots.jl 96.19% <85.71%> (-0.31%) ⬇️
src/containers/clock.jl 96.42% <97.03%> (+2.31%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown

Comment thread src/containers/clock.jl Outdated
Comment thread src/containers/clock.jl Outdated
lkdvos and others added 4 commits October 15, 2026 11:09
The default constructor had unbound type parameters, which Aqua flags on
Julia 1.10, since K and V only occur in the Union element type of ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The counter array holds a power of two of cache lines, so a hit finds its
thread's line with a mask instead of mod. Hits and misses are an @enum,
and the hand wraps with a comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- DictCache, the supertype of SlotCache and ClockCache, holds their snapshot
  iteration and text/plain display; getindex uses the AbstractDict fallback.
- The clock ring is a Vector{Entry{K, V}} with free positions unassigned.
- nextslot replaces the repeated probe arithmetic; shorter comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If the internal function disappears, freeing a slot should fail loudly
rather than keep the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lkdvos
lkdvos merged commit 4ae606e into main Oct 15, 2026
11 checks passed
@lkdvos
lkdvos deleted the lockfree-clock branch October 15, 2026 18:44
@lkdvos lkdvos mentioned this pull request Oct 15, 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