feat(perf): v0.4.1 -- layer-by-layer perf instrumentation infrastructure - #3
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.7image is unchanged from v0.4.0; instrumentation lives in a separatetidesdb/mysql:9.7-perfvariant built with--build-arg TIDESDB_PERF=1. WithTIDESDB_PERF=0(the default) everyTDB_PERF_SCOPE(...)macro expands to((void)0)— zero runtime cost.Plan, spec, validation
What lands
TDB_PERF_SCOPE(MethodId)RAII macro across the full plugin entry surface (32MethodIdvalues).TLS_Ring(default 2^16 samples),Samplestruct (24 bytes, static_asserted), lock-free per-thread ring registration intog_rings_head..binfiles +meta.jsonwith calibrated TSC frequency.tidesdb_perf_capture(BOOL, OFF),tidesdb_perf_output_dir(STR),tidesdb_perf_ring_capacity_pow2(INT, 16),tidesdb_perf_flush_interval_ms(INT, 1000).tools/tidesdb_perf_analyze(Python + numpy); markdown report +--comparediff mode.bench/perf/run-perf-capture.sh; wrapsbench/hammerdb/run-hammerdb.sh, mounts/tmp/perf-out, docker-mediates the artifact copy back to host, runs the analyser.docker/patches/tidesql/0001-perf-instrumentation.patch(880 lines).Dockerfile.mysqlbuild-argTIDESDB_PERF=0|1(forwarded as-Dand env-var on the plugin cmake steps; the env-var is load-bearing because cmake-Ddoesn't always propagate into the plugin sub-scope across cmake versions).Headline numbers (WARE=10 RUNVU=8 1m+3m HammerDB TPROC-C)
Captured from
tidesdb/mysql:9.7-perf(sha256:ab1afdcc7e57) on the validation host:Throughput with perf ON: 1841 NOPM (vs ~2028 NOPM baseline — approx 9% capture overhead).
write_rowp99 = 17us but max = 72ms — long compaction-stall tail dominates total.index_read_mapp95 (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
sut-mariadb-tidesdb:9.3.0-perfimage build (TideSQL patch is committed, image not built yet).Test plan
TIDESDB_PERF=1(OL9 Stage 1, gcc-toolset-14)TIDESDB_PERF=0(no perf TUs, scope macro to no-op)tidesdb/mysql:9.7-perf; all 4 sysvars present at expected defaults.bin+meta.json+report.md