55 comments

[ 2.8 ms ] story [ 132 ms ] thread
People who use hyperbole are literally worse than Hitler.
Literally?
It was obviously supposed to be a joke. Geez, HN has no sense of humor :-(
HN "has" a sense of humor, insofar as any group of people can be said to have one.

What HN doesn't seem very much to like is cheap humor. Reddit is that-away, if those are the lulz you're looking for.

I would love to see Slashdot "Funny" mods on HN.

For those who don't know how it works. On Slashdot, you give a reason why you upvote or downvote. Downvotes have labels like troll, redundant, offtopic where upvotes have interesting, informative, and funny. Users can then chose to give weights to these labels. For example, they may ignore "funny" upvotes if they don't want to see the jokes.

We had a similar task a year ago in a Python codebase and we ended up building a function in Rust for it. Rust's splitting iterators over `&str` return references to the same data as their own `&str`s, which is a huge performance win. We would filter the split strings and conditionally store them in a `HashMap<&str, &str>`, which we later drained, both automatically freeing memory behind the scenes as able. We also shaved off a lot of time in some cases by avoiding parsing and comparing strings in cases where we had fixed-length integers (e.g., UNIX timestamps in a known range).
Yes this is one aspect that surprised me about Python.

I expect a scripting language to be really good with strings and I/O. Python does a pretty good job at both. Strings are immutable, which IMO is a good choice and then I get this really disappointing surprise that slices don't share content. I understand their motivation though.

Memoryviews can substitute for immutable string slices at times, but since these are not strings, its annoying. One can get a string out of a memoryview but I believe Python copies at that point.

If you'd be sharing slices naively you might run into cases like

    a = "large" * 10000000
    self.b = a[1:10]
this tiny slice would then "magically" keep the entire huge large string alive. It would be easy to defacto leak memory with that.

Even if you are less naive and introduce some kind of heuristic to determine when to share and when to copy, you might still be leaking "lots" of memory if you have a lot of small slices (into small-ish strings), which might hurt on low speced/embedded systems with tiny memory available.

I would point out that by "it would be easy to leak memory with that", in reality almost every run time that has ever implemented this "optimization" to automatically apply has had to either pull it back out, or make it explicit whether or not you're taking a copy of the underlying string. I've seen it at least three times. The probability of a given program having this problem is lowish (not that low, but reasonably low), but the probability of one of a language community's flagship projects having this problem shoots to 100% almost instantly for any non-trivial language or community.
I like it how Guile does it. There it is very explicit whether you are getting a copy or a view.
> Rust's splitting iterators over `&str` return references to the same data as their own `&str`s, which is a huge performance win.

Seems similar to Swift's Substrings (and other collection "slices"):

https://developer.apple.com/documentation/swift/substring

Similar, except for this key difference:

> A substring holds a reference to the entire storage of the string it comes from, not just to the portion it presents, even when there is no other reference to the original string.

Rust's `&str` doesn't do that, since the borrow checker will ensure at compile time that the `&str` reference doesn't live longer than the data it points to. At runtime there is no structural difference between an "original" `&str` and a `&str` pointing to a substring of the original.

Zero-copy substring classes are hugely useful for parsing. I first encountered this approach in the Zeus Web Server back in the early 2000s, in non-STL C++.
Heck, even in Java, String.split returns shared references (with different offsets) to the same backing byte array.

Mind you, that can bite you pretty hard if you're taking in giant documents, splitting down to a few tiny tokens, and then holding those tiny tokens in a long-lived in-memory cache (ugh, voice of experience here) but most of the time that's the Right Thing.

I'm not in a scenario with any of my .NET code where this level of optimization is actually useful/valuable, but this was a pretty interesting read. It made me ponder a bit about how I handle strings in my code. I might try a couple of things for fun.
It's sometimes amazing what low-hanging fruit can be kicking around that you didn't realize until you actually strap up a profiler to something.

The other day I was trying to figure out why one web request in an application was taking an absurd amount of time. I started looking at SQL queries and other external calls, only to realize that it turned out to be a logging statement that used a little innocent looking helper function to stringify a List<T>. Except it was using concatenation, with Aggregate e.g. Aggregate("", (a, b)=> a + b + ", ")... Replace that with a StringBuilder, and I went from seconds to milliseconds on that call. It's one of those things that you go "Eh, I'm just doing this on a few items, it'll be fine", and then month or years later, the wheels come off because something happens to try using it with tens of thousands of items.

I've written that logging code, and over the years I've used both the StringBuilder and Aggregate styles. I've discovered that string.Join(", ", list) is a great fit for this, too. It's as quick as using StringBuilder, and also concise and composable (tastes may vary).
Concatenating strings like this is known as "Shlemiel the Painter's Algorithm", since each concatenation starts back at the beginning of the string.

From https://www.joelonsoftware.com/2001/12/11/back-to-basics

> Shlemiel gets a job as a street painter, painting the dotted lines down the middle of the road. On the first day he takes a can of paint out to the road and finishes 300 yards of the road. “That’s pretty good!” says his boss, “you’re a fast worker!” and pays him a kopeck.

> The next day Shlemiel only gets 150 yards done. “Well, that’s not nearly as good as yesterday, but you’re still a fast worker. 150 yards is respectable,” and pays him a kopeck.

> The next day Shlemiel paints 30 yards of the road. “Only 30!” shouts his boss. “That’s unacceptable! On the first day you did ten times that much work! What’s going on?”

> “I can’t help it,” says Shlemiel. “Every day I get farther and farther away from the paint can!”

The more general problem is being "accidentally quadratic", e.g. assuming that N concatenations will be O(N), but it's actually O(N^2) because each must traverse the intermediate string, and (assuming each input string has O(1) length) that intermediate string will have length O(N). There's a Tumblr blog at https://accidentallyquadratic.tumblr.com which collects examples of this, but I can't seem to get past Tumblr's GDPR landing page to see it.

I'm currently in extreme optimization mode in C++, and even assembly & hardware some days. BUT -- the first rule of optimization is to only fix measurable, perceivable problems. This article was fun, but it didn't measure the right thing. The sum of all allocated memory is an almost useless metric, it doesn't reveal a problem to fix, and reducing it doesn't necessarily result in perceivable improvements.

I love Michael Abrash's introduction to the philosophy of optimization in the Black Book of Graphics:

"Before we can create high-performance code, we must understand what high performance is. The objective (not always attained) in creating high-performance software is to make the software able to carry out its appointed tasks so rapidly that it responds instantaneously, as far as the user is concerned. In other words, high-performance code should ideally run so fast that any further improvement in the code would be pointless.

Notice that the above definition most emphatically does not say anything about making the software as fast as possible. It also does not say anything about using assembly language, or an optimizing compiler, or, for that matter, a compiler at all. It also doesn’t say anything about how the code was designed and written. What it does say is that high-performance code shouldn’t get in the user’s way—and that’s all."

http://www.jagregory.com/abrash-black-book/#introduction

It seemed to me the author had a clear problem and objective. That the import process has to take place outside of business hours because of it's memory usage. So wasn't reducing memory exactly the goal here? And doesn't reducing it then make it possible to do the import during business hours? (Which is arguably, far more instantaneous to users than "thanks, we'll process this tonight when we have memory available".)
Make sure to look over the table at the end. They saved 2 seconds of run time. They didn’t reduce much memory, they reduced heap allocations. It’s the same as allocating and then releasing a 1MB array 1000 times and then worrying that you’ve used a gigabyte, even though the most you ever had allocated was 1MB.
I would argue reducing the time spent and memory used both by 25% is pretty significant, considering this is one of presumably a large number of jobs.

The comments on the article also state: "With regards to time taken and peak working set, that was not our main focus. Our main focus was to reduce the number of allocations and reduce the time spent in GC. Unfortunately this data import process is not a standalone one time application. It is a part of a much larger application which is core to our entire platform."

It's possible that this has no meaningful impact to their application, but it's far more likely that the blog author just hasn't provided you all of the information you need to understand why it's important. I feel reading charitably is the best case here.

25% is great! You're right, there might be more here, and it's always a good idea to be charitable in your reading, that's absolutely worth keeping in mind.

It's just that the headline sales pitch of the post is "from 7GB to 32KB". Unfortunately, that doesn't mean much and it's misleading. As is the title "Strings are evil". Those sentences combined with no obvious reason to ignore the primary optimization metrics and instead focus on something that doesn't affect either memory usage or run time very much, those things together make me feel like it's more likely the author either wanted a good blog post, or doesn't have a ton of experience with optimization. Those are both great, but that's no reason for me not to comment and try to add some clarity.

To me it seems a bit strange to say that time taken was not the main focus, and follow that with "reduce the time spent in GC." The singular primary reason to reduce allocations is to reduce run time of the program, there is no other reason here.

FWIW, it's highly likely that this program's run time could be further improved by more than 2x by allocating a single buffer the size of the file and doing a single fread() call, then processing the entire file in memory. That would increase the working set from 16MB to 300MB, or whatever the file size is, but would allow much faster processing than line buffered input.

Call me crazy, but for something as simple as taking a CSV file and shoving its data into a database, why wouldn't you just use a combination of shell tools? Don't get me wrong, I'm a Java programmer, but sometimes you don't need the overhead of the JVM, and something like awk would save you lots of time and energy (and memory).
Absolutely - use the simplest available approach first.
(comment deleted)
> Codeweavers is a financial services software company

To avoid the confusion, this is a different company from Codeweavers which are the primary Wine developers.

seems like a lot of work and a lot more code, and a lot more complexity to shave 2 seconds off the runtime. I pay my devs a lot of money and I pay Azure very little money. I personally wouldn't want to optimize for the later at the expense of the former..
My thought too, but he did mention that it was running in off hours due to memory constraints. With these optimizations, maybe they can improve their business process to offer this functionality in realtime for the customer. If not, I agree that the extra complexity and maintainability might not be worth it.
I don't know about the rest but 2 seconds doesn't seem negligible to me. And while renting more servers might solve your throughput problem, you also need to consider latency.

Slow code is a plague. Cloud architectures make it easy to buy more power. And if you ignore small but real performance improvements because the cloud is cheaper than devs, you will end up with bigger and bigger bills, and soon enough, it won't be cheap anymore. Even worse, your tasks may end up being too big. And if you don't think about optimization, the answer is to split the big task into smaller tasks. It goes well with the cloud model, it is scalable, etc... very nice. But now, you are introducing possibly more complexity than old fashioned optimization, and your code new requires even more resources for all that I/O. That's how companies end up paying thousands to cloud providers for something that could fit in a single server.

For such a long deep dive, I'm surprised it made a big deal about memory while overlooking the obvious. Almost no memory was actually saved. The peak working set was 16Mb at version 1, and it ended at 12Mb. The temporary strings that are allocated are all immediately released. The timing went from 8.7 seconds to 6.7 seconds. So all that effort bought a total of a 25% improvement in memory usage and CPU time.

Great - 25% is nothing to sneeze at. Just noting that memory wasn't the problem.

And of course there's a hidden development cost to doing fancy indexing on strings rather than being comfortable with String.Split() making your parser go 25% slower. The code at the start is more functional, it's easier to look at and know it will do what's expected. The optimized code took longer to write, and it's harder to understand, and harder to modify.

Yeah, you could easily argue (as I've had many, many bosses do) that the final version is objectively worse as it's doubtful that it will save as much time as it cost to develop (and debug - I doubt he got it right on the first pass!) and will drive in ongoing maintenance: the first version is simple and easy to understand (but inefficient) while the final version is almost impenetrable unless you read through the whole blog post, which is probably not part of source code control.
The perfect is the enemy of the good. For a few hundred dollars I can buy more memory etc. that I can use over and over again. The time that is sunk into building it to make it more efficient is gone forever.

Admittedly, I liked the write up. It's a fun exercise to go over, but I would ask my devs to go with the original program.

In a similar strain.

Premature optimization is the root of all evil. - Knuth

Here is the larger quote:

“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.”

You can't buy memory on your user's devices.
Explaining the circumstances behind this particular optimization target would certainly have been more helpful; as a standalone exercise, reducing allocations does look rather useless.

But the author presumably has some reason for focusing on allocations (and GC generations, though that never really comes up). Perhaps the subject code is causing excessive GC pauses in the larger program it is part of? If they're doing "real-time calculations" as part of financial services, I can see where adding latency for several seconds could be a bad thing.

I agree, I totally buy that it's possible there's an unstated reason to worry about the sum of allocations over time. Though, in an article of this length and depth, not spending a sentence or two and stating that reason seems like an obvious omission. And a reason is given, so I don't know why there'd be another separate hidden reason:

"Currently this import process has to take place outside of business hours because of the impact it has on memory usage."

That statement is pretty confusing to me; if the high watermark is 16MB, there's no memory usage problem to begin with. And if saving 25% CPU time is enough to go from after hours to during business hours, it won't take much to push the jobs back to after hours, right?

The hyperbole in the title & headlines combined with the vague issue of memory usage also leads me to believe this might be more about exposure for the blog post than fixing a problem. That's perfectly fine, but it's also okay to discuss and question what we're presented with, right?

It all feels to me like optimizing to the wrong metric. If you really want a parser to go fast, then eliminate line buffered input and block read the thing. Then eliminate all other memory allocations and process the file in memory. That will increase the memory usage in this case substantially, but it will be much faster than the improved version v11 they ended up with.

> The timing went from 8.7 seconds to 6.7 seconds. So all that effort bought a total of a 25% improvement in memory usage and CPU time.

AFAIK .NET has a concurrent garbage collector. For all we know there was a 2nd core running 100% to collect strings that is now almost entirely idle. It's possible the effort more than doubled overall throughput if you consider the ability to run multiple jobs.

That said, ad hoc parsing is a fundamentally messy approach in every language, not just languages like C with poor string handling.[1] Parser generators exist for a good reason--easier to read and great performance without even trying. Find a good one for your language of choice. The learning curve is steep but you'll never look back.

[1] Actually, I find C one of the best languages for parsing as you're much less likely to conflate string manipulation with parsing. Though that realization comes more quickly for some than for others.

Why are you parsing strings anyway instead of using an efficient binary format with introspection, say, something as simple as Google Protobuf?

If the GC is the problem, you can pool the message instances.

About the only place where you parse strings is if you're dealing with human input or something almost unstructured like aforementioned CSV.

The article's comments point out heavily that they missed lazier generators/iterables (LINQ) as the obvious place to start and a key to both readability and minimizing memory footprint. Even if they preferred yield return style generators to LINQ from/where/select, and even if the code was otherwise the same within the scope generators, fewer of the allocations would leave the GC "nursery", and thus overall less memory needs (if more likelihood of GC Gen 0 collections thrashing some of their CPU, but this isn't a CPU-bound process so that's not a huge concern).

Sometimes the simpler, easier to read solution is the right one.

That said, the complex, advanced new solution to try that is also missing in their efforts in this article is Span<T>, and its ability to immutably splice strings in stack space (no GC allocations at all). (This is what parsers in the ASP.NET HTTP stack have been moving to.)

This article is a good analysis! A few things I noticed while reading that may be useful (mostly really basic stuff, since obviously I'm not in the profiler working with the code).

Easy Win #1 could have been passing 2 as the second argument to the first call to String.Split, which would have sidestepped the "2 calls to split" problem.

It's worth pointing out that Easy Win #2 has a bug, because you said you wanted "MNO" lines, but now you're also going to pull in "MNOX" lines. You should probably use StartsWith("MNO,"), since this would have behaved the same.

In TCL, everything is a sting, and its... certainly useful.

https://wiki.tcl.tk/47683

It isn't, really. Everything has a transform to a string, and in a pinch, everything can be converted to a string, have string operations performed on it, and converted back to the target type transparently, but under the hood, TCL uses numbers and such whenever possible. If you add ten number together, at most, it'll convert ten strings to numbers; it doesn't convert the first two strings to a number, add them, convert that back to a string, the convert the partial total back to a number and the third number to a number to add them, etc. The language implements a string-based interface to everything, but the runtime is not literally dealing in nothing but strings.
Yes, an important distinction, called shimmering, in case anyone was wondering.

Regardless of being a seemingly double-edged sword, I feel like that structure is fascinatingly useful. I've yet to utilize the C api or delve into the intricacies of tcl_obj, so I still feel one edge is sharper than the other.

I mean, look at the diagram of how type inference may work in the tclQuadCode wiki and try not to say 'whoa'

https://wiki.tcl.tk/3033 - shimmering

https://wiki.tcl.tk/40985 - tclQuadCode

https://wiki.tcl.tk/47683 - type diagram

I agree with the comments here - I don't think the final solution should go into production as it is so complex. And the optimisation for total allocations seems odd.

The V02 with VERY simple optimisation is only 2% slower than the final version - and infinitely more readable.

Nice article nevertheless.

This is a great article. I did something very similar in C#, for reading in GB of data in a daily process. It is very fun to use the profiler and continually make optimizations. Two of my biggest wins were: 1) writing a custom DateTime parser and 2) using a integer dictionary instead of Convert.ToInt32().
(comment deleted)
I am probably missing something here. Isn't the article just rediscovering what a scanner is? Wasn't this resolved eons ago by scanner generators like flex?
If you are really after performance writing parsers in .NET while keeping the code structure manageable you should look into the new Span<T> type.