Skip to content

src: give small stream reads their own buffer - #67327

Open
orgads wants to merge 1 commit into
nodejs:mainfrom
orgads:stream-slab-small-reads
Open

orgads wants to merge 1 commit into
nodejs:mainfrom
orgads:stream-slab-small-reads

Conversation

@orgads

@orgads orgads commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Summary

StreamReadSlab hands every stream read to JS as a view over an env-wide 64KB slab. Commit() copies a read out only when it wastes more than a quarter of its reservation, which covers plain TCP: libuv suggests 64KB, and small reads are copied and rewound. TLS asks for exactly the decrypted size (EmitAlloc(avail) in TLSWrap::ClearOut()), so the copy never triggers. Every TLS chunk is a view at slab offset 0, 320, 640, …, and one chunk that JS keeps pins the whole 64KB plus every other read in it.

 EmitToJSStreamListener::OnStreamAlloc(suggested_size)
+  if suggested_size < StreamReadSlab::kSlabSize
+    return env->allocate_managed_buffer(suggested_size)   // as before the slab
   return env->stream_read_slab().Allocate(isolate, suggested_size)

Large reads (plain TCP) still use the slab. TLS already memcpys into the buffer it asked for, so it gets no extra copy, only one allocation per read, as before the slab was added.

How this shows up in an application: we run a voice server with TTS audio over HTTPS. The audio arrives faster than real time and waits in JS queues for seconds. The chunks survive scavenges, so their slabs are promoted, and each pins 64KB of external memory. ArrayBuffer memory grew ~57MB between major GCs, and ~80% of major GCs started with [IncrementalMarking] Start (external memory pressure) while the JS heap was far below its limit. On a busy 2-vCPU host each of those GCs stalled the event loop for 300–650ms.

Evidence

  • New test test/parallel/test-tls-small-read-own-buffer.js, which checks that each TLS chunk has byteOffset === 0 and buffer.byteLength === chunk.length.
    Before: fails. After: passes.

  • Retention, keeping 1 in 50 TLS chunks of a 3MB transfer, then gc():
    Before (v26.12.1): kept 63KB of chunks, arrayBuffers 3.36MB (53x).
    After: 208KB (3.3x).

  • Production load (v26.12.1 with this patch, ~1000 concurrent calls on 2 processes):
    Before: a major GC every 6.5s per process, and 11–23 audio underruns per 10-minute run.
    After: a major GC every 25.5s, and 0 underruns.

  • Throughput, benchmark/tls/throughput-s2c.js type=buf recvbuflen=0 dur=5, 10 alternating runs per binary on an idle 2-vCPU Debian 13 host. Both binaries are v26.12.1 from the same toolchain.

    sendchunklen before after change
    256 6519 6280 −3.7% (t=−2.9)
    32768 9613 9276 −3.5% (t=−4.4)
    131072 9134 9688 +6.1% (t=+3.4)
    16777216 7585 8448 +11.4% (t=+2.6)

    Small writes pay one allocation per read. Large transfers gain, and their before-runs are much noisier (sd 1015 vs 215).

  • Tests: tools/test.py on parallel/test-{tls,net,stream,http,http2,https,child-process,pipe,buffer}-* gave 1664 passed and 6 failed. The same 6 fail on the unpatched build in my environment (getaddrinfo ENOTFOUND localhost, system CA).

Merge Danger

Door: two-way. It changes only which allocator a read uses, so a revert restores the slab path.

Blast Radius: stream reads.

  • Reads smaller than 64KB (TLS, and any listener that asks for less) get one allocation each instead of a slab reservation. That costs ~3.5% throughput on small TLS writes.
  • Reads of 64KB or more, which covers plain TCP, are unchanged.
  • An alternative is to copy at Commit(), but for TLS that is a second memcpy.
  • Other options I considered and why I rejected them:
    • Keeping the slab only for reads of 16KB or more still pins up to 4x for full TLS records.
    • Per-stream slabs cost 64KB per idle socket.
    • Trimming the slab isn't possible: V8 can't shrink a backing store that has live views.
    • Reporting only the used bytes to V8 would hide memory that really is pinned.

🤖 Generated with Claude Code

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Nov 7, 2026
@orgads
orgads force-pushed the stream-slab-small-reads branch from 3b8f3af to 9fa96cc Compare November 7, 2026 20:21
Every read was carved out of the env-wide 64KB StreamReadSlab and
handed to JS as a view over it. TLS asks for exactly the decrypted
size, so the copy-on-waste path in Commit() never triggers, and any
TLS chunk that JS keeps pins the whole slab and every other read in
it. Chunks queued for seconds (e.g. audio streamed faster than real
time) get the slabs promoted, and their external memory then drives
most major GCs.

Reads asking for less than a slab now get a right-sized managed
buffer, as before the slab existed. Large reads (plain TCP, where
libuv suggests 64KB) still use the slab.

Assisted-by: Claude Code
Signed-off-by: Orgad Shaneh <orgad.shaneh@audiocodes.com>
@orgads
orgads force-pushed the stream-slab-small-reads branch from c1abe65 to 724c69a Compare November 7, 2026 21:48
@codecov

codecov Bot commented Nov 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.68%. Comparing base (5f602d5) to head (724c69a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #67327   +/-   ##
=======================================
  Coverage   90.68%   90.68%           
=======================================
  Files         797      797           
  Lines      279574   279577    +3     
  Branches    53778    53784    +6     
=======================================
+ Hits       253525   253544   +19     
+ Misses      16344    16306   -38     
- Partials     9705     9727   +22     
Files with missing lines Coverage Δ
src/env.h 98.70% <ø> (+1.29%) ⬆️
src/stream_base.cc 76.41% <100.00%> (+0.28%) ⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lpinca lpinca added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Nov 8, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Nov 8, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants