Skip to content

perf(ext/node): remove per-chunk copying on TLSWrap data paths#35705

Merged
nathanwhit merged 6 commits into
denoland:mainfrom
tomas-zijdemans:perf/tls-wrap-hot-path
Jul 6, 2026
Merged

perf(ext/node): remove per-chunk copying on TLSWrap data paths#35705
nathanwhit merged 6 commits into
denoland:mainfrom
tomas-zijdemans:perf/tls-wrap-hot-path

Conversation

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

A single 32 MB socket.write() through a node:tls loopback connection drops from ~760 ms to ~50 ms (debug build, macOS, 3 runs each) with this change. The cause was accidentally-quadratic buffer management in clear_in(): cleartext is fed to rustls in 48 KB chunks, and after every chunk the entire unwritten tail was re-allocated and copied (data[offset..].to_vec()). For an n-byte write that's ~n²/96KB bytes of cumulative memcpy — about 1 GB of copying for a 10 MB write.

This PR fixes that plus two smaller per-chunk costs on the same data paths. All three changes are behavior-preserving:

  • clear_in() tail copying: track consumed bytes with a pending_cleartext_offset instead of re-allocating the remainder after every chunk. The buffer is released once fully consumed, and the write-error semantics (drop the tail) are unchanged.
  • Decrypted read delivery (do_emit_read): build the ArrayBuffer from a backing store created from the bytes (one memcpy) instead of ArrayBuffer::new — which zero-initializes the allocation — followed by a byte-by-byte copy through Cell accessors. That was two full passes over every decrypted chunk handed to JS. Same pattern already used in ext/node/ops/http2/session.rs and llhttp/binding.rs.
  • Encrypted output collection (enc_out_collect / enc_out_flush_only): write_tls serializes directly into pending_enc_out (Vec's io::Write impl appends) instead of round-tripping every record batch through a temporary 16 KB Vec.

Also removes two write-only fields found while auditing these paths (EncryptedWriteReq.has_write_callback, TLSWrapInner.session_was_setisSessionReused() reports from the negotiated handshake kind, so setSession is now an explicit no-op), and adds two unit tests against a real rustls ClientConnection: one covering enc_out_collect gathering/non-duplication, one covering clear_in's offset bookkeeping (bounded chunks, monotonic progress, buffer never mutated while partially consumed, released when drained).

Verification

  • cargo test -p deno_node --lib tls_wrap — 5/5 (includes the 2 new tests)
  • unit_node::tls_test, unit_node::https_test, unit_node::http2_test — pass
  • All 13 specs::node::tls_* spec tests — pass
  • node_compat TLS suite (240 tests): failure set is byte-identical to a main baseline build run on the same machine (57 pre-existing failures, no additions/removals)
  • Manual end-to-end: 8 MB and 32 MB transfers in both directions over loopback TLS with SHA-256 verification of the received stream, byte-exact
  • tools/format.js and tools/lint.js pass

I used Cursor (Claude) to help investigate and write this change.

tomas-zijdemans and others added 2 commits July 2, 2026 10:32
Three behavior-preserving changes that cut allocation and copying on
the TLS hot paths:

- Deliver decrypted reads to JS via a backing store built from the
  bytes (one memcpy, zero-copy for the JS-stream enc-out path) instead
  of zero-initializing an ArrayBuffer and then writing it byte-by-byte
  through Cell accessors — two full passes over every decrypted chunk.
- Serialize rustls output directly into pending_enc_out (Vec's
  io::Write impl appends) instead of round-tripping every record batch
  through a temporary 16KB Vec.
- Track consumed pending_cleartext with an offset instead of
  re-allocating and copying the unwritten tail after every 48KB chunk,
  which made large writes O(n^2): a single 32MB socket.write() drops
  from ~760ms to ~50ms in a local loopback echo benchmark.

Also removes the write-only fields EncryptedWriteReq.has_write_callback
and TLSWrapInner.session_was_set, and adds unit tests covering enc_out
collection and clear_in offset bookkeeping against a real rustls client
connection.

Co-authored-by: Cursor <[email protected]>
In workspace-wide CI builds, cargo feature unification enables both of
rustls' crypto backends (ring and aws-lc-rs), so ClientConfig::builder
can no longer infer the process-level provider and panics. Install the
aws-lc-rs provider explicitly in the test helper, matching what
ext/fetch's tests already do.

Co-authored-by: Cursor <[email protected]>

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One small suggestion on the offset bookkeeping — otherwise this looks great.

Comment thread ext/node/ops/tls_wrap.rs
@bartlomieju

Copy link
Copy Markdown
Member

Took a close look at the buffer handling here (offset invariants, the JS handoff paths, and the removed fields) and this is almost ready to go. The offset bookkeeping in clear_in() is sound at every mutation site, the enc_write_cb resume check is an exact equivalent of the old emptiness test, and both removed fields were write-only on main. A few minor observations, none blocking:

  • do_enc_out_js is #[allow(dead_code)], so the zero-copy improvement there is on a path nothing currently calls (JS streams pull via drain_enc_out). Harmless, and correct if the path is ever revived, but worth knowing it is not exercised.
  • In do_emit_read, new_backing_store_from_bytes(bytes.to_vec().into_boxed_slice()) could be the slightly simpler new_backing_store_from_vec(bytes.to_vec()). Cosmetic only, no realloc either way.
  • The final release assertion in clear_in_chunks_pending_cleartext_via_offset is conditional (if inner.pending_cleartext.is_empty()), and with rustls' default 64 KB plaintext buffer limit and no completed handshake the test likely stalls before full consumption, so the release path is mostly covered by the integration tests rather than this unit test. Fine as-is, just slightly weaker than the doc comment implies.

The verification section is unusually thorough (byte-identical node_compat failure set vs a main baseline, hash-verified 32 MB transfers). Nice work.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor Author

Thanks, included the feedback now

@bartlomieju

Copy link
Copy Markdown
Member

Verified in a release build with a clean same-tree A/B: both binaries are release builds of this branch's tip (0efd9f31ef); the baseline is that tree with this PR's tls_wrap.rs diff reverse-applied (git apply -R of the PR diff), so the only difference is this change.

node:tls loopback, client.write(payload) timed from write() to its completion callback, SHA-256 integrity-checked on the receiving end each run, medians of 7:

write size baseline (per-chunk tail copy) this PR speedup
8 MiB 29.9 ms 6.2 ms 4.8x
16 MiB 90.6 ms 12.5 ms 7.2x
32 MiB 276.3 ms 23.9 ms 11.6x
64 MiB 955.3 ms 47.9 ms 19.9x

The accidental-quadratic is clearly visible: the baseline grows ~3x per doubling (O(n²)), while this PR grows ~2x per doubling (O(n)), so the win scales with payload size. At the 32 MiB point from the description that's 11.6x in release (276 ms -> 24 ms); the description's 760 ms -> 50 ms was a debug build, where the per-byte memcpy cost is higher, so the absolute numbers differ but the fix reproduces and is even more dramatic at 64 MiB (~20x). Integrity check passed on every run (byte-exact receipt).

Nice fix. ✅

@nathanwhit nathanwhit left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks!

@nathanwhit
nathanwhit merged commit 90760e7 into denoland:main Jul 6, 2026
136 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.

3 participants