From a8ef005d1518d4eef9d3448825256cab7e5c32d1 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:34:14 +0100 Subject: [PATCH 1/9] feat(hash): rework latency benchmark to fixed key-length distributions BmHash64Latency previously walked keys by keys[hash & mask], a functional iteration that collapses into a short rho-cycle: it re-hashed only the few keys on the cycle and read as near-zero whenever the cycle hit an empty/tiny key. Replace it with two fixed, documented key-length distributions given as inverse-CDF control points (Short-Identifier, Web-URL). Keys are sampled once with a fixed seed and SHARED across every algorithm in a run, so the length multiset is reproducible and identical for all algorithms; a plain sequential sweep (no hash in the index) then hashes them. Registered as one benchmark per scenario: BmHash64Latency/. String building uses absl::StrCat/StrJoin (adds @abseil-cpp//absl/strings). Report generator + README rendering for the new two-scenario latency come in a follow-up PR after re-measuring on clean main. --- CHANGELOG.md | 1 + mbo/hash/BUILD.bazel | 1 + mbo/hash/hash_benchmark.cc | 111 +++++++++++++++++++++++++++---------- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db534ab..b652fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 0.13.2 +- Reworked the hash latency benchmark to two fixed, reproducible key-length distributions (Short-Identifier, Web-URL) sampled once and shared across algorithms, replacing the hash-indexed key walk that collapsed into a degenerate cycle (near-zero anomalies). - Added a `compare` command reporting per-case Δ% and a geomean between two datasets. - Made `tables`/`plot`/`compare`/`quality` accept a bundle `.tgz` or a results JSON, positionally or via `--results`/`--bundle`. - Added `plot --kind` (throughput/latency/all) and `--scale` (log-log/linear-log). diff --git a/mbo/hash/BUILD.bazel b/mbo/hash/BUILD.bazel index a695c47..365fed0 100644 --- a/mbo/hash/BUILD.bazel +++ b/mbo/hash/BUILD.bazel @@ -207,6 +207,7 @@ cc_binary( tags = ["manual"], deps = [ ":hash_test_util", + "@abseil-cpp//absl/strings", "@com_github_google_benchmark//:benchmark", ], ) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index e293fed..8214850 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -23,6 +23,7 @@ // the ns-vs-length graph. #include +#include #include #include #include @@ -30,8 +31,11 @@ #include #include #include +#include #include +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" #include "benchmark/benchmark.h" #include "mbo/hash/hash_test_util.h" @@ -179,25 +183,75 @@ void BmHash128(benchmark::State& state) { state.SetLabel(std::string(Algo::Name())); } -// Latency benchmark: keys of unpredictable random length in [0, max_len], and -// each hash result selects the next key, serializing the chain. This defeats -// the branch predictor on the size dispatch and measures latency rather than -// hot-loop throughput -- the cost profile hash-table workloads actually pay. -template -void BmHash64Latency(benchmark::State& state) { - const auto max_len = static_cast(state.range(0)); - std::mt19937_64 rng( - 0x1a7e9c1); // NOLINT(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed key set per run - constexpr std::size_t kNumKeys = 1'024; // power of two for cheap masking - std::vector keys; - keys.reserve(kNumKeys); - for (std::size_t i = 0; i < kNumKeys; ++i) { - keys.push_back(algo::RandomString(rng, rng() % (max_len + 1))); +// --- Latency benchmark: hashing a realistic MIX of key lengths -------------- +// +// A hash table does not hash one length in a hot loop (that is BmHash64); it +// hashes a stream of differently-sized keys, so the per-length size dispatch +// cannot be branch-predicted. We model that with two fixed, documented length +// distributions given as inverse-CDF control points (cumulative percentile -> +// length in bytes), piecewise-linear between points: +// - Short-Identifier: programming identifiers / DB keys / UUIDs (log-normal). +// - Web/URL: paths, URLs, and larger text keys (heavy-tailed). +// The keys are sampled ONCE from these with a fixed-seed PRNG and SHARED across +// every algorithm in a run, so all algorithms hash the byte-identical key set +// (fair comparison) and the set is reproducible across runs (only the string +// LENGTHS matter; the bytes are irrelevant filler). +constexpr std::size_t kLatencyKeys = 1'024; // power of two for cheap masking + +struct LatencyDist { + std::string_view name; + std::array, 8> cdf; // ascending (percentile, length) +}; + +constexpr std::array kLatencyDists = {{ + {"Short-Identifier", + {{{0.10, 8}, {0.25, 12}, {0.50, 16}, {0.75, 23}, {0.90, 31}, {0.95, 38}, {0.99, 53}, {0.999, 80}}}}, + {"Web-URL", + {{{0.10, 15}, {0.25, 28}, {0.50, 45}, {0.75, 75}, {0.90, 120}, {0.95, 220}, {0.99, 512}, {0.999, 2'048}}}}, +}}; + +// Inverse CDF: percentile p in [0,1) -> length. Piecewise-linear between control +// points; below the first point interpolate from (0, 1 byte), at/above the last +// clamp to its length (do not extrapolate the tail into huge outliers). +std::size_t SampleLength(const LatencyDist& dist, double percentile) { + double prev_p = 0.0; + double prev_len = 1.0; + for (const auto& [pct, len] : dist.cdf) { + if (percentile < pct) { + const double frac = (percentile - prev_p) / (pct - prev_p); + return static_cast(std::lround(prev_len + frac * (len - prev_len))); + } + prev_p = pct; + prev_len = len; } - uint64_t hash = 0; + return static_cast(dist.cdf.back().second); +} + +// The two key sets, built once (fixed seed) and shared by every latency +// benchmark in the run. `state.range(0)` selects the distribution by index. +const std::vector& LatencyKeys(std::size_t dist_index) { + static const std::array, kLatencyDists.size()> kKeySets = [] { + std::array, kLatencyDists.size()> sets; + for (std::size_t d = 0; d < kLatencyDists.size(); ++d) { + std::mt19937_64 rng( + 0x1a7e9c1); // NOLINT(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set + sets[d].reserve(kLatencyKeys); + for (std::size_t i = 0; i < kLatencyKeys; ++i) { + const double percentile = static_cast(rng()) / (static_cast(UINT64_MAX) + 1.0); + sets[d].push_back(algo::RandomString(rng, SampleLength(kLatencyDists[d], percentile))); + } + } + return sets; + }(); + return kKeySets[dist_index]; +} + +template +void BmHash64Latency(benchmark::State& state, std::size_t dist_index) { + const std::vector& keys = LatencyKeys(dist_index); + std::size_t counter = 0; for (auto _ : state) { - hash = Algo::GetHash64(keys[hash & (kNumKeys - 1)], kSeed); - benchmark::DoNotOptimize(hash); + benchmark::DoNotOptimize(Algo::GetHash64(keys[counter++ & (kLatencyKeys - 1)], kSeed)); } state.SetItemsProcessed(state.iterations()); state.SetLabel(std::string(Algo::Name())); @@ -209,19 +263,22 @@ template void RegisterAlgo() { const std::string name(Algo::Name()); const std::span sizes = ThroughputSizes(); - auto* const hash64 = benchmark::RegisterBenchmark("BmHash64<" + name + ">", BmHash64); + auto* const hash64 = benchmark::RegisterBenchmark(absl::StrCat("BmHash64<", name, ">"), BmHash64); for (const int size : sizes) { hash64->Arg(size); } if constexpr (HasGetHash128) { - auto* const hash128 = benchmark::RegisterBenchmark("BmHash128<" + name + ">", BmHash128); + auto* const hash128 = benchmark::RegisterBenchmark(absl::StrCat("BmHash128<", name, ">"), BmHash128); for (const int size : sizes) { hash128->Arg(size); } } - auto* const latency = benchmark::RegisterBenchmark("BmHash64Latency<" + name + ">", BmHash64Latency); - for (const int size : sizes) { - latency->Arg(size); + // One benchmark per scenario, named "BmHash64Latency/" (not an Arg), + // so each realistic distribution is its own reported result. + for (std::size_t dist = 0; dist < kLatencyDists.size(); ++dist) { + benchmark::RegisterBenchmark( + absl::StrCat("BmHash64Latency<", name, ">/", kLatencyDists[dist].name), + [dist](benchmark::State& state) { BmHash64Latency(state, dist); }); } } @@ -245,20 +302,16 @@ int main(int argc, char** argv) { // differs), so record what THIS binary was built with in the dataset context; // the stored bundle's filename is tagged with `compiler` too. #if defined(__clang__) - benchmark::AddCustomContext("compiler", "clang-" + std::to_string(__clang_major__)); + benchmark::AddCustomContext("compiler", absl::StrCat("clang-", __clang_major__)); benchmark::AddCustomContext("compiler_version", __clang_version__); #elif defined(__GNUC__) - benchmark::AddCustomContext("compiler", "gcc-" + std::to_string(__GNUC__)); + benchmark::AddCustomContext("compiler", absl::StrCat("gcc-", __GNUC__)); benchmark::AddCustomContext("compiler_version", __VERSION__); #endif // Emit the curated README size subset (kReadmeSizes) so the report tool extracts // the small table straight from a FULL dataset - no separate fast run, and no // second size list to drift (this C++ list is the single source of truth). - std::string readme_sizes; - for (const int size : mbo::hash::kReadmeSizes) { - readme_sizes += (readme_sizes.empty() ? "" : ",") + std::to_string(size); - } - benchmark::AddCustomContext("readme_sizes", readme_sizes); + benchmark::AddCustomContext("readme_sizes", absl::StrJoin(mbo::hash::kReadmeSizes, ",")); benchmark::RunSpecifiedBenchmarks(); benchmark::Shutdown(); return 0; From f184e27994d9057b81250f9fc257ac3ed96b69b6 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:40:44 +0100 Subject: [PATCH 2/9] Lint fixes --- mbo/hash/hash_benchmark.cc | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index 8214850..462a1f4 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -42,7 +42,7 @@ namespace mbo::hash { namespace { -// NOLINTBEGIN(*-magic-numbers) +// NOLINTBEGIN(*-array-index,*-magic-numbers) constexpr uint64_t kSeed = 5'381; @@ -159,8 +159,8 @@ std::span ThroughputSizes() { template void BmHash64(benchmark::State& state) { const auto length = static_cast(state.range(0)); - std::mt19937_64 rng( - 0x1234); // NOLINT(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed data per length + // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed data per length + std::mt19937_64 rng(0x1234); const std::string data = algo::RandomString(rng, length); for (auto _ : state) { benchmark::DoNotOptimize(Algo::GetHash64(data, kSeed)); @@ -173,8 +173,8 @@ template requires HasGetHash128 void BmHash128(benchmark::State& state) { const auto length = static_cast(state.range(0)); - std::mt19937_64 rng( - 0x1234); // NOLINT(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed data per length + // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed data per length + std::mt19937_64 rng(0x1234); const std::string data = algo::RandomString(rng, length); for (auto _ : state) { benchmark::DoNotOptimize(Algo::GetHash128(data, kSeed)); @@ -204,10 +204,10 @@ struct LatencyDist { }; constexpr std::array kLatencyDists = {{ - {"Short-Identifier", - {{{0.10, 8}, {0.25, 12}, {0.50, 16}, {0.75, 23}, {0.90, 31}, {0.95, 38}, {0.99, 53}, {0.999, 80}}}}, - {"Web-URL", - {{{0.10, 15}, {0.25, 28}, {0.50, 45}, {0.75, 75}, {0.90, 120}, {0.95, 220}, {0.99, 512}, {0.999, 2'048}}}}, + {.name = "Short-Identifier", + .cdf = {{{0.10, 8}, {0.25, 12}, {0.50, 16}, {0.75, 23}, {0.90, 31}, {0.95, 38}, {0.99, 53}, {0.999, 80}}}}, + {.name = "Web-URL", + .cdf = {{{0.10, 15}, {0.25, 28}, {0.50, 45}, {0.75, 75}, {0.90, 120}, {0.95, 220}, {0.99, 512}, {0.999, 2'048}}}}, }}; // Inverse CDF: percentile p in [0,1) -> length. Piecewise-linear between control @@ -219,7 +219,7 @@ std::size_t SampleLength(const LatencyDist& dist, double percentile) { for (const auto& [pct, len] : dist.cdf) { if (percentile < pct) { const double frac = (percentile - prev_p) / (pct - prev_p); - return static_cast(std::lround(prev_len + frac * (len - prev_len))); + return static_cast(std::lround(prev_len + (frac * (len - prev_len)))); } prev_p = pct; prev_len = len; @@ -232,13 +232,13 @@ std::size_t SampleLength(const LatencyDist& dist, double percentile) { const std::vector& LatencyKeys(std::size_t dist_index) { static const std::array, kLatencyDists.size()> kKeySets = [] { std::array, kLatencyDists.size()> sets; - for (std::size_t d = 0; d < kLatencyDists.size(); ++d) { - std::mt19937_64 rng( - 0x1a7e9c1); // NOLINT(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set - sets[d].reserve(kLatencyKeys); + for (std::size_t idx = 0; idx < kLatencyDists.size(); ++idx) { + // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set + std::mt19937_64 rng(0x1a7e9c1); + sets[idx].reserve(kLatencyKeys); for (std::size_t i = 0; i < kLatencyKeys; ++i) { const double percentile = static_cast(rng()) / (static_cast(UINT64_MAX) + 1.0); - sets[d].push_back(algo::RandomString(rng, SampleLength(kLatencyDists[d], percentile))); + sets[idx].push_back(algo::RandomString(rng, SampleLength(kLatencyDists[idx], percentile))); } } return sets; @@ -290,7 +290,7 @@ void RegisterAll(std::tuple /*algorithms*/) { (RegisterAlgo(), ...); } -// NOLINTEND(*-magic-numbers) +// NOLINTEND(*-array-index,*-magic-numbers) } // namespace } // namespace mbo::hash From ff61831b1901034b5643be52153f939e0046764d Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:02:17 +0100 Subject: [PATCH 3/9] ChangesUpdated LatencyDist CDF array bounds to size 9. Added {1.0, 128} to the Short-Identifier distribution. Added {1.0, 4096} to the Web-URL distribution.Refactored LatencyKeys() generation lambda to loop $K-1$ times and append the explicit ceiling length at the end of each set. --- mbo/hash/hash_benchmark.cc | 50 +++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index 462a1f4..20c38cb 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -200,14 +200,34 @@ constexpr std::size_t kLatencyKeys = 1'024; // power of two for cheap masking struct LatencyDist { std::string_view name; - std::array, 8> cdf; // ascending (percentile, length) + std::array, 9> cdf; // ascending (percentile, length) }; constexpr std::array kLatencyDists = {{ {.name = "Short-Identifier", - .cdf = {{{0.10, 8}, {0.25, 12}, {0.50, 16}, {0.75, 23}, {0.90, 31}, {0.95, 38}, {0.99, 53}, {0.999, 80}}}}, + .cdf = {{ + {0.10, 8}, + {0.25, 12}, + {0.50, 16}, + {0.75, 23}, + {0.90, 31}, + {0.95, 38}, + {0.99, 53}, + {0.999, 80}, + {1.0, 128}, // Clean 100% ceiling representing the SSO/AVX-512 transition + }}}, {.name = "Web-URL", - .cdf = {{{0.10, 15}, {0.25, 28}, {0.50, 45}, {0.75, 75}, {0.90, 120}, {0.95, 220}, {0.99, 512}, {0.999, 2'048}}}}, + .cdf = {{ + {0.10, 15}, + {0.25, 28}, + {0.50, 45}, + {0.75, 75}, + {0.90, 120}, + {0.95, 220}, + {0.99, 512}, + {0.999, 2'048}, + {1.0, 4'096}, // Clean 100% ceiling representing a full virtual page + }}}, }}; // Inverse CDF: percentile p in [0,1) -> length. Piecewise-linear between control @@ -236,10 +256,21 @@ const std::vector& LatencyKeys(std::size_t dist_index) { // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set std::mt19937_64 rng(0x1a7e9c1); sets[idx].reserve(kLatencyKeys); - for (std::size_t i = 0; i < kLatencyKeys; ++i) { + + // Generate exactly 1023 keys using the distribution + for (std::size_t i = 0; i < kLatencyKeys - 1; ++i) { const double percentile = static_cast(rng()) / (static_cast(UINT64_MAX) + 1.0); sets[idx].push_back(algo::RandomString(rng, SampleLength(kLatencyDists[idx], percentile))); } + + // Enforce that the 1024th key is guaranteed to be the 100% bounds anchor. + const std::size_t absolute_max_len = kLatencyDists[idx].cdf.back().second; + // Passing the RNG to RandomString is safe here because the RNG is only + // used for generating the string content, not for determining its length. + // Since this is the final step of a isolated vector generation block, it + // does not affect the reproducibility of the earlier keys, but it does + // guarantee that the random string data itself remains completely unique. + sets[idx].push_back(algo::RandomString(rng, absolute_max_len)); } return sets; }(); @@ -249,11 +280,22 @@ const std::vector& LatencyKeys(std::size_t dist_index) { template void BmHash64Latency(benchmark::State& state, std::size_t dist_index) { const std::vector& keys = LatencyKeys(dist_index); + + // Optional: Track cumulative distribution weight + int64_t total_bytes_per_shuffle = 0; + for (const auto& key : keys) { + total_bytes_per_shuffle += static_cast(key.size()); + } + std::size_t counter = 0; for (auto _ : state) { benchmark::DoNotOptimize(Algo::GetHash64(keys[counter++ & (kLatencyKeys - 1)], kSeed)); } + state.SetItemsProcessed(state.iterations()); + // Allow plotting the total processed gigabytes per second even during + // unpredictable random branching profiles: + state.SetBytesProcessed(state.iterations() * (total_bytes_per_shuffle / static_cast(kLatencyKeys))); state.SetLabel(std::string(Algo::Name())); } From ea29b03a3055766bd650b75c9c392f402271d06b Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:09:24 +0100 Subject: [PATCH 4/9] docs(hash): document the latency distributions and Lmax anchors --- mbo/hash/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mbo/hash/README.md b/mbo/hash/README.md index 14bca12..42150e7 100644 --- a/mbo/hash/README.md +++ b/mbo/hash/README.md @@ -279,6 +279,23 @@ sub-nanosecond sizes. Bold marks the fastest per row; the tables use a curated set of lengths (straddling the dispatch-tier and SSO boundaries), the log-log charts a denser one. +The **latency** benchmark models how a hash table actually calls a hash: a +stream of differently-sized keys whose per-length size dispatch cannot be +branch-predicted. Keys are drawn from two fixed, reproducible length +distributions - **Short-Identifier** (log-normal: identifiers, DB keys, UUIDs) +and **Web-URL** (heavy-tailed: paths and URLs) - sampled once with a fixed seed +and shared byte-for-byte across all algorithms (only the lengths matter; the +bytes are filler). Each distribution is an inverse-CDF table capped by a 100% +limit anchor `Lmax`: **128 B** for Short-Identifier (two L1 cache lines - the +AVX-512 / medium-key-to-bulk transition and a jemalloc/tcmalloc size-class +ceiling) and **4096 B** for Web-URL (one x86/ARM64 virtual page, where a larger +allocation can page-fault). Because the percentile draw is half-open `[0, 1)` it +never samples the `1.0` entry by chance (a 1024-key set misses the top region +~36% of the time), so exactly one key per set is pinned to `Lmax` - a guaranteed +worst-case anchor that keeps the curve bounded and exercises the SSO-spill and +bulk-tier code paths, while the other 1023 keys preserve the branch-prediction +noise. + Everything between the markers is generated per machine by `publish` from the committed bundles - regenerate it, don't hand-edit: From bb4bc2bce29cc4f724a897427cdb5378efe86a69 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:13:40 +0100 Subject: [PATCH 5/9] docs(hash): expand CDF acronym with a Wikipedia link --- mbo/hash/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mbo/hash/README.md b/mbo/hash/README.md index 42150e7..982469b 100644 --- a/mbo/hash/README.md +++ b/mbo/hash/README.md @@ -285,8 +285,9 @@ branch-predicted. Keys are drawn from two fixed, reproducible length distributions - **Short-Identifier** (log-normal: identifiers, DB keys, UUIDs) and **Web-URL** (heavy-tailed: paths and URLs) - sampled once with a fixed seed and shared byte-for-byte across all algorithms (only the lengths matter; the -bytes are filler). Each distribution is an inverse-CDF table capped by a 100% -limit anchor `Lmax`: **128 B** for Short-Identifier (two L1 cache lines - the +bytes are filler). Each distribution is an inverse-CDF (Cumulative Distribution +Function, see [Wikipedia](https://en.wikipedia.org/wiki/Cumulative_distribution_function)) +table capped by a 100% limit anchor `Lmax`: **128 B** for Short-Identifier (two L1 cache lines - the AVX-512 / medium-key-to-bulk transition and a jemalloc/tcmalloc size-class ceiling) and **4096 B** for Web-URL (one x86/ARM64 virtual page, where a larger allocation can page-fault). Because the percentile draw is half-open `[0, 1)` it From 857dd596d4fb904e400e9e3db2998bbcb3007aa7 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:25:22 +0100 Subject: [PATCH 6/9] fix(hash): register latency scenarios as Args so they form a table Two separately-named latency benchmarks each reported as a lone value with no grouping. Register one family per algo with the scenarios as Args instead (BmHash64Latency/0, /1) so it is grouped table data like the throughput families; emit the Arg-index -> distribution-name legend as the latency_dists context. --- mbo/hash/hash_benchmark.cc | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index 20c38cb..28cc5c4 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -278,8 +278,8 @@ const std::vector& LatencyKeys(std::size_t dist_index) { } template -void BmHash64Latency(benchmark::State& state, std::size_t dist_index) { - const std::vector& keys = LatencyKeys(dist_index); +void BmHash64Latency(benchmark::State& state) { + const std::vector& keys = LatencyKeys(static_cast(state.range(0))); // Optional: Track cumulative distribution weight int64_t total_bytes_per_shuffle = 0; @@ -315,12 +315,14 @@ void RegisterAlgo() { hash128->Arg(size); } } - // One benchmark per scenario, named "BmHash64Latency/" (not an Arg), - // so each realistic distribution is its own reported result. + // One benchmark family with the scenarios as Args, so it reports as a grouped + // table (BmHash64Latency/0, /1 - one row per scenario), parallel to the + // throughput families. The Arg index -> distribution name legend is emitted as + // the `latency_dists` custom context (see main). + auto* const latency = + benchmark::RegisterBenchmark(absl::StrCat("BmHash64Latency<", name, ">"), BmHash64Latency); for (std::size_t dist = 0; dist < kLatencyDists.size(); ++dist) { - benchmark::RegisterBenchmark( - absl::StrCat("BmHash64Latency<", name, ">/", kLatencyDists[dist].name), - [dist](benchmark::State& state) { BmHash64Latency(state, dist); }); + latency->Arg(static_cast(dist)); } } @@ -354,6 +356,13 @@ int main(int argc, char** argv) { // the small table straight from a FULL dataset - no separate fast run, and no // second size list to drift (this C++ list is the single source of truth). benchmark::AddCustomContext("readme_sizes", absl::StrJoin(mbo::hash::kReadmeSizes, ",")); + // Legend for the latency benchmark's Arg index -> distribution name, in order, + // so the report can label BmHash64Latency/0, /1 with the scenario names. + benchmark::AddCustomContext( + "latency_dists", + absl::StrJoin(mbo::hash::kLatencyDists, ",", [](std::string* out, const mbo::hash::LatencyDist& dist) { + absl::StrAppend(out, dist.name); + })); benchmark::RunSpecifiedBenchmarks(); benchmark::Shutdown(); return 0; From b90e5b9668894b165c74f42effed3d349ef69572 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:17:54 +0100 Subject: [PATCH 7/9] feat(hash): measure throughput over upper-bounded length ranges Replace the distribution-aggregate BmHash64Latency with BmHash64Throughput: for each (distribution, upper bound) it hashes the realistic [0..L] length mix truncated and renormalized to that bound, reporting bytes/s -- an (upper-bound -> throughput) curve. Two documented distributions given as inverse-CDF tables (Short <=128 B, Web <=4096 B); key sets built once with a fixed seed and shared byte-for-byte across algorithms; one anchor key pinned at each bound so the boundary is always represented. Sequential sweep (no hash-indexed walk), so no rho-cycle collapse. The exact-length BmHash64 / BmHash128 stay as the per-length latency view. --- mbo/hash/hash_benchmark.cc | 128 +++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index 28cc5c4..9c589f9 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -183,28 +183,36 @@ void BmHash128(benchmark::State& state) { state.SetLabel(std::string(Algo::Name())); } -// --- Latency benchmark: hashing a realistic MIX of key lengths -------------- +// --- Throughput over upper-bounded length ranges ---------------------------- // -// A hash table does not hash one length in a hot loop (that is BmHash64); it -// hashes a stream of differently-sized keys, so the per-length size dispatch -// cannot be branch-predicted. We model that with two fixed, documented length -// distributions given as inverse-CDF control points (cumulative percentile -> -// length in bytes), piecewise-linear between points: -// - Short-Identifier: programming identifiers / DB keys / UUIDs (log-normal). -// - Web/URL: paths, URLs, and larger text keys (heavy-tailed). -// The keys are sampled ONCE from these with a fixed-seed PRNG and SHARED across -// every algorithm in a run, so all algorithms hash the byte-identical key set -// (fair comparison) and the set is reproducible across runs (only the string -// LENGTHS matter; the bytes are irrelevant filler). +// BmHash64 / BmHash128 above hash ONE key of an exact length in a hot loop: the +// exact-length -> time curve (the "latency" view). This is the complementary +// throughput view - how a realistic, upper-BOUNDED range of key lengths +// translates to a single bytes/s number. +// +// Two documented length distributions as inverse-CDF control points (cumulative +// percentile -> length in bytes), piecewise-linear between points: +// - Short: identifiers / DB keys / UUIDs (log-normal), ceiling 128 B. +// - Web: paths, URLs, larger text keys (heavy-tailed), ceiling 4096 B. +// Each control-point length doubles as an upper BOUND: we run the mix truncated +// to each bound, with the kept buckets' weights renormalized to 100% (which +// falls straight out of scaling the percentile draw into [0, cdf[bound].pct)). +// A run therefore yields (X = upper-bound length, Y = bytes/s), and sweeping the +// bounds gives the upper-length -> throughput curve. Keys are built once per +// (distribution, bound) with a fixed seed and shared across every algorithm, so +// the set is reproducible and identical for all algorithms (only the LENGTHS +// matter; the bytes are filler). The unpredictable length order defeats the +// size-dispatch branch predictor - the cost a real mixed workload pays. constexpr std::size_t kLatencyKeys = 1'024; // power of two for cheap masking +constexpr std::size_t kCdfPoints = 9; // inverse-CDF control points per distribution struct LatencyDist { std::string_view name; - std::array, 9> cdf; // ascending (percentile, length) + std::array, kCdfPoints> cdf; // ascending (cumulative pct, length); last = {1.0, Lmax} }; constexpr std::array kLatencyDists = {{ - {.name = "Short-Identifier", + {.name = "Short", .cdf = {{ {0.10, 8}, {0.25, 12}, @@ -214,9 +222,9 @@ constexpr std::array kLatencyDists = {{ {0.95, 38}, {0.99, 53}, {0.999, 80}, - {1.0, 128}, // Clean 100% ceiling representing the SSO/AVX-512 transition + {1.0, 128}, // 100% ceiling: two L1 cache lines / the SSO & AVX-512 transition }}}, - {.name = "Web-URL", + {.name = "Web", .cdf = {{ {0.10, 15}, {0.25, 28}, @@ -226,13 +234,14 @@ constexpr std::array kLatencyDists = {{ {0.95, 220}, {0.99, 512}, {0.999, 2'048}, - {1.0, 4'096}, // Clean 100% ceiling representing a full virtual page + {1.0, 4'096}, // 100% ceiling: one x86/ARM64 virtual page }}}, }}; // Inverse CDF: percentile p in [0,1) -> length. Piecewise-linear between control -// points; below the first point interpolate from (0, 1 byte), at/above the last -// clamp to its length (do not extrapolate the tail into huge outliers). +// points; below the first point interpolate from (0, 1 byte). Scaling p into +// [0, cdf[bound].pct) restricts the draw to buckets <= that bound and +// renormalizes their weights to 100% - the truncation the sweep needs. std::size_t SampleLength(const LatencyDist& dist, double percentile) { double prev_p = 0.0; double prev_len = 1.0; @@ -247,55 +256,48 @@ std::size_t SampleLength(const LatencyDist& dist, double percentile) { return static_cast(dist.cdf.back().second); } -// The two key sets, built once (fixed seed) and shared by every latency -// benchmark in the run. `state.range(0)` selects the distribution by index. -const std::vector& LatencyKeys(std::size_t dist_index) { - static const std::array, kLatencyDists.size()> kKeySets = [] { - std::array, kLatencyDists.size()> sets; - for (std::size_t idx = 0; idx < kLatencyDists.size(); ++idx) { - // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set - std::mt19937_64 rng(0x1a7e9c1); - sets[idx].reserve(kLatencyKeys); - - // Generate exactly 1023 keys using the distribution - for (std::size_t i = 0; i < kLatencyKeys - 1; ++i) { - const double percentile = static_cast(rng()) / (static_cast(UINT64_MAX) + 1.0); - sets[idx].push_back(algo::RandomString(rng, SampleLength(kLatencyDists[idx], percentile))); +// Key sets built once, one per (distribution, bound), shared by every algorithm. +// Bound `b` truncates distribution `d` to lengths <= cdf[b].length: 1023 keys +// drawn from the renormalized truncated distribution, plus one anchor key pinned +// to the bound length so the boundary is always represented. +const std::vector& ThroughputKeys(std::size_t dist_index, std::size_t bound_index) { + static const std::array, kCdfPoints>, kLatencyDists.size()> kKeySets = [] { + std::array, kCdfPoints>, kLatencyDists.size()> sets; + for (std::size_t d = 0; d < kLatencyDists.size(); ++d) { + const LatencyDist& dist = kLatencyDists[d]; + for (std::size_t b = 0; b < kCdfPoints; ++b) { + // NOLINTNEXTLINE(cert-msc51-cpp,cert-msc32-c,bugprone-random-generator-seed): fixed, reproducible set + std::mt19937_64 rng(0x1a7e9c1); + const double bound_pct = dist.cdf[b].first; + const auto bound_len = static_cast(dist.cdf[b].second); + std::vector& keys = sets[d][b]; + keys.reserve(kLatencyKeys); + for (std::size_t i = 0; i + 1 < kLatencyKeys; ++i) { + const double draw = static_cast(rng()) / (static_cast(UINT64_MAX) + 1.0); + keys.push_back(algo::RandomString(rng, SampleLength(dist, draw * bound_pct))); + } + keys.push_back(algo::RandomString(rng, bound_len)); // anchor at the upper bound } - - // Enforce that the 1024th key is guaranteed to be the 100% bounds anchor. - const std::size_t absolute_max_len = kLatencyDists[idx].cdf.back().second; - // Passing the RNG to RandomString is safe here because the RNG is only - // used for generating the string content, not for determining its length. - // Since this is the final step of a isolated vector generation block, it - // does not affect the reproducibility of the earlier keys, but it does - // guarantee that the random string data itself remains completely unique. - sets[idx].push_back(algo::RandomString(rng, absolute_max_len)); } return sets; }(); - return kKeySets[dist_index]; + return kKeySets[dist_index][bound_index]; } template -void BmHash64Latency(benchmark::State& state) { - const std::vector& keys = LatencyKeys(static_cast(state.range(0))); - - // Optional: Track cumulative distribution weight - int64_t total_bytes_per_shuffle = 0; - for (const auto& key : keys) { - total_bytes_per_shuffle += static_cast(key.size()); +void BmHash64Throughput(benchmark::State& state, std::size_t dist_index, std::size_t bound_index) { + const std::vector& keys = ThroughputKeys(dist_index, bound_index); + int64_t total_bytes = 0; + for (const std::string& key : keys) { + total_bytes += static_cast(key.size()); } - std::size_t counter = 0; for (auto _ : state) { benchmark::DoNotOptimize(Algo::GetHash64(keys[counter++ & (kLatencyKeys - 1)], kSeed)); } - state.SetItemsProcessed(state.iterations()); - // Allow plotting the total processed gigabytes per second even during - // unpredictable random branching profiles: - state.SetBytesProcessed(state.iterations() * (total_bytes_per_shuffle / static_cast(kLatencyKeys))); + // Throughput headline: average key length x iterations = bytes hashed -> bytes/s. + state.SetBytesProcessed(state.iterations() * (total_bytes / static_cast(kLatencyKeys))); state.SetLabel(std::string(Algo::Name())); } @@ -315,14 +317,16 @@ void RegisterAlgo() { hash128->Arg(size); } } - // One benchmark family with the scenarios as Args, so it reports as a grouped - // table (BmHash64Latency/0, /1 - one row per scenario), parallel to the - // throughput families. The Arg index -> distribution name legend is emitted as - // the `latency_dists` custom context (see main). - auto* const latency = - benchmark::RegisterBenchmark(absl::StrCat("BmHash64Latency<", name, ">"), BmHash64Latency); + // Throughput over upper-bounded length ranges: one benchmark per (distribution, + // bound), named "BmHash64Throughput/:", each reporting + // bytes/s. Sweeping the bounds gives the upper-length -> throughput curve. for (std::size_t dist = 0; dist < kLatencyDists.size(); ++dist) { - latency->Arg(static_cast(dist)); + for (std::size_t bound = 0; bound < kCdfPoints; ++bound) { + benchmark::RegisterBenchmark( + absl::StrCat( + "BmHash64Throughput<", name, ">/", kLatencyDists[dist].name, ":", kLatencyDists[dist].cdf[bound].second), + [dist, bound](benchmark::State& state) { BmHash64Throughput(state, dist, bound); }); + } } } From 62bb04e0ba99f2c9c35375ba8d5c061197b4a901 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:21:48 +0100 Subject: [PATCH 8/9] feat(hash): export the throughput length distributions as context Replace the stale latency_dists Arg-index legend with throughput_dists: the full inverse-CDF per distribution ("Short=pct:len,...;Web=..."), so a dataset records exactly which length mix produced its BmHash64Throughput numbers (provenance; the benchmark names carry only the bound lengths, not the weights). --- mbo/hash/hash_benchmark.cc | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mbo/hash/hash_benchmark.cc b/mbo/hash/hash_benchmark.cc index 9c589f9..6444cae 100644 --- a/mbo/hash/hash_benchmark.cc +++ b/mbo/hash/hash_benchmark.cc @@ -360,12 +360,17 @@ int main(int argc, char** argv) { // the small table straight from a FULL dataset - no separate fast run, and no // second size list to drift (this C++ list is the single source of truth). benchmark::AddCustomContext("readme_sizes", absl::StrJoin(mbo::hash::kReadmeSizes, ",")); - // Legend for the latency benchmark's Arg index -> distribution name, in order, - // so the report can label BmHash64Latency/0, /1 with the scenario names. + // Export the throughput length distributions in use as "name=pct:len,...;..." + // (the full inverse-CDF, not just the bound labels), so a dataset records + // exactly which mix produced its BmHash64Throughput/: numbers. benchmark::AddCustomContext( - "latency_dists", - absl::StrJoin(mbo::hash::kLatencyDists, ",", [](std::string* out, const mbo::hash::LatencyDist& dist) { - absl::StrAppend(out, dist.name); + "throughput_dists", + absl::StrJoin(mbo::hash::kLatencyDists, ";", [](std::string* out, const mbo::hash::LatencyDist& dist) { + absl::StrAppend( + out, dist.name, "=", + absl::StrJoin(dist.cdf, ",", [](std::string* cdf_out, const std::pair& point) { + absl::StrAppend(cdf_out, point.first, ":", point.second); + })); })); benchmark::RunSpecifiedBenchmarks(); benchmark::Shutdown(); From db95af0ff026522e540376d527fa0f7ea89192eb Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:26:06 +0100 Subject: [PATCH 9/9] docs(hash): update CHANGELOG bullet to the final benchmark design --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b652fcc..f89421b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 0.13.2 -- Reworked the hash latency benchmark to two fixed, reproducible key-length distributions (Short-Identifier, Web-URL) sampled once and shared across algorithms, replacing the hash-indexed key walk that collapsed into a degenerate cycle (near-zero anomalies). +- Reworked the hash mixed-length benchmark: dropped the hash-indexed key walk that collapsed into a rho-cycle (near-zero anomalies) and added `BmHash64Throughput` reporting bytes/s over two documented length distributions (Short ≤128 B, Web ≤4096 B) truncated to each upper bound; the per-exact-length `BmHash64`/`BmHash128` remain the latency view. Distributions are exported as the `throughput_dists` context. - Added a `compare` command reporting per-case Δ% and a geomean between two datasets. - Made `tables`/`plot`/`compare`/`quality` accept a bundle `.tgz` or a results JSON, positionally or via `--results`/`--bundle`. - Added `plot --kind` (throughput/latency/all) and `--scale` (log-log/linear-log).