Strings Are Evil
dev.to
dev.to
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.
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.
Premature optimization is the root of all evil. - Knuth
“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%.”
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.
"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.
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.
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.
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.)
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.
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.
Seems similar to Swift's Substrings (and other collection "slices"):
> 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.
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.
The V02 with VERY simple optimisation is only 2% slower than the final version - and infinitely more readable.
Nice article nevertheless.
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.
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.
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 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."
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.
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.
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.
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
To avoid the confusion, this is a different company from Codeweavers which are the primary Wine developers.