Fix unfenced epoch announce in LightEpoch (x86-64) - #2015
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a subtle x86-64 memory-ordering race in Tsavorite’s LightEpoch entry/announce path by making the slot-claim CAS operate on localCurrentEpoch (the epoch “announce” word) rather than threadId, preventing a reclaimer from transiently observing a live reader’s slot as free. It also factors LightEpoch into a standalone Garnet.LightEpoch project and adds an isolated hardware litmus harness plus focused unit tests.
Changes:
- Rework epoch-table slot acquisition to
CompareExchangeonlocalCurrentEpoch(0 → announced epoch), then setthreadIdnon-atomically once exclusive ownership is established. - Adjust
Release()to clearthreadIdbefore publishing the slot free vialocalCurrentEpoch = 0. - Add
Garnet.LightEpoch+Garnet.LightEpoch.testprojects and aLightEpochLitmusplayground harness for on-hardware validation.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| playground/LightEpochLitmus/README.md | Documents the on-hardware litmus harness and how to run it (native + Docker). |
| playground/LightEpochLitmus/QuarantineLitmus.cs | Implements the quarantine-style litmus loop (reader/reclaimer/disturbers) and violation detection. |
| playground/LightEpochLitmus/Program.cs | CLI, control run, stress iterations, JSON reporting, and exit codes. |
| playground/LightEpochLitmus/LightEpochLitmus.csproj | Adds a signed standalone executable targeting net10.0 and referencing Garnet.LightEpoch. |
| playground/LightEpochLitmus/helpers/TwoThreadBarrier.cs | Provides a two-thread lockstep barrier + shutdown protocol for the harness. |
| playground/LightEpochLitmus/helpers/Platform.cs | Implements page mapping/unmapping and thread pinning (Windows/Linux). |
| playground/LightEpochLitmus/helpers/PagePool.cs | Provides a fixed page pool mapping and poisoning mechanism for quarantine. |
| playground/LightEpochLitmus/helpers/EpochUnderTest.cs | Wraps fixed vs buggy epoch implementations behind a generic, devirtualized harness-facing API. |
| playground/LightEpochLitmus/helpers/Emulation.cs | Best-effort detection of emulator contexts where memory-ordering results are not meaningful. |
| playground/LightEpochLitmus/helpers/CoreLayout.cs | Selects/publishes a core pinning layout intended to expose the reordering. |
| playground/LightEpochLitmus/helpers/BuggyLightEpoch.cs | Frozen pre-fix LightEpoch copy used as a negative control for the harness. |
| playground/LightEpochLitmus/Dockerfile | Builds and publishes the harness into a runtime image for longer runs. |
| libs/storage/Tsavorite/cs/test/test.epoch/ProtectionTests.cs | Adds lifecycle tests validating slot ownership/cleanup and refresh behavior. |
| libs/storage/Tsavorite/cs/test/test.epoch/helpers/ParkedReaderThread.cs | Adds a helper thread that holds epoch protection to test draining behavior. |
| libs/storage/Tsavorite/cs/test/test.epoch/helpers/EpochTestBase.cs | Adds a shared test base integrating Garnet’s TestBase and epoch setup/teardown. |
| libs/storage/Tsavorite/cs/test/test.epoch/helpers/EpochProtection.cs | Adds a using-scoped protection helper to ensure suspend on assertion failure. |
| libs/storage/Tsavorite/cs/test/test.epoch/Garnet.LightEpoch.test.csproj | Introduces a dedicated LightEpoch test project wired into the solution. |
| libs/storage/Tsavorite/cs/test/test.epoch/DrainTests.cs | Adds drain list correctness tests (ordering, blocking when full, exactly-once execution). |
| libs/storage/Tsavorite/cs/src/epoch/Murmur3.cs | Adds a local Murmur3 hash helper for start-offset selection without relying on other core helpers. |
| libs/storage/Tsavorite/cs/src/epoch/LightEpoch.TestHooks.cs | Adds internal test hooks for reading epoch-table state used by new tests/harness. |
| libs/storage/Tsavorite/cs/src/epoch/LightEpoch.EntryTable.cs | Adds shared helpers for indexed entry access and debug assertions in the split partial class. |
| libs/storage/Tsavorite/cs/src/epoch/LightEpoch.cs | Implements the core fix: slot claim CAS on localCurrentEpoch, adjusted release ordering, and refactoring to partial/IDisposable. |
| libs/storage/Tsavorite/cs/src/epoch/IEpochAccessor.cs | Adds the IEpochAccessor interface in the new epoch project layout. |
| libs/storage/Tsavorite/cs/src/epoch/Garnet.LightEpoch.csproj | Introduces the standalone LightEpoch project with IVT to tests and harness. |
| libs/storage/Tsavorite/cs/src/core/Tsavorite.core.csproj | References the new epoch project and exposes internals to the new test assembly. |
| Garnet.slnx | Wires in the new epoch project, litmus harness, and LightEpoch test project. |
Suppressed comments (2)
libs/storage/Tsavorite/cs/src/epoch/LightEpoch.TestHooks.cs:34
- TestHookThreadIdAt may be observed from another thread in tests/harness scenarios; using a non-volatile read can return stale values or be hoisted by the JIT. Use Volatile.Read for a proper cross-thread observation point.
/// <summary>
/// The thread id recorded in epoch table slot <paramref name="entry"/>, or 0 if the slot is free.
/// </summary>
internal int TestHookThreadIdAt(int entry) => EntryAt(entry).threadId;
libs/storage/Tsavorite/cs/src/epoch/LightEpoch.cs:549
- PR description claims no barriers were added, but this change introduces Volatile.Read/Volatile.Write in the slot-claim and release paths. Even on x86 these are observable ordering primitives at the C# memory model level; please update the PR description to match (e.g., clarify that no hardware fence was added on x86-64).
A thread entering a protected region announced its epoch with a plain store, which is not ordered against the reclaimer's later load of the same slot. A reclaimer could scan a live reader's slot, see it as free, raise SafeToReclaimEpoch past the reader's epoch, and free a page the reader was about to dereference. The claim CAS now writes localCurrentEpoch directly, so claiming the slot and announcing the epoch are one locked RMW and the announce is globally visible before any load in the protected region can issue. No barrier and no atomic is added; the lock cmpxchg was already there. localCurrentEpoch doubles as the ownership word, sound because a protected thread never announces epoch 0. Release() correspondingly clears threadId before freeing the slot. LightEpoch moves to its own Garnet.LightEpoch project so it can be tested and disassembled in isolation, with unit tests and a quarantine litmus harness under playground/LightEpochLitmus. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38cd2f3a-d460-407c-8a96-a7330974ce99
30d3992 to
216b7b3
Compare
CodeQL builds the whole solution with 'dotnet build -f net8.0', which failed because the litmus project only targeted net10.0. Inherit the repo default net8.0;net10.0 and pin the Dockerfile/README commands to net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f062b9-2d72-4cf0-b6f3-4c9beb98d068
Drop the Garnet prefix from the epoch library and its unit test project so they match the Tsavorite.core / Tsavorite.test.* naming of the rest of the storage engine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d306b783-4a33-4673-9e29-790995df8179
Undo the split of LightEpoch into a standalone project: the sources return to src/core/Epochs/ and the duplicated Murmur3 helper is dropped in favor of the existing Utility.Murmur3. LightEpochLitmus now references Tsavorite.core. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d306b783-4a33-4673-9e29-790995df8179
LightEpoch already exposed a public Dispose(); declaring the interface adds nothing and was not part of the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d306b783-4a33-4673-9e29-790995df8179
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d306b783-4a33-4673-9e29-790995df8179
Epoch BDN results — no regressionRan
For Bottom lineNo regression on any of the three epoch benchmarks. |
Summary
A thread entering a protected region announces its epoch with a plain store, which is not ordered against the reclaimer's later load of the same slot. A reclaimer can scan a live reader's slot, see it as free, raise
SafeToReclaimEpochpast the reader's epoch, and free a page the reader is about to dereference.The fix changes which word the claim CAS operates on. No barrier and no atomic is added — the
lock cmpxchgwas already there, it just lands on a different word.Scoped to x86-64. Weaker architectures additionally need a release store in
Release()and an acquire load inProtectAndDrain(); that ordering work, along with herd7 models and TLA+ specs, is deliberately left out of this change.The bug
Store-then-load-of-another-address is the one reordering TSO permits:
threadId= tidlocalCurrentEpoch= 5 (buffered)SafeToReclaimEpoch= 5, free the pageThe fix
The CAS writes
localCurrentEpochdirectly, so claiming the slot and announcing the epoch are one locked RMW — the announce is globally visible before any load in the protected region can issue:localCurrentEpochdoubles as the ownership word, sound because a protected thread never announces epoch 0 (asserted by a test).Release()correspondingly clearsthreadIdbefore freeing the slot.An
mfenceafter the announce would also fix it on x86-64, but costs a full barrier on the hottest path in the store and is the kind of line a later refactor drops with no test failing. The relocated CAS makes the atomicity structural: the successfullock cmpxchgis the linearization point.LightEpochalso moves to its ownGarnet.LightEpochproject (git mv, history preserved) so it can be tested and disassembled in isolation.Violation proof
8-hour hardware run
Two Azure VMs, quarantine litmus, baseline vs fixed (2026-07-30, westus2, x86-64):
Local repro
--buggypoints the harness atBuggyLightEpoch, a frozen copy ofmain's version, so both arms run back to back on one machine (20 logical processors, x86-64, 15 s runs):Benchmarks
BDN
RawStringOperations, 3 interleaved runs per arm on a dedicated x86 VM (Xeon 8272CL, 16 vCPU): mean |Δ| 1.46%, 5 faster / 5 slower, 0 B allocated on every benchmark in both arms.GetNotFoundhad the widest spread, so it was re-run alone with 8 interleaved runs per arm — the spread is host noise, and the fix arm is the steadier of the two:Δ = −1.65% (fix faster), Welch t = 0.77, df = 8.7 — not significant. Baseline's own run-to-run spread is an order of magnitude larger than the effect being chased. No measurable cost.