Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
fs.promises.readFile is 40% slower than fs.readFile (github.com/nodejs)
145 points by Jarred on March 3, 2021 | hide | past | favorite | 74 comments


What really disturbs me is this comment:

,,But no one said fs.promises.readFile should perform similarly to fs.readFile ''

I'm happy that it was downvoted at least, but there should be a better way to set expectations for excellence in development.


I've posted about this before, but I know someone whose coworkers actually tried to defend themselves with "not leaking memory wasn't in the requirements," in a meeting with several managers, the subject of which was the memory leak.


I don't know whether its true but there's an old story of engineers a few decades ago working on the guidance system for a missile.

The programmers didn't bother to free() any of their memory - instead they just calculated the maximum amount of RAM their program could consume while the rocket was in the air, doubled it, then installed that much ram in the rockets.



> the ultimate in garbage collection is performed without programmer intervention.

The author owes me a new keyboard.


Thanks for the link.

Am I the only one who is terrified by reading an "amusing story" about "on-board software for a missile"? The amusement sort of requires us to completely ignore the individuals being blown to pieces.


I mean writing software for missiles kind of requires that mind set anyway


That's normal, as the rockets don't run an OS, run a specific , very explicitly defined, embedded program, with known states.

It's not going to malloc randomly...

In fact, it's common in real time applications to pre-allocate everything you need at the start of the program or use static memory, and never allocate afterwards.


I think the ultimate fact here is that you have a specific runtime period. Once the missile has reached its destination, you have zero memory and the runtime disappears together with the missile.


The ultimate free().


A literal destructor.


Ooh that's better.


Sounds funny, but is it actually unreasonable?

Presumably, the RAM was a rounding error in the total budget.

And this move eliminated an entire class of bugs (use after free).

edit: And, you don't have to worry about memory fragmentation, or how long free() might take in the worst case.


When the European space agency designed the Ariane 4 rocket allocated 16 bits to some variable relating to its horizontal velocity. This variable was only used during the first minute of flight, and engineers computed that it could not possibly overflow given the launch profile Ariane 4 used. Because of this and limited processing power, engineers omitted an overflow check. This worked fine, and Ariane 4 would go on to launch succesfully 113/116 times (97%).

In 1996, Ariane 4's successor, Ariane 5, took its maiden voyage to carry the Cluster constellation into orbit, when this happened https://www.youtube.com/watch?v=gp_D8r-2hwk&t=54s

Ariane 5 used a different launch profile. The impossible was now possible and the 16 bit value overflowed. The inertial reference system failed, along with its backup (which was running the same code), causing the rocket to receive wrong data.


Sounds like more of a warning against blindly copy-pasting code across projects than anything against the Ariane 4 design.


Yes I agree.

At the same time, having added big uppercase warnings in the Ariane 4 source code and architecture docs, might have prevented this.

So, important, in safety critical code, to assume that others might blindly copy paste in bad ways -- and then try to warn them, although you might not be there any longer


This sounds distressingly similar to the Therac-25 failures. A radiotherapy machine who's software was (blindly?) copy-pasted from a previous version of the system which had hardware interlocks to prevent unsafe configurations of the radiation emitter.

The new one did not have these interlocks, instead doing everything in software. The result of this was a few people being fatally irradiated when a certain race condition was triggered.


I know the relevant xkcd, but it is always Ariane, Therac and Mars Climate Orbiter.


I guess the problem is if their calculations are wrong and they are actually allocating more memory than expected.

I think a better approach (and one which NASA uses from what I've read) is to only use static memory.


Not at all unreasonable. You could very rightly consider any other arrangement ‘overengineered’


Eminently reasonable: dispensing with memory management (allocate/gc) for an embedded real-time missile guidance system. It may have even had static memory layout which likely further improves performance.


I'd be inclined not to use the heap at all though in that case. Just preallocate static arrays in each location.


More common than you think and a good way to do it. In a hard-real time environment where you need strong guaranties provably overshooting is a sane strategy. Freeing is not free. Missiles are extreme because they never will be reused but you can do the same at the process level. You just give the process more than it will need during its lifetime and don't bother with memory management.


It doesn't disturb me unless he/she is a core dev of node.js. I mean, anyone can say anything they want. Not to mention it's likely a joke.


I agree -- and I'd venture to say that the sentiment could be alluding to the fact that mistakes and things overlooked in software development are inevitable.

Don't take this for defending him, but it's in the realm of possibilities ¯\_(ツ)_/¯


Well his point is this technically isn’t a bug because comparable performance to fs.readFile was never guaranteed as part of the API contract. I don’t think his statement implies we generally ought not to expect excellence nor that effort shouldn’t be spent on improving things.

I think his point is valid even if slightly tongue in cheek. Bugs are defined by the contract / specification not wishful thinking.


As engineers, we should be capable of critical thinking beyond this. Otherwise you will end up with barely working enterprise software like the sibling comment mentioned.

If this is how you do your work, you are asking for a computer to replace you! We should aspire to be better than this. It’s crazy to me that people take massive salaries and expect other people to spoon feed their tasks to them.


>It’s crazy to me that people take massive salaries and expect other people to spoon feed their tasks to them.

I remember being one of two software engineers working on a project in a small company. We were cooperating with two bigger companies delivering other parts of the system.

I kept asking myself: "These guys have hundreds of well paid engineers working on this, and they never deliver! Are they sabotaging the project?".

In the end we delivered the finished prototype and the customer ran a 48h acceptance test, their parts fizzled out almost immediately, while ours just kept running.

It took me a few years and joining a bigger company to notice that thinking about what you're doing is not expected in bigger corporations, where everything is bureaucratized. Also if the company is big enough it can shift the deadlines set by its customers. It drove me near madness whenever a colleague said something "wasn't their job". I never heard such an attitude before, and these people were paid twice as much as in my previous company.

I still, to this day, hope that a lot of these people get replaced by computers. Defective programs can at least be improved.


Agree with this sentiment. I had a short stint at a large tech company and the sort of attitude you describe is what turned me off from working there longer.

“Not my job” people tend to be the most frustrating folks to work with. While I understand wanting to mainly do the work you were hired for, there are always going to be times where you have to roll up your sleeves and do something different.

I wish I was the kind of person who would thrive in that environment because it sounds nice, but I’m not wired that way.


I'm a large tech company employee, and I use "Not my job" sometimes because it means that I believe that the issue could be handled more effectively by someone else, or that I should not be the one to address the issue for political reasons. So it's less of a "not my job" and leave it at that, but more like "not my job, but Jane over on that other team is closer to that code and has a better understanding of what's going on."


I was speaking more of different departments giving the run around pointing to each other in a circle and saying "not my job". The problem I see here as that there are many things that need to be done and rarely is the coverage of "job descriptions" 100%.


Rant: I've worked with a few where a simple "not my job" is actually an improvement. I've had "not my job, you figure it out" when asking for assistance from others, which is weirdly worse.

"When I pass X in, I'm not getting anything back - no error, I can't see the logs. Can you assist?"

"Not my job - you figure it out. We can't be expected to know how everything works."

"Yes but... this is literally something your team wrote in the last 3 weeks to support the larger project we're all on, and I was told to make my code work with yours. Have you ever tested taking input? If so, what am I supposed to pass in to your system?"

"Not my job - we're not here to babysit you - go read the code."

Later... someone else...

"Why are you spending all the time trying to trace through this code? You just have to ask. You have to learn to talk to people."

I sent over screenshots of the previous 'conversation' and got nothing back.

Thankfully this doesn't happen very often, but I'd realized that this org had segmented some teams so poorly, and put such deadlines on, that the culture of half the teams was "do minimal work and throw it over to someone else". It was more confusing because I interacted between a few internal teams, and some teams literally did not believe other people behaved this way. Even with written evidence, the problem was always assumed to be "oh, you're just not asking the correct way".


It seems to be magnified on the front end. The name of the game is “easier”, everything else be damned. Try take someone’s pet framework away (or jQuery).


> It’s crazy to me that people take massive salaries and expect other people to spoon feed their tasks to them.

I’m not sure where in my comment you got the idea that I was advocating for engineers to have their tasks spoon fed to them by other people. My comment is simply about reading comprehension and not arguing against strawmen.


This is how enterprise software is written. Performs exactly as specified, and completely unusable.


It’s also how great software is written. Assuming a subcomponent behaves in a way that it does not claim to isn’t a recipe for functional software, enterprise or otherwise.


It wasn't categorized as a bug, but as a speed issue.

Do you categorize thinking that JSON parsing takes at most O(n log n) time as wishful thinking as well?

It's humanly impossible to code for contract, that's why generally it's important to strive for no surprises in an API. This is the only way to build more and more complex software over time.


I fear that the individual might even be serious...


It seemed pretty clear that it's a joke to me, with the thinky-face emoji after it, but that's just my reading of it.


Last I checked that wasn't an indicator for sarcasm, but maybe GitHub emoji culture has just gotten away from me.


I don't know, I might say it as a joke, therefore seeing it said I assume they say it in the same manner as I would with the sound of innocent wonder that can be used to denote sarcasm.

I think as a general rule it is good to assume the best, even though I myself might not always be able to do so because of various limitations of understanding.


Sarcasm in text can be hard to discern, but that looks like sarcasm to me.

Then again, 206 downvoters didn't seem to think so (unless their reason for downvoting was objection to such a low form of wit).


Sarcasm -- yes maybe, especially with the thoughtful smiley

Now when I think about it

> such a low form of wit

I think it's fun :-)


Excellence? This is NodeJs we’re talking about.


    const readFilePromise = (...args) => new Promise((resolve, reject) => readFile(...args, (err, result) => err ? reject(err) : resolve(result))) 
How can the native implementation be much more complex than that, beyond maybe some argument assertions? That will take almost the exact same amount of time as readFile, the additional time cost is a small constant value.


I believe the difference here is that these newly introduced promise wrappers allow you to pass an AbortSignal instances to them. Wrapping the old callback style methods would not allow you to "abort" in the middle of reading a file. You could respond to a passed in AbortSignal instance in the simple `readFilePromise` function you provided by simply rejecting early, but it wouldn't free up the underlying file descriptor until after the entire file had been read.


Actually I was completely wrong. They added support for consuming an AbortSignal in the old callback style methods as well. Now I'm at a loss as to why they even have two different implementations instead of just doing something like:

    fs.promises.readFile = util.promisify(fs.readFile);


I agree in principle, but for some reason, the underlying implementation for fs.promises is completely separate.

I think this is where fs.promises.readFile reads the actual file https://github.com/nodejs/node/blob/master/lib/internal/fs/p..., with many layers of indirection before that

fs.readFile is this one function: https://github.com/nodejs/node/blob/master/lib/fs.js#L327-L3...


I'm brand new to the code, but I don't think it is as simple as you state.

The 'one function' you linked to just passes a ReadFileContext into binding.open[1]. The ReadFileContext appears to have its own read that uses its own allocated Buffers[2] under some circumstances, but under others they are mysteriously already there[3]. Then binding.open refers to an internalBinding of fs[4], which I'm unsure of, is that js or C++ code? This[5] suggests C++ is involved, but there is also an open in lib/fs.js[6] with that signature, though it also just calls binding.open at the end with the same signature, so I think it's off into C++ by then. At that point, I lose the trail. Maybe C++ is even doing the allocating.

This is not easy code to grok in its full extent.

1 - https://github.com/nodejs/node/blob/master/lib/fs.js#L356

2 - https://github.com/nodejs/node/blob/master/lib/internal/fs/r...

3 - https://github.com/nodejs/node/blob/master/lib/internal/fs/r...

4 - https://github.com/nodejs/node/blob/master/lib/fs.js#L74

5 - https://github.com/nodejs/node/blob/3b2863db12818778671713b5...

6 - https://github.com/nodejs/node/blob/master/lib/fs.js#L472

edit: I was curious so I kept reading, It seems like open is probably C++ here[7], which makes an AsyncCall or SyncCall to "open", which are just wrappers around syscalls[8]. Still not sure about other places the buffer comes from, though.

7 - https://github.com/nodejs/node/blob/master/src/node_file.cc#...

8 - https://github.com/nodejs/node/blob/606df7c4e79324b9725bfcfe...


On a SSD reading files is fast. However read() is a blocking operation that could take forever - reading some /dev/ device that never returns any data, or a slower medium that takes hundreds of milliseconds per read. That’s a big no-no. So it gets dispatched to a dedicated blocking-io threadpool with limited workers which does the blocking operation without interrupting the event loop.

I’m guessing as disk speeds have increased the relative overhead of dispatching the task to a thread pool has also increased.

At least, that’s how I think it works based on some half-remembered blog post.

Edit: yep - http://docs.libuv.org/en/v1.x/design.html#file-i-o


I can fully believe that there's some cleverness going on with node file I/O - but there should be no meaningful difference in performance between returning the result via a Promise, versus a callback.


It depends - I would assume the callback pattern is much easier to JIT than the promise pattern and requires less objects to be allocated, but you’re right. That’s a huge difference.

You’d need to benchmark another non-io bound function to find how much overhead comes from promises themselves.


Not enough to add 40% overhead to a 1mb file read, I can tell you that much.


It depends. If disks got so fast that function call overhead starts to dominate, then you might see an effect like this. But I have to imagine reading from an SSD is still 1000x slower (at least) than a function invocation in Javascript.


I don't see that basically ever being the case.


The most recent comment is insightful:

"My guess is that the promise version is idling when IO capacity is available due to scheduling? Is it implemented in an event driven way internally, and if it is (for example using epoll) is the epoll event able to interrupt and cause the promise to continue to be evaluated or does it have to wait for the vm to get around to scheduling it again?"

It didn't occur to me that the async promise is rescheduled. That right there is a concern.

The OP also points out buffer concatenation, which is an odd choice. Reading the file into memory in one go should start with an stat and then a single buffer alloc. But that is probably naive.


Network filesystems, compressed filesystems, and of course the ever-dreaded on-access virus scanners on Windows that 'break' one open source project after another. Who knew that opening the same file 10 times is slow? Everybody. Everybody knew that.

Looking at you, svn team.


But these are both non-blocking APIs. readFileSync is the blocking version.


I believe orf is talking about the read(2) syscall, which is blocking.


Some side notes:

- The readFileSync in this benchmark has UTF-8 decoding (produces a string instead of a Buffer). That’s why it’s slower; not sure why it’s included.

- The first guess/suggestion,

> I suspect the cause is right here: [https://github.com/nodejs/node/blob/3b2863db12818778671713b5...]

> Instead of creating a new Buffer for each chunk, it could allocate a single Buffer and write to that buffer. I don't think Buffer.concat or temporary arrays are necessary.

is already what the code does. Multiple chunks and Buffer.concat are only involved if more data gets read than stat initially reports.


>The readFileSync in this benchmark has UTF-8 decoding

Do you have benchmark results to show this?


Well, did they deliver on their promise? I wonder why they have set out to make it slower!


  const readFileP = (...a) => new Promise((res,rej)=>readFile(...a, (e,r)=>e?rej(e):res(r)));
This is a one-line implementation and is probably way faster. I would totally just use this one-liner anyway.


Did you benchmark it? OP provided the test code. Show us the data.


I did just because you asked, and mine (readFileP) is faster, barely different than the callback version. Actually it's the fastest one which makes no sense, so I think this benchmark may have quite a bit of error.

Results:

  fs.readFileSync x 696 ops/sec ±1.96% (76 runs sampled)
  fs.readFile x 2,045 ops/sec ±6.43% (72 runs sampled)
  fs.promises.readFile x 435 ops/sec ±1.76% (78 runs sampled)
  util.promisify(fs.readFile) x 2,053 ops/sec ±6.58% (67 runs sampled)
  readFileP x 2,182 ops/sec ±4.00% (67 runs sampled)
  fs.promises.readFile CACHED readFile x 438 ops/sec ±1.56% (79 runs sampled)
  Fastest is readFileP,util.promisify(fs.readFile),fs.readFile
  Slowest is fs.promises.readFile,fs.promises.readFile CACHED readFile
index.js:

  const Benchmark=require("benchmark"),fs=require("fs"),path=require("path"),chalk=require("chalk"),util=require("util"),promisifed=util.promisify(fs.readFile),bigFilepath=path.resolve(__dirname,"./big.file"),suite=new Benchmark.Suite("fs"),readFileP=(...e)=>new Promise((i,r)=>fs.readFile(...e,(e,l)=>{e?r(e):i(l)})),readFileC=fs.promises.readFile;suite.add("fs.readFileSync",e=>{fs.readFileSync(bigFilepath,"utf8"),e.resolve()},{defer:!0}).add("fs.readFile",e=>{fs.readFile(bigFilepath,(i,r)=>{e.resolve()})},{defer:!0}).add("fs.promises.readFile",e=>{fs.promises.readFile(bigFilepath).then(()=>{e.resolve()})},{defer:!0}).add("util.promisify(fs.readFile)",e=>{promisifed(bigFilepath).then(()=>{e.resolve()})},{defer:!0}).add("readFileP",e=>{readFileP(bigFilepath).then(()=>{e.resolve()})},{defer:!0}).add("fs.promises.readFile CACHED readFile",e=>{readFileC(bigFilepath).then(()=>{e.resolve()})},{defer:!0}).on("cycle",function(e){console.log(String(e.target))}).on("complete",function(){console.log("Fastest is "+chalk.green(this.filter("fastest").map("name"))),console.log("Slowest is "+chalk.red(this.filter("slowest").map("name")))}).run({defer:!0});
This little bit is my "implementation":

  readFileP=(...e)=>new Promise((i,r)=>fs.readFile(...e,(e,l)=>{e?r(e):i(l)}))
Run:

  npm install --save benchmark chalk && node ./index.js


If you look near the end of the line for each result, that % value is the margin of error.

> readFileP x 2,182 ops/sec ±4.00% (67 runs sampled)

This means readFileP took between 2,094 and 2,270 ops/second.

> fs.readFile x 2,045 ops/sec ±6.43%

This means fs.readFile took between 1,913 and 2,176 ops/second.

So its possible it did run faster, but it's also possible it ran slower. You'd probably need to run it a lot more times to have an answer.


I agree, the error in their claimed deviation isn't accurate. You would need to run many trials across many devices at different times of the day for a practical benchmark; it could be a service itself.

I do think the data here is enough to know that a promise wrapper here is insignificant in the grand scheme of things. This fs.promises library must do way more under the hood.


Edit: see comment below, misunderstood the specific case.


This doesn't compare async to sync: in fact, `readFileSync` is also benchmarked here, and is slower than every async alternative. This compares `readFile` (async) with `promises.readFile` (also async).


Even in that case, you are doing more work but 40% is excessive.


Why doesn’t benchmark suite use promise/async and instead resorts to some defer.resolve()? It’s not even cps-styled next().


That doesn't matter. It's a constant across all tests and would likely not introduce much noise.


Welcome to JavaScript.


Why is this on the front page? What's the message here?




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: