perf(mysql): deliver a whole command packet per poll_read (ephpm#322) - #37
Merged
Conversation
`CommandFilter` returned the four-byte header plus the command byte on
their own and made the caller poll again for the body. That is a legal
`AsyncRead`, but it is pathological for the one reader that consumes it.
`opensrv_mysql::PacketReader::next_async` loops until a whole packet
parses, and its scratch buffer escalates from `PACKET_BUFFER_SIZE`
(4 KiB) to `PACKET_LARGE_BUFFER_SIZE` (1 MiB) on the *second* iteration:
let mut buffer_size = PACKET_BUFFER_SIZE; // 4 KiB
loop {
...
if self.bytes.len() - end < buffer_size {
self.bytes.resize(max(buffer_size, end * 2), 0); // memset
}
let read = self.r.read(&mut self.bytes[end..]).await?;
buffer_size = PACKET_LARGE_BUFFER_SIZE; // 1 MiB
}
Handing back a header with no body guarantees that second iteration, so
every command packet paid a `Vec::resize(1_048_576, 0)` — a 1 MiB zeroing
memset — before its body could be read. Nothing about it is visible from
this crate; it looks like a five-byte read.
Measured on a wire microbenchmark (litewire MySQL frontend over the Turso
backend, fresh connection + ten sequential point SELECTs per iteration,
n=600, three reps, two pinned cores):
litewire e34c639 (pre-filter) 626 / 625 / 663 req/s
litewire 10345a8 (filter, as-is) 527 / 542 / 544 req/s -15.7%
litewire 10345a8 + this change 628 / 628 / 648 req/s -0.5%
`perf` attributes 12.8% of cycles in the regressed build to
`__memset_avx2_unaligned_erms`; that symbol is absent from both the
pre-filter build and this one. Total cycle count drops 5.35e9 -> 4.50e9.
The fix is to fall through from `Emit` into `Pass` within a single
`poll_read`, so the header and body reach the reader together, exactly as
an unfiltered socket delivered them.
That has a correctness corollary: `poll_read` may no longer answer
`Pending` once it has written into the caller's buffer, or those bytes sit
in a `ReadBuf` the caller keeps, waiting on a wakeup that a half-duplex
peer — parked on the reply to the command we withheld — will never cause.
The new `poll_io!` returns what it has instead. Both properties are pinned
by tests: `one_poll_read_yields_the_whole_packet` and
`partial_packet_returns_what_it_has_instead_of_parking`.
No behavioural change to what is forwarded, rewritten or refused: the
filter's existing suite (112 tests in litewire-mysql, whole workspace
green) passes unchanged.
Reported as ephpm/ephpm#322 — the pdo_mysql wire path measured 14-31%
slower on ePHPm v0.7.0 than v0.6.3, with the in-process bridge flat.
Contributor
Author
|
CI note: That is not from this change — the diff is one source file with zero
|
The cargo-deny leg fails on a newly-published advisory reaching litewire via axum -> hyper: h2 queues empty DATA frames without limit (unbounded memory / possible panic). Lockfile-only; the same bump ePHPm took in #309. Unblocks the cargo-deny gate on this PR; unrelated to the packet-split fix.
luthermonson
added a commit
that referenced
this pull request
Sep 1, 2026
…#37) * perf(mysql): deliver a whole command packet per poll_read (ephpm#322) `CommandFilter` returned the four-byte header plus the command byte on their own and made the caller poll again for the body. That is a legal `AsyncRead`, but it is pathological for the one reader that consumes it. `opensrv_mysql::PacketReader::next_async` loops until a whole packet parses, and its scratch buffer escalates from `PACKET_BUFFER_SIZE` (4 KiB) to `PACKET_LARGE_BUFFER_SIZE` (1 MiB) on the *second* iteration: let mut buffer_size = PACKET_BUFFER_SIZE; // 4 KiB loop { ... if self.bytes.len() - end < buffer_size { self.bytes.resize(max(buffer_size, end * 2), 0); // memset } let read = self.r.read(&mut self.bytes[end..]).await?; buffer_size = PACKET_LARGE_BUFFER_SIZE; // 1 MiB } Handing back a header with no body guarantees that second iteration, so every command packet paid a `Vec::resize(1_048_576, 0)` — a 1 MiB zeroing memset — before its body could be read. Nothing about it is visible from this crate; it looks like a five-byte read. Measured on a wire microbenchmark (litewire MySQL frontend over the Turso backend, fresh connection + ten sequential point SELECTs per iteration, n=600, three reps, two pinned cores): litewire e34c639 (pre-filter) 626 / 625 / 663 req/s litewire 10345a8 (filter, as-is) 527 / 542 / 544 req/s -15.7% litewire 10345a8 + this change 628 / 628 / 648 req/s -0.5% `perf` attributes 12.8% of cycles in the regressed build to `__memset_avx2_unaligned_erms`; that symbol is absent from both the pre-filter build and this one. Total cycle count drops 5.35e9 -> 4.50e9. The fix is to fall through from `Emit` into `Pass` within a single `poll_read`, so the header and body reach the reader together, exactly as an unfiltered socket delivered them. That has a correctness corollary: `poll_read` may no longer answer `Pending` once it has written into the caller's buffer, or those bytes sit in a `ReadBuf` the caller keeps, waiting on a wakeup that a half-duplex peer — parked on the reply to the command we withheld — will never cause. The new `poll_io!` returns what it has instead. Both properties are pinned by tests: `one_poll_read_yields_the_whole_packet` and `partial_packet_returns_what_it_has_instead_of_parking`. No behavioural change to what is forwarded, rewritten or refused: the filter's existing suite (112 tests in litewire-mysql, whole workspace green) passes unchanged. Reported as ephpm/ephpm#322 — the pdo_mysql wire path measured 14-31% slower on ePHPm v0.7.0 than v0.6.3, with the in-process bridge flat. * fix(deps): bump h2 0.4.13 -> 0.4.16 (RUSTSEC-2026-0258) The cargo-deny leg fails on a newly-published advisory reaching litewire via axum -> hyper: h2 queues empty DATA frames without limit (unbounded memory / possible panic). Lockfile-only; the same bump ePHPm took in #309. Unblocks the cargo-deny gate on this PR; unrelated to the packet-split fix.
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.
What
CommandFilterhandedopensrv-mysqlthe four-byte packet header plus thecommand byte on their own, then made it poll again for the body. This makes
that split disappear: header and body now reach the reader in a single
poll_read, exactly as an unfiltered socket delivered them.Why
That split is a legal
AsyncRead, and pathological for the one reader thatconsumes it.
opensrv_mysql::PacketReader::next_asyncloops until a wholepacket parses, and its scratch buffer escalates from
PACKET_BUFFER_SIZE(4 KiB) to
PACKET_LARGE_BUFFER_SIZE(1 MiB) on the second iteration:Returning a header with no body guarantees that second iteration, so
every command packet paid a
Vec::resize(1_048_576, 0)— a 1 MiB zeroingmemset — before its body could be read. From inside this crate it looks like
a harmless five-byte read.
Evidence
Wire microbenchmark: this crate's MySQL frontend over the Turso backend, one
fresh TCP connection plus ten sequential text-protocol point SELECTs per
iteration (the shape of ePHPm's
db.phpfixture). n=600 per rep, three reps,interleaved, two pinned cores, load average ~1.2, nothing else building.
e34c6392— before the filter existed10345a86— filter as shipped10345a86+ this changeTurso was held constant across all three (0.7.0 vs 0.7.2 was measured
separately and is a no-op for this workload, within ±1%).
Layer-peel on
10345a86confirms the filter is the whole cost — theArc<TcpStream>/SocketHandleplumbing and the 8 KiBBufReaderintroducedin the same PR are free:
into_split()plumbing, filter removedperfon the regressed build puts 12.8% of cycles in__memset_avx2_unaligned_erms(the AVX2 memset loop). That symbol does notappear at all in the pre-filter build or in this one. Total cycle count for
the same work drops 5.35e9 → 4.50e9.
The correctness corollary
Falling through from
EmitintoPassinside one call meanspoll_readmayno longer answer
Pendingonce it has written into the caller's buffer:those bytes sit in a
ReadBufthe caller keeps, waiting on a wakeup that ahalf-duplex peer — parked on the reply to the command we withheld — will
never cause. The new
poll_io!returns what it has instead of parking.Both halves are pinned by tests:
one_poll_read_yields_the_whole_packet— the perf invariant, with the1 MiB escalation spelled out in the failure message so a future reader
knows why it matters.
partial_packet_returns_what_it_has_instead_of_parking— the deadlockinvariant.
Behaviour
Unchanged. Same commands forwarded, rewritten and refused; the filter's
existing suite passes untouched (112 tests in
litewire-mysql, wholeworkspace green, clippy
-D warningsclean,cargo +nightly fmt --checkclean).
Reported downstream as ephpm/ephpm#322 — ePHPm v0.7.0's
pdo_mysqlwire pathmeasured 14–31% slower than v0.6.3 with the in-process bridge flat, which is
the signature of a per-packet cost on the wire path only.