You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reduce memory overhead by using bit maps instead of many booleans for state
Use a deque instead of a linked list for the BufferList
Optimize for a single pipe rather than an array of pipes and fallback to array of pipes
More wild ideas:
Split Readable into several classes and "bailout" from fast to slow ones (e.g. objectMode and bytes, multiple readers vs. one, flowing vs. non flowing) based on most streams not changing their type during their lifetime.
Don't require a "full" event emitter necessarily for a stream unless the user is listening to an event and "fast-path" if a stream is piped
Reduce memory overhead by using bit maps instead of many booleans for state
Not sure whether this is worth the effort. Is memory usage a problem? Number of streams in typical application * state size should be quite low, relatively speaking? I tried this once but the performance impact was negligible. I was thinking that being able to keep more of the state in a single cache line should make a impact but I was wrong. Might be different now...
Use a deque instead of a linked list for the BufferList
I think adding unshift to FixedQueue would be one way to go?
Optimize for a single pipe rather than an array of pipes and fallback to array of pipes
Not sure what this is? We already optimized for single pipe? What am I missing
More wild ideas:
Split Readable into several classes and "bailout" from fast to slow ones (e.g. objectMode and bytes, multiple readers vs. one, flowing vs. non flowing) based on most streams not changing their type during their lifetime.
Difficult.
Don't require a "full" event emitter necessarily for a stream unless the user is listening to an event and "fast-path" if a stream is piped
Skeptical if this is worth it.
Other stuff:
add support in pipeline for sync transformer function
Rewrite transform stream as a Readable which implements the Writable trait. Avoiding the double buffering from Duplex.
Try to avoid nextTick calls. I have a PR for this.
Reacted by Benjamin Gruenbaum, Vinicius Lourenço, Luigi Pinca, Kerem Kat, Carlos Fuentes, Moshe Atlow and Zhenwei Jin
TBH. I think the biggest impact we can have is to try and increase the highwatermark across the board, e.g. increasing from 16k to 128k has a theoretical perf improvement of 8x in the context of streams overhead.
Not sure whether this is worth the effort. Is memory usage a problem? Number of streams in typical application * state size should be quite low, relatively speaking? I tried this once but the performance impact was negligible. I was thinking that being able to keep more of the state in a single cache line should make a impact but I was wrong. Might be different now...
i'll open a PR and we can check but since streams are so omnipresent I would suspect so especially when people construct smaller streams (e.g. to use the iterator helpers). I feel like doing the work and seeing would be easier and I feel like I got sucked into a lot more talking than coding roles recently so that would be fun.
I think adding unshift to FixedQueue would be one way to go?
That would work
Not sure what this is? We already optimized for single pipe? What am I missing
We always store a .pipes array even though most streams only pipe to one place, we could make the default behavior (one pipe) faster and bail out on multiple pipes.
Do we have any actual profile data to back our efforts?
No, I went through the systems analyzer with --log-all and noticed some stuff (optimzing streams wasn't the meeting topic V8 was)
We always store a .pipes array even though most streams only pipe to one place, we could make the default behavior (one pipe) faster and bail out on multiple pipes.
Basically all streams become slightly smaller and lookup for the pipe becomes slightly faster which would have very small impact in "many big streams" but measurable in "many small streams"
The main performance bottleneck in streams comes from the cost of buffering at each step of a stream pipeline. From both an algorithmic and memory perspective, this is heavy. Worsely, we do this double buffering in http, because both the socket and IncomingRequests are Readable.
We should internalize a library such as syncthrough or minipass (or one built with the same principles) that does not perform any buffering. Look at the benchmarks.
@mcollina we can explore/add a BYOB fast mode where different steps of the transform mutate the data in place instead of copying it which should ideally mean 0 copies
we can explore/add a BYOB fast mode where different steps of the transform mutate the data in place instead of copying it which should ideally mean 0 copies
That is impossible with the current API. We did a full design for this a long time ago, but there was no budget to make it happen. Using synchthrough/minipass where you'd use a Transform is a good intermediate step (you'd need this anyway for a BYOB solution).
For a BYOB strategy to work, you need to "lock" the two streams sides so that others could not interject and inspect that. Our current stream implementation is designed as a one-to-many abstraction.
For a BYOB strategy to work, you need to "lock" the two streams sides so that others could not interject and inspect that.
Correct, we could for example have a base class that is not an event emitter and only pipelines (and composes etc) and listening to events on that would throw or some such. Would need a lot of thought but can happen I think?
Some object mode Readable implementations could benefit from a new pushv API. Those that get data from their source in bulk and currently do for (const chunk of chunks) this.push(chunk) which could instead be this.pushv(chunks) to skip repeated state checks.
Sitting together with Ronag he pointed out some more important/useful ideas for the bitmap:
errored -> move to flags (and mutate shape on error - make non-error path faster)
decoder -> kHasDecoder and optimize for fast path
encoding -> optimize for utf-8 or buffer and if it's different then mutate shape
defaultEncoding -> similarly (utf8 or buffer or different)
pipes -> optimize for single pipe? Why the heck do we keep a ref to the pipe?
flowing -> convert to 2 bits (true/false/null)
kPaused -> can just be a flag in the bitmap since that's private
We should also optimize for non-legacy streams and keep compat in a slow path.
Reacted by Carlos Fuentes, linkgoron and Vitali Kuzmich
I know it sounds ignorant... Why do we call destroy in next tick? All the other operations are done in the current loop. What makes destroy so special?
I know it sounds ignorant... Why do we call destroy in next tick? All the other operations are done in the current loop. What makes destroy so special?
If we did not emit on the next tick, we could have streams that are errored before the user has a chance to attach an handler. Unfortunately this pattern is pervasive in our codebase and the ecosystem. A possible optimization in this regard is to not nextTick if there is a listener.
Reacted by Robert Nagy, Vinicius Lourenço, Benjamin Gruenbaum and Carlos Fuentes
nextTick isn't that expensive (though its queue should use a deque if it doesn't assuming we can't move to V8 queueMicrotask for timing reasons) but the nextTick is also there to provide consistent ordering so code isn't sometimes synchronous it always fires listeners asynchronously
Activity
ronag commentedon Sep 21, 2023
Not sure whether this is worth the effort. Is memory usage a problem? Number of streams in typical application * state size should be quite low, relatively speaking? I tried this once but the performance impact was negligible. I was thinking that being able to keep more of the state in a single cache line should make a impact but I was wrong. Might be different now...
I think adding
unshifttoFixedQueuewould be one way to go?Not sure what this is? We already optimized for single pipe? What am I missing
Difficult.
Skeptical if this is worth it.
Other stuff:
Readablewhich implements theWritabletrait. Avoiding the double buffering fromDuplex.nextTickcalls. I have a PR for this.ronag commentedon Sep 21, 2023
Do we have any actual profile data to back our efforts?
ronag commentedon Sep 21, 2023
TBH. I think the biggest impact we can have is to try and increase the highwatermark across the board, e.g. increasing from 16k to 128k has a theoretical perf improvement of 8x in the context of streams overhead.
benjamingr commentedon Sep 21, 2023
i'll open a PR and we can check but since streams are so omnipresent I would suspect so especially when people construct smaller streams (e.g. to use the iterator helpers). I feel like doing the work and seeing would be easier and I feel like I got sucked into a lot more talking than coding roles recently so that would be fun.
That would work
We always store a
.pipesarray even though most streams only pipe to one place, we could make the default behavior (one pipe) faster and bail out on multiple pipes.No, I went through the systems analyzer with
--log-alland noticed some stuff (optimzing streams wasn't the meeting topic V8 was)ronag commentedon Sep 21, 2023
I don't see how this affects any hot path.
benjamingr commentedon Sep 21, 2023
Basically all streams become slightly smaller and lookup for the pipe becomes slightly faster which would have very small impact in "many big streams" but measurable in "many small streams"
benjamingr commentedon Sep 21, 2023
Said PR nodejs/node#49745
mcollina commentedon Sep 21, 2023
The main performance bottleneck in streams comes from the cost of buffering at each step of a stream pipeline. From both an algorithmic and memory perspective, this is heavy. Worsely, we do this double buffering in http, because both the socket and IncomingRequests are Readable.
We should internalize a library such as syncthrough or minipass (or one built with the same principles) that does not perform any buffering. Look at the benchmarks.
benjamingr commentedon Sep 21, 2023
@mcollina we can explore/add a BYOB fast mode where different steps of the transform mutate the data in place instead of copying it which should ideally mean 0 copies
mcollina commentedon Sep 21, 2023
That is impossible with the current API. We did a full design for this a long time ago, but there was no budget to make it happen. Using synchthrough/minipass where you'd use a Transform is a good intermediate step (you'd need this anyway for a BYOB solution).
benjamingr commentedon Sep 21, 2023
@mcollina why? We keep the same design and just emit the buffers that were passed to us, we keep a pointer to what we're currently using etc?
benjamingr commentedon Sep 21, 2023
@ronag trying FixedQueue suggestion in nodejs/node#49754
mcollina commentedon Sep 21, 2023
For a BYOB strategy to work, you need to "lock" the two streams sides so that others could not interject and inspect that. Our current stream implementation is designed as a one-to-many abstraction.
benjamingr commentedon Sep 22, 2023
Correct, we could for example have a base class that is not an event emitter and only
pipelines (and composes etc) and listening to events on that would throw or some such. Would need a lot of thought but can happen I think?mcollina commentedon Sep 22, 2023
The design for that was already done in https://github.com/Fishrock123/bob. I think this was 2017-2018 work.
2 remaining items
Uzlopak commentedon Sep 23, 2023
I checked it locally, and it would break for Writable.
benjamingr commentedon Sep 23, 2023
@Uzlopak why?
Uzlopak commentedon Sep 23, 2023
Idk. But if you check... Writable has also not the following check
vweevers commentedon Sep 23, 2023
Some object mode Readable implementations could benefit from a new
pushvAPI. Those that get data from their source in bulk and currently dofor (const chunk of chunks) this.push(chunk)which could instead bethis.pushv(chunks)to skip repeated state checks.ronag commentedon Sep 23, 2023
Another thing we could look into is to merge the
Readablefrom undici into core. It has some smart tricks to reduce overhead.https://github.com/nodejs/undici/blob/main/lib/api/readable.js
benjamingr commentedon Sep 26, 2023
Sitting together with Ronag he pointed out some more important/useful ideas for the bitmap:
errored -> move to flags (and mutate shape on error - make non-error path faster)
decoder -> kHasDecoder and optimize for fast path
encoding -> optimize for utf-8 or buffer and if it's different then mutate shape
defaultEncoding -> similarly (utf8 or buffer or different)
pipes -> optimize for single pipe? Why the heck do we keep a ref to the pipe?
flowing -> convert to 2 bits (true/false/null)
kPaused -> can just be a flag in the bitmap since that's private
We should also optimize for non-legacy streams and keep compat in a slow path.
Uzlopak commentedon Sep 27, 2023
I know it sounds ignorant... Why do we call destroy in next tick? All the other operations are done in the current loop. What makes destroy so special?
mcollina commentedon Sep 27, 2023
To support this flow:
If we did not emit on the next tick, we could have streams that are errored before the user has a chance to attach an handler. Unfortunately this pattern is pervasive in our codebase and the ecosystem. A possible optimization in this regard is to not nextTick if there is a listener.
Uzlopak commentedon Sep 27, 2023
Then we should consider this. Actually nextTick is the sole reason, that destroy is slower than the other ops.
benjamingr commentedon Sep 27, 2023
nextTick isn't that expensive (though its queue should use a deque if it doesn't assuming we can't move to V8 queueMicrotask for timing reasons) but the nextTick is also there to provide consistent ordering so code isn't sometimes synchronous it always fires listeners asynchronously
ronag commentedon Oct 4, 2023
Current state of recent improvements vs 20.7.0:
ronag commentedon Oct 4, 2023
@benjamingr @mcollina ☝️
benjamingr commentedon Oct 4, 2023
That's great
anonrig commentedon Oct 4, 2023
Good work!
benjamingr commentedon Oct 4, 2023
We can: