My Node.js is a bit Rusty
gal.hagever.com
gal.hagever.com
That’s about 700kb/s.
I’m all for playing with rust, but this post strikes me as engineers being distracted by shiny new things because they are only able to parse a TSV at 700kb/s?
You should be able to understand why your current system performs so absolutely terribly before you decide to rewrite it in any language.
Hand-waving at the garbage collector whilst seemingly doing very little to understand what’s happening doesn’t convince me that there isn’t another, possibly pretty obvious issue going on.
We have had very similar issues with Java - excessive heap allocations can bring the system to a crawl and there is often no easy way out.
This channel is a real gem, show what talent really is: https://www.youtube.com/watch?v=a1L7k35EHIc
Most of your application probably doesn't need it and the tight loop might not be big enough to be worth using JNI to write in a non-GC language (as well as maintaining another compiler stack for)
Ideally the runtime should allow you to manually manage memory when you need to, like some keyword that lets you manually keep track of an object lifetime. But as far as I know most GC languages don't have that
Anyway, I'm not sure if this is in Java already or is upcoming, but Java will finally add value objects, that is, stack variables instead of just making everything a heap object. For tight loops that will be much more efficient, since it can just reuse the same memory, stay within the cpu registers, etc. (I'm talking out of my ass btw from a superficial knowledge of Go and Java and half-remembered theory from college).
$ pv lol.tsv | node lol.js
1.71GiB 0:00:07 [ 225MiB/s] [================================>] 100%
Finished reading the file.
Do we live in a world where engineers using a mature language like NodeJS with a pretty simple line-by-line file reading process and a string split think they hit a hard wall at 700kb/s, and the problem is not their bad code?1. https://gist.github.com/orf/92bc26381e3dc00c8e96af262768591b
The point of the article is that you can write really boring normal code in NodeJS and it's really slow, or you can write really boring normal code in Rust and it's a lot faster, and it's not that hard to use the latter to replace hot paths in the former.
I know basically nothing about Node though, so I could be completely wrong here.
If they are seeing any GC activity once the loop gets hot, something else is going very wrong.
1. It should never be that stupidly slow
2. That should be obvious to anyone with a napkin and some math
3. Excessive allocations are highly unlikely to be the root cause of the slowness
4. Instead of debugging, they seemingly spent a lot of time putting it on Kubernetes, scaling it out to 25 instances and then finally rewriting a portion of it in Rust.
The whole thing smells of confusion and multiple issues being conflated together, with the rewrite in rust fixing these by virtue of deletion.
And if the root is was the GC, then some `--trace-gc` output in the post would be nice at the absolute minimum. Showing a for loop with a string split doesn't cut it.
And I can definitely say: IO speed is pretty good. To be honest, I've never come across a language with bad IO speed and I've seen quite a lot of languages.
I also did some benchmarks (like orf did) and speed is pretty good. Maybe Rust is a little bit better because it's nearer to syscalls than Node.js is but we're not talking about 760kb/s vs. 250mb/s.
200GB of logs and 25 instances means 8GB per instance. Not sure what they're doing, but processing of 8GB taking 3h is like doing it with my 80286 processor ;-) Even with inserting stuff in MongoDB this seems way to slow. And they only replaced the file IO part with Rust...
I guess the records array is getting pretty huge which might result in swapping to disk. But then: don't let it happen, just flush it to the database or whatever. I believe the problem could have been solved with Node.js without issues.
With the limited information provided in the article, there are other points that I would check before going as far as writing a module in another language. For example
> records.push({
They are collecting all the parsed rows in a single array. This is going to consume memory proportional to the size of the file, potentially in the same order of magnitude (depends on how much information is being collected and how much discarded).
In my experience, a common mistake in handling files too large for memory is to read the data faster than you process it, consequently running out of memory.
For example, maybe they have a loop reading lines and doing a bunch of async DB writes: your process will read at disk speed and write at database speed (relatively slower), and you'll run out of memory due to async tasks/context queuing up in memory.
Perhaps this is why it worked to split the file into 8GB units X 25 servers: it didn't blow their memory budget per server.
With my first try with bigger data it blew up with an out of heap memory error. The article mentions them having memory problems as well. They probably operated at the upper levels of available memory and even got into swapping and then 700kb/s is not surprising.
So yes, this absolutely looks like a memory allocation issue.
Try modifying it to clear the array (a DB flush, if you will) every 100k lines.
Now we don't have any Rust code to compare so we can't say how it's different. But everything points towards a memory allocation issue that is just not their in simple Rust code.
* "unbounded array" - are you referring to `records`?
* If so, is the memory of `records` too large? Or is it that `records[i].pathname` has a reference to `fields[7]`, AND v8 will retain all copies of `fields` until the loop is done?
Do you think this code would be more GC-friendly?
```
let count = 0;
const MAX = 1000;
const flushedRecords = [];
for await (const line of readlineStream) {
const fields = line.split('\t');
const httpStatus = Number(fields[5]);
if (httpStatus < 200 || httpStatus > 299) continue;
records.push({
pathname: fields[7],
referrer: fields[8],
// ...
});
if(count == MAX || isLastLine(line)){
flushedRecords.push([...records]);
records = [];
count = 0;
} else {
count++;
}
}```
Or were you thinking something totally different?
1. It parses 200gb at an average rate of 700kb/s
2. The job splits strings into a format that is loaded into mongodb
3. There where OOM errors that went away with a smaller workload
None of this points to an inherent issue with NodeJS and the way it references memory.
The speed issue alone points to a different problem root cause, and the post is completely lacking in concrete analysis.
If v8 is being dumb and retains references to huge strings, then understanding this is the case and forcing a copy (and thus enabling the gc to reclaim data) is quicker and easier than any of their attempts at fixing the issue.
Splitting a small TSV file and loading it into mongo can be done with even the most memory inefficient language without issue.
I do agree here, but I think it's telling that the rust solution is, without analysis, just better.
Is this the best developer or best case study? Perhaps not, but it is interesting in that little analysis was needed.
In the case where you have some constraint placed on you (running in the browser, company policy forbids rust e.g.), the analysis is worthwhile. Otherwise, assuming the fix didn't take long (relative to the requisite analysis and dev training), the decision to switch to a less shoot-yourself-in-the-foot language seems reasonable to me.
BUT, what I am interested in - if I'm using javascript to parse a large file line by line, plucking off specific fields like the example does, what can I try to see if it's more GC-friendly? Obviously the answer is always some flavor of "profile it" and "it depends". If the blog author could have gotten a big boost in performance by adding 10 lines of js code to their loop, that is something all readers of the article could benefit from.
I will play around with it and see what I can learn about the V8 GC. For reference: here is a talk about profiling the ruby gc, which helped me fix a memory leak at work (https://www.youtube.com/watch?v=kZcqyuPeDao).
* How long does it take to ramp up on the new code base (author notwithstanding as a user of Rust outside of the company)?
* If the author of the code is sick and there's a bug, or logic needs to be added, how hard would it be for someone else to do this work?
* What is the build story for the Rust code like? How does it integrate with existing CI?
* How do you store the Rust library? Some artifact store that Node can interact with?
Without more details, unless the company was already writing Rust, this doesn't seem like a reasonable choice at all. Doing some profiling and experimenting with allocation, along with comments as to why you're doing something that's not naive (e.g. appending to a large array) seems much cheaper than doing all this Rust work.
I've been in situations at $WORK where we were locked into using Python for certain code. Rewriting portions of it in Rust (if it needed running in the runtime) or Java (if it was across a service boundary) would have been enjoyable. But answering all those questions made us stick to Python. And surely, in most of those instances, we never really came back to those hot loops again so it never needed subsequent work. There were a few cases where we did find ourselves constantly trying to eke out more performance and in most of those cases a rewrite did end up going through, but those are rare and part of the craft of software engineering is to identify these areas and get ahead of rewrites early enough to not waste cycles.
IMO that's not an inappropriate response here. You can make this code in Node, but it's super-awkward trying to reduce string allocations in JavaScript. Much easier to rewrite in Rust where such things will be fast even with the naive code. For code such as this you can likely do a 1:1 port of the Node code and it will be 10x faster. Which is much better than getting yourself in twists trying to make Node fast at tasks it's not good at.
Does this really, really not scream “there is another issue” to you?
Do you really think, even with string allocations in a hot loop, NodeJS with its very optimised runtime would struggle to hit 1mb/s?
Does node not similarly allocate on the stack in hot loops?
What field do you have in mind? If it's typically well optimized then fair enough but my bread and butter is compilers which are a pretty typical approximately zero compute, branchy pointer chasing type of workload.
a) You don't care about pause times since latency isn't your key metric
b) You can almost always just throw more money at compute power to increase throughput
It's almost always the case in such a system that your bottleneck is on the databases that you're pushing data through, as your main approach of "scale horizontally" typically leads to those sorts of bottlenecks.
Databases do typically have exactly the same memory problems though, again this time I mean memory locality rather than allocating it, obviously with the transactional elements on top.
They definitely just wanted to A.) Play with Rust, B.) Write a post about it.
This is the key line:
> As an intermediate Rustacean (I do have a fairly successful open-source project written in Rust, called fnm),
It's fine to say "I rewrote this code in Rust because I like Rust". We don't have to do a whole song and dance about why X needed a rewrite in Rust because X wasn't doing a good enough job. People do exactly this work in Node every day successfully. They just wanted to write Rust instead of optimizing some Javascript. _Which is fine_, just totally not newsworthy.
for await ...
will will process the whole file, then give you all lines at once.So the problem is probably that all lines in all files are parsed at the time creating a bottleneck somewhere and ballooning memory usage. The solution is to use streams, pause the stream in order to parse the line, then continue the stream. And pipe the parsed result to stdout or a tcp stream. That will use minimal memory, and if the GC doesn't do it's job Node.js now supports manual GC. So you could probably parse the whole thing using only a few kilobytes of memory.
async function* infinite() {
while (1) {
yield 'example'
}
}
(async function () {
for await (const value of infinite()) {
console.log(value)
}
})()
If the generator was consumed all at once, this would never print (because the generator is infinite). `for await... of` should only consume a single step of the stream / generator at a time. It's just syntactic sugar for the usual calls to .next() and etc. See the docs here[0][0] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[1] https://nodejs.org/api/readline.html#rlsymbolasynciterator
700kb/s smell super fishy.
I can parse that with one Python VM and a few MB of RAM way faster. And node has a JIT.
Besides, let's imagine it would be effectively the case that Python and Node are too slow for this, that would be the perfect case to delegate the task to infra instead of introducing a whole new language and ecosystem plus a rewrite. E.G: a perfect use case for DuckDB.
That's a couple of orders of magnitude slower than a WiFi transfer. 1000x slower than SSD read speeds. TSV is possible to parse at RAM speeds — 5000x faster.
I suspect that widespread use of scripting languages, and hosting on micro instances containerized like a turducken, with disks over network, set people's expectations pretty low. You don't need a cluster of 25 machines for simple tabulation of 200GB of data.
This is pure voodoo profiling. “The job is slow. I guess it’s. . . . here!”
Although the author has my sympathy - I spent a month rewriting a module, optimizing every line of code, squeezing every last bit of performance out to discover that the slowness was entirely due to a downstream SQL statement to which I had made a minor “innocuous” change.
Like, if you show me code I can probably tell you where the expensive bits are just by looking. I could be wrong, but it's usually not very hard. Complex runtimes make it a lot harder because then I have to reason through things like JIT and any implicit yielding, but even still.
So I'm pretty sympathetic to someone looking at a fairly small section of code, going "oh yeah, that's gonna create a lot of allocations", and then doing work to avoid those allocations. The alternative is that they write benchmarks and start profiling, which is actually quite hard to do well, and takes time. I'm not convinced that the author would have been better served by doing so here.
Maybe your language that requires contortions is the problem.
Because of this, I’ve just learned to never trust my intuition when it comes to this. Compilers are crazy these days, and it’s very difficult to tell if something will be slow or if the compiler is going to clean it up behind the scenes and make it a non issue. Add to that JIT for certain languages and I don’t even know how you would begin to intuit where a hotspot would be.
Edit: And just for additional context, I’ve used Intel’s VTune profiler, Tracy, Optick, and written a few manual benchmarks. Using these tools isn’t always easy, but they give a lot of insights that I wouldn’t pick up on my own.
> But the one thing I’ve learned is I’m always very surprised by where the hotspots in my code come from.
Are you looking at small or large codebases? The code in the article is quite tiny. The major complexity in terms of understanding that code's performance is really just V8 specific stuff. As someone who only reads casually about V8 I'd be much less comfortable guessing about performance.
To be clear, there's plenty of times where I can't guess. Certain languages make it really hard, like if there's a lot of inheritance I can't "glance" at the code at all, I have to dig all around the hierarchies. Extremely annoying, I'll 100% have to benchmark in that sort of language.
> Compilers are crazy these days, and it’s very difficult to tell if something will be slow or if the compiler is going to clean it up behind the scenes and make it a non issue.
For sure, like I said, the big question here is V8. If this were in a system I'm more familiar with I'd have an easier time telling you which optimizations would or would not work. But even still it's not too hard to guess, sometimes. Knowledge of your runtime is the big thing, I think. For example, reusing an allocation might seem like a trivial optimization but allocations can actually have effects (like failing) so an optimization that removes one would be somewhat aggressive - the situations where that optimization will kick in is going to depend on your compiler/JIT.
As an example, it's really easy to find documentation about how escape analysis in the JVM works. There are a few rules and you can sometimes look at code and evaluate those rules in your head. You could use those similar rules for C# or Go or JS but you'd be risking that the rules are different in those languages.
Example of an article that goes into some depth on JVM escape analysis and the implications: https://blogs.oracle.com/javamagazine/post/escape-analysis-i...
> Because of this, I’ve just learned to never trust my intuition when it comes to this.
That's totally fine fwiw. I'm definitely pro "just benchmark it", but I will maintain that:
a) Benchmarking and profiling are hard. Getting a representative benchmark of your system under load is particularly difficult and a GC can have wildly different performance characteristics under load.
b) It's often not that hard to guess. The more complex the runtime, the more complex the system, the harder it is to guess.
> and I don’t even know how you would begin to intuit where a hotspot would be.
The short answer is you just learn about how a JIT works just like you learn about how a compiler works.
Just to reiterate, I'm very much pro "measure before optimizing". Like, very pro. But also, the article shows that guessing definitely works. And I think guessing is super effective and a fine tool to use at times. Perhaps I've overstated my ability to "just look at code and see the slow bits" - it's obviously very much dependent on the code itself, my familiarity with the underlying components such as the runtime or compiler, and perhaps a bit of luck.
I’ve still got a long way to go in learning more about profiling, especially when it comes to JITed stuff or things with a heavy runtime. But it’s always nice to hear about how other developers approach profiling (it can feel like black magic to me a lot of the time).
Thanks for the article on escape analysis too! I’ll have to check that out in a bit :)
A silly example: it doesn't matter how much you optimize your bubble sort implementation, it'll lose out to the most naively written quicksort, which will lose out to keeping the data always sorted and inserting with binary search, which will lose out to finding out how to make the data inherently sorted, which will lose out to figuring out how to avoid the need for the data to be sorted in the first place. The fastest code is no code at all.
This is the case if the rewrite would take more time than the time it takes to debug and fix the issue. If you already have evidence that this is just an issue with the runtime (ie: "I have a very hard time controlling allocations in this language and allocations are a significant part of the compute time") I don't think it's the wrong call to just pick a language where you have very high confidence that it'll be "just fast".
I've run into this with Python. I just didn't care to figure out what was going on that was making things slow. Rewriting the logic in Rust was not hard, so I did it, and things were fast. I look back now, in this moment, and absolutely don't give a shit about why the Python code was slow or if I could have made it fast.
The thought process this blog seems to use is 'we have a lot of allocations and nodejs is garbage collected, therefore there is no alternative to using a different language'.
But anyway, you'd have to first stop it from being called at all, then call it manually after. This seems very hard, I don't know how much Node would like this, especially as you would have concurrent calls to the GC potentially. There's a very scary difference between "turn off, turn back on" and "turn back on, turn off", which can easily happen if you have multiple concurrent calls to the GC. Imagine if you threw an exception after turning the GC off and never turned it back on? Oof.
I'd frankly prefer someone just uses Rust than starts to fiddle with GC at runtime in such a way.
> Also, wouldn't it be better to have the fields (and httpstatus) variable be declared above the for loop and just replace the contents of the fields variable instead of allocating a new one each time?
Maybe. It's possible that V8 will do that. But also you're adding extra lines to manage your buffer if you want to take control over that allocation. It's probably what I would do tbh, although the post makes it clear that just using Rust, where such management is trivial, is lower cost than expected thanks to the FFI library used.
> The thought process this blog seems to use is 'we have a lot of allocations and nodejs is garbage collected, therefore there is no alternative to using a different language'.
I think the main issue I have with the post is this line:
"Perhaps it's time to consider that JavaScript may be the problem."
Javascript isn't so much "the problem" as it is simply a tool that makes optimizing around allocations difficult. It's very hard to know when you're properly managing your memory or not, and you're subject to the whims of V8 to optimize things for you.
If the author had phrased this instead as "I'm sure there's some way that I could get V8 to optimize the allocations, or avoid them altogether, I'd have faster code - but the cost of doing that is far higher than simply using a language where these problems don't exist" I would have no real objections.
Chances are insanely low that the GC is actually your problem.
If you’re working on something and it’s slow, and during some iteration cycle you find that changing some lines makes it fast, cool. No need to debug it or spend time digging into it.
And it’s good that it’s faster, the post screams “we don’t know how to debug these issues” rather than “we don’t care why it’s slow”.
For you perhaps but it’s a pretty massive change in the longer term, though. You now have Rust in your codebase. How many engineers on the team will be able to work on it? If you leave tomorrow will the code become a liability?
With the numbers the author is providing it sounds very much like the problem could be solved in Node, even if it means using an unconventional programming style and there would be a smaller maintenance overhead than reaching for Rust.
In my experience, basically all of them. Rust isn't that hard to learn on the job, it's maybe hard for some people to learn in isolation.
Obviously adding a new language requires understanding that there's going to need to be build system support, dev support, etc. I guess I assumed that was there? Unless Wiz is still in the 'wild west' phase where you can just do whatever you want.
I've learned Go in a day. Did the Go tour, and immediately felt comfortable writing it.
I've tried learning Rust multiple times, did tours, tutorials, even started reading the book - still don't feel comfortable writing it.
Rust is not a simple language. I say that, and my first language was C++.
Not guaranteed. Maybe it was that one guy who decided to introduce Rust to the codebase, then quit.
But I agree, generally, learning anything is easier when you have someone to ask.
I learn new languages all the time. I make it a personal goal to never let not knowing a language get in the way of what I do.
Rust was one of the easiest I've ever picked up. There is a ton of good information, examples, books, and tooling out there that helps me.
On the other hand I've had to pick up Go several times in the past. It's never stuck. Basically every project I have seen written in it is convoluted and just messed up to read and interact with.
People ALWAYS overstate the effort that Rust takes. There are a lot of spots that you can get messed up on. But not all of us are out here writing code that hits these things. People act like the existence of lifetimes means you have to write them everywhere and think about it. That's not the case for almost any of us.
I have to disagree with that. I've learned Rust but it was one of the most challenging languages I've picked up. Stuff like lifetimes are very difficult to learn on the job without already doing a lot of reading.
In that scenario I have never seen onboarding to the language be a problem.
> Just to clarify, you were learning Rust while employed
they did not say that, but you had to frame it that way to, again, claim that Rust is easy, like who cares.
I was responding to this:
> You now have Rust in your codebase. How many engineers on the team will be able to work on it? If you leave tomorrow will the code become a liability?
It's obviously relevant to that question.
> they did not say that,
I said that in my experience learning Rust at companies is easier than learning it in isolation.
They said that they had trouble.
I asked for clarification if they had trouble while learning in a work environment.
You jumped in to say that a work environment isn't relevant.
> like who cares.
Every other person in the thread who is discussing exactly that you absolute dunce
Adding an entirely new language and toolchain to your development process reeks of over engineering. Now your project needs a Rust dev on hand, what if you leave? Will there definitely be someone to pick up the slack?
This is not some case of bad pick of algorithms. The entire optimization work will happen through microoptimizations spread through the code.
If you insist on doing the microoptimizations on a language that is actively antagonistic to them, you will only create a lot of useless work, and have only mildly useful results to show for it, that will go away as soon as anything on your ecosystem changes (in JS that is measured in what? minutes?). And also, you will end up with an horrible codebase that's probably much less legible than what a lower level language will give you.
And that besides not having any good tooling or community support, because all the tools and the community are focused on developer productivity, not software performance, as that's the entire focus of the language.
Could it be done in 1 millisecond with assembly? Probably.
But we are talking about over a days runtime here. That is not a micro optimisation, regardless of the language you’re using. You’re just using the language wrong, the same as if it took a day to parse with C.
Your attitude, that it’s pointless to even try and investigate performance issues because it’s written in JS rather than whatever language you see as optimal, is totally counterproductive.
I know you're probably just knowingly spewing hyperbole to make your point, but unless your L1 cache is 200GB memory bandwidth alone makes this impossible.
Otherwise, I still need to be convinced that the "line" array is causing trouble but not anything else -- the article definitely doesn't provide detail about how they found this problem. It is almost funny that the author tried to put this on Kubernetes instead of even attempting to optimize the JS code. I wish I had that luxury and tell my boss I should use more resources instead of fixing my code.
That project seems to be creating platform-specific DLL's, which I guess doesn't matter too much if it's wrapped up nicely in an npm package and you're deploying Docker files on Linux anyway? But conceptually, it seems like it would be more portable to generate a single WebAssembly binary instead of a bunch of DLL's.
Alternatively, writing very C-like code using a Uint8Array and not allocating in the loop might have fixed it too? But if you don't use JavaScript string libraries, it's not much like writing JavaScript anymore.
I've done a bit of data manipulation in node and while imo js is not the ideal language for the job, it can be very fast. Strings are hard though, I'll admit that
This is true, but N-API will generally perform better (sometimes significantly, usually in exactly the same CPU-bound scenarios where WASM will perform better than V8). And NAPI-RS is quite good at paving the happy path for multi-target packages.
If the database IO happens in Rust, the option with WebAssembly is fetch, meaning HTTP and the Atlas-only Data API. If the IO is done in JS, you pay some extra CPU time transferring UTF-16 strings over the border and copy them as UTF-8 in Rust.
I think I must be misunderstanding something, but isn’t that exactly what you should expect?
We use node.js to process solar plant data. Which is massive amounts of data as each individual solar panel will log information about its power generation and status every few seconds. We can then use this to figure out which solar panels have a birds nest on them, and so on. Node performs this task well enough, but it obviously can’t be aggregated. Even if you were to spread it out on 25 node instances “concurrently” with control shift you’d run into out-of-memory issues extremely fast. I guess you could up the upper limit on when node.js stops being cool with its own memory usage, but honestly we tend to simply avoid the use promise aggregation exactly because it tends to ”break” node.
I think moving to Rust was probably a good idea for this. Go might’ve been a better option if you were going for a “modern” concurrent language, but whatever works! We tend to use C ourselves when node needs a little boost. But often Node can handle these things on its own. With the solar data we use worker threads to collect and process the data in real time, which is really parallel-execution and not concurrency, but it works well and it doesn’t get you into performance issues.
Out of curiosity, how do you use Node for this purpose? Is it building a real time dashboard?
My company has possibly similar challenges. We have lots of sensors on equipment that streams information every few ms through a time series database from the vendor. It's used to build monitoring dashboards, trigger alarms and help engineers to prioritize and optimize equipment settings, and look for trends over time.
It's such high volumes that capturing it and analyzing can be difficult, so we use a combination of vendor systems, Azure Synapse and Power BI. There are still lots of opportunities to improve, and I'd be interested to hear how you landed on your setup and what you do with the data.
We use node to gather the data and transform it into consumptive formats that our 3rd party AI vendor can work with, as well as store it in a range of SQL databases for our own PowerBI consumption. We do no frontend work.
We don’t use node because it’s a good idea to use node for this. We use node because we try to use TypeScript as much as possible because we’re a small team and having a single language covering as much of our stack as possible makes it easier to work together.
Go doesn't embed in other runtimes as nicely as Rust, nor it cannot call foreign code (including even C) as efficiently as Rust does. Rust strength is that it needs virtually no runtime and there is no magic needed to cross the ffi boundary.
Aside from that it can be hard to hire people for Java around here. It’s not a language a lot of people want to work with, and it’s common enough that the ones who do already have jobs.
If only two async jobs already run OOM, your issue is probably bad code, not node.js. From the code snippet posted, it looks like they do a `records.push(...)`, whereas it probably would've been more efficient to just continue using streams downstream as well. Additionally, using a transform stream to split the string rather than doing it in a loop might've yielded better results because the previous row could be cleaned up. Obviously, this is just poking in the dark, but such stark difference in numbers in the Rust implementation leads me to believe that they changed some other fundamental part of the design (e.g. streaming the results to the database rather than inserting everything in one big batch at the end).
Another counterpoint: By switching to Rust, another language was added to the company's technology stack. This means that they now need to hire for Rust as well, if the author decides to leave. The simple graphics on https://boringtechnology.club/ are pretty interesting in that regard.
I don't know anything about this company, but the general advice I'd give is: be careful with adding languages, it adds an exponent to the complexity of your organization and hiring.
In my experience the language was never a bottleneck at onboarding new people to the project. It is always the complexity of the project itself, all the non-standard in-house built things that engineers invented over previous X years and often didn't even take time to document properly.
I'd personally prefer a boring / standard solution in a relatively modern tech stack I have to learn from scratch than a pile of cleverness in something I know well (let's say Java). In the former case I can learn it quite quickly from the publicly available materials. In the latter case I need to reverse-engineer all the cleverness.
The original code should consume not much more memory than the optimized one. Yes you're going to allocate a lot of more memory, but whatever doesn't finish in the record becomes garbage after every single line, so I can expect worse performance, but not using two order of magnitude more memory.
I would see more problematic the fact that you're collecting all the records in a single array. Depending on how you use them it can be beneficial to use a generator and consume them incrementally.
They are doing something very wrong.
They're doing something slightly wrong that's making a non-bottleneck part of their code less optimal than it should be.
That way they don't get gigabytes and gigabits abbreviated with the same letters and we don't have to rely on a capitalization convention
Join the enlightened gigaoctet convention
Also as others have mentioned the js code could have been optimized.
Id be very interested in a part two testing out some of the suggestions and sharing the benchmarks!
I am eager to explore the possibility of enhancing the line parsing process using Node.js. If you could provide a runnable example on a GitHub repository, it would enable me to compare its memory efficiency with the Rust rewrite version.
I have seen convoluted stuff done in JS when a few shell commands would have taken care of things, before.
I don't have enough experience with super-optimizing Javascript, but wouldn't this be an easy solution? Instead of creating new variables in each loop and let the engine clear them, reuse the variables to avoid using more memory:
let line, fields, httpStatus;
for await (line of readlineStream) {
fields = line.split('\t');
httpStatus = Number(fields[5]);
if (httpStatus < 200 || httpStatus > 299) continue;
await dbWrite({
pathname: fields[7],
referrer: fields[8],
// ...
});
}
Edit: and as others suggest, keep streaming those records, instead of just pushing them into an arrayYou should be saving the strings to the database so the GC has something to reclaim. It doesn't work when you keep fields 5, 7, 8 around in your records array.
Bulk insert should probably be used and bulk write should be called periodically. You should pause processing while it saves so you keep total active allocations nice and compact.
Otherwise the process will slow to a crawl when you get near your memory limit and all the time is spent in the GC trying to find ways to reclaim memory that you are still hanging onto.
You see massive swings in processing time by chunking the data because you are handing memory very poorly.
This should be bounded by database writes, not your language. Sigh.
Based on the descriptions, I wonder if the real problem was not Node.js but that all these solutions were implemented inside a constrained environment.
If this process was an on-demand pipeline they wouldn't have to deal with such a storm of data every day.
It would also mean people got stats 'closer-to-live' which feels like it would be useful.
The beauty of this is that it allows teams to isolate and optimize just the problematic portion of the code, preventing them from going down a rabbit hole of extensive changes that can be more harmful than beneficial.
NAPI is such a good tool for this; that I've been advocating the use of it to my team since forever.
Hard disagree here. Node is extremely fast to get up and running with, has as close to 0 barrier to entry as possible, and doesn't suffer from slow startup times that plague JVM languages. It can also be fast for tight loops.
But in the experience I've had with it"generally", it's orders of magnitude slower than other languages. A hello world with express.js and gin in go are a full order of magnitude apart, and the express app with default settings dies under a load test that I would describe as "very light".
Stories like this thread are common in the JS world where technically it can be rewritten to be faster, but like with Bun, esbuild, and many other examples we've seen here on HN, a better language choice makes an enormous difference. And to me, the only time node is the right tool for the job is if you have a team of JavaScript developers who can't learn another language
But let's face it: In most of the projects we work on, clients don't need to handle millions of requests from distributed systems and geolocated servers all over the world. Generally, our clients just want a CRUD that is a little more elegant than Excel and can help them solve their real-world problems.
IMO if someone from your team tells you that you cannot do something with an average of ~10,000 req/s [1] (around that number is the consensus nowadays for Javascript whereas Bun is highly superior to everyone here), you may be solving the problem in the wrong way.
[1] That's around 36 million requests per hour!
Java ecosystem now has AOT compilation.
Our golang app starts up, binds a port, signs the jwt and shuts down before our kotlin app even hits our code.
Then it would serve requests at the same speed, if not faster, as your Go app.
I’d push back just a little on that. If you’re on a small team writing a small app, having the frontend and backend both be in js/ts, and in the same repo, can be a big help.
Completely agree about the same repo though.
How could this be written in node to use streams more effectively (at whatever stage necessary, parsing, reading, etc ..) to limit memory usage?
If your constantly running out of memory you have a bad design.
This seems like a native code agenda type post which I have a problem with. I will not code in native code ever again. Its not worth it anymore, its a horrible practice and it just enables bad code to pretend its good.