Conversation
Mirrors asyncpg's _bind_execute_many: packs Bind/Execute frame pairs into ~128 KiB batches (4×32 KiB), appends one Sync per batch, and drains BindComplete/CommandComplete/ReadyForQuery responses — avoiding the per-row round-trip cost of calling execute in a loop.
Dev-iL
force-pushed
the
feat/bind-execute-many
branch
from
May 24, 2026 08:57
f04edff to
614bbce
Compare
Author
|
@paolobarbolini Could you please take a look? |
Member
You shouldn't ping someone 23 hours after the initial submission. |
Author
|
@paolobarbolini How about now? |
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.
Note
Disclosure: the code and description of this PR were created with the help of an LLM (Claude Sonnet 4.6) under human (my) supervision.
Motivation
tokio-postgrestoday has no first-class bulk-execute primitive. The typical pattern — aforloop callingclient.execute(&stmt, params)— pays one full request/response round-trip per row: each call emitsBind → Execute → Syncand then waits forBindComplete → CommandComplete → ReadyForQuerybefore the next row can start. On a real network this is catastrophic; even on localhost it dominated psqlpy benchmarks at ~28× slower throughput than asyncpg forexecute_manyworkloads.How asyncpg does it
asyncpg's
_bind_execute_many(coreproto.pyx, lines 1022–1092) uses a fundamentally different approach: it packs Bind/Execute pairs for many rows into shared write buffers and ships them all at once viawritelines(scatter-gather I/O), appending a single trailingSyncfor the whole batch. The server processes every pair under that oneSync, streams back oneBindComplete + CommandCompleteper row, and closes the batch with a singleReadyForQuery. The constants it uses —_EXECUTE_MANY_BUF_SIZE = 32_768(32 KiB per chunk) and_EXECUTE_MANY_BUF_NUM = 4(four chunks per send, ≈128 KiB total) — were arrived at empirically and represent a mature tuning point.The net effect: N rows cost one network round-trip per ~128 KiB of wire data instead of one per row.
What this PR adds
Internal function —
query::bind_execute_manyinsrc/query.rsA free function alongside
execute/execute_typedthat implements the buffer-batching loop:Bindframe (via the existingencode_bind) and anExecuteframe (viafrontend::execute) into a sharedBytesMut.BIND_EXECUTE_MANY_BUF_SIZE × BIND_EXECUTE_MANY_BUF_COUNT(131 072 bytes), append a singleSync, freeze the buffer, send it viaInnerClient::send, and drain all server responses (BindComplete,DataRow,CommandComplete,ReadyForQuery), summing the row-affected counts from eachCommandComplete.u64.No new connection plumbing. No new dependencies. The two buffer constants are named
consts (BIND_EXECUTE_MANY_BUF_SIZE = 32_768,BIND_EXECUTE_MANY_BUF_COUNT = 4) lifted directly from asyncpg.A companion function
query::bind_execute_many_with_flush_thresholdexposes the threshold as a runtime parameter;bind_execute_manydelegates to it with the default. This is also surfaced onClientas#[doc(hidden)]for callers with atypical row sizes that need to tune the constant.Public API —
Client::bind_execute_manyinsrc/client.rsPlaced next to
execute_raw, following the same generic-over-iterators style. Takes a pre-prepared&Statement(no implicit Parse per row) and an iterator of parameter-sets. Returns the sum ofCommandCompleterow-affected counts.Error behavior. A server-side error mid-batch (constraint violation, type mismatch, etc.) returns
Errimmediately. The server discards remaining Bind/Execute frames in that batch up to theSync, then sendsReadyForQuery, so the connection is fully recovered and usable for subsequent calls. Any batches that were already flushed and committed before the error are not rolled back — wrap in an explicitBEGIN/COMMITif atomicity is required.Arity check. Each parameter-set is validated against
statement.params().len()before any bytes are written for that row. A mismatch returnsErrimmediately; batches already flushed will have executed.Benchmark
Methodology
A harness-free benchmark in
benches/bind_execute_many.rsruns two experiments:Baseline comparison. Measures
bind_execute_manyvs aforloop ofexecutecalls, 1 000 single-columnINTrows per iteration, 2 warmup + 5 measured iterations each. Rows/second computed as(rows × iters) / elapsed_secs.Flush-threshold × row-size matrix. Sweeps nine flush thresholds (512 B → 1 MiB) across four representative payload sizes — ~35 B (INT), ~200 B (TEXT), ~1 KiB (TEXT), ~5 KiB (TEXT) — using 10 000 rows and 5 measured iterations per cell. Each section reports rows/s and the ratio relative to the asyncpg default (128 KiB) for that row size, and concludes with whether the default falls within 10% of peak.
Environment: Postgres in a local container (port 25433), client and server on the same host.
Results
Baseline:
A 12× speedup on localhost, where round-trip latency is already near zero. On a real network the gap widens considerably — each of the 1 000 saved
Syncmessages carries its own TCP round-trip.Flush-threshold × row-size matrix:
Threshold choice rationale
For row sizes up to ~1 KiB the 128 KiB default is at or within 4% of peak throughput in every case — the plateau is flat and the constant is robust. For INT rows it is the outright peak. The 5 KiB case shows a 19% gap against larger buffers, but this is a localhost measurement where sync-round-trip cost is minimized; on a real network fewer syncs help even more, making larger thresholds more attractive there too. However, larger thresholds increase peak memory per in-flight batch and delay the first server acknowledgment (relevant for callers that stream progress rather than waiting for completion). 128 KiB is the right default for the common case; callers with consistently large rows can use
bind_execute_many_with_flush_thresholdto tune.Files changed
tokio-postgres/src/query.rsbind_execute_many,bind_execute_many_with_flush_threshold,drain_batch, and two named buffer constantstokio-postgres/src/client.rsClient::bind_execute_manywith full rustdoc; add#[doc(hidden)] bind_execute_many_with_flush_thresholdtokio-postgres/tests/test/bind_execute_many.rstokio-postgres/tests/test/main.rsmod bind_execute_many;tokio-postgres/benches/bind_execute_many.rstokio-postgres/Cargo.toml[[bench]]entrytokio-postgres/CHANGELOG.md