Repository navigation
Reduce memory consumption of streams for large amounts of data #6078
Description
- Version: 0.10.x to 5.x
- Platform: all
- Subsystem: streams
Node streams can (and usually do) eat up a lot of system memory if a lot of data passes through them. Here's a simple demo script to show what I'm talking about:
console.log(process.memoryUsage().rss);
process.stdin.once('end', function() {
console.log(process.memoryUsage().rss);
});
process.stdin.resume();Run using dd if=/dev/zero bs=1M count=1024|node script.js
On my machine, simply reading this data from stdin consumes a horrendous 340MB of RAM!
This is obviously due to the fact that streams work by constantly creating new Buffers, and rely on the GC to eventually clean things up. Whilst this may be the 'GC way of doing things', it's extremely wasteful considering that Buffers can quickly consume a lot of memory.
Unfortunately, there doesn't appear to be any easy way around this (that I know of). Even trying to hint the GC into running more often (reducing max_old_space_size) doesn't seem to help much.
But if we change the last line above to process.stdin.on('data', gc); (and use expose_gc), the memory used to read the stream drops to a sane 1.6MB. Whilst this confirms that it is a GC related issue, it's not a good solution, especially for applications where GC is slow.
For files, we can work around this with the fs module by directly invoking read into a single fixed size Buffer. Doing so prevents this growth of memory usage, and is significantly more efficient than using a ReadStream (in terms of memory usage, less pressure on GC and fewer memory (de)allocations).
Unfortunately, Node uses streams all over the place, so this strategy doesn't work everywhere, making it difficult/impossible to work around this inefficiency.
I don't have any good solutions to this, but I think that it should be considered, for those who wish to keep system memory usage low (or rather, not be wasteful about it). Solutions obviously need to stay efficient with smaller amounts of data, since I expect many streams to be of that nature.
Some ideas I have:
- Add the ability to deallocate Buffers. Once deallocated, all references (including sub-slices) to the object return a 0 length Buffer. But I'm not sure whether this is feasible given that Buffers run on top of Uint8Array now
- Some way of enabling streams to write directly (or copy) into destination Buffers (similar to
fs.read). The stream would likely have its own internal buffer that never gets reallocated. Writes to the stream copy to the buffer, and reads copy out of it. Memory copies may seem wasteful in some circumstances, so it would have to be optional somehow. - Perhaps an option to a readable stream to indicate that the returned Buffer, for the
dataevent orreadfunction, can be volatile. Users need to ensure that the data is consumed before returning from the callback (or resumed after pausing). This enables the stream (and the write component) to internally use a fixed Buffer without constantly having to allocate new ones whilst reading.
I'm hoping that others may be able to come up with better ideas :)
Thank you for your consideration!
Activity
Fishrock123 commentedon Apr 6, 2016
That doesn't seem too untypical. Newer versions of v8 like to keep the allocations they make as long as possible.
If it's actually an issue in streams like you imply, we could look into using an existing buffer I suppose.
cc @nodejs/streams?
calvinmetcalf commentedon Apr 6, 2016
Streams were designed for use within node.js programs where they are one of many parts of the program, so relying on garbage collection isn't all that big a deal for them because if they didn't some other part would so there wouldn't be a net gain. I'm not discounting that streams are allocation heavy but I'm not sure much they add to the memory usage in a real program where they are one of many things allocating objects.
In a real program a stream will often be one of several in a chain where asynchronous actions are being done in the middle. This is the use case where streams excel as they may use hundreds of megabytes of memory, they will consistently use only that much memory no matter how much data is being piped through them.
Another thing to bear in mind when talking about something (like memory usage) being wasteful, is that it actually might be buying something else, for instance you're version which does frequent garbage collection probably runs slower and use more CPU cycles. Streams are a compromise between being lazy and being performant, so for instance they all have input buffers so that different parts don't have to wait around for others, I'd guess that they might be an issue in real world programs taking up a more significant amount of memory.
Streams are a good abstraction, but they are not all things to all users so if you need absolute minimum external memory usage streams have some features that might make that hard and you might be better off using fs.read. It would be pretty hard to fix some of these things without breaking changes, for instance you can currently keep a copy of a buffer you got from a stream and not do anything with it, e.g. this not terribly uncommon code:
So I guess my questions for you are
mcollina commentedon Apr 6, 2016
First of all: are you getting an out of memory error? V8 gc takes into account the amount of physical memory that is available, and it performs best given the amount of memory that is available on the system. Because of this, allocating 300MB of RAM is probably the best thing to do to read that file as quickly as possible. On the other end, I completely agree that streams are not the best API for specific use cases, but they work great for 99% of the times.
Also, streams are built to slow things down. They are built so that the producer is going at the speed of the consumer, and they offer a "simple" API for doing so. Implementing it manually takes a lot of code and skills.
ChALkeR commentedon Apr 6, 2016
Actually, that's not true, and calling a scavenge each 50-th
'data'event limits the memory usage to ~30 MiB and makes it faster, even for larger data volumes.mcollina commentedon Apr 6, 2016
On my tests calling
gc()every 50th'data'event increases the time spent reading the file by 30%. The memory increases by less than 30 MB.Here is my code:
I am still not understanding what problem having free or uncollected memory creates. What is the use case we are discussing, and what are the implications. What could node do better if the memory consumption of streams would be lower?
Anyway, the fact that calling gc() every once in a while reduced the memory consumption is extremely good, because it means it is probably a setting in v8 that can be configured for doing it in that way.
ChALkeR commentedon Apr 6, 2016
@mcollina Scavenge is
gc(true), so it would becounter++ % 50 === 0 && gc(true).Nevertheless, manual mark-sweep calls each 1000-th data event also speeds things up a bit, but the memory usage is ~150 MiB.
mcollina commentedon Apr 6, 2016
@ChALkeR you are right! Calling
gc(true)every 50'data'event halves the time. In this case, the mark-and-sweep collector never gets called.Here is what is happening: the objects are short-lived, and an (early) collection via scavenge solves the issues. Using
--trace-gcI can see this:Basically, we keep hitting mark-and-sweep.
Given that normally the GC picks the right decision (like HTTP servers), what is special in this case that is making the GC do the wrong one?
ChALkeR commentedon Apr 6, 2016
@mcollina I opened #1671 to track that some time ago.
animetosho commentedon Apr 6, 2016
Thank you for all the replies!
I am not getting any out of memory errors, or even hitting 2GB remotely, this is just an observation that I've made, having seen rather simple scripts consume a lot of system RAM. So basically, things are working :)
I understand that for most "real" programs, there are usually other allocations than streams, and are affected by GC too. It's just that most variables typically consume bytes of memory, whilst stream Buffers often consume 10000's of bytes, so it's much easier for the latter to overwhelm memory than the former, and can be done so in fairly trivial cases.
I disagree with claims that consuming 300MB of RAM somehow improves efficiency in any way. Allocations force all sorts of performance penalties, from overwhelming CPU caches, forcing more context switches (due to increased page faults), forcing the GC to work more etc. See my example script below.
There are many good reasons for keeping the amount of external memory usage low. From making more memory available to other applications, reducing swap usage, to enabling the OS to more efficiently use disk buffering/caching. I don't believe V8 is intelligent enough to know about how a user may be using on their system, to effectively judge what amount of RAM is usage is 'available' (other than less = better) for optimal overall system performance.
For
fs, yes, but elsewhere, not that I know of unfortunately (ideas welcome though).I don't believe a change necessarily has to be breaking to client code, though I don't think a solution is necessarily easy. In the example below, I've added a fake
allowVolatileoption to the stream. This could be considered a hint to the underlying stream that overwriting the buffer is considered OK.Here's a demonstration for calculating the MD5 hash of a file. Toggle the if condition to see the effect of the stream vs manually reading into two buffers (I use two buffers here to offset the effect of stalling that synchronous hashing induces).
On the system I have here (running node v0.10.29), using streams:
Using
fs.read:The latter is both faster and keeps memory usage low, as somewhat expected.
Thanks again for the responses!
6 remaining items
mcollina commentedon Apr 7, 2016
One side note, the two branches of this example behave in the same way (with memory balooning):
animetosho commentedon Apr 7, 2016
Oh, oops. If the GC can clean up the objects earlier, then that will be a big improvement. Not as efficient as static memory allocation, but it's probably sufficient most of the time.
Thank you very much for all the effort put into this!
SourceBoy commentedon Jan 23, 2017
My app also seems to be experiencing this with Node 6.9.4 under Linux. I have a pair of stream.Duplex on 2 different machines in a LAN:machine Alet stream = fs.createReadStream('1GB-file').pipe(/* writeStream over LAN to machine B */)Memory keeps spiking up to +1GB on both ends as the file transfers, and memory isn't freed after transfer is complete, not even settingstreamto null using:stream.on('end', () => { stream = null; });mcollina commentedon Jan 23, 2017
@SourceBoy can you upload a barebone script to reproduce your problem using just
'fs'and'net'?SourceBoy commentedon Jan 24, 2017
I apologize. After conducting further tests, it may have been the underlying transport implementation in userland that led me to believe it was the stream.
irsl commentedon May 14, 2017
In my use case (passing through large amount of data from a process stdout to a socket) it would be helpful if underlying implementation of .pipe() checked whether both the source and destination streams are file descriptor based and then it could relay the data without routing it through the V8 memory management...
owenallenaz commentedon Jul 16, 2017
I believe we may be hitting this issue, but am having a lot of trouble isolating it down to figure out what the root cause is. We run 100 or so Node applications per server, in general they use about 100mb of memory each. We have a few of them where the RSS is all the way up to 3-5gb. At the same time the heap is only 130mb or so. In example here is a dump of the following:
Looking at the dump you can see the rss is absolutely massive, but the heap isn't. We have been pouring over heapsnapshots for about a week now, and we can't find any evidence of anything leaking, and based on the fact that the heap isn't growing, this makes sense. In the above process it's been running for about 1 week or so, and the heap hasn't really changed size in that amount of time.
The issue is that we have 100 apps running similar code, but it's clear something in a few of them is causing this RSS to balloon, while others are fine. Looking at the Node issues there is this one there is #13917, both are entirely possible as we have a few routes which do some key encrypt/decrypt. We also upload large files to Cloudinary. We accept POST requests from users.
Lets say the problem we face is this issue, is there some way we could locate it? Right now it's basically impossible to try and find every piece of code that could be using Buffers, since they are everywhere...
node-mongodb-native,request,googleapisit could be anywhere. We did not notice this issue a month ago when we were on Node 7.4.0, but we also updated many other dependencies (node-mongodb-native), and altered a lot of code in that time, so we can't say for sure what changed.mcollina commentedon Jul 17, 2017
@owenallenaz those are extremely hard to debug, but I would say that you are not being hit by this issue. Generically, some piece of code that you are running are making V8 garbage collector balloon the RSS. This typically happens for long-running requests, you are accumulating the results (maybe using a stream) and then rendering those to the end users. If you pass two gc Scavenge cycles, your data is going to end up in old-space, which will then be collected by a full mark-and-sweep but before this happens slowly and V8 would try to bump the RSS if the problem persists.
BTW, which version of Node are you running? I have tested this is Node 8, and the problem was described here was fixed, so I am closing this bug.
owenallenaz commentedon Jul 17, 2017
We are running Node 7.10.0. On average our mean response time is 300ms, we have some responses that vary between 2000 - 5000ms when we are communicating with external services, slow form submissions to legacy infrastructure. We ran a test over the weekend where one set of our processes were moved down to 7.4.0 while the rest remained at 7.10.0. The ones that we moved down to 7.4.0, did not balloon the RSS. When I run the scripts from @animetosho on both 7.4.0 and 7.10.0, I can see that 7.4.0 (148mb) balloons the RSS worse on stream than it does on 7.10.0 (88mb). So that pretty much says that this isn't my problem, unfortunately.
I'm still trying to understand how the different memory spaces work. I've read this post (http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection) pretty thoroughly which is old but I'm hoping still accurate. Unfortunately he doesn't really connect the dots between old space and RSS. So if you could help explain that would be awesome.
So with that in mind, lets say something passes 2 GC scavenge cycles, it gets promoted to the old space. According to my tests as the old space increases so does the RSS, when the old space decreases the RSS decreases as well. The odd thing is if you look at the dumps above, you can see the the RSS is 5gb and yet the old space is only 66mb. Is this a case of memory fragmentation where there is still some in-use memory on page in RSS, preventing it from being deallocated, even though the old-space has been? I have not been able to figure out a way to get my RSS to keep growing and yet my old-space stays the same, but this tells me I must be missing a key piece of understanding on how these mechanics work. Is there a simplistic script which could be run to see this in action? If I'm able to reproduce it in isolation may give a clue as to what we're doing in production to cause the issue.
mcollina commentedon Jul 18, 2017
Extremely slowly, and over a long period of time. So, when old space increase, it might be just for good.
Also, Node 8 solves this problem, so I recommend upgrading.
jorangreef commentedon Aug 3, 2017
@owenallenaz I think we are having the same issue as you, and on Node 8, see #1671 (comment)
There have been a few memory and fragmentation related issues in the past few months.
owenallenaz commentedon Aug 3, 2017
@jorangreef Is there a chance you are using
WeakMapin your code? Right now our running theory is that ours is caused by #14420. Unfortunately we won't know till mid-week next week when we are deploying a new code version with ourWeakMaprefactored out.jorangreef commentedon Aug 4, 2017
We're not using WeakMap anywhere.
mikkorantalainen commentedon May 4, 2020
Is there some reason Stream couldn't work more like Linux pipe buffers where maximum buffer size is closer to 4 MB? If source tries to write faster to pipe, Linux will prefer giving CPU time to the process that reads from the pipe until the buffer is not full. This allows the buffer to often fit in L2 cache of the CPU and the performance is much better than going via system RAM. If a stream could balloon up to 4 MB then gc wouldn't need to run that often only because of lots of stream data which should improve overall performance, too.