Skip to content

perf: coalesce query response into a single socket write (feed instead of send) - #452

Merged
sunng87 merged 1 commit into
sunng87:masterfrom
genezhang:perf/coalesce-query-response
Aug 2, 2026
Merged

perf: coalesce query response into a single socket write (feed instead of send)#452
sunng87 merged 1 commit into
sunng87:masterfrom
genezhang:perf/coalesce-query-response

Conversation

@genezhang

Copy link
Copy Markdown
Contributor

Summary

send_query_response and send_execution_response call .send() (feed + flush) for the intermediate RowDescription and CommandComplete messages. With TCP_NODELAY enabled (the common setup, and what the examples use), each .send() flush becomes its own sendto syscall and its own TCP segment. A single-row SELECT therefore costs three sendtos:

  1. RowDescription
  2. DataRow + CommandComplete (the DataRow is already feed-buffered)
  3. ReadyForQuery

This PR changes those three .send() calls to .feed(), so the whole response coalesces into the one terminal flush the connection loop already performs:

  • simple querysend_ready_for_query (a .send() on ReadyForQuery, which flushes) — including the error path via process_error
  • extended queryon_sync / on_flush (both call client.flush())

No message is left unsent, message ordering is unchanged, and no protocol semantics change — only the number of socket writes/segments drops (3 → 1 for a single-row SELECT).

send_partial_query_response (the portal-suspend / max_rows path) is intentionally left on .send(), since an Execute with max_rows can be followed by more Executes before a Sync.

Measured impact

Embedding this in a Postgres-wire server (sysbench over TCP loopback, TCP_NODELAY on):

Metric Before After
sendto per query 3 1
SELECT 1 round-trip latency 0.029 ms 0.020 ms
oltp_point_select throughput +36%

The change

Three .send().feed() in src/api/query.rs (send_query_response: RowDescription + CommandComplete; send_execution_response: CommandComplete), plus short explanatory comments. cargo fmt clean; cargo check clean.

send_query_response and send_execution_response called `.send()` (feed + flush)
for the intermediate RowDescription and CommandComplete messages. With
TCP_NODELAY on (the common setup), each flush is its own `sendto` and its own TCP
segment, so a single-row SELECT costs three sendtos: RowDescription;
DataRow+CommandComplete; ReadyForQuery.

Use `.feed()` for those messages instead. The whole response then coalesces into
the one terminal flush the connection loop already performs — send_ready_for_query
for the simple-query protocol (including the error path via process_error), and
on_sync / on_flush for the extended-query protocol. No message is left unsent,
ordering is unchanged, and no protocol semantics change; only the number of socket
writes drops (3 -> 1 for a single-row SELECT).

send_partial_query_response is intentionally left on `.send()` since an Execute
with max_rows can be followed by more Executes before a Sync.

Measured in a downstream server (sysbench, TCP loopback, TCP_NODELAY on):
SELECT 1 round-trip 0.029ms -> 0.020ms; oltp_point_select +36% throughput.
@genezhang

Copy link
Copy Markdown
Contributor Author

One behavioral note for reviewers, in the interest of full disclosure:

In the extended-query path, _on_execute has no terminal flush of its own — it relies on the client's following Sync (on_sync) or Flush (on_flush). So switching send_execution_response from send to feed means an Execute's CommandComplete is no longer flushed immediately; it now waits for the client's Sync/Flush.

This is arguably more conformant — real PostgreSQL buffers extended-query responses until Sync/Flush, and the protocol requires the client to send one — but it is an observable change for any client that today sends Execute and blocks on CommandComplete without a following Sync/Flush. Such a client is technically non-conformant, but it would have worked before this change.

If you'd prefer to be conservative, an alternative is to apply feed only to send_query_response (the simple-query path, whose loop always reaches send_ready_for_query) and leave send_execution_response on send. That still captures most of the syscall win. Happy to trim the PR to whichever scope you prefer.

@sunng87 sunng87 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@sunng87

sunng87 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Thank you @genezhang

@sunng87
sunng87 merged commit d2ace9b into sunng87:master Aug 2, 2026
10 checks passed
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