Skip to content

Commit 01bc58a

Browse files
Jesse Wrightclaude
andcommitted
perf: dedup redundant endReadableNT teardown ticks
On the short-lived flowing path, ending a readable can call endReadable() several times after end (from flow() and two resume_() sites). Each call schedules an endReadableNT() nextTick, but endReadableNT only ever emits 'end' once (it bails on kEndEmitted), so the extra ticks are pure waste, and the teardown tick cascade is the dominant per-stream teardown cost. Add a kEndScheduled state bit: endReadable() only schedules when neither kEndEmitted nor kEndScheduled is set; endReadableNT() clears it on entry so a later endReadable() can still reschedule if this tick bails out (e.g. a last-moment unshift raised state.length). 'end' still fires exactly once, on the same tick it otherwise would - no observable ordering change. For one flowing PassThrough -> Transform unit (objectMode, 4 chunks): 15 -> 12 nextTick hops/unit (endReadableNT scheduled 5x -> 2x). A paired A/B CPU benchmark (base vs patched in one process) shows a median ~4.5-7.7% CPU reduction on this shape. Benchmarks added under benchmark/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 88df210 commit 01bc58a

4 files changed

Lines changed: 221 additions & 2 deletions

File tree

benchmark/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# endReadableNT teardown-tick benchmarks
2+
3+
These two scripts measure the `process.nextTick()` teardown cascade of a
4+
short-lived, flowing `PassThrough -> Transform` pipeline — the shape produced
5+
by e.g. a per-request or per-file streaming stage. They exist to demonstrate
6+
the `endReadableNT` tick-dedup change to `lib/internal/streams/readable.js`.
7+
8+
They are **not** published to npm (the package `files` field only ships
9+
`lib`, `LICENSE`, `README.md`).
10+
11+
## `endReadable-ticks.js`
12+
13+
Counts the exact number of `nextTick` hops one unit incurs, broken down by
14+
call site, so the redundant `endReadableNT()` scheduling is visible.
15+
16+
```
17+
node benchmark/endReadable-ticks.js [chunksPerUnit=4]
18+
```
19+
20+
Compare base vs this change (the `lib/` change is the only tracked edit, so it
21+
stashes cleanly while the untracked `benchmark/` dir stays put):
22+
23+
```
24+
git stash push -- lib/internal/streams/readable.js
25+
node benchmark/endReadable-ticks.js # BASE
26+
git stash pop
27+
node benchmark/endReadable-ticks.js # PATCHED
28+
```
29+
30+
Expected (objectMode, 4 chunks):
31+
32+
| | total nextTick hops | `endReadableNT()` scheduled |
33+
|--|--|--|
34+
| base | 15 | 5x |
35+
| patched | 12 | 2x |
36+
37+
The 3 removed hops are duplicate `endReadableNT()` ticks (base schedules it 3x
38+
from `flow()` + 2x from `resume_()`; only one ever emits `'end'`). `'end'`
39+
still fires exactly once, on the same tick — behaviour is unchanged.
40+
41+
## `endReadable-cpu.js`
42+
43+
CPU time (from `process.cpuUsage()`, an OS counter, more stable than wall time
44+
on a loaded machine) for a wave of `N` short-lived units.
45+
46+
```
47+
node --expose-gc benchmark/endReadable-cpu.js [units=8000] [chunksPerUnit=4] [reps=15]
48+
```
49+
50+
Run it on both branches as above. A paired A/B run (base and patched loaded
51+
side-by-side in one process, alternating rep-by-rep so shared-box drift
52+
cancels) measured a **median ~4.5–7.7% CPU reduction with the patched build
53+
faster in the large majority of paired reps** on a 2-core machine shared with
54+
other load. The magnitude tracks the tick reduction (~20% fewer teardown
55+
ticks on this shape).

benchmark/endReadable-cpu.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use strict'
2+
3+
// CPU throughput for a wave of short-lived, flowing PassThrough -> Transform
4+
// units (the endReadableNT teardown-tick shape). Reports CPU time from
5+
// process.cpuUsage() (an OS counter), which is more stable than wall time on a
6+
// loaded/shared machine. Run on the base branch and on this branch to compare:
7+
//
8+
// git stash && node --expose-gc benchmark/endReadable-cpu.js
9+
// git stash pop && node --expose-gc benchmark/endReadable-cpu.js
10+
//
11+
// Args: [units] [chunksPerUnit] [reps]
12+
13+
const { PassThrough, Transform } = require('..')
14+
15+
const N = parseInt(process.argv[2] || '8000', 10)
16+
const K = parseInt(process.argv[3] || '4', 10)
17+
const reps = parseInt(process.argv[4] || '15', 10)
18+
19+
function oneUnit() {
20+
return new Promise((resolve, reject) => {
21+
const src = new PassThrough({ objectMode: true })
22+
const xform = new Transform({
23+
objectMode: true,
24+
transform(c, e, cb) {
25+
cb(null, c)
26+
}
27+
})
28+
let sink = 0
29+
src.pipe(xform)
30+
xform.on('data', (d) => {
31+
sink += (d && d.n) | 0
32+
})
33+
xform.on('end', () => resolve(sink))
34+
xform.on('error', reject)
35+
for (let i = 0; i < K; i++) src.write({ n: i, s: 'term' + i })
36+
src.end()
37+
})
38+
}
39+
40+
async function workload() {
41+
const WAVE = 32
42+
for (let i = 0; i < N; i += WAVE) {
43+
const batch = []
44+
for (let j = i; j < Math.min(i + WAVE, N); j++) batch.push(oneUnit())
45+
await Promise.all(batch)
46+
}
47+
}
48+
49+
async function measure() {
50+
if (global.gc) global.gc()
51+
const c0 = process.cpuUsage()
52+
await workload()
53+
const c1 = process.cpuUsage(c0)
54+
return (c1.user + c1.system) / 1000 // ms of CPU
55+
}
56+
57+
async function main() {
58+
await workload() // warm up
59+
await workload()
60+
const samples = []
61+
for (let r = 0; r < reps; r++) samples.push(await measure())
62+
samples.sort((a, b) => a - b)
63+
const med = samples[samples.length >> 1]
64+
console.log(
65+
JSON.stringify({
66+
units: N,
67+
chunksPerUnit: K,
68+
reps,
69+
cpuMedianMs: +med.toFixed(1),
70+
usPerUnit: +((med * 1000) / N).toFixed(2)
71+
})
72+
)
73+
}
74+
75+
main().catch((e) => {
76+
console.error(e)
77+
process.exit(1)
78+
})

benchmark/endReadable-ticks.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict'
2+
3+
// Counts the process.nextTick() hops incurred by ONE short-lived, flowing
4+
// PassThrough -> Transform unit (the shape produced e.g. by a per-request or
5+
// per-file streaming pipeline). It also breaks the hops down by call site so
6+
// the redundant endReadableNT() scheduling is visible.
7+
//
8+
// Run it on the base branch and on this branch:
9+
//
10+
// git stash && node benchmark/endReadable-ticks.js # base
11+
// git stash pop && node benchmark/endReadable-ticks.js # patched
12+
//
13+
// Expected (objectMode, K=4 chunks):
14+
// base : 15 hops total, endReadable scheduled 5x
15+
// patched : 12 hops total, endReadable scheduled 2x
16+
//
17+
// The 3 removed hops are duplicate endReadableNT() ticks; 'end' still fires
18+
// exactly once, on the same tick, so behaviour is unchanged.
19+
20+
const { PassThrough, Transform } = require('..')
21+
22+
const sites = new Map()
23+
let count = 0
24+
const realNextTick = process.nextTick.bind(process)
25+
process.nextTick = function (fn, ...args) {
26+
count++
27+
const stack = new Error().stack
28+
.split('\n')
29+
.slice(2, 5)
30+
.map((l) =>
31+
l
32+
.trim()
33+
.replace(/\(.*streams\//, '(')
34+
.replace(/:\d+\)$/, ')')
35+
)
36+
.join(' <- ')
37+
sites.set(stack, (sites.get(stack) || 0) + 1)
38+
return realNextTick(fn, ...args)
39+
}
40+
41+
const K = parseInt(process.argv[2] || '4', 10)
42+
43+
function oneUnit() {
44+
return new Promise((resolve, reject) => {
45+
const src = new PassThrough({ objectMode: true })
46+
const xform = new Transform({
47+
objectMode: true,
48+
transform(c, e, cb) {
49+
cb(null, c)
50+
}
51+
})
52+
src.pipe(xform)
53+
xform.on('data', () => {})
54+
xform.on('end', resolve)
55+
xform.on('error', reject)
56+
for (let i = 0; i < K; i++) src.write({ n: i, s: 'term' + i })
57+
src.end()
58+
})
59+
}
60+
61+
oneUnit().then(() => {
62+
process.nextTick = realNextTick
63+
let endReadableHops = 0
64+
for (const [site, n] of sites) {
65+
if (site.startsWith('at endReadable (')) endReadableHops += n
66+
}
67+
console.log(`nextTick hops for ONE unit (K=${K} chunks): ${count}`)
68+
console.log(` of which endReadableNT() scheduled by endReadable(): ${endReadableHops}`)
69+
console.log('per call site:')
70+
const rows = [...sites.entries()].sort((a, b) => b[1] - a[1])
71+
for (const [site, n] of rows) console.log(` ${n}x ${site}`)
72+
})

lib/internal/streams/readable.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ const kCloseEmitted = 1 << 15
9292
const kMultiAwaitDrain = 1 << 16
9393
const kReadingMore = 1 << 17
9494
const kDataEmitted = 1 << 18
95+
// Set while an endReadableNT() nextTick is already pending, so repeated
96+
// read()/resume() calls after end don't schedule duplicate ticks.
97+
const kEndScheduled = 1 << 19
9598

9699
// TODO(benjamingr) it is likely slower to do it this way than with free functions
97100
function makeBitMapDescriptor(bit) {
@@ -141,7 +144,9 @@ ObjectDefineProperties(ReadableState.prototype, {
141144
multiAwaitDrain: makeBitMapDescriptor(kMultiAwaitDrain),
142145
// If true, a maybeReadMore has been scheduled.
143146
readingMore: makeBitMapDescriptor(kReadingMore),
144-
dataEmitted: makeBitMapDescriptor(kDataEmitted)
147+
dataEmitted: makeBitMapDescriptor(kDataEmitted),
148+
// If true, an endReadableNT tick is already pending.
149+
endScheduled: makeBitMapDescriptor(kEndScheduled)
145150
})
146151
function ReadableState(options, stream, isDuplex) {
147152
// Duplex streams are both readable and writable, but share
@@ -1219,14 +1224,23 @@ function fromList(n, state) {
12191224
function endReadable(stream) {
12201225
const state = stream._readableState
12211226
debug('endReadable', state.endEmitted)
1222-
if (!state.endEmitted) {
1227+
// On the flowing short-lived path read()/resume() can call endReadable()
1228+
// several times after end; endReadableNT only ever emits 'end' once, so
1229+
// skip scheduling a duplicate tick when one is already pending.
1230+
if (!state.endEmitted && !state.endScheduled) {
12231231
state.ended = true
1232+
state.endScheduled = true
12241233
process.nextTick(endReadableNT, state, stream)
12251234
}
12261235
}
12271236
function endReadableNT(state, stream) {
12281237
debug('endReadableNT', state.endEmitted, state.length)
12291238

1239+
// Clear the scheduled flag now that the tick is running, so that if this
1240+
// tick bails out below (e.g. a last-moment unshift raised state.length),
1241+
// a later endReadable() can schedule a fresh tick.
1242+
state.endScheduled = false
1243+
12301244
// Check that we didn't get one last unshift.
12311245
if (!state.errored && !state.closeEmitted && !state.endEmitted && state.length === 0) {
12321246
state.endEmitted = true

0 commit comments

Comments
 (0)