Skip to content

perf(bench): add tail-latency percentiles and optional jemalloc/tcmalloc baselines#116

Merged
danielPoloWork merged 4 commits into
masterfrom
perf/bench-external-baselines-p99
Jul 9, 2026
Merged

perf(bench): add tail-latency percentiles and optional jemalloc/tcmalloc baselines#116
danielPoloWork merged 4 commits into
masterfrom
perf/bench-external-baselines-p99

Conversation

@danielPoloWork

Copy link
Copy Markdown
Owner

Summary

Implements ROADMAP item 9.4 (issue #111), the last Milestone-9 item, decided in ADR-0045 (extends ADR-0014, does not supersede it). It closes the two residual spec-review (#105 §6.3) critiques left standing after ADR-0014's methodology was verified as already delivered: no tail-latency percentiles and only the system malloc baseline. Both additions are strictly additive — the ADR-0014 aggregate ns/op table and its committed numbers are unchanged.

Tail-latency percentiles (--percentiles)

An opt-in mode that times the work per operation (one sample per op, vs the aggregate path's one per repeat) and emits a separate TSV table — p50 / p90 / p99 / p999 + samples. It covers the interleaved scenario for every allocator plus a dynamic-pool growth row whose p99/p999 surface the microsecond-scale growth spike the aggregate median averages away (the issue's own motivating example).

Opt-in precisely so per-op timing overhead never perturbs the committed aggregate numbers. Documented caveat: per-op resolution is the platform steady_clock tick — ≈1 ns on Linux/macOS, ≈100 ns on Windows (where the columns quantize) — so the columns are for tail/relative comparison and surfacing μs-scale events, not absolute per-op cost; the aggregate table stays authoritative.

Optional jemalloc / tcmalloc baselines

CMake-feature-detected (find_library + find_path): when present, each appears as an extra comparison row in the aggregate and percentile tables, driven through mallocx/dallocx and tc_malloc/tc_free so they don't override the system malloc row. When absent — the default, and every MSVC build — the guarded code is compiled out and the output is unchanged but for a # baselines: disclosure line. Spec §3.3's zero-external-dependency posture is preserved. A RawAllocator (name + alloc/free fn pointers) unifies system malloc and the baselines for the added paths; the dedicated malloc runners producing the committed numbers are untouched.

Validation

  • Built + ran the bench under MSVC (bench preset): default output schema intact (only a # baselines: malloc line added), --percentiles table works, and the dynamic-pool growth p999 clearly shows the growth spike (≈7 µs) even through the 100 ns Windows clock quantization.
  • clang-format clean; clang-tidy clean on the changed file (incl. tidying two pre-existing #if defined(__clang__)#ifdef).
  • consistency_lint.py OK; all doc links resolve; the spec's translation rows are already stale, so no i18n debt.
  • The jemalloc/tcmalloc paths (not installable on the maintainer's MSVC box) are exercised by a new Linux bench-baselines CI cell that installs both allocators, asserts they were feature-detected, and asserts the baseline rows + percentile table are present — gating on exit code 0, not numbers (ADR-0014 §8).

Docs

New ADR-0045 (+ index row) with a forward-reference note added to ADR-0014; spec §6.3 / §7 (the deferred list is now empty — every item once tracked there has shipped); ROADMAP.md (9.4 → done, closing Milestone 9); CHANGELOG.md [Unreleased]; and the bench README (usage, the percentile caveat, the baseline how-to). Per the established sequencing, the README.md performance-section refresh is deferred to the v1.2.0 release PR to keep this PR off the translated docs surface.

Closes #111.

🤖 Generated with Claude Code

…loc baselines

Close the two residual spec-review (#105 §6.3) critiques left standing after
ADR-0014's methodology was verified as already delivered, by extending the
pool-vs-malloc microbenchmark (ADR-0045 extends ADR-0014; both additive).

Tail latency:
- A new opt-in `--percentiles` mode times the work PER OPERATION (one sample
  per op, vs the aggregate path's one sample per repeat) and emits a separate
  TSV table with p50/p90/p99/p999 + sample count. It covers interleaved for
  every allocator plus a dynamic-pool growth row whose p99/p999 surface the
  microsecond-scale growth spike the aggregate median averages away.
- Opt-in so per-op timing overhead never perturbs the committed ADR-0014 ns/op
  numbers. Documented caveat: per-op resolution is the platform steady_clock
  tick (~1ns Linux/macOS, ~100ns Windows where it quantizes), so the columns
  are for tail/relative comparison and surfacing us-scale events, not absolute
  per-op cost — the aggregate table stays authoritative.

External baselines:
- jemalloc and tcmalloc are optional, CMake-feature-detected baselines (via
  find_library + find_path); when present they appear as extra rows in the
  aggregate and percentile tables, driven through mallocx/dallocx and
  tc_malloc/tc_free so they don't override the system malloc row. When absent
  (the default, and every MSVC build) the guarded code is compiled out and the
  output is unchanged but for a `# baselines:` disclosure line — spec §3.3's
  zero-external-dependency posture is preserved.

A RawAllocator (name + alloc/free fn pointers) unifies system malloc and the
baselines for the added rows and the percentile recorder; the dedicated malloc
runners that produce the committed numbers are untouched.

A Linux `bench-baselines` CI cell installs both allocators and asserts the
baseline rows + percentile table are present; like every bench cell it gates on
exit code 0, not numbers (ADR-0014 §8).

Closes #111.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danielPoloWork danielPoloWork added this to the v1.2.0 milestone Jul 9, 2026
@danielPoloWork danielPoloWork added the perf Performance improvement (Conventional Commit: perf) label Jul 9, 2026
@danielPoloWork danielPoloWork self-assigned this Jul 9, 2026
danielPoloWork and others added 3 commits July 9, 2026 21:59
The first implementation linked both allocators (`-ljemalloc -ltcmalloc`),
which crashed the bench at runtime (SIGSEGV): both export strong malloc /
operator new symbols, so co-linking makes their initializers fight over the
global allocator, and even linking one interposes the process malloc — which
would silently turn the "malloc" row into that allocator.

Load each at run time via dlopen(RTLD_LOCAL) and call only its explicit
extended API (mallocx/dallocx, tc_malloc/tc_free), resolved by dlsym. With
RTLD_LOCAL their symbols stay out of global resolution, so the process malloc
remains the true system allocator, the two never conflict, and pool / malloc /
jemalloc / tcmalloc all appear as distinct rows in one run. dlsym's void* is
copied into the typed function pointer with memcpy (not a reinterpret_cast
between object- and function-pointer types, which -Wpedantic rejects).

The mechanism is POSIX-only (#ifdef PBR_BENCH_DLOPEN) and compiled out on
Windows; the only build dependency is ${CMAKE_DL_LIBS}. The CI cell now
installs the runtime shared objects (libjemalloc2, libgoogle-perftools4t64)
and asserts both baselines load at run time. ADR-0045, bench README, spec,
ROADMAP, and CHANGELOG updated to describe the dlopen approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both the linked and the dlopen(RTLD_LOCAL) approaches crashed at runtime
(SIGSEGV): jemalloc and tcmalloc are designed to BE the process allocator —
they take over global malloc/operator new on load (strong symbols, and for
tcmalloc a library constructor), so there is no safe way to have two of them
plus the system allocator coexist in one process.

Pivot to the standard, crash-free method: measure each external allocator by
re-running the SAME bench under LD_PRELOAD, which swaps the whole process
allocator. 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 across every scenario (including the percentile table). A new
`# allocator:` header line (read from LD_PRELOAD; POSIX only) discloses which
allocator each run measured.

Consequently the bench carries NO allocator-specific code: removed the
RawAllocator abstraction, the dlopen loader, the generic raw runners, and the
in-table baseline rows. The default build stays byte-for-byte ADR-0014 and
zero-external-dependency (spec §3.3). The percentile table now covers pool +
the process malloc + the dynamic-pool growth tail.

The bench-baselines CI cell installs the jemalloc/tcmalloc runtime .so and
re-runs the bench under each via LD_PRELOAD, asserting the allocator
disclosure + percentile table (still non-asserting on numbers). ADR-0045,
bench README, spec, ROADMAP, and CHANGELOG updated to the LD_PRELOAD design
(and record why in-process was rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bench-baselines step died before running: `je="$(find / ... | head -1)"`
under `set -euo pipefail` failed because `find /` exits non-zero on the
permission-denied pseudo-filesystems, and pipefail propagated that into the
command-substitution assignment (which set -e treats as fatal).

Drop the filesystem search entirely: LD_PRELOAD resolves a bare soname
(libjemalloc.so.2 / libtcmalloc_minimal.so.4) through the loader cache that
ldconfig populated on package install. Simpler and robust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danielPoloWork danielPoloWork marked this pull request as ready for review July 9, 2026 20:27
@danielPoloWork danielPoloWork merged commit 3331335 into master Jul 9, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Performance improvement (Conventional Commit: perf)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(bench): add jemalloc/tcmalloc baselines and p99 tail-latency reporting

1 participant