Skip to content

perf(mysql): deliver a whole command packet per poll_read (ephpm#322) - #37

Merged
luthermonson merged 2 commits into
mainfrom
fix/command-filter-packet-split
Aug 19, 2026
Merged

perf(mysql): deliver a whole command packet per poll_read (ephpm#322)#37
luthermonson merged 2 commits into
mainfrom
fix/command-filter-packet-split

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

What

CommandFilter handed opensrv-mysql the four-byte packet header plus the
command 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 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
}

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 zeroing
memset — 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.php fixture). n=600 per rep, three reps,
interleaved, two pinned cores, load average ~1.2, nothing else building.

litewire rep1 rep2 rep3 vs baseline
e34c6392 — before the filter existed 626.0 624.8 663.2
10345a86 — filter as shipped 542.1 526.6 544.4 −15.7%
10345a86 + this change 628.3 647.5 628.2 −0.5%

Turso 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 10345a86 confirms the filter is the whole cost — the
Arc<TcpStream>/SocketHandle plumbing and the 8 KiB BufReader introduced
in the same PR are free:

variant req/s
new plumbing + filter (as shipped) 494–542
new plumbing, filter removed 637–643
old into_split() plumbing, filter removed 643–646

perf on the regressed build puts 12.8% of cycles in
__memset_avx2_unaligned_erms (the AVX2 memset loop). That symbol does not
appear 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 Emit into Pass inside one call means poll_read may
no longer answer Pending once it has written into the caller's buffer:
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 of parking.

Both halves are pinned by tests:

  • one_poll_read_yields_the_whole_packet — the perf invariant, with the
    1 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 deadlock
    invariant.

Behaviour

Unchanged. Same commands forwarded, rewritten and refused; the filter's
existing suite passes untouched (112 tests in litewire-mysql, whole
workspace green, clippy -D warnings clean, cargo +nightly fmt --check
clean).

Reported downstream as ephpm/ephpm#322 — ePHPm v0.7.0's pdo_mysql wire path
measured 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.

`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.
@luthermonson

Copy link
Copy Markdown
Contributor Author

CI note: cargo-deny fails on this PR with

error[vulnerability]: h2 unbounded empty DATA frames

That is not from this change — the diff is one source file with zero
dependency movement, and h2 arrives through axum on the hrana feature.
It is advisory-database drift: main last ran cargo-deny green on 2026-08-17
(run 32098305308, 10345a86), and the advisory has been published since, so
re-running that same commit today would fail identically.

clippy, rustfmt, msrv (1.88) and test all pass here. The h2 bump
belongs in its own dependency PR rather than riding along on a perf fix.

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
luthermonson merged commit ce52816 into main Aug 19, 2026
5 checks passed
@luthermonson
luthermonson deleted the fix/command-filter-packet-split branch August 19, 2026 06:25
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.
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