Skip to content

Make ClockCache hits lock-free - #21

Open
lkdvos wants to merge 4 commits into
mainfrom
lockfree-clock
Open

lkdvos wants to merge 4 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.93023% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/containers/clock.jl 97.27% 4 Missing ⚠️
src/containers/slots.jl 66.66% 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.49% <66.66%> (ø)
src/containers/clock.jl 96.68% <97.27%> (+2.57%) ⬆️

... 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
mutable struct ClockCache{K, V} <: AbstractCache{K, V}
@atomic table::SlotTable # entries, read without the lock
occupied::Int # slots of `table` holding an entry or a tombstone
const ring::Vector{Union{Nothing, Entry{K, V}}} # the clock, `nothing` for a free position

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider power of two sizes to have fast ring wrapping?

@lkdvos lkdvos Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ring only wraps in evict!, under the lock on a miss, so a power-of-two ring would gain nothing measurable there, and it would leave empty positions for the hand to sweep. It now wraps with a comparison instead of %.

The modulo that mattered was in countlookup!, on every hit: mod(threadid() - 1, nthreads). The counter array now holds a power of two of cache lines, so it is a mask (cb8ecb7). Single-threaded Int hits: 9.1 ns (BenchmarkTools).

Comment thread src/containers/clock.jl Outdated
# would bounce between cores on every hit. Two tasks on one thread cannot interleave between the
# load and the store, since neither yields; threads adopted after the cache was created share
# counters, and may then lose a few counts.
const HIT, MISS = 1, 2

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enum ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: @enum LookupOutcome HIT = 1 MISS = 2, and countlookup! takes a LookupOutcome.

lkdvos and others added 2 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>

This branch has not been deployed

No deployments
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