Skip to content

Add Client::bind_execute_many for pipelined bulk execution - #1352

Open
Dev-iL wants to merge 2 commits into
rust-postgres:masterfrom
SummitSG-LLC:feat/bind-execute-many
Open

Dev-iL wants to merge 2 commits into
rust-postgres:masterfrom
SummitSG-LLC:feat/bind-execute-many

Conversation

@Dev-iL

@Dev-iL Dev-iL commented May 24, 2026

Copy link
Copy Markdown

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-postgres today has no first-class bulk-execute primitive. The typical pattern — a for loop calling client.execute(&stmt, params) — pays one full request/response round-trip per row: each call emits Bind → Execute → Sync and then waits for BindComplete → CommandComplete → ReadyForQuery before 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 for execute_many workloads.

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 via writelines (scatter-gather I/O), appending a single trailing Sync for the whole batch. The server processes every pair under that one Sync, streams back one BindComplete + CommandComplete per row, and closes the batch with a single ReadyForQuery. 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_many in src/query.rs

A free function alongside execute / execute_typed that implements the buffer-batching loop:

  1. For each parameter-set in the caller's iterator, encode a Bind frame (via the existing encode_bind) and an Execute frame (via frontend::execute) into a shared BytesMut.
  2. When the buffer reaches BIND_EXECUTE_MANY_BUF_SIZE × BIND_EXECUTE_MANY_BUF_COUNT (131 072 bytes), append a single Sync, freeze the buffer, send it via InnerClient::send, and drain all server responses (BindComplete, DataRow, CommandComplete, ReadyForQuery), summing the row-affected counts from each CommandComplete.
  3. After the iterator is exhausted, flush any remaining data the same way.
  4. Return the total row-affected count as 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_threshold exposes the threshold as a runtime parameter; bind_execute_many delegates to it with the default. This is also surfaced on Client as #[doc(hidden)] for callers with atypical row sizes that need to tune the constant.

Public API — Client::bind_execute_many in src/client.rs

pub async fn bind_execute_many<P, I, J>(
    &self,
    statement: &Statement,
    params_sets: I,
) -> Result<u64, Error>
where
    I: IntoIterator<Item = J>,
    J: IntoIterator<Item = P>,
    J::IntoIter: ExactSizeIterator,
    P: BorrowToSql,

Placed 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 of CommandComplete row-affected counts.

Error behavior. A server-side error mid-batch (constraint violation, type mismatch, etc.) returns Err immediately. The server discards remaining Bind/Execute frames in that batch up to the Sync, then sends ReadyForQuery, 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 explicit BEGIN/COMMIT if atomicity is required.

Arity check. Each parameter-set is validated against statement.params().len() before any bytes are written for that row. A mismatch returns Err immediately; batches already flushed will have executed.

Benchmark

Methodology

A harness-free benchmark in benches/bind_execute_many.rs runs two experiments:

Baseline comparison. Measures bind_execute_many vs a for loop of execute calls, 1 000 single-column INT rows 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:

bind_execute_many :      89 650 rows/s
per-row execute   :       7 440 rows/s

bind_execute_many is 12.0x faster than per-row execute

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 Sync messages carries its own TCP round-trip.

Flush-threshold × row-size matrix:

── Row size ~35 B  (INT) (~341 KiB total) ──
  flush threshold                     syncs      rows/s  vs asyncpg
  512 B                                 684       38 953     0.32x
  4 KiB                                  86       82 159     0.67x
  16 KiB                                 22      112 117     0.92x
  32 KiB                                 11      119 733     0.98x
  64 KiB                                  6      119 389     0.98x
  128 KiB  ← asyncpg default              3      121 823     1.00x
  256 KiB                                 2      120 180     0.99x
  512 KiB                                 1      120 317     0.99x
  1 MiB                                   1      117 615     0.97x
  asyncpg default is the peak

── Row size ~200 B (TEXT) (~1 953 KiB total) ──
  flush threshold                     syncs      rows/s  vs asyncpg
  512 B                                3907       13 821     0.16x
  4 KiB                                 489       32 720     0.38x
  16 KiB                                123       68 613     0.80x
  32 KiB                                 62       76 384     0.89x
  64 KiB                                 31       81 559     0.95x
  128 KiB  ← asyncpg default             16       85 879     1.00x
  256 KiB                                 8       87 372     1.02x
  512 KiB                                 4       87 114     1.01x
  1 MiB                                   2       89 050     1.04x
  asyncpg default within 10% of peak (1 MiB @ 89 050 rows/s)

── Row size ~1 KiB (TEXT) (~10 000 KiB total) ──
  flush threshold                     syncs      rows/s  vs asyncpg
  512 B                               20000        5 342     0.15x
  4 KiB                                2500       16 032     0.45x
  16 KiB                                625       26 416     0.74x
  32 KiB                                313       30 720     0.86x
  64 KiB                                157       35 595     1.00x
  128 KiB  ← asyncpg default             79       35 592     1.00x
  256 KiB                                40       35 603     1.00x
  512 KiB                                20       35 722     1.00x
  1 MiB                                  10       35 269     0.99x
  asyncpg default within 10% of peak (512 KiB @ 35 722 rows/s)

── Row size ~5 KiB (TEXT) (~50 000 KiB total) ──
  flush threshold                     syncs      rows/s  vs asyncpg
  512 B                              100000        3 406     0.22x
  4 KiB                               12500        2 856     0.18x
  16 KiB                               3125        7 970     0.51x
  32 KiB                               1563       10 438     0.67x
  64 KiB                                782       12 910     0.83x
  128 KiB  ← asyncpg default            391       15 645     1.00x
  256 KiB                               196       17 021     1.09x
  512 KiB                                98       18 134     1.16x
  1 MiB                                  49       18 666     1.19x
  asyncpg default is 19% below peak (1 MiB @ 18 666 rows/s)

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_threshold to tune.

Files changed

File Change
tokio-postgres/src/query.rs Add bind_execute_many, bind_execute_many_with_flush_threshold, drain_batch, and two named buffer constants
tokio-postgres/src/client.rs Add Client::bind_execute_many with full rustdoc; add #[doc(hidden)] bind_execute_many_with_flush_threshold
tokio-postgres/tests/test/bind_execute_many.rs 5 integration tests (new file)
tokio-postgres/tests/test/main.rs Add mod bind_execute_many;
tokio-postgres/benches/bind_execute_many.rs Baseline + threshold × row-size matrix benchmark (new file)
tokio-postgres/Cargo.toml Register new [[bench]] entry
tokio-postgres/CHANGELOG.md Unreleased entry

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
Dev-iL force-pushed the feat/bind-execute-many branch from f04edff to 614bbce Compare May 24, 2026 08:57
@Dev-iL

Dev-iL commented May 25, 2026

Copy link
Copy Markdown
Author

@paolobarbolini Could you please take a look?

@paolobarbolini

Copy link
Copy Markdown
Member

@paolobarbolini Could you please take a look?

You shouldn't ping someone 23 hours after the initial submission.

@Dev-iL

Dev-iL commented Jun 28, 2026

Copy link
Copy Markdown
Author

@paolobarbolini How about now?

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