perf(ext/node): zero-copy async Buffer writes on the stream_wrap path#35780
Merged
nathanwhit merged 1 commit intoJul 6, 2026
Merged
Conversation
write_buffer's async fallback (taken whenever a Buffer write does not fully drain synchronously — backpressure, or any large write) went through uv_write, whose collect_bufs copies the entire undrained tail into an owned Vec. A single large socket.write(buf) therefore memcpy'd nearly the whole payload and held a duplicate of it until the write completed, doubling peak memory. The copy was redundant: JS already retains the Buffer on the write request until oncomplete (req.buffer = data in stream_base_commons' handleWriteReq), the same retention contract the writev op relies on for its zero-copy iovecs. Queue a one-entry iovec pointing at the ArrayBuffer backing store via uv_writev_owned instead. One exception keeps an owned copy: V8 copies on-heap typed arrays (<= 64 bytes, TYPED_ARRAY_MAX_SIZE_IN_HEAP) into get_contents' stack buffer, which dies with the op — detect that by pointer identity and route it through uv_write_owned. Co-authored-by: Cursor <[email protected]>
tomas-zijdemans
marked this pull request as ready for review
July 4, 2026 21:16
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.
A large
socket.write(buffer)onnode:netmemcpy'd (nearly) the entire payload into an ownedVecand held that duplicate until the write completed — doubling peak memory and burning a redundant copy. This PR queues the write as an iovec pointing directly at the ArrayBuffer's backing store:Loopback
node:net,client.write(payload)timed fromwrite()to its completion callback with a full-speed reader; medians of 7 per cell, macOS aarch64, both binaries built from the same tree with the same profile — the baseline is this branch's base commit, so the only difference is this diff. The tell is in the RSS column: the saving equals the payload size at every point (the eliminated duplicate), and the ~1.1–1.2x time win is the skipped memcpy. Node 26.3 on the same machine and bench: 201 ms / 658 MiB at 512 MiB, so this closes most of the gap.Integrity: a patterned payload (offset stamped every 4 KiB, so reordering or corruption changes the digest) hashed with streaming SHA-256 at the receiver matches the sender's hash — byte-exact, and the same digest on baseline and patched binaries.
Cause
write_buffer(ext/node/ops/stream_wrap.rs) starts with a syncuv_try_write, which usually drains small writes. Whenever it can't — backpressure, or any write larger than the socket buffer — the async fallback went throughuv_write, whosecollect_bufscopies the whole undrained tail into an ownedVec. The copy was redundant: JS already retains the Buffer on the write request untiloncomplete(req.buffer = datainhandleWriteReq,stream_base_commons.ts) — the very same retention contract thewritevop already relies on for its zero-copy iovecs.Fix
Queue a one-entry iovec at the backing store via
uv_writev_owned(the existing zero-copy machinery), instead ofuv_write's copy.One case keeps an owned copy: V8 stores small typed arrays (≤ 64 bytes,
TYPED_ARRAY_MAX_SIZE_IN_HEAP) on the JS heap, andget_contentscopies those into a stack buffer that dies when the op returns. That case is detected by pointer identity against the stack buffer and routed throughuv_write_owned— a ≤ 64-byte copy.No regressions on adjacent paths
uv_try_writepath): 22.8 → 22.6 µs/rt, medians of 5.writev.node:tlswrite patterns (2000 × 4 KB awaited / fire-and-forget / corked writev / 1 × 32 MB): all within noise — TLS encrypted output goes through its ownuv_writewith Rust-owned ciphertext and is untouched by this change.Verification
unit_node::net_test,unit_node::http_test,unit_node::tls_test— pass on this branch. Full transparency: in 22 runs oftls_teston this branch, one run failedtls js-backed duplex client end() during handshake still sends close_notify (TLSv1.3)(a test that racesend()against handshake completion); it passed the other 21 runs here and 12/12 runs on the baseline, and I could not reproduce it again.specs::node(257 tests): failure set byte-identical to an upstream/main baseline build run on the same machine — the same 2 pre-existingchild_process_ipc_handle::destroy_before_ack_*flakes on both (they reproduce at ~3/30 on unpatched debug builds).tools/format.jsandtools/lint.jspass.Benchmark source (large write)
I used Cursor (Claude) to help investigate and write this change.