Skip to content

perf: serialize COM_STMT_EXECUTE in a single exact-size pass - #4494

Merged
sidorares merged 3 commits into
masterfrom
claude/packet-buffer-allocation-65e8c1
Aug 23, 2026
Merged

perf: serialize COM_STMT_EXECUTE in a single exact-size pass#4494
sidorares merged 3 commits into
masterfrom
claude/packet-buffer-allocation-65e8c1

Conversation

@sidorares

Copy link
Copy Markdown
Owner

What

Execute.toPacket() serialized every COM_STMT_EXECUTE twice: a dry run against Packet.MockBuffer() to learn the packet length, then the real pass. Both passes re-encoded every parameter, and since lengthCodedStringLength and writeLengthCodedString each encode internally, a string parameter was UTF-8-encoded four times per execute(). Query.toPacket() had the same shape on its attributes path (the no-attributes fast path from #4486 is untouched).

Every parameter encoder already reports the exact wire length of its value, so the dry run is redundant. toPacket now encodes each parameter once, sums the exact packet length arithmetically, allocates once, and writes once. String-shaped values leave toParameter/lengthCodedEncoder as encoded bytes and are written with writeLengthCodedBuffer, so each string is encoded exactly once. timeEncoder declared length: 13 while writeTime emits 1, 9 or 13 bytes; it now computes the exact length (Packet.timeLength, kept next to writeTime).

Because a length/writer drift would otherwise send uninitialized allocUnsafe bytes to the server, both serializers throw on offset !== length instead of returning a short-filled packet, and a new unit test pins parameter.length to the bytes each writer actually emits across the parameter type matrix.

Bug fixes that fell out of the rewrite

Two pre-existing COM_QUERY attribute-path bugs, now aligned with the COM_STMT_EXECUTE path and covered by regression tests that fail against the previous serializer:

  • a typed NULL attribute (e.g. TypedParameter.INT(null)) kept its declared type, so the null bitmap was never set and the packet was malformed;
  • the unsigned flag of typed attributes was hardcoded to 0.

Measurements

New isolated benchmark benchmarks/perf/serialize.js (sync toPacket() loop, no socket), interleaved best-of-3 vs baseline:

Scenario Baseline This PR Δ
execute, 3 params (num/str/dbl) 222k ops/s 1454k 6.6×
execute, 10 short strings 46k 245k 5.3×
execute, 3 params + attribute 163k 727k 4.5×
execute, same, no attr flag (5.7/MariaDB) 328k 1399k 4.3×
execute, 3 dates 191k 750k 3.9×
execute, typed mixed 174k 559k 3.2×
execute, no params 1950k 5329k 2.7×
execute, 16KB blob 166k 226k 1.4×
query, 2 attributes 211k 629k 3.0×
query, no attributes 3393k 3687k unchanged

Buffer pooling and heuristic sizing for send buffers were also evaluated (serialize.js --alloc) and rejected: allocUnsafe costs 76–286ns under 1KB against ~70ns for a pooled-slab view, while stream.write() retention (plus TLS, async zlib and >16MB slicing aliasing the buffer) makes recycling unsafe to track for a few percent of best-case gain. Data and reasoning are recorded in benchmarks/perf/ANALYSIS.md §7.

Verification

  • 1100 randomized packets byte-identical to the previous serializer (legacy, typed and hinted parameters, utf8/big5/binary charsets, both flag modes, attributes with multibyte names).
  • Full suite: 238 files pass against MySQL 8.3 and MariaDB LTS; execute/typed/prepare suites pass against MySQL 5.7; MySQL 9 shows only the pre-existing test-execute-nocolumndef failure, which also fails on master.
  • Lint and typecheck clean.

Execute.toPacket serialized every statement twice: a dry run against
Packet.MockBuffer() to learn the packet length, then the real pass.
Both passes re-encoded every parameter, and since lengthCodedStringLength
and writeLengthCodedString each encode internally, a string parameter
was UTF-8-encoded four times per execute(). Query.toPacket had the same
shape on its attribute path.

Every parameter encoder already reports the exact wire length of its
value, so the dry run is redundant: parameters are now encoded once,
the packet length is summed arithmetically, and the packet is written
in a single pass. String-shaped values leave toParameter as encoded
bytes and are written with writeLengthCodedBuffer, so each string is
encoded exactly once. timeEncoder declared length 13 while writeTime
emits 1, 9 or 13 bytes; it now computes the exact length via
Packet.timeLength, kept next to writeTime. Since a length/writer drift
would send uninitialized allocUnsafe bytes to the server, both
serializers throw on offset !== length instead of returning a
short-filled packet, and a new unit test pins parameter.length to the
bytes each writer emits across the parameter type matrix.

Byte equivalence with the previous serializer was verified over 1100
randomized packets: legacy, typed and hinted parameters, utf8/big5/
binary charsets, both flag modes, attributes with multibyte names.

Two pre-existing COM_QUERY attribute bugs fell out of the rewrite: a
typed NULL attribute kept its declared type so the null bitmap was
never set, producing a malformed packet, and the unsigned flag of
typed attributes was hardcoded to 0. Both now match the
COM_STMT_EXECUTE path, with regression tests that fail against the
previous serializer.

serialize.js (new): isolated toPacket() benchmark plus an
allocation-strategy micro used to evaluate buffer pooling. Interleaved
best-of-3: execute with 3 params 6.6x, 10 strings 5.3x, dates 3.9x,
typed 3.2x, with attributes 4.5x, without the attribute flag 4.3x,
query with attributes 3.0x; the untouched query fast path is
unchanged. Pooling send buffers and heuristic sizing were measured and
rejected for this path; the data and reasoning are recorded in
ANALYSIS.md.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.50%. Comparing base (493f9ec) to head (b5fc84d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4494      +/-   ##
==========================================
+ Coverage   92.45%   92.50%   +0.05%     
==========================================
  Files          93       93              
  Lines       15855    15909      +54     
  Branches     2254     2275      +21     
==========================================
+ Hits        14658    14717      +59     
+ Misses       1197     1192       -5     
Flag Coverage Δ
compression-0 92.12% <100.00%> (+0.05%) ⬆️
compression-1 92.48% <100.00%> (+0.05%) ⬆️
static-parser-0 91.32% <100.00%> (+0.06%) ⬆️
static-parser-1 91.43% <100.00%> (+0.06%) ⬆️
tls-0 92.07% <100.00%> (+0.05%) ⬆️
tls-1 92.50% <100.00%> (+0.05%) ⬆️

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 null-bitmap spill byte on the COM_QUERY attribute path (nine
  attributes), including read-back of both bitmap bytes
- both offset-vs-length drift guards, via a temporarily patched
  writeDouble that under-reports its advance
- writeNothing: the writer of a NULL parameter is now invoked in the
  length-invariant test and must emit zero bytes

lib/packets/query.js is at 100% patch coverage from the unit suite
alone; the remaining uncovered lines in execute.js/encode_parameter.js
predate this branch.
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