diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1672e98..d6fae9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -459,6 +459,56 @@ jobs: "$bin" --iterations 10000 --repeats 3 echo "OK — bench binary ran to completion." + # --------------------------------------------------------------------------- + # ROADMAP §9.4 — external-allocator baselines + tail-latency percentiles + # (ADR-0045). External baselines are measured the safe way: re-run the SAME + # bench under LD_PRELOAD, which swaps the whole process allocator (jemalloc / + # tcmalloc take over global malloc on load, so they cannot be linked or dlopen'd + # beside the system allocator). Exercises the opt-in --percentiles table too. + # Linux only; never touches the MSVC leg. Asserts exit code 0, not numbers. + # --------------------------------------------------------------------------- + bench-baselines: + name: bench / external baselines + percentiles + runs-on: ubuntu-24.04 + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Install CMake and Ninja + uses: lukka/get-cmake@latest + + - name: Install jemalloc and tcmalloc runtime libraries + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libjemalloc2 libtcmalloc-minimal4t64 + + - name: Configure and build (bench preset) + shell: bash + run: | + export CC=gcc CXX=g++ + cmake --preset bench + cmake --build --preset bench + + - name: Baseline via LD_PRELOAD + the percentile table + shell: bash + run: | + set -euo pipefail + bin="build/bench/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench" + # LD_PRELOAD resolves a soname through the loader cache (ldconfig ran on + # install), so no filesystem search is needed. + run() { # label, LD_PRELOAD value ("" = none), expected "# allocator:" text + echo "=== allocator: $1 ===" + out="$(LD_PRELOAD="$2" "$bin" --scenario all --iterations 20000 --repeats 3 --percentiles)" + echo "$out" + echo "$out" | grep -q "# allocator: $3" || { echo "FAIL: allocator disclosure ($1)"; exit 1; } + echo "$out" | grep -q "p99_ns/op" || { echo "FAIL: percentile table missing ($1)"; exit 1; } + } + run "system malloc" "" "system malloc" + run "jemalloc" "libjemalloc.so.2" "libjemalloc.so.2" + run "tcmalloc" "libtcmalloc_minimal.so.4" "libtcmalloc_minimal.so.4" + echo "OK — pool benchmarked against system malloc, jemalloc, and tcmalloc; percentile table present." + # --------------------------------------------------------------------------- # thread-safety — build and run the existing (single-threaded) test suite # under each non-default PBR_MEMORY_POOL_THREAD_SAFETY policy (ADR-0020 / diff --git a/CHANGELOG.md b/CHANGELOG.md index d799dd1..533a7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ dated version block (`## [X.Y.Z] — YYYY-MM-DD`) when a release PR closes a mil ### Added +- **Benchmark extension — tail-latency percentiles and optional jemalloc/tcmalloc baselines.** + The pool-vs-malloc microbenchmark gains an opt-in `--percentiles` mode that emits a separate + per-operation **p50/p90/p99/p999** table — the dynamic-pool growth row surfaces the + microsecond-scale growth spike the aggregate median hides — and **jemalloc** / **tcmalloc** + baselines measured the safe way, by re-running the bench under **`LD_PRELOAD`** (those + allocators take over global `malloc` on load, so they cannot be linked or `dlopen`'d beside + the system allocator without crashing). A `# allocator:` header line discloses which allocator + each run measured; the bench carries no allocator-specific code, so the default build stays + zero-external-dependency (spec §3.3). Both are strictly additive: the + [ADR-0014](docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md) aggregate ns/op table + and its committed numbers are unchanged. A Linux `bench-baselines` CI cell installs both + allocators and exercises the new paths (non-asserting on numbers, per ADR-0014 §8). + [ADR-0045](docs/adr/0045-benchmark-percentiles-and-external-baselines.md). Closes #111. - **Coverage-guided fuzzing harness for the pool surface.** A new [`pool_fuzz.cpp`](src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp) drives randomized `alloc` / `free(valid)` / `free(NULL)` / `free(foreign)` sequences — over both fixed and diff --git a/ROADMAP.md b/ROADMAP.md index 840499b..923aed8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,7 +139,7 @@ Goal: a coherent, post-`v1.0.0` wave of **additive, ABI-compatible** capabilitie - [x] 9.1 **`std::pmr::memory_resource` adapter** (`PoolMemoryResource`) — the "door left open" in [ADR-0018](docs/adr/0018-stl-allocator-adapter.md): a `std::pmr::memory_resource` subclass binding one `Pool` so any `std::pmr`-aware container can draw from it through `std::pmr::polymorphic_allocator`, without the `PoolAllocator` per-type rebind. Deterministic `(bytes, alignment)` routing to the bound pool — over-sized / over-aligned requests delegate to a configurable upstream resource, and exhaustion of a pool-eligible request throws `std::bad_alloc` rather than falling back (preserving the deterministic deallocate routing) — with `is_equal` by `(pool, upstream)` identity, gated behind `PBR_MEMORY_POOL_HAS_PMR` where `` is available. Header-only, additive, ABI-compatible. Decided in [ADR-0042](docs/adr/0042-pmr-memory-resource-adapter.md); implemented in [`pool_memory_resource.hpp`](src/main/cpp/it/d4np/memorypool/pool_memory_resource.hpp) with [`pool_memory_resource_test.cpp`](src/test/cpp/it/d4np/memorypool/pool_memory_resource_test.cpp) (issue #107). - [x] 9.2 **Opt-in debug hardening** — freed-block poisoning, canaries, and free-list safe-linking (which also yields double-free detection); zero cost when the gate is off (issue #109). Decided in [ADR-0043](docs/adr/0043-opt-in-debug-hardening.md); implemented in [`memory_pool.cpp`](src/main/cpp/it/d4np/memorypool/memory_pool.cpp) behind the compile-time `PBR_MEMORY_POOL_HARDENING` knob (a `harden` CMake preset), with the swappable violation-handler surface in [`pool_hardening.hpp`](src/main/cpp/it/d4np/memorypool/pool_hardening.hpp) and [`pool_hardening_test.cpp`](src/test/cpp/it/d4np/memorypool/pool_hardening_test.cpp) (CTest `pool_hardening`). The "canary" is realized as one trailing **guard word** living in *added* slot stride — so the user-visible `block_size` and the ADR-0009 alignment guarantee are unchanged and the default build is byte-for-byte unchanged (the mechanism is fully compiled out). Poisoning (`0xDE`) catches use-after-free on the next allocation, the guard word catches a write past `block_size` and a double-free, and glibc-style safe-linking (`ptr XOR (slot_addr >> 12)`) protects the in-band next-link. A `harden` CI matrix cell builds and tests the hardened configuration on each Tier-1 platform. Works with fixed and dynamic pools across all three thread-safety policies. - [x] 9.3 **Coverage-guided fuzzing harness** for the pool surface — a libFuzzer target, time-boxed in CI (issue #108). Decided in [ADR-0044](docs/adr/0044-coverage-guided-fuzzing-harness.md); implemented in [`pool_fuzz.cpp`](src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp) as a stateful opcode interpreter with a shadow oracle that asserts the no-alias, canary-intact, foreign/NULL-no-op ([ADR-0012](docs/adr/0012-foreign-pointer-and-out-of-range-pointer-policy.md)) and `InstrumentedPool` accounting invariants across fixed and dynamic pools. One engine-agnostic source yields both the Clang-only libFuzzer target `pool_fuzz` (opt-in `PBR_MEMORY_POOL_BUILD_FUZZERS`, a `fuzz` preset under `-fsanitize=fuzzer,address,undefined`) and the always-built standalone [`pool_fuzz_replay`](src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/) target that replays the seed corpus as a portable regression gate (CTest `pool_fuzz_replay`) on every platform, including MSVC. A dedicated `fuzz` CI job replays the corpus and fuzzes for a bounded time on every PR; a crash is filed in the bug ledger ([ADR-0039](docs/adr/0039-bug-ledger-and-triage-protocol.md)). Test-only and additive — the release build and the benchmark numbers are untouched. -- [ ] 9.4 **Benchmark extension** — external allocator baselines (jemalloc / tcmalloc) and p99 tail-latency reporting (issue #111). +- [x] 9.4 **Benchmark extension** — external allocator baselines (jemalloc / tcmalloc) and p99 tail-latency reporting (issue #111). Decided in [ADR-0045](docs/adr/0045-benchmark-percentiles-and-external-baselines.md) (extends [ADR-0014](docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md)); implemented in [`pool_vs_malloc_bench.cpp`](src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp). An opt-in `--percentiles` mode adds a separate per-operation p50/p90/p99/p999 table (the dynamic-pool growth row surfaces the amortized-growth spike the median hides); jemalloc / tcmalloc baselines are measured by re-running the bench under `LD_PRELOAD` (they take over global `malloc` on load, so they cannot be linked or `dlopen`'d beside the system allocator without crashing — an in-process attempt was tried and rejected); a `# allocator:` header discloses which allocator each run measured. The bench carries no allocator-specific code, so the default build keeps zero external dependencies (spec §3.3) and the [ADR-0014](docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md) aggregate table and its committed numbers are unchanged. A Linux `bench-baselines` CI cell re-runs the bench under each allocator's preload and asserts the disclosure + percentile table (non-asserting on numbers). --- diff --git a/docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md b/docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md index 4dc18dd..824d054 100644 --- a/docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md +++ b/docs/adr/0014-microbenchmark-methodology-pool-vs-malloc.md @@ -1,9 +1,11 @@ # ADR-0014: Microbenchmark methodology — pool vs. `malloc` -- **Status:** Accepted +- **Status:** Accepted (extended by [ADR-0045](0045-benchmark-percentiles-and-external-baselines.md)) - **Date:** 2026-06-11 - **Deciders:** Daniel Polo (maintainer), Claude (architect agent) -- **Related:** spec §6.3, ROADMAP §2.9, [ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) §3 (compiler matrix), [ADR-0006](0006-code-style-and-static-analysis-baseline.md) (style + lint baseline), [ADR-0009](0009-free-list-layout-block-size-constraints-and-alignment-guarantee.md) §2 (block-size constraints the benchmark must respect), [ADR-0013](0013-doxygen-for-api-markdown-for-narrative.md) (narrative goes in Markdown — bench reports too) +- **Related:** spec §6.3, ROADMAP §2.9, [ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) §3 (compiler matrix), [ADR-0006](0006-code-style-and-static-analysis-baseline.md) (style + lint baseline), [ADR-0009](0009-free-list-layout-block-size-constraints-and-alignment-guarantee.md) §2 (block-size constraints the benchmark must respect), [ADR-0013](0013-doxygen-for-api-markdown-for-narrative.md) (narrative goes in Markdown — bench reports too), [ADR-0045](0045-benchmark-percentiles-and-external-baselines.md) (adds tail-latency percentiles + optional jemalloc/tcmalloc baselines) + +> **Extended by [ADR-0045](0045-benchmark-percentiles-and-external-baselines.md)** (2026-07-09): an opt-in `--percentiles` per-operation table (p50/p90/p99/p999) and optional feature-detected jemalloc/tcmalloc baselines. Both are strictly additive — the §4 aggregate statistics, the §6 output contract, and the committed numbers below are unchanged. ## Context diff --git a/docs/adr/0045-benchmark-percentiles-and-external-baselines.md b/docs/adr/0045-benchmark-percentiles-and-external-baselines.md new file mode 100644 index 0000000..d74abc7 --- /dev/null +++ b/docs/adr/0045-benchmark-percentiles-and-external-baselines.md @@ -0,0 +1,75 @@ +# ADR-0045: Benchmark tail-latency percentiles and optional external allocator baselines + +- **Status:** Accepted +- **Date:** 2026-07-09 +- **Deciders:** Daniel Polo (maintainer / project architect) +- **Extends:** [ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md) (the microbenchmark methodology — this ADR adds to it without superseding; the §1–8 contract there stays in force) +- **Related:** spec [§6.3](../specs/01_spec_cpp_memory_pool.md#63-performance-benchmark), [ADR-0004](0004-versioning-and-release-policy.md) (SemVer-neutral tooling change), [ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) §3 (the platform split the baseline cell inherits), [ADR-0009](0009-free-list-layout-block-size-constraints-and-alignment-guarantee.md) §2 (block-size constraints), [ADR-0024](0024-dynamic-growth-synchronization-and-creation-surface.md) §2 (dynamic-growth support, whose events the p99 row surfaces), issue #111, origin issue #105 + +## Context + +[ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md) delivered a rigorous hand-rolled microbenchmark — warm-up, min/median/mean/max/stddev over repeats, anti-optimization barriers, disclosed host, per-release reports, a non-asserting CI smoke, and (later) concurrent and growth scenarios. The spec review (#105 §6.3) is otherwise satisfied; **two residual critiques** stand: + +1. **No tail latency.** `median` is effectively p50; there is no p99/p999. For an allocator, the tail — e.g. the microsecond-scale spike when a dynamic pool grows — is exactly what a latency-sensitive consumer cares about, and the aggregate median averages it away. +2. **Only the system `malloc` baseline.** A reviewer compares an allocator against jemalloc / tcmalloc; without them the headline ratios are only vs glibc/MSVC `malloc`. + +This ADR extends ADR-0014 to close both, under two hard constraints: the existing aggregate ns/op numbers (the ADR-0014 committed contract) must **not** be perturbed, and the default build must keep spec §3.3's **zero external dependencies**. + +## Decision + +### 1. Tail-latency percentiles — opt-in `--percentiles`, per-operation timing, separate table + +Percentiles are only meaningful over a large sample set, so `--percentiles` switches on a **per-operation** timing path (one latency sample per op) — distinct from the aggregate path (one sample per repeat), which is left exactly as ADR-0014 froze it. It is **opt-in** so per-op timing overhead never perturbs the committed aggregate numbers. It emits a **separate TSV table** (`scenario, allocator, p50_ns/op, p90_ns/op, p99_ns/op, p999_ns/op, samples`) after the headline block, so the ADR-0014 aggregate table's 8-column schema is untouched. + +Percentiles use the **nearest-rank** method over the sorted per-op samples. The table covers the interleaved scenario (pool / `malloc` / each compiled baseline) and a **dynamic-pool growth** row whose p99/p999 expose the growth events — the headline motivation. + +**Documented caveat (honest methodology).** Per-op timing carries a fixed clock-read overhead common to every allocator, and its resolution is the platform `steady_clock` tick: on Linux/macOS (≈1 ns) the percentiles are fine-grained; on Windows (≈100 ns) they **quantize to the tick**, so p50–p99 of a single ~5–50 ns op collapse onto multiples of 100 ns. The percentile columns are therefore for **tail / relative comparison and for surfacing microsecond-scale events** (a growth spike shows as p999 ≫ p50 on every platform); for absolute per-op cost the aggregate ns/op table remains authoritative. This is stated in the bench README and the report template. + +### 2. Optional external baselines — measured under `LD_PRELOAD`, never a hard dependency + +jemalloc and tcmalloc are measured by **re-running the same bench binary under `LD_PRELOAD`**, which swaps the whole process allocator. This is deliberate and load-bearing. Those libraries are *designed to be the process allocator*: they take over global `malloc`/`operator new` — via strong symbols and, for tcmalloc, a library constructor — on load. So they cannot be linked, nor even `dlopen`'d, alongside the system allocator to produce side-by-side in-process rows: linking one silently turns the "malloc" row into that allocator, linking both crashes (their initializers fight over `malloc`), and `dlopen` does not help because the constructor still runs. Both were tried and both crashed (SIGSEGV). + +`LD_PRELOAD` sidesteps all of that: one process, one allocator, no mixing. Under `LD_PRELOAD=libjemalloc.so.2` the bench's `malloc` rows — and the pool's own backing store — are served by jemalloc, so a preloaded run is a clean, complete `pool`-vs-*that-allocator* comparison across every scenario (including the percentile table). The bench therefore carries **no allocator-specific code at all**: the default build stays byte-for-byte what ADR-0014 produced and spec §3.3's zero-external-dependency posture holds trivially. A `# allocator:` header line (read from `LD_PRELOAD`, POSIX only; "system malloc" otherwise) discloses which allocator each run's numbers reflect, so the three reports are unambiguous. + +### 3. Output contract + +Additive per ADR-0014 §6: the aggregate 8-column table is unchanged; percentile data is a new, separate table with its own header; a `# allocator:` header line discloses which allocator this run measured (the `LD_PRELOAD` basename, or "system malloc"). No existing row or column changes, so the M7.x report-diffing tooling still parses old and new reports; a baseline comparison is a set of reports, one per allocator, distinguished by that header line. + +### 4. CI + +A `bench-baselines` job (Linux) installs the jemalloc + tcmalloc **runtime** shared objects (`libjemalloc2`, `libtcmalloc-minimal4t64`), builds once, and runs `--scenario all --percentiles` three times — plain, `LD_PRELOAD=libjemalloc.so.2`, and `LD_PRELOAD=libtcmalloc_minimal.so.4` — asserting each disclosed the expected allocator, emitted the percentile table, and exited cleanly. Like every bench cell it gates on **exit code 0, not numbers** (ADR-0014 §8 — shared runners are too noisy for numeric thresholds). It is Linux-only, never touching the MSVC leg (ADR-0005 §3), consistent with the sanitizer-preset split. + +## Alternatives Considered + +- **Always-on percentiles (fold p99 into the existing per-repeat `Stats`).** Rejected. p99 over ~9 per-repeat aggregates is meaningless (it is essentially the max), and per-op timing on the default path would perturb the committed ADR-0014 ns/op numbers. Per-op sampling behind an opt-in flag is the only way to get a meaningful p99 without disturbing the frozen numbers. +- **HdrHistogram (or another bucketed recorder).** Rejected for now: it is an external dependency, and for this benchmark's needs a `std::vector` of per-op samples with a nearest-rank query is sufficient and keeps the methodology inspectable (the ADR-0014 §1 pedagogy argument). Revisit if memory for the sample vector becomes a constraint at very large iteration counts. +- **In-process side-by-side rows — link the allocators (`-ljemalloc`/`-ltcmalloc`) or `dlopen` them.** Rejected after *both* were implemented and *both crashed* (SIGSEGV). The libraries take over global `malloc` on load (strong symbols; tcmalloc also via a constructor), so co-linking makes their initializers fight over `malloc`, linking one silently rebinds the "malloc" row to that allocator, and `dlopen(RTLD_LOCAL)` still runs the constructor. There is no safe way to have two of these allocators plus the system allocator live in one process. `LD_PRELOAD` — one allocator per process, chosen from outside — is the standard, crash-free way to benchmark them, at the cost that a full comparison is N reports rather than N rows in one. +- **A hard dependency on jemalloc/tcmalloc.** Rejected — it would break spec §3.3 and the MSVC leg. Feature-detection with a silent skip keeps the default build dependency-free. +- **CI numeric thresholds on the baselines/percentiles.** Rejected for the ADR-0014 §8 runner-noise reasons; the new cell is a build/run gate, not a performance gate. + +## Consequences + +**Positive** + +- p99/p999 tail latency is reportable, and the dynamic-pool growth row makes the amortized-growth spike visible where the median hid it — the #105 §6.3 critique is closed. +- Modern baselines (jemalloc/tcmalloc) are available for comparison wherever they are installed, via `LD_PRELOAD`, and validated in CI. +- The default build and the committed ADR-0014 numbers are unchanged; zero external dependencies preserved. + +**Negative** + +- Per-op percentiles are resolution-bound: on Windows's ~100 ns `steady_clock` tick they quantize, so they are meaningful there only for microsecond-scale tail events, not for sub-tick medians (documented; the aggregate table stays authoritative). +- A baseline comparison is **N reports, not N rows in one** — the user (or CI) re-runs the bench under each `LD_PRELOAD`. This is exercised on the Linux CI cell; the maintainer's MSVC box has no `LD_PRELOAD`, so it reports "system malloc" only. + +**Tooling / documentation (same PR)** + +- [`pool_vs_malloc_bench.cpp`](../../src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp) — the `--percentiles` mode and the `# allocator:` header disclosure. +- [bench `CMakeLists.txt`](../../src/bench/cpp/it/d4np/memorypool/CMakeLists.txt) — no allocator link; the `# allocator:` header disclosure is the only bench code touched for baselines. +- [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) — the `bench-baselines` job. +- [bench `README.md`](../../src/bench/cpp/it/d4np/memorypool/README.md) — the new columns/baselines and the percentile caveat. +- [`ROADMAP.md`](../../ROADMAP.md) item 9.4, spec §7.1, [`CHANGELOG.md`](../../CHANGELOG.md) `Unreleased`. + +## References + +- [ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md) — the methodology this extends. +- jemalloc and gperftools tcmalloc — `LD_PRELOAD` drop-in allocator replacements; both take over global `malloc` on load, which is why they are measured out-of-process here. +- Gil Tene, *How NOT to Measure Latency* — the case for percentiles/tail over averages in latency reporting. diff --git a/docs/adr/README.md b/docs/adr/README.md index a7ad7e1..90e4120 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -77,5 +77,6 @@ Do **not** write one for purely local implementation details, formatting, or tri | 0042 | [`std::pmr::memory_resource` adapter — `PoolMemoryResource`](0042-pmr-memory-resource-adapter.md) | Accepted | | 0043 | [Opt-in debug hardening — poisoning, a guard word, and free-list safe-linking](0043-opt-in-debug-hardening.md) | Accepted | | 0044 | [A coverage-guided fuzzing harness for the pool surface](0044-coverage-guided-fuzzing-harness.md) | Accepted | +| 0045 | [Benchmark tail-latency percentiles and optional external allocator baselines](0045-benchmark-percentiles-and-external-baselines.md) | Accepted | When adding a new ADR, append a row to this table in the same PR. diff --git a/docs/specs/01_spec_cpp_memory_pool.md b/docs/specs/01_spec_cpp_memory_pool.md index 3e9dd6a..5441f70 100644 --- a/docs/specs/01_spec_cpp_memory_pool.md +++ b/docs/specs/01_spec_cpp_memory_pool.md @@ -204,7 +204,7 @@ valgrind --leak-check=full --show-leak-kinds=all ./test_pool ### 6.3 Performance Benchmark -`memory_pool_alloc`/`free` are compared against standard `malloc`/`free`. The methodology is fixed by [ADR-0014](../adr/0014-microbenchmark-methodology-pool-vs-malloc.md): warm-up repeat discarded, min/median/mean/max/stddev reported (not a single wall-clock loop), anti-optimization barriers, disclosed compiler flags and host, per-release reports under `docs/bench/`, and a non-asserting CI smoke run. A **concurrent** scenario (T threads on a shared pool, `MUTEX` vs `LOCKFREE`) provides the contended baseline. +`memory_pool_alloc`/`free` are compared against standard `malloc`/`free`. The methodology is fixed by [ADR-0014](../adr/0014-microbenchmark-methodology-pool-vs-malloc.md): warm-up repeat discarded, min/median/mean/max/stddev reported (not a single wall-clock loop), anti-optimization barriers, disclosed compiler flags and host, per-release reports under `docs/bench/`, and a non-asserting CI smoke run. A **concurrent** scenario (T threads on a shared pool, `MUTEX` vs `LOCKFREE`) provides the contended baseline. An opt-in `--percentiles` mode adds a per-operation **p50/p90/p99/p999 tail-latency** table (surfacing, for a dynamic pool, the microsecond-scale growth spike the median averages away), and **jemalloc / tcmalloc** can be measured as baselines by re-running the bench under `LD_PRELOAD` (a `# allocator:` header discloses which) — additive, with no allocator-specific code in the bench, so the default build stays dependency-free ([ADR-0045](../adr/0045-benchmark-percentiles-and-external-baselines.md)). ### 6.4 Sanitizers & CI @@ -229,17 +229,18 @@ Every requirement above is realized and recorded. The table maps the spec to its | §5.3 error semantics | [ADR-0012](../adr/0012-foreign-pointer-and-out-of-range-pointer-policy.md), [ADR-0016](../adr/0016-exception-policy-at-the-c-cpp-boundary.md) | | §5.4 instrumentation / observers | [ADR-0025](../adr/0025-decorator-for-instrumented-pool.md), [ADR-0026](../adr/0026-observer-for-pool-lifecycle-events.md) | | §6.3 benchmark methodology | [ADR-0014](../adr/0014-microbenchmark-methodology-pool-vs-malloc.md) | +| §6.3 benchmark percentiles & external baselines | [ADR-0045](../adr/0045-benchmark-percentiles-and-external-baselines.md) | | §6.4 coverage-guided fuzzing harness | [ADR-0044](../adr/0044-coverage-guided-fuzzing-harness.md) | | Spec-compliance acceptance audit | [ADR-0029](../adr/0029-spec-compliance-acceptance-audit.md) | ### 7.1 Deferred / tracked -These are explicitly out of the current build and tracked as issues: - -- **Benchmark extension** — external baselines (jemalloc/tcmalloc) and p99 percentile reporting (issue #111). +Every item once deferred here has now shipped; nothing from the original spec review remains outstanding. **Opt-in debug hardening** (freed-block poisoning, a guard word for overflow + double-free detection, and free-list safe-linking), once deferred here, now ships behind the `PBR_MEMORY_POOL_HARDENING` compile-time knob — see [§4.1](#41-constraints--guarantees) and [ADR-0043](../adr/0043-opt-in-debug-hardening.md) (issue #109). +The **benchmark extension** — external allocator baselines (jemalloc/tcmalloc) and p99 tail-latency percentiles — once deferred here, now ships as the opt-in `--percentiles` table plus `LD_PRELOAD`-based allocator baselines — see [§6.3](#63-performance-benchmark) / [§6.4](#64-sanitizers--ci) and [ADR-0045](../adr/0045-benchmark-percentiles-and-external-baselines.md) (issue #111). + The **coverage-guided fuzzing harness**, once deferred here, now ships as the libFuzzer target `pool_fuzz` plus a portable standalone corpus-replay gate — see [§6.4](#64-sanitizers--ci) and [ADR-0044](../adr/0044-coverage-guided-fuzzing-harness.md) (issue #108). The **C4 component diagram** of the pool internals, once deferred here, now ships in [Section 4.2](#42-component-diagram-c4) (its tooling decision is [ADR-0041](../adr/0041-mermaid-diagram-tooling.md)). diff --git a/src/bench/cpp/it/d4np/memorypool/CMakeLists.txt b/src/bench/cpp/it/d4np/memorypool/CMakeLists.txt index ef3cb50..e12b3b3 100644 --- a/src/bench/cpp/it/d4np/memorypool/CMakeLists.txt +++ b/src/bench/cpp/it/d4np/memorypool/CMakeLists.txt @@ -30,6 +30,11 @@ elseif(PBR_MEMORY_POOL_THREAD_SAFETY STREQUAL "LOCKFREE") PBR_MEMORY_POOL_THREAD_SAFETY=PBR_MEMORY_POOL_THREAD_SAFETY_LOCKFREE) endif() +# Optional external-allocator baselines (jemalloc / tcmalloc) are obtained by +# re-running the binary under LD_PRELOAD (ADR-0045), which swaps the whole +# process allocator. The bench links nothing allocator-specific, so the default +# build keeps spec §3.3's zero external dependencies. + # Property name used by IDEs that group targets; matches the layout under # src/bench/cpp/. set_target_properties(pool_vs_malloc_bench PROPERTIES diff --git a/src/bench/cpp/it/d4np/memorypool/README.md b/src/bench/cpp/it/d4np/memorypool/README.md index 4c070fc..816cdfe 100644 --- a/src/bench/cpp/it/d4np/memorypool/README.md +++ b/src/bench/cpp/it/d4np/memorypool/README.md @@ -35,10 +35,12 @@ The binary lands at `build/bench/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc ```text usage: pool_vs_malloc_bench [--iterations N] [--repeats N] [--block-size N] - [--scenario {bulk|interleaved|both}] + [--threads N] + [--scenario {bulk|interleaved|concurrent|growth|both|all}] + [--percentiles] ``` -Defaults match the spec §6.3 contract — 1,000,000 iterations, 10 repeats (first discarded as warm-up, nine measured), 64-byte block size, both scenarios. +Defaults match the spec §6.3 contract — 1,000,000 iterations, 10 repeats (first discarded as warm-up, nine measured), 64-byte block size, both the `bulk` and `interleaved` scenarios (`concurrent` and `growth` are opt-in via `--scenario`). ```bash # Full canonical run — what the committed bench reports use. @@ -87,6 +89,33 @@ interleaved malloc alloc+free 47.600 51.360 49.480 51.360 All times are in **nanoseconds per single operation** (`ns/op`), computed as `(elapsed_ns_for_repeat / iterations)`. Min / median / mean / max / stddev are across the measured repeats only (the warm-up at index 0 is dropped before statistics). +## Tail-latency percentiles (`--percentiles`, ADR-0045) + +`--percentiles` appends a **separate** TSV table timed **per operation** (one sample per op, rather than one per repeat), so p50/p90/p99/p999 are meaningful. It covers the interleaved scenario for every allocator plus a dynamic-pool **growth** row whose p99/p999 expose the microsecond-scale spike of a growth event — the tail a latency-sensitive consumer cares about, which the aggregate median averages away. + +```text +# percentile table — per-op timing (ADR-0045); tail/relative, not absolute per-op cost +scenario allocator p50_ns/op p90_ns/op p99_ns/op p999_ns/op samples +interleaved pool 3.000 4.000 6.000 31.000 1000000 +growth pool 3.000 4.000 6.000 7000.000 1000000 +``` + +**Caveat:** per-op timing carries a fixed clock-read overhead, and its resolution is the platform `steady_clock` tick — ≈1 ns on Linux/macOS, but ≈100 ns on Windows, where the columns **quantize** to the tick. Treat the percentile columns as a **tail / relative** comparison and a way to surface microsecond-scale events (a growth spike shows as p999 ≫ p50 on every platform); the aggregate ns/op table above stays authoritative for absolute per-op cost. The `--percentiles` path is opt-in precisely so its overhead never perturbs those aggregate numbers. + +## External allocator baselines (jemalloc / tcmalloc, ADR-0045) + +jemalloc and tcmalloc are measured as baselines by **re-running the bench under `LD_PRELOAD`**, which swaps the whole process allocator. This is the only safe way: those libraries take over global `malloc`/`operator new` on load, so they cannot be linked or `dlopen`'d beside the system allocator to produce side-by-side rows (both were tried; both crashed). Under a preload, the bench's `malloc` rows — and the pool's own backing — are served by that allocator, so each run is a clean `pool`-vs-*allocator* comparison; the `# allocator:` header line discloses which. The bench carries no allocator-specific code, so the default build is dependency-free (spec §3.3). On Debian/Ubuntu: + +```bash +sudo apt-get install -y libjemalloc2 libtcmalloc-minimal4t64 +cmake --preset bench && cmake --build --preset bench +bin=./build/bench/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench +"$bin" --scenario all --percentiles # system malloc +LD_PRELOAD=libjemalloc.so.2 "$bin" --scenario all --percentiles # vs jemalloc +LD_PRELOAD=libtcmalloc_minimal.so.4 "$bin" --scenario all --percentiles # vs tcmalloc +# the "# allocator:" header line records which allocator each run measured +``` + ## Reporting Every release that closes a milestone ships a bench report under [`docs/bench/`](../../../../../../docs/bench/). One file per release × host, named `v---.md`: @@ -101,7 +130,7 @@ The file wraps the raw benchmark output in a Markdown report disclosing the full ## CI -The `bench-smoke` job in [`.github/workflows/ci.yml`](../../../../../../.github/workflows/ci.yml) builds the bench binary with the `bench` preset and runs it with `--iterations 10000 --repeats 3` — proves the binary still compiles, links, and runs to completion. It deliberately does **not** assert numeric thresholds; shared-runner noise makes that gate flaky without adding signal. ADR-0014 §8 documents the rationale. +The `bench-smoke` job in [`.github/workflows/ci.yml`](../../../../../../.github/workflows/ci.yml) builds the bench binary with the `bench` preset and runs it with `--iterations 10000 --repeats 3` — proves the binary still compiles, links, and runs to completion. A companion `bench-baselines` job (Linux) installs jemalloc + tcmalloc and re-runs `--scenario all --percentiles` under each via `LD_PRELOAD`, asserting each run disclosed the expected allocator and emitted the percentile table (ADR-0045). Both deliberately **not** assert numeric thresholds; shared-runner noise makes that gate flaky without adding signal. ADR-0014 §8 documents the rationale. ## Methodology snapshot diff --git a/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp b/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp index 3c9bfb9..8aff426 100644 --- a/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp +++ b/src/bench/cpp/it/d4np/memorypool/pool_vs_malloc_bench.cpp @@ -58,6 +58,14 @@ #include #include +// External-allocator baselines (jemalloc / tcmalloc) are obtained by re-running +// this binary under LD_PRELOAD, which swaps the whole process allocator — the +// only safe way, since those libraries take over global malloc on load, so they +// cannot be linked or dlopen'd alongside the system allocator without crashing. +// The bench itself therefore carries no allocator-specific code and keeps spec +// §3.3's zero external dependencies; the header discloses the active allocator. +// See ADR-0045. + namespace mem = it::d4np::memorypool; namespace { @@ -77,6 +85,7 @@ struct Config { bool run_interleaved_ = true; bool run_concurrent_ = false; // M4.5 — opt-in (single-thread fast path vs concurrent path) bool run_growth_ = false; // M5.4 — opt-in (amortized cost of dynamic growth) + bool percentiles_ = false; // M9.4 — opt-in per-op tail-latency table (ADR-0045) }; constexpr std::size_t MIN_REPEATS = 2U; @@ -146,6 +155,36 @@ Stats summarise(std::vector samples) { return Stats{samples.front(), samples.at(n / 2U), mean, samples.back(), stddev}; } +// --------------------------------------------------------------------------- +// Tail-latency percentiles over a per-operation sample set (M9.4 / ADR-0045). +// Unlike Stats (computed over a handful of per-repeat aggregates), these are +// computed over one sample *per operation*, so p99 / p999 are meaningful — in +// particular they surface the microsecond-scale spikes of a dynamic-pool growth +// event that the aggregate median averages away. +// --------------------------------------------------------------------------- +struct Percentiles { + double p50_ns_; + double p90_ns_; + double p99_ns_; + double p999_ns_; + std::size_t count_; +}; + +Percentiles percentiles_of(std::vector samples) { + std::sort(samples.begin(), samples.end()); + const std::size_t n = samples.size(); + // Nearest-rank on a zero-based sorted vector: index = ceil(q*n) - 1, clamped. + const auto at_quantile = [&samples, n](double q) { + if (n == 0U) { + return 0.0; + } + const auto raw_rank = static_cast(std::ceil(q * static_cast(n))); + const std::size_t rank = std::min(std::max(raw_rank, 1U), n); + return samples.at(rank - 1U); + }; + return Percentiles{at_quantile(0.50), at_quantile(0.90), at_quantile(0.99), at_quantile(0.999), n}; +} + // --------------------------------------------------------------------------- // Timed runners. Each returns the elapsed nanoseconds per iteration for ONE // repeat. The outer loop (run_* runners below) takes care of warm-up + stats. @@ -244,6 +283,72 @@ double time_malloc_interleaved(const Config& cfg) { return ns_per_iter(t0, t1, cfg.iterations_); } +// --------------------------------------------------------------------------- +// Per-operation timers for the percentile table (ADR-0045). Each records ONE +// latency sample per operation into `out`, so a percentile summary is +// meaningful. Per-op timing adds a fixed clock-read overhead common to every +// allocator, so the percentile columns are for tail / relative comparison and +// for surfacing microsecond-scale events (e.g. a growth) — not an absolute +// per-op cost, for which the aggregate ns/op table stays authoritative. +// --------------------------------------------------------------------------- +void perop_pool_interleaved(mem::Pool& pool, const Config& cfg, std::vector& out) { + out.clear(); + out.reserve(cfg.iterations_); + for (std::size_t i = 0; i < cfg.iterations_; ++i) { + const auto t0 = clock::now(); + void* const p = pool.try_allocate(); + touch_byte(p, i); + do_not_optimize(p); + pool.deallocate(p); + const auto t1 = clock::now(); + out.push_back(ns_per_iter(t0, t1, 1U)); + } +} + +void perop_malloc_interleaved(const Config& cfg, std::vector& out) { + out.clear(); + out.reserve(cfg.iterations_); + for (std::size_t i = 0; i < cfg.iterations_; ++i) { + const auto t0 = clock::now(); + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + void* const p = std::malloc(cfg.block_size_); + touch_byte(p, i); + do_not_optimize(p); + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + std::free(p); + const auto t1 = clock::now(); + out.push_back(ns_per_iter(t0, t1, 1U)); + } +} + +// A dynamic pool bulk-allocated with per-op timing: the growth events show up +// in the p99 / p999 tail — the headline motivation for this feature. Returns +// false if dynamic mode is unsupported in this build (lock-free — ADR-0024 §2). +bool perop_pool_growth(const Config& cfg, std::vector& out) { + std::optional opt = mem::Pool::make_dynamic(cfg.block_size_, GROWTH_INITIAL_BLOCKS, GROWTH_FACTOR); + if (!opt.has_value()) { + return false; + } + mem::Pool& pool = *opt; + out.clear(); + out.reserve(cfg.iterations_); + std::vector slots; + slots.reserve(cfg.iterations_); + for (std::size_t i = 0; i < cfg.iterations_; ++i) { + const auto t0 = clock::now(); + void* const p = pool.try_allocate(); + const auto t1 = clock::now(); + touch_byte(p, i); + do_not_optimize(p); + slots.push_back(p); + out.push_back(ns_per_iter(t0, t1, 1U)); + } + for (void* const p : slots) { + pool.deallocate(p); // untimed cleanup so destroy is leak-free + } + return true; +} + // --------------------------------------------------------------------------- // Concurrent scenario (M4.5). T threads each run cfg.iterations_ interleaved // alloc/free against a SHARED pool, started together via a release/acquire @@ -494,7 +599,8 @@ struct ParseLoc { std::cerr << argv0 << ": " << msg << "\n"; std::cerr << "usage: " << argv0 << " [--iterations N] [--repeats N]"; std::cerr << " [--block-size N] [--threads N]"; - std::cerr << " [--scenario {bulk|interleaved|concurrent|growth|both|all}]\n"; + std::cerr << " [--scenario {bulk|interleaved|concurrent|growth|both|all}]"; + std::cerr << " [--percentiles]\n"; std::exit(EXIT_FAILURE); } @@ -571,6 +677,8 @@ Config parse_args(int argc, char* argv[]) { } } ++i; + } else if (a == "--percentiles") { + cfg.percentiles_ = true; // M9.4 — opt-in tail-latency table (ADR-0045) } else if (a == "-h" || a == "--help") { die_with_usage(argv0, "help requested"); } else { @@ -592,7 +700,7 @@ Config parse_args(int argc, char* argv[]) { // std::ostringstream for the headline tail. // --------------------------------------------------------------------------- std::string_view compiler_name() { -#if defined(__clang__) +#ifdef __clang__ return "clang"; #elif defined(__GNUC__) return "gcc"; @@ -605,7 +713,7 @@ std::string_view compiler_name() { std::string compiler_version() { std::ostringstream s; -#if defined(__clang__) +#ifdef __clang__ s << __clang_major__ << "." << __clang_minor__ << "." << __clang_patchlevel__; #elif defined(__GNUC__) s << __GNUC__ << "." << __GNUC_MINOR__ << "." << __GNUC_PATCHLEVEL__; @@ -624,6 +732,22 @@ void print_header(std::ostream& os, const Config& cfg) { os << "# hardware_concurrency: " << std::thread::hardware_concurrency() << "\n"; os << "# max_align_t: " << alignof(std::max_align_t) << " bytes\n"; os << "# thread_safety_policy: " << policy_name() << "\n"; + // Disclose the active allocator behind the `malloc` rows. External baselines + // (jemalloc / tcmalloc) are measured by re-running under LD_PRELOAD, which + // swaps the whole process allocator — the only safe way, since those + // libraries take over global malloc on load (ADR-0045). This line records + // which allocator the `malloc` (and pool-backing) numbers actually reflect. + // LD_PRELOAD is POSIX-only; on Windows the allocator is always the system one. +#ifdef _MSC_VER + const char* const preload = nullptr; +#else + // NOLINTNEXTLINE(concurrency-mt-unsafe) + const char* const preload = std::getenv("LD_PRELOAD"); +#endif + os << "# allocator: " << ((preload != nullptr && preload[0] != '\0') ? preload : "system malloc") << "\n"; + if (cfg.percentiles_) { + os << "# percentiles: on (per-op tail-latency table appended — ADR-0045)\n"; + } os << "# config: iterations=" << cfg.iterations_; os << " repeats=" << cfg.repeats_; os << " block_size=" << cfg.block_size_; @@ -736,6 +860,42 @@ std::string run_and_report_growth(const Config& cfg, std::ostream& body) { return tail.str(); } +// M9.4 — the tail-latency table (ADR-0045). A separate TSV section (its own +// header) so the ADR-0014 aggregate table's schema is untouched. Per-op timing +// for interleaved across pool and the process `malloc` (which an external +// allocator baseline replaces under LD_PRELOAD — see the header disclosure and +// ADR-0045), plus a dynamic-pool growth row whose p99 / p999 expose the spikes. +void print_percentile_table_header(std::ostream& os) { + os << "scenario\tallocator\tp50_ns/op\tp90_ns/op\tp99_ns/op\tp999_ns/op\tsamples\n"; +} + +void print_percentile_row(std::ostream& os, std::string_view scenario, std::string_view allocator, + const Percentiles& p) { + os << scenario << "\t" << allocator << "\t" << p.p50_ns_ << "\t" << p.p90_ns_ << "\t" << p.p99_ns_ << "\t" + << p.p999_ns_ << "\t" << p.count_ << "\n"; +} + +void run_and_report_percentiles(const Config& cfg, std::ostream& body) { + body << "\n# percentile table — per-op timing (ADR-0045); tail/relative, not absolute per-op cost\n"; + print_percentile_table_header(body); + std::vector samples; + + std::optional pool = mem::Pool::make(cfg.block_size_, INTERLEAVED_POOL_CAPACITY); + if (pool.has_value()) { + perop_pool_interleaved(*pool, cfg, samples); + print_percentile_row(body, "interleaved", "pool", percentiles_of(samples)); + } + + perop_malloc_interleaved(cfg, samples); + print_percentile_row(body, "interleaved", "malloc", percentiles_of(samples)); + + if (perop_pool_growth(cfg, samples)) { + print_percentile_row(body, "growth", "pool", percentiles_of(samples)); + } else { + body << "# percentile: growth skipped (dynamic mode unsupported in this build — ADR-0024 §2)\n"; + } +} + } // namespace // NOLINTNEXTLINE(bugprone-exception-escape,cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays,hicpp-avoid-c-arrays) @@ -763,5 +923,10 @@ int main(int argc, char* argv[]) { tail += run_and_report_growth(cfg, std::cout); } std::cout << "\n" << tail; + // The opt-in per-op tail-latency table is a separate section after the + // headlines so the ADR-0014 aggregate table is untouched (ADR-0045). + if (cfg.percentiles_) { + run_and_report_percentiles(cfg, std::cout); + } return 0; }