Faster Integer Parsing
kholdstare.github.io
kholdstare.github.io
Credit card numbers can be up to 19 characters and as short as 12 characters. (I think they can theoretically be shorter but I'm not aware of any issuer that actually does that).
Also, they are strings over the alphabet of decimal digits. They are not intended to be interpreted as representing integers, and doing so is a bad idea. Leading 0s matter in credit card numbers. The first few digits identify the issuer. You could in theory have two card numbers where one is N digits without any leading zeros and the other is N+k digits with k leading zeros and the remaining N digits the same as the first card's. These would be different cards from different issuers but if you just parsed them into numbers would come out the same.
There's exactly one division required.
(Implementing it using vector instructions is left as an exercise for the reader. A very elementary exercise, so I encourage you to try it if you're curious about SSE and such.)
https://lemire.me/blog/2019/02/08/faster-remainders-when-the...
On the "culture of 'optimization is the root of all evil'" remark in the conclusions: I find this to be a nice example for the full Knuth quote.
If you face an arbitrary task including parsing 64 bit integers, starting by developing/using the technique from the article (as a _premature_ optimization) is probably a bad idea since it costs time for the implementation (and even more time for debugging and understanding the code a few months later), while in most cases, it is probably not what dominates the running time of your code. If you however have built a solution that does the job, but is just not fast enough, and profiling shows you that you spend considerable time parsing integers, this kind of optimization is the way to go.
Recently I made a comment on the overhead of textual formats from the validation perspective: https://news.ycombinator.com/item?id=23582056
I think that a textual format is overall a horrible idea if the use-case is not presenting the vast majority of the content to humans.
In an ideal world, communication between machines would be all in binary protocols, and developers would know how to read/write them with tools like hex editors as naturally as a second language.
Instead, countless amounts of time and space are wasted by machines converting their native integers into strings, wrapping it in JSON, base64'ing that, wrapping it into XML, then sending it over the network (whose lower layers are thankfully binary) to another machine where the reverse process happens, but with additional checks during parsing. (I am not exaggerating. I have seen systems like this.) 99.99999...% of this data will never be seen by a human. What a disgusting waste of computing power.
"The fastest way to do something is to not do it at all."
The human readability argument doesn't really hold any water because if you have a structured description of a protocol (e.g. a C struct), you can always write simple tools to inspect the protocol and make it just as humanly readable as JSON is. This is even easier if a language has reflection to generate all this code.
It starts with the library version, moves onto a custom version, then unrolls, then does clever tricks then finally onto SIMD.
Worth a read if you enjoy optimization stories!
It would have been more interesting to try to optimize something closer to the problem the library routines are solving: skip leading whitespace, error if the first character isn't a digit, accumulate digits until a non-digit is reached. Can the article writer beat the standard functions? By how much?
This would be different if we were benchmarking to compare libraries or hardware or languages or whatnot, or even calling the new implementation "better" without qualifiers.
If you say:
while (i++)
if ( notWhitespace(chars[i]) && isDigit(chars[i]) )
doWork(chars[i])
speculative execution will give you near full performance when the input is clean, as long as you didn't write it in a way that invites mispredicts. You'll only suffer the cost of checking when your input is not clean.Reason? the next to last iteration of the code grabs 8 characters in an unsigned long, parses them, grabs 8 more characters, parses them, multiplies the first number by 100,000,000, and adds it to the second.
So, it does 2 iterations per 16-digit number, not 16.
The last result further improves on that by grabbing all 16 digits in one go.
Still, very cool work.
I did encounter a similiar issue a while ago in Javascript parsing files that could be hundreds of megabytes with mostly floating point numbers as text in them. parseFloat turned out to be the bottleneck in this case after optimizing everything else, and I used a very naive (and entirely wrong) function to parse the floats that worked very similar like the earlier optimization in this case. Simply reading each digit, multiplying it by 10*position and adding it all up. Of course this creates the wrong result because of rounding, but it was close enough for my use case. And it was actually something like 2-3 times faster for the overall runtime (not just microbenchmarking the float parsing). What helped here was that the number of digits was generall low, so you typically had ~3 digits after the dot, not the maximum floats could represent.
I'm pretty sure someone that knows what they're doing could have written an even faster and more accurate version there given the same constraints, but I was surprised just by how much I could beat the library function for my specific, restricted use case with a very naive implementation.
I’m going to start by ignoring this in my code. Just make it work correctly, right? But as we get closer to deployment performance probably matters (it does to us). Then we start to notice lots of important coupling. For example input validation typically* isn’t required inside the system: datastructures and generated and used by our code. Validation applies at the “surface” or external inputs of the system. Log records which are generated by one part of the system may need to be dredged rapidly — forcing the representation to be fixed means the code that parses it can make assumptions the library code cannot. Metering will tell you where optimization opportunities lie.
* super long-lived applications sometimes need to do consistency checks even on internal data to avoid subtle corruption issues that could have hardware or software origin and could be very expensive to encounter. Things like phone switches and spacecraft that need to go years or even decades without a reboot.
[1]: https://gist.github.com/jcdickinson/9a4205287ae107e9f4e5f676...
That treats all numbers as decimals (double) as Javascript treats all numbers that way. If you need to parse integers only, or even only positive integers, bit shifting is the only way to fly (bonus points for SIMD).
One detail the author didn't go into was cache latency and branching. I can't find the numbers right now, but to get sub nanosecond is impressive. I can't find the numbers right now, off the top of my head: L1 is ~1ns, L2 ~4ns, L3 ~8ns (the L3 cache on Ryzen 3900x & 3950x is 64MB! in 8MB clusters with 3-4 cores), and main memory is around 80ns (let's not get into memory ranking). So to get down to those speeds you cannot afford ANY branching, and have to do operations in parallel.
And I didn't mention localisation with some Europeans using commas instead of periods and periods instead of commas - madness!
Since the problem in question is concerned with 16-digit integers (i.e. 128 bit wide), SSSE3 is a perfect match.
AVX instructions could be used to convert two numbers at a time, though.
I'm always curious on these... microbenchmarks can skew things because of CODE cache localilty? E.g. startup time, ability to inline, etc.... any thoughts on how to do a more "real world" test?
Then you can use larger than byte operations and larger than byte multipliers if you need them.
Alternatively,
First pass you do all the 1,10,100 cases in one pass, using multipliers 1,10,100,0 (throw out last byte).
Then mask (or use the compression instructions as needed) and use the larger bit versions and do all the 1000 multiplier cases in one pass.
Then you merge them all in one pass.
It merges 4 at a time for the large pass and 3 at a time for the small pass instead of 2 at a time.
I'll write it up later today if I get time.