Skip to content

perf: remove per-query and per-row allocation hotspots - #4486

Merged
sidorares merged 19 commits into
masterfrom
claude/library-performance-analysis-d4d3b6
Aug 23, 2026
Merged

perf: remove per-query and per-row allocation hotspots#4486
sidorares merged 19 commits into
masterfrom
claude/library-performance-analysis-d4d3b6

Conversation

@sidorares

Copy link
Copy Markdown
Owner

Summary

Ten independent performance fixes (one commit each) found by profiling the client against Docker MySQL 8.3/9 across typical workloads: small command/response loops, 1–100 row queries, 10k–1M row scans, 10/100-column rows, and date-heavy results. The last commit adds the benchmark harness (benchmarks/perf/) and the full write-up (benchmarks/perf/ANALYSIS.md) with methodology, baseline profiles, and negative results.

All changes are pure JS with feature-detected fallbacks — no native modules. Full suite (226 files), lint, and typecheck pass; each commit parses standalone for bisectability.

Highlights

  • Query.toPacket serialized every query twice on MySQL 8+ (query-attributes capability): a dry run against Packet.MockBuffer(), which itself rebuilt a patched Buffer per call. Now single-pass when no attributes are set.
  • The binary row parser allocated typeCast wrapper objects (metadata + 3 closures) per column per row even with no typeCast configured — this made execute() slower than query() on wide rows.
  • Text DATE/DATETIME went through new Date(string) (V8 slow path, ~62% of date-heavy scans); now parsed as digits straight from the buffer, value-identical, with string-path fallback for non-standard shapes.
  • Smaller fixes: lazy column-def caching without Object.defineProperty, parser-cache key without per-field JSON.stringify, direct utf8Slice/latin1Slice decode, cached resultset arrays in the row path, Long-free int64 reads, and the promise wrapper now honors trace: false for its per-query stack capture.

Measured (isolated wire replay = client CPU only; e2e = Docker MySQL 8.3)

Scenario Baseline This branch Δ
SELECT 1 loop (replay) 40.7k ops/s 250k ops/s 6.1×
1 row × 10 cols (replay) 28.3k 96.9k 3.4×
100 rows × 10 cols (replay) 12.9k 22.1k +71%
100k date rows (replay) 10.2 14.3 +40%
1M rows × 3 cols (replay) 4.57 5.30 (5.3M rows/s) +16%
100×100 execute() (e2e) 600 ops/s 1 087 +81%
100×10 query / execute (e2e) 2 178 / 2 126 2 755 / 2 795 +26% / +31%
insert loop (e2e) 16% client CPU ~9% client CPU latency stays fsync-bound

⚠️ One intentional contract change

perf: reuse a single Packet instance for complete packets changes the internal PacketParser.onPacket contract: packets are delivered through one mutable instance, so a consumer retaining a packet past the synchronous callback must clone() it. The only such place in the client (paused-connection queue) now clones, and the packet-parser unit test was updated accordingly. If external code constructs PacketParser directly and retains packets, this is the one commit to scrutinize — happy to drop it or put it behind an option; it's worth ~5% on large scans plus GC relief.

Reproducing

docker run -d --name mysql83-bench -e MYSQL_ALLOW_EMPTY_PASSWORD=1 -e MYSQL_DATABASE=test -p 3308:3306 mysql:8.3
MYSQL_PORT=3308 node benchmarks/perf/setup.js
MYSQL_PORT=3308 node benchmarks/perf/capture.js
./benchmarks/perf/run-all.sh all 3308

See benchmarks/perf/ANALYSIS.md for baseline profiles, per-fix attribution, negative results (manual UTF-8 decode no longer beats native on modern V8; Object.create(config) options is a 2× regression; WASM parser assessment), and ranked remaining opportunities.

Query.toPacket always serialized the command twice when
CLIENT_QUERY_ATTRIBUTES was negotiated (any MySQL 8+ server): a dry run
against Packet.MockBuffer() to compute the length, then the real pass.
MockBuffer() itself rebuilt a noop-patched Buffer on every call by
iterating Buffer.prototype.

With no attributes the packet length is known upfront, so the SQL is
now encoded straight into the packet buffer in a single pass; the
attribute path still uses the dry run but MockBuffer is memoized.

Isolated benchmark (wire replay, no server): SELECT 1 command loop goes
from 40.7k to ~220k ops/s; every query()/execute() round trip benefits.
The generated binary row parser called wrap(fields[i], packet) for
every non-null column of every row, allocating a metadata object plus
three closures, even when no typeCast function is configured (the
default). This made execute() slower than query() on wide rows.

The wrapper is now only emitted inside the typeCast-function branch.

e2e, 100 rows x 100 cols via execute(): 599 -> 966 ops/s; execute() is
faster than query() again across the matrix.
parseDateTime decoded the value into a string and handed it to the
Date-from-string constructor, which is V8's slow parsing path and
dominated date-heavy result sets (~62% of client CPU).

The wire format 'YYYY-MM-DD HH:MM:SS[.ffffff]' is now parsed as digits
straight from the buffer with no intermediate string. Fixed-offset
timezones reuse a memoized offset instead of string concatenation and
Date-from-string. Values that don't match the standard shape, zero
dates and years below 100 (where new Date(y, ...) changes meaning)
keep the previous string-based behaviour.

Verified value-identical against the old implementation across
timezones (local, Z, +05:30, -08:00), fractional seconds, zero dates
and range edges. 100k-row DATETIME/TIMESTAMP/DATE scan: +40% (replay),
+32% (e2e); with timezone 'Z' the per-value cost drops ~7x.
The lazy catalog/schema/table/orgTable/orgName getters cached their
decoded value with Object.defineProperty on the instance. Column
definitions are recreated on every query, and the parser cache key
reads schema and table, so every query paid two defineProperty calls
per column. The value is now cached in a prototype-declared slot,
keeping instance shapes stable.
keyFromFields allocated a nested array per field and serialized the
whole structure with JSON.stringify on every query. The fixed-size
options head keeps JSON.stringify (preserving its normalization of
exotic values), while the per-field part is now built directly with
length-prefixed strings, which keeps keys collision-free without the
throwaway allocations.
buffer.toString() re-normalizes and re-dispatches the encoding on
every call, and StringParser.decode additionally ran Buffer.isEncoding
per value. For the encodings that appear in row parsing (utf8, latin1,
binary, ascii) decode now calls the corresponding slice method
directly, with explicit offset clamping to keep toString()'s
out-of-range semantics. Runtimes without these methods fall back to
the previous path.
Query.prototype.row resolved this._rows[this._resultIndex] and
this._fields[this._resultIndex] on every row packet. The current
arrays are now cached when the resultset header is read and reused for
the whole resultset (shared by Execute through the common prototype
methods).
PacketParser allocated a Packet object per protocol packet, one per
row on streams of row data (a 1M-row result allocated 1M of them).
Complete packets are now delivered through one mutable instance owned
by the parser.

This changes the onPacket contract: a consumer that keeps a packet
past the synchronous callback must clone() it. The only such place in
the client is the paused-connection queue, which now clones; the
packet parser unit test retains packets and was updated the same way.
Large multi-packet payloads keep their freshly assembled Packet.

1M-row scan: +5% rows/s and measurably fewer minor GC pauses.
readInt64JSNumber/readSInt64JSNumber always allocated a Long only to
call toNumber(), and readInt64/readSInt64 allocated one even when the
value fits a safe integer. The number is now computed directly from
the two 32-bit words (verified bit-identical to Long.toNumber over
randomized 64-bit values, including the unsafe range); Long is only
constructed when the exact decimal string is actually needed.
The promise wrappers captured an Error stack per query/execute call
unconditionally, costing roughly 10% of small-query client CPU, even
though the trace connection option (default true) exists for exactly
this. Capture is now skipped when trace is false; default behaviour is
unchanged.
Two-layer benchmark setup used to find and validate the preceding
performance commits:

- e2e.js: scenario matrix against a real MySQL server (insert loop,
  1/100/10k/100k/1M rows, 10/100 columns, dates, query vs execute)
  with per-process isolation, warmup, latency percentiles, CPU% and
  GC pause tracking.
- capture.js/replay.js: record the raw server byte stream for a query
  once, then replay it through PacketParser and the command state
  machine with a stubbed connection - client-only CPU measurement with
  controllable chunking, no server variance.
- profile-summary.js: aggregate --cpu-prof output by self time.
- micro-decode.js: candidate implementations for hot primitives.
- ANALYSIS.md: methodology, baseline profiles, measured results and
  remaining opportunities.

Generated fixtures and profiles are gitignored.
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.45%. Comparing base (8ec20f1) to head (b9b41cc).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4486      +/-   ##
==========================================
+ Coverage   92.29%   92.45%   +0.15%     
==========================================
  Files          93       93              
  Lines       15671    15855     +184     
  Branches     2190     2254      +64     
==========================================
+ Hits        14464    14658     +194     
+ Misses       1207     1197      -10     
Flag Coverage Δ
compression-0 92.06% <100.00%> (+0.19%) ⬆️
compression-1 92.43% <100.00%> (+0.15%) ⬆️
static-parser-0 91.26% <100.00%> (+0.16%) ⬆️
static-parser-1 91.37% <99.23%> (+0.02%) ⬆️
tls-0 92.01% <100.00%> (+0.15%) ⬆️
tls-1 92.45% <100.00%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The digit fast path returned Invalid Date for timezone values that are
not 'local', 'Z' or a fixed '+HH:MM' offset. The previous
implementation concatenated such values onto the datetime string, and
V8's lenient parser accepts some timezone abbreviations there (only
when fractional seconds are present, e.g.
'2020-01-01 13:45:56.123456PST'). Fall back to the string path for
unrecognized timezones so behaviour is unchanged.

Found by the new parseDateTime coverage tests.
Bumps patch coverage for the paths codecov flagged:

- parseDateTime: fractional seconds, fixed and repeated (memoized)
  timezone offsets, zero/invalid dates, years below 100, non-standard
  shapes, timezone abbreviations, SQL NULL - each verified against the
  legacy Date-from-string semantics.
- int64 readers: safe/unsafe magnitudes, signed boundaries, JS-number
  approximation equality with Number(bigint), exact string forms.
- StringParser.decode: toString-compatible clamping of out-of-range
  offsets, non-fast-path Buffer encodings, iconv encodings with and
  without options.

lib/parsers/string.js is at 100% line coverage; all lines added by
this branch in lib/packets/packet.js are covered.
@sidorares

Copy link
Copy Markdown
Owner Author

Pushed two commits addressing the patch-coverage report:

  • test: cover datetime, int64 and string decode edge paths — targeted unit tests for every uncovered path codecov flagged. lib/parsers/string.js is now at 100% line coverage and all branch-added lines in lib/packets/packet.js are covered (verified locally with c8 + lcov cross-referenced against git blame).
  • fix: preserve string parsing for non-offset timezones in parseDateTime — the new tests caught a real divergence: the fast path returned Invalid Date for timezone values outside 'local'/'Z'/'+HH:MM', while the old string path let V8 parse some timezone abbreviations (only when fractional seconds are present, e.g. 2020-01-01 13:45:56.123456PST). Unrecognized timezones now fall back to the string path, so behaviour is bit-for-bit unchanged.

handleCompressedPacket closed over the packet and read
packet.numPackets after a nextTick plus asynchronous zlib.inflate. The
packet parser now reuses its packet instance, so by then the instance
may describe a later frame. It happens to be harmless today only
because the reused instance's numPackets is invariantly 1 and
multi-part payloads get fresh instances, but any shift in that
invariant would silently desync the compressed sequence id.

numPackets is now read out synchronously before the task is queued.
The regression test mutates the reused instance right after the
hand-off and fails against the previous code, for both the deflated
and the uncompressed branch.
The zero/invalid date paths returned a module-level INVALID_DATE
singleton. Date is mutable - setFullYear() on an Invalid Date makes it
valid per spec - so a single caller mutating one result would corrupt
every later invalid-date value in the process, across all connections,
and hand every caller the same object. The text-protocol fast path in
this branch extended the reach of that wart; the binary readDateTime
path had it before.

All invalid-date returns now allocate new Date(NaN); the invalid path
is rare so the allocation is irrelevant. Regression tests cover both
the text and binary paths, including the mutate-then-reparse scenario.
@sidorares

Copy link
Copy Markdown
Owner Author

Two more fixes pushed, addressing review findings:

fix: read numPackets before queueing the compressed inflate task — the PR description's claim that the paused-connection queue was "the only place" retaining packets past the synchronous callback was wrong: handleCompressedPacket in lib/compressed_protocol.js closes over the packet and read packet.numPackets after a nextTick + async zlib.inflate. Safe today only because the reused instance's numPackets is invariantly 1 (multi-part payloads get fresh instances), but a silent compressed-sequence-id desync if that invariant ever shifts. numPackets is now read into a local before queueing. The new regression test (test-compressed-protocol-packet-reuse.test.mts) mutates the reused instance right after the synchronous hand-off and fails against the previous code — verified for both the deflated and passthrough branches. Also ran the integration suite with MYSQL_USE_COMPRESSION=1: green.

fix: return a fresh Date for invalid dates instead of a shared instance — the fast path returned the module-level INVALID_DATE singleton, and Date is mutable: setFullYear() on an Invalid Date makes it valid per spec, so one caller mutating a zero-date result would corrupt every later invalid-date value process-wide (and hand every caller the same object). All invalid-date returns — including the pre-existing binary readDateTime paths — now allocate new Date(NaN); the invalid path is rare, so the allocation is irrelevant. Regression tests cover text + binary paths including the mutate-then-reparse scenario.

The clamping guards missed NaN offsets (NaN comparisons are false, so
NaN start or end slipped through to the *Slice methods, which throw
'Index out of range' where buffer.toString returns a string) as well
as fractional offsets. No current caller can produce those shapes -
readString normalizes undefined and length-encoded lengths are
non-negative - but decode is a general utility and should stay a true
drop-in for toString.

The fast path now runs the same bounds algorithm as
Buffer.prototype.toString: negative and NaN starts coerce to 0 via
|0, NaN and negative ends coerce to 0 (empty result), fractional
offsets truncate, and an undefined end means the buffer length.

The test compares decode against buffer.toString across a grid of
degenerate offset shapes (NaN, +/-Infinity, negative, fractional,
oversized, undefined) for each fast-path encoding.
@sidorares

Copy link
Copy Markdown
Owner Author

Pushed fix: replicate toString bounds coercion exactly in decode fast path — instead of just NaN-proofing the two guards, the fast path now runs the same bounds algorithm as Buffer.prototype.toString (start <= 0 → 0, start >= len → '', |0 coercion which also maps NaN → 0 and truncates fractionals, undefined/oversized end → len). That covers the fuzz shapes (NaN start with oversized end, negative end) plus fractional offsets and undefined end, so decode stays a true drop-in even for callers that don't exist yet.

The new test asserts decode ≡ toString over the full grid of degenerate offsets (NaN, ±Infinity, negative, fractional, oversized, undefined) × fast-path encodings. Spot-checked the hot path is unaffected: 100k×10cols replay unchanged (~2.05M rows/s).

@sidorares
sidorares marked this pull request as draft August 16, 2026 22:28
@sidorares

Copy link
Copy Markdown
Owner Author

@wellwelwel a bunch of Claude perf related findings. I'll let it sit for a bit, want to review it myself first, but mostly looks OK. I saw you tagging copilot for reviews - wonder what would it find here

@wellwelwel

wellwelwel commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@wellwelwel a bunch of Claude perf related findings. I'll let it sit for a bit, want to review it myself first, but mostly looks OK. I saw you tagging copilot for reviews - wonder what would it find here

Hey, @sidorares! I was off for a while due to tech events 🙋🏻‍♂️

About Copilot reviews, I tag it under various conditions, but especially when everything is done by AI, even if I respond humanly, it will be an AI reading what I said, so I use Copilot so that the AIs interact with each other before I act humanly, but also when:

  • I see common findings already "trained" in the Copilot Review skill
  • I can't find much context about what the contributor did (usually due to lack of issues or a very vague explanation)
  • When I want a quick/basic second perspective/review

About what Copilot reviews (and how it reviews), from time to time I update the native skill for this based on the contributions the project receives at .github/skills/code-review/SKILL.md

Conflicts: lib/packets/packet.js
- kept master's ZERO_DATE prefix detection for binary zero dates
  (#4491) but return a fresh new Date(NaN) instead of the shared
  INVALID_DATE singleton, which this branch removed (Date is mutable)
- dropped the now-unused INVALID_DATE constant
@sidorares
sidorares marked this pull request as ready for review August 23, 2026 09:10
@sidorares
sidorares merged commit c86fe5a into master Aug 23, 2026
104 checks 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.

2 participants