diff options
| author | Scott Gasch <[email protected]> | 2026-10-05 22:22:24 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-05 22:22:24 -0700 |
| commit | 447130db3df87b40d080dc26429d45e9213c19f7 (patch) | |
| tree | a47e3ef8ea671eb557cfe1b45356e8df93a4e4e9 /src/testhash.c | |
| parent | a8308030473f56f84a9a8c7179fe029524c68b3a (diff) | |
Lockless main hash table (Hyatt/Crafty scheme)
Replaces the sharded-spinlock protection around every main-hash probe
and store with the classic lockless-hashing technique: HASH_ENTRY's
existing 8-byte data word (mv/uDepth/bvFlags/iValue, now addressable
as u64Data via an anonymous union) and its 8-byte u64Sig are written
as two independent plain 64-bit stores -- u64Data first, then
u64Sig = realKey ^ u64Data -- instead of under a lock. Every probe
reads both words into a local snapshot and verifies
(storedSig ^ storedData) == realKey before trusting anything; a
concurrent writer racing a reader can only ever produce a
self-inconsistent pair that fails this check (an ordinary miss), not
corruption, except in the one documented, harmless case where the two
racing entries' data words happen to be bit-identical.
LockCompareExchange (the old spinlock's cost) was already showing up
at 0.51% self-time in a `bench` profile -- real, measured overhead on
a path hit roughly once per searched node, now removed.
HashLookup's contract changes from "return a pointer into the live
table, or NULL" to "return TRUE/FALSE, with *pOut holding a validated
local copy on TRUE" -- callers must never read back through a pointer
into the live table after this function returns, since there's no
longer a lock serializing that against a concurrent writer. The one
real caller (search.c) and the DEBUG-only VERIFY_HASH_HIT diagnostic
are updated accordingly.
testhash.c: AnalyzeFullHashTable's duplicate-entry check used to
compare two entries' raw u64Sig directly, which no longer means "same
position" now that u64Sig depends on each entry's own data word --
fixed to un-corrupt both sides first. Adds TestLocklessHashAlgebra, a
pure algebra test (no concurrency) verifying the XOR round-trip and
torn-write self-detection property over 200k random trials, wired
into the TEST=1 startup self-test suite.
Verified: TestLocklessHashAlgebra passes; single-threaded runs of the
locked and lockless binaries produce identical node counts and solve
scores across all three curated suites (ecm_ringers/confident_quick/
hard_quick); several minutes of real contention (tiny 4MB hash table,
12 threads at ~1200% CPU on a Ryzen 9 3900X, plus a 4-core run
locally) produced zero crashes/assertions/corruption on either
machine.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/testhash.c')
| -rw-r--r-- | src/testhash.c | 135 |
1 files changed, 133 insertions, 2 deletions
diff --git a/src/testhash.c b/src/testhash.c index 6059a1b..6ea2fee 100644 --- a/src/testhash.c +++ b/src/testhash.c @@ -91,7 +91,131 @@ Return value: } -void +FLAG +TestLocklessHashAlgebra(void) +/** + +Routine description: + + Pure algebra test for the lockless-hash scheme (Hyatt/Crafty -- + see hash.c's module comment) -- no concurrency involved, just + verifies the XOR round-trip and the self-detection property a + concurrent writer's torn update relies on. Operates directly on + bare HASH_ENTRY structs, nothing from the real hash table. + + Three things checked, each over many random (key, data) pairs: + + 1. Round-trip: write u64Data, write u64Sig = key ^ u64Data, + then verify (u64Sig ^ u64Data) == key. This is the whole + scheme -- if this doesn't hold, nothing else about the + lockless rewrite can be trusted. + + 2. Torn-write detection: simulate two threads' writes + interleaving (data from one, sig from the other) and confirm + the un-corrupted key essentially never matches either real + key -- the self-check that makes the scheme safe without a + lock. + + 3. The one documented, harmless exception: when the two + "threads'" data words happen to be bit-identical, the torn + read un-corrupts to the SAME key either real writer would + have produced -- confirmed explicitly, not just allowed to + slip through the random sampling in (2). + +Parameters: + + void + +Return value: + + FLAG + +**/ +{ + const ULONG uIterations = 200000; + ULONG u; + FLAG fRet = TRUE; + UINT64 u64FalsePositives = 0; + + Trace("Testing lockless hash algebra...\n"); + + for (u = 0; u < uIterations; u++) + { + UINT64 u64Key1 = (((UINT64)randomMT()) << 32) | randomMT(); + UINT64 u64Data1 = (((UINT64)randomMT()) << 32) | randomMT(); + UINT64 u64Key2 = (((UINT64)randomMT()) << 32) | randomMT(); + UINT64 u64Data2 = (((UINT64)randomMT()) << 32) | randomMT(); + UINT64 u64Sig1, u64Sig2; + UINT64 u64TornKey; + + // 1. Plain round-trip, both "entries" independently. + u64Sig1 = u64Key1 ^ u64Data1; + u64Sig2 = u64Key2 ^ u64Data2; + if (((u64Sig1 ^ u64Data1) != u64Key1) || + ((u64Sig2 ^ u64Data2) != u64Key2)) + { + Trace("TestLocklessHashAlgebra: round-trip failed for " + "iteration %lu\n", u); + fRet = FALSE; + } + + // 2. Torn write: data from "writer 2", sig from "writer 1" + // (as if a reader's two plain loads landed on either side of + // a concurrent full update). Must not un-corrupt to either + // writer's real key, UNLESS the two data words happen to + // coincide (case 3, checked separately below) -- that's the + // one case this random sampling could legitimately hit, so + // it's explicitly excluded here rather than miscounted as a + // false positive. + u64TornKey = u64Sig1 ^ u64Data2; + if (u64Data1 != u64Data2) + { + if ((u64TornKey == u64Key1) || (u64TornKey == u64Key2)) + { + u64FalsePositives++; + } + } + else if (u64TornKey != u64Key1) + { + // This is case 3 by construction (u64Data1 == u64Data2) + // -- the torn read MUST un-corrupt to u64Key1 here (and, + // since the data words are equal, u64Key1's own sig1 is + // exactly what a from-scratch write of u64Key2 with this + // same data would have produced too -- not checked + // separately, same algebra). + Trace("TestLocklessHashAlgebra: case-3 coincidence did not " + "verify at iteration %lu\n", u); + fRet = FALSE; + } + } + + // u64FalsePositives should be *exactly* zero -- a torn (sig1, + // data2) pair un-corrupting to a real key by pure chance (data1 + // != data2) would require a specific 64-bit XOR coincidence, + // astronomically unlikely across 200k random trials. Any nonzero + // count here across a large sample is a red flag that the + // algebra isn't what this comment thinks it is, not expected + // noise -- hence ASSERT(FALSE), not just a logged warning. + if (0 != u64FalsePositives) + { + Trace("TestLocklessHashAlgebra: %" COMPILER_LONGLONG_UNSIGNED_FORMAT + " torn-write false positive(s) out of %lu trials\n", + u64FalsePositives, uIterations); + ASSERT(FALSE); + fRet = FALSE; + } + + if (FALSE == fRet) + { + UtilPanic(TESTCASE_FAILURE, + NULL, "TestLocklessHashAlgebra", NULL, NULL, + __FILE__, __LINE__); + } + return(fRet); +} + + +void AnalyzeFullHashTable(void) /** @@ -160,11 +284,18 @@ Return value: // if (uEntry % NUM_HASH_ENTRIES_PER_LINE) { + // Lockless hash (see hash.c's module comment): u64Sig + // no longer holds the plain key, it holds (realKey ^ + // u64Data), so two entries for the SAME position don't + // have equal raw .u64Sig unless their .u64Data also + // happens to match -- un-corrupt each side before + // comparing. fUnique = TRUE; u = uEntry - 1; do { - if (p->u64Sig == g_pHashTable[u].u64Sig) + if ((p->u64Sig ^ p->u64Data) == + (g_pHashTable[u].u64Sig ^ g_pHashTable[u].u64Data)) { fUnique = FALSE; break; |
