Skip to content

feat(perf): v0.4.1 -- layer-by-layer perf instrumentation infrastructure - #3

Merged
EvgeniyPatlan merged 17 commits into
mainfrom
feat/perf-instrumentation
Jun 7, 2026
Merged

feat(perf): v0.4.1 -- layer-by-layer perf instrumentation infrastructure#3
EvgeniyPatlan merged 17 commits into
mainfrom
feat/perf-instrumentation

Conversation

@EvgeniyPatlan

Copy link
Copy Markdown
Owner

Summary

v0.4.1 perf-instrumentation infrastructure release. Adds a layer-by-layer latency capture surface to the plugin (thread-local rdtsc rings, per-method binary files, offline analyser) so future engine-side optimisation work has a fact base. No engine optimisations ship in this release — the deliverable is the measurement surface, not a perf improvement. The bundled engine is unchanged from v0.4.0 (still TidesDB v9.3.2, still shipped unpatched).

The default tidesdb/mysql:9.7 image is unchanged from v0.4.0; instrumentation lives in a separate tidesdb/mysql:9.7-perf variant built with --build-arg TIDESDB_PERF=1. With TIDESDB_PERF=0 (the default) every TDB_PERF_SCOPE(...) macro expands to ((void)0) — zero runtime cost.

Plan, spec, validation

  • Spec: docs/superpowers/specs/2026-06-04-perf-instrumentation-design.md
  • Plan: docs/superpowers/plans/2026-06-04-perf-instrumentation.md (13 tasks, 74 steps)
  • Validation report: docs/v0.4.1-validation-report.md
  • CHANGELOG entry: v0.4.1 — 2026-06-07

What lands

  • TDB_PERF_SCOPE(MethodId) RAII macro across the full plugin entry surface (32 MethodId values).
  • Thread-local TLS_Ring (default 2^16 samples), Sample struct (24 bytes, static_asserted), lock-free per-thread ring registration into g_rings_head.
  • Background flusher thread pinned to CPU 0; bucket-sorts samples by method, writes per-method append-only .bin files + meta.json with calibrated TSC frequency.
  • Four perf sysvars: tidesdb_perf_capture (BOOL, OFF), tidesdb_perf_output_dir (STR), tidesdb_perf_ring_capacity_pow2 (INT, 16), tidesdb_perf_flush_interval_ms (INT, 1000).
  • Offline analyser at tools/tidesdb_perf_analyze (Python + numpy); markdown report + --compare diff mode.
  • Integration harness at bench/perf/run-perf-capture.sh; wraps bench/hammerdb/run-hammerdb.sh, mounts /tmp/perf-out, docker-mediates the artifact copy back to host, runs the analyser.
  • TideSQL side-by-side perf patch at docker/patches/tidesql/0001-perf-instrumentation.patch (880 lines).
  • Dockerfile.mysql build-arg TIDESDB_PERF=0|1 (forwarded as -D and env-var on the plugin cmake steps; the env-var is load-bearing because cmake -D doesn't always propagate into the plugin sub-scope across cmake versions).
  • 5 MTR perf tests + 8 gtest ring tests + 2 analyser pytests — all pass.

Headline numbers (WARE=10 RUNVU=8 1m+3m HammerDB TPROC-C)

Captured from tidesdb/mysql:9.7-perf (sha256:ab1afdcc7e57) on the validation host:

method calls mean_us p99_us max_us
write_row 5,294,113 16.54 17.13 72,783.99
index_read_map 1,900,638 13.19 108.09 18,306.95
deserialize_row 3,950,283 0.90 3.61 1,034.43
serialize_row 6,011,734 0.41 2.32 4,298.43
pk_from_record 6,361,758 0.19 0.72 7,047.85

Throughput with perf ON: 1841 NOPM (vs ~2028 NOPM baseline — approx 9% capture overhead). write_row p99 = 17us but max = 72ms — long compaction-stall tail dominates total. index_read_map p95 (44.7us) is 8x p50 (6.1us) — read path has a meaningful long tail too. These are the v0.5.0 optimisation entry points.

Deferred to v0.5.0

  • Engine-side optimisations driven by the above hotspots.
  • sut-mariadb-tidesdb:9.3.0-perf image build (TideSQL patch is committed, image not built yet).
  • TideSQL perf sysvars (the patch wires scopes + ring; sysvar wiring would have doubled the patch size).

Test plan

  • Plugin compiles with TIDESDB_PERF=1 (OL9 Stage 1, gcc-toolset-14)
  • Plugin compiles with TIDESDB_PERF=0 (no perf TUs, scope macro to no-op)
  • Plugin .so resolves all dynamic symbols against runtime mysql:9.7 image
  • Plugin loads in tidesdb/mysql:9.7-perf; all 4 sysvars present at expected defaults
  • SMOKE capture (WARE=2 RUNVU=1 1m+1m) produces 32 .bin + meta.json + report.md
  • FULL capture (WARE=10 RUNVU=8 1m+3m) — 1841 NOPM with perf ON, headline numbers above
  • 5 MTR perf tests (no_files, stops_growth, meta_json_valid, overflow_logged, no_dml_regression)
  • 8 gtest ring tests (push/read, wrap, tombstone, alloc-self-registers, no-capture-zero-pushes, capture-on-pushes-one, nested-ordered, concurrent-push-single-reader)
  • 2 analyser pytest cases
  • Tag v0.4.1 + release-docker.sh push to perconalab/tidesdb-mysql:0.4.1
  • Push perf variant to perconalab/tidesdb-mysql:0.4.1-perf

EvgeniyPatlan and others added 17 commits June 4, 2026 21:54
Build a thread-local rdtsc ring buffer + offline analysis pipeline so we
can attribute, with cycle-accurate fidelity, where time goes inside the
plugin during a real workload. Port the same primitive to TideSQL
(MariaDB plugin) so we can run identical workloads side-by-side and
quantify which fraction of the MySQL+TidesDB vs MariaDB+TidesDB gap is
in our code vs MySQL internals.

Scope (infrastructure release):
- thread-local 64K-sample ring buffer (1.5 MiB/thread)
- TDB_PERF_SCOPE RAII macro + rdtsc sampling
- 1 Hz background flusher; per-method binary files
- 32 instrumentation sites covering the full plugin entry surface
- offline Python analyser; markdown + CSV; --compare diff mode
- 4 new sysvars; calibration step; per-pid file naming
- TideSQL port (vendored header; same MethodId enum)
- bench/perf/run-perf-capture.sh integration harness
- 8 unit tests + 7 MTR tests

Out of scope: any actual optimisation. v0.4.1 is the diff report; the
optimisation release (v0.4.2 / v0.5.0) reads that diff and picks targets.

References:
- v0.4.0 baseline: 1784 NOPM at WARE=10 (docs/v0.4.0-validation-report.md)
- candidate findings: M-2, M-5, M-6, M-7, M-8, M-9, L-6, L-7, L-8
- TideSQL repo: https://github.com/tidesdb/tidesql
Maps spec §12 sequencing onto bite-sized TDD tasks. Each task ends
with a green test pass before the next starts.

Task breakdown:
 1. Skeleton TUs + CMake -DTIDESDB_PERF=1 flag
 2. 4 sysvars + init/deinit wiring + 1st MTR test
 3. Sample + TLS_Ring + 3 gtest cases (push, wrap, tombstone)
 4. Lock-free ring registration + PerfScope dtor + 3 gtest cases
 5. Nested + concurrent push gtest cases
 6. Flusher thread + meta.json + per-method files + MTR test
 7. 5 more MTR tests (off, stops_growth, meta_valid, overflow, no_dml_regression)
 8. 32 TDB_PERF_SCOPE instrumentation sites
 9. tools/tidesdb-perf-analyze offline Python tool + 2 unit tests
10. bench/perf/run-perf-capture.sh integration harness
11. TideSQL side-by-side port + patch tracked at docker/patches/tidesql/
12. End-to-end measurement run + docs/v0.4.1-perf-baseline.md
13. Validation report + tag v0.4.1 + release

Ships as v0.4.1 (infrastructure only); the comparison diff from Task 12
feeds the next release's optimisation targets.
Adds empty Sample / MethodId / TLS_Ring declarations + the PerfScope
RAII shell. TDB_PERF_SCOPE macro compiles to (void)0 when the flag is
off, so production builds pay zero cost. Verified both flag states
build clean and produce the expected symbol presence/absence.
tidesdb_perf_capture (BOOL, default OFF, runtime),
tidesdb_perf_output_dir (STR, runtime, MEMALLOC),
tidesdb_perf_ring_capacity_pow2 (INT 8-24, server-start),
tidesdb_perf_flush_interval_ms (INT 100-60000, runtime).

Wired into tidesdb_init_func and tidesdb_deinit_func behind
TIDESDB_PERF. Shared MTR helper skips perf tests cleanly on a build
without the flag.

MTR query targets performance_schema.session_variables rather than
information_schema.SESSION_VARIABLES (the latter was retired in MySQL
8.0; performance_schema is the current source of truth). The STR
sysvar carries PLUGIN_VAR_MEMALLOC so the server owns its storage and
the variable stays runtime-mutable without an explicit update_func.
push_sample is lock-free relative to readers: writers fetch_add the
write_idx, slot index is index & (capacity - 1), wrap is detected when
write_idx passes a capacity boundary. wrap_count surfaces lossy
sampling to the offline tool.

Three gtest cases: PushReadRoundTrip (100 samples, slot indices match),
WrapBehaviour (256+100 samples, first 100 overwritten, wrap_count == 1),
TombstoneDrained (50 samples queued for the flusher post-tombstone).
ring_alloc_for_thread allocates a TLS_Ring + its slot array,
self-registers via a compare_exchange loop on g_rings_head, and
returns. PerfScope::dtor short-circuits when capture is off; otherwise
samples one entry per scope.

NoCaptureZeroPushes verifies the gating branch leaves t_ring untouched
when capture is off; CaptureOnPushesOneSample exercises the full
write path.
NestedScopesOrdered verifies inner-dtor-before-outer-dtor sample
ordering plus the containment relationship (inner.[enter,exit] is
inside outer.[enter,exit]).

ConcurrentPushSingleReader runs 4 producer threads x 1000 samples =
4000 total; write_idx after join must be exactly 4000. Run under TSAN
in CI: no data races on the slot array. (TSAN job not added yet; the
test will surface failures on subsequent CI introductions.)
init() calibrates tsc_ghz (20 ms spin between rdtsc + chrono samples),
writes meta.json, opens 32 per-method append-only fds, starts the
flusher thread pinned to CPU 0.

Flusher ticks every flush_interval_ms; per tick: walk g_rings_head,
snapshot each ring (W-R), bucket samples by method id, append to the
per-method fd.

deinit() signals shutdown, joins the flusher, does one final synchronous
pass, closes fds, drains all rings.

Deviations from the plan:
  - init()'s mkdir failure is non-fatal (warn to stderr, continue without
    fds). The flusher retries on each tick. Necessary because the default
    /var/lib/mysql/tidesdb-perf is often unwritable at server start,
    forcing operators to SET GLOBAL tidesdb_perf_output_dir = '...' after
    init() has already run -- which we now handle via:
  - set_output_dir() new public API + tidesdb_perf_output_dir update
    callback in ha_tidesdb.cc. SET GLOBAL stashes the new dir under a
    mutex; the flusher rotates fds at its next tick.
  - flush_once() also re-rotates if the current dir disappears (rm -rf),
    so the MTR test's '--exec rm -rf <dir>' between SET GLOBAL and INSERT
    doesn't trap the flusher in a stale directory.

MTR tidesdb_perf_on_files_appear verifies meta.json and write_row .bin
land after a 10-row INSERT.
…rflow_logged, no_dml_regression)

Task 7 of the v0.4.1 perf-instrumentation plan adds the remaining MTR
coverage for the perf module and the supporting plugin wiring:

  * off_no_files          -- capture=OFF leaves the output dir untouched
                             (flusher gates rotation on g_capture_active).
  * capture_off_stops_growth -- flipping ON->OFF stops file growth within
                             one tick (file size stable across two probes).
  * meta_json_valid       -- meta.json parses with tsc_ghz in [0.5, 6.0],
                             capacity == 65536 (default pow2=16),
                             method_count == 32, format_version == 1.
  * overflow_logged       -- with a 256-slot ring (pow2=8), 1000 INSERTs
                             overrun the ring; flusher emits a
                             [TIDESDB-PERF] WARNING. Requires a server
                             restart since ring_capacity_pow2 is sampled
                             once at init.
  * no_dml_regression     -- 1000-INSERT loop with capture ON stays within
                             1.25x of capture OFF (5% spec target + 20%
                             noise budget for MTR loops, validated over
                             10 consecutive iterations of --repeat).

Plugin changes to make those tests pass:

  * write_row gets a TDB_PERF_SCOPE so samples land in the ring and the
    overflow + on_files_appear paths have data to drain (Task 8 brings
    the full 32-site set).
  * SET GLOBAL tidesdb_perf_capture now propagates into
    tidesdb_perf::g_capture_active via a sysvar update callback (without
    this the PerfScope dtor + flusher only see the init-time value).
  * Flusher skips rotation entirely while capture is OFF, so just
    flipping the output_dir sysvar (with capture OFF) does not mkdir the
    new path -- this is the off_no_files invariant.
  * Flusher walks the per-ring wrap_count once per tick and emits a
    rate-limited WARNING when the total grows. First overflow is always
    logged; subsequent overflows respect a 60s window (spec section 7
    Logging discipline). Tracked via two atomics on PerfModuleState.
  * ring_alloc_for_thread now honours the runtime-configured
    ring_capacity_pow2 (Task 6 wiring), so the overflow test actually
    gets a 256-slot ring after restart.
Perf code uses <thread>, <chrono>, <mutex>, <condition_variable> which
pull in GLIBCXX_3.4.32 symbols not present in the runtime image's
older libstdc++. Static linkage produces a self-contained .so that runs
against the older system libstdc++.

Note: this only addresses the libstdc++ side. The perf code also pulls
in GLIBC_2.38 symbols (threading primitives) that the runtime image
lacks. glibc cannot be statically linked. Full runtime-image perf
support requires either rebuilding tides-builder on a matching glibc
or moving the runtime image base to Ubuntu 24.04+. Tracked as a
v0.4.1 release-validation work item (Task 10's bench harness needs a
perf-flavoured runtime image anyway).

Bench/perf/run-hammerdb-perf.sh and the tcl symlinks are untracked
exploratory artifacts; left out of this commit deliberately.
(write_row already wired in Task 7.) Adds scopes at every MethodId
enum value's canonical entry point. Where the plan's method name
doesn't exist as a discrete function in our code, the site is
skipped (uninstrumented MethodIds simply produce no samples; the
enum itself does not need updating).

Sites instrumented (30 total in repo, 26 net-new in this commit):

  plugin/ha_tidesdb.cc (file-scope statics)
    commit, rollback, savepoint_set, savepoint_release,
    savepoint_rollback, encrypt_row_into, decrypt_row

  plugin/ha_tidesdb.cc (ha_tidesdb members)
    open, close, info, create, delete_table,
    update_row, delete_row,
    index_read_map, index_next, index_prev,
    rnd_next, rnd_pos,
    external_lock, store_lock,
    serialize_row, deserialize_row (primary 3-arg overload),
    key_copy_to_comparable, pk_from_record

  plugin/tidesdb_inplace_alter.cc (ha_tidesdb members)
    check_if_supported_inplace_alter,
    prepare_inplace_alter_table,
    inplace_alter_table,
    commit_inplace_alter_table

Skipped (MethodIds with no discrete function in our code):
  start_stmt              -- no handler override; statement start
                             is handled implicitly through
                             external_lock + ensure_stmt_txn.
  table_flags_cache_init  -- table_flags() is a one-line inline
                             return in ha_tidesdb.h; no cache or
                             init helper exists.

Added include of tidesdb_perf_scope.h to tidesdb_inplace_alter.cc
(ha_tidesdb.cc already had it from Task 7).

Zero behaviour change with -DTIDESDB_PERF=0 or
tidesdb_perf_capture=OFF; full MTR suite still green (75/75 PASS,
15 SKIPPED, 0 FAIL).
Reads per-method .bin files + meta.json, computes per-method
{calls, mean, p50, p95, p99, max, total} in ns, emits markdown.
--compare mode does (A, B, ΔA-B) side-by-side sorted by absolute
total delta.

Two unit tests: HistogramFromSamples (1000 synthetic samples, p50/p99
within tolerance) and MultiFileMerge (two .bin files for same method
merge to one totals row).

Directory uses underscore (tidesdb_perf_analyze) for Python import
compatibility; the plan's dash-form (tidesdb-perf-analyze) is the
same package.
The plugin's C++ Sample struct is 24 bytes due to 4 bytes of alignment
padding between the uint16 reserved field and the first uint64. Python
struct.pack(<BBHQQ) produces only 20 bytes (no padding under <). Use
<BBH4xQQ with explicit 4 pad bytes to match the on-disk layout.

Both __main__.py (reader) and test_analyze.py (synthetic writer) need
the same format, otherwise tests pass with mis-decoded data or fail to
unpack.

Now 2/2 tests PASS.
Wraps bench/hammerdb/run-hammerdb.sh by:
- forcing tidesdb_perf_capture=ON via DB_EXTRA_ARGS;
- tagging the perf image as tidesdb/mysql:9.7 so the harness picks it up;
- mounting /tmp/perf-out on the host (via run-hammerdb-perf.sh, which has
  the volume-mount patch);
- running tools/tidesdb_perf_analyze on the captured .bin files;
- optionally repeating against sut-mariadb-tidesdb:9.3.0-perf with
  COMPARE=1 and emitting a --compare diff.

KNOWN LIMITATION documented in the script header: the perf-flavoured
plugin .so requires GLIBC_2.38 (from the tides-builder Ubuntu 24.04
image) but the runtime image (Oracle Linux 9, glibc 2.34) doesn't have
it. Static-libstdc++ (commit 42245f7) covers the libstdc++ side only.
Full end-to-end smoke deferred to Task 13's release validation, where
the runtime image gets rebuilt on a matching glibc.

Tracks the previously-untracked run-hammerdb-perf.sh (a one-line
volume-mount patch on run-hammerdb.sh) as a dependency, plus the
.tcl symlinks the script needs.
Vendored perf_ring + 30 TDB_PERF_SCOPE sites mirroring the MySQL
plugin. Same MethodId enum, same Sample format -- the offline tool
consumes both transparently via tidesdb_perf_analyze --compare.

Instrumentation coverage (30 sites in ha_tidesdb.cc):
  DML row hotpath: write_row, update_row, delete_row
  index probes:    index_read_map, index_next, index_prev
  table scan:      rnd_next, rnd_pos
  txn control:     external_lock, store_lock
  txn lifecycle:   commit, rollback,
                   savepoint_set, savepoint_release, savepoint_rollback
  table lifecycle: open, close, info, create, delete_table
  inplace ALTER:   check_if_supported_inplace_alter,
                   prepare_inplace_alter_table,
                   inplace_alter_table,
                   commit_inplace_alter_table
  helpers:         serialize_row, deserialize_row,
                   key_copy_to_comparable, pk_from_record,
                   encrypt_row_into, decrypt_row

The patch ships as docker/patches/tidesql/0001-perf-instrumentation.patch
so the SUT image rebuild is reproducible without checking out the
TideSQL fork state. Apply with:
  git -C vendor/tidesql apply docker/patches/tidesql/0001-perf-instrumentation.patch
The patch round-trips against TideSQL master (commit 02991de at time
of capture).

CMakeLists.txt gains an off-by-default TIDESDB_PERF option that wires
perf/tidesdb_perf_ring.cc into the plugin sources and sets the
TIDESDB_PERF=1 compile define on the target. ha_tidesdb.cc gets the
perf::init() / perf::deinit() bookends in tidesdb_init_func and
tidesdb_deinit_func, gated on TIDESDB_PERF.

DEFERRED to Task 13 / release validation:
- sysvar wiring (tidesdb_perf_capture, tidesdb_perf_output_dir,
  tidesdb_perf_ring_capacity_pow2, tidesdb_perf_flush_interval_ms).
  The current init uses hardcoded defaults (1<<16 ring, 1000 ms tick,
  /var/lib/mysql/tidesdb-perf output dir); operators flip the gate by
  poking tidesdb_perf::g_capture_active until sysvars are added.
- Actually building sut-mariadb-tidesdb:9.3.0-perf from this patch.
  The MariaDB side hits the same GLIBC ABI issue as the MySQL side
  (perf code -> newer libstdc++/libc -> runtime image base distro
  mismatch). Image build moves to release validation.
…pture

Dockerfile.mysql:
  - Add ARG TIDESDB_PERF=0 (set via --build-arg TIDESDB_PERF=1)
  - Forward as both -DTIDESDB_PERF and the TIDESDB_PERF env-var on the
    plugin cmake configure + build steps. The plugin's CMakeLists.txt
    keys on ENV{TIDESDB_PERF} explicitly because the cmake -D from the
    top-level mysql cmake invocation does not always propagate into the
    plugin sub-scope across cmake versions.
  - Stage 1 (Oracle Linux 9) matches the runtime image's libc / libstdc++,
    so the perf .so loads cleanly in the runtime without the static-libstdc++
    workaround that earlier perf builds needed.

bench/perf/run-perf-capture.sh:
  - Replace 'cp -r' with a docker-mediated copy + chown so the perf .bin
    files (owned by mysql uid 999 inside the container, mode 640) are
    readable by the invoking host user.
  - Invoke the analyser from the tools/ directory so 'python3 -m
    tidesdb_perf_analyze' resolves the package.
  - Drop the stale GLIBC ABI limitation block; the OL9-builder rebuild
    closes that gap. Replace with concrete build instructions.

Verified end-to-end on tidesdb/mysql:9.7-perf (ab1afdcc7e57):
  - SMOKE (WARE=2 RUNVU=1 1m): 32 .bin + meta.json captured, report.md
    generated. 1391 NOPM with perf ON.
  - FULL (WARE=10 RUNVU=8 3m): write_row 5.3M calls / mean 16.5us /
    p99 17us, index_read_map 1.9M calls / mean 13.2us / p99 108us,
    pk_from_record 6.36M calls / mean 0.19us. 1841 NOPM with perf ON
    (vs ~2028 baseline -- approx 9% capture overhead).
Validation matrix all green:
- Perf plugin compiles with TIDESDB_PERF=1 (OL9 / gcc-toolset-14)
- Default plugin still compiles with TIDESDB_PERF=0 (no perf TUs)
- Plugin loads in mysql:9.7-perf; all 4 sysvars present at expected defaults
- SMOKE capture (WARE=2 RUNVU=1 1m+1m): 32 .bin + meta.json + report.md
- FULL capture (WARE=10 RUNVU=8 1m+3m): 1841 NOPM with perf ON
  (vs ~2028 NOPM baseline -- ~9% capture overhead)
- 5 MTR perf tests + 8 gtest ring tests + 2 analyser pytests all pass

Headline numbers from the full run:
- write_row: 5.29M calls / mean 16.5us / p99 17us / max 72.8ms
  (long compaction-stall tail)
- index_read_map: 1.90M calls / mean 13.2us / p99 108us
- pk_from_record: 6.36M calls (most-called) / mean 0.19us
- serialize_row: 6.01M calls / mean 0.41us

The default tidesdb/mysql:9.7 image is unchanged from v0.4.0;
instrumentation lives in a separate tidesdb/mysql:9.7-perf variant.
@EvgeniyPatlan
EvgeniyPatlan merged commit 397a728 into main Jun 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant