summaryrefslogtreecommitdiff
path: root/src/testhash.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-05 22:22:24 -0700
committerScott Gasch <[email protected]>2026-10-05 22:22:24 -0700
commit447130db3df87b40d080dc26429d45e9213c19f7 (patch)
treea47e3ef8ea671eb557cfe1b45356e8df93a4e4e9 /src/testhash.c
parenta8308030473f56f84a9a8c7179fe029524c68b3a (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.c135
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;