Loading CSV File at the Speed Limit of the NVMe Storage
liuliu.me
liuliu.me
I wrote a CSV parser in Javascript for my site https://www.csvplot.com.
Even the "industry standard" PapaParse JS library was a lot slower than my no-frills implementation, so I thought I was onto something.
Then I read the "So You Want To Write your own CSV Code" [0]. I realized if I supported every corner case I would lose my entire speed advantage, which in hindsight is obvious. That newlines can exist inside a column if quoted ( Something like "my \n value with newline") is specifically what caused the most issues. Incidentally the article also points at that this ruins the possibility of processing each row independently.
I started reading about highly efficient text parsing, but most of what I could find was dedicated to JSON parsing, with simdjson as one example. I briefly considered using webassembly to leverage existing fast implementations, but that never happened. My motivation for this specific side project ran out the minute it was fast enough for my own needs.
[0] http://thomasburette.com/blog/2014/05/25/so-you-want-to-writ...
Sort of like branch prediction where a failed prediction is costly but on average you are right.
The problem with csv is that many people consuming it don't actually choose that format. They have to parse what they are given.
Split the data in linebreaks.
Process each line in parallel (massive speedup, can use many cores or even GPU).
Record "errors" (ie. Where a quoted block doesn't end before the newline)
Remove and reprocess all records containing errors or following a record with errors. Repeat until no errors exist.
I never finished packaging and writing tests, but https://github.com/dw/csvmonkey hits 1.9gb/sec on certain data sets with one thread, and that implementation is not even representative of the limits of single thread performance. https://github.com/geofflangdale/simdcsv follows a similar approach to simdjson, where multiple passes over the input are used, but with lower latency vector instructions (csvmonkey used one of the slowest). Character set decoding is also fused into the parser. There are no benchmarks published in that repo, but I expect the approach trounces my effort even if the current implementation doesn't.
Why be satisfied saturating one NVMe drive when a few threads could saturate the PCIe bus instead?
Is the null-termination requirement due to returning the value of cells as C strings? I couldn't find an easy answer looking through the provided code. Otherwise if its just an internal detail, I would think a (pointer,length) string type would be ideal to allow you to use the buffer without modification or copying.
> It is about 2x slower than csv2. This is expected because we need to null-terminate strings and copy them to a new buffer.
I think it is strange to settle for a 2x slowdown in order to not modify the original buffer. Substituting a null byte for the separator / newline should be simpler than copying and null-terminating. So is keeping the original buffer free from modifications important? In most cases preserving the original buffer seems unimportant.
In this particular case, I would hope it only copies to memory from disk. If only one program is accessing a file, I would expect the "copy" of copy-on-write to be the copy to memory.
https://medium.com/@sasha_f/why-mmap-is-faster-than-system-c...
Note that just replacing separators and newlines with nulls isn't enough to parse CSVs because the file format supports escape sequences and optional quotes around values. If you're modifying a buffer in place, you'll still be stuck copying data to replace (longer) escape sequences with their (shorter) final values.
I don't understand the line you've quoted from the article regarding why this code is slower than csv2. The csv2 code seemingly does copy from the original buffer rather than manipulating in place. Indeed, the csv2 parser copies data from a buffer into a C++ STL container object, which I would expect to be quite an expensive operation. Something does not add up here, either in my understanding or in the mechanics/description of the benchmark.
I had forgotten that escape sequences needed to also be changed. Unescaping in place does make it non-trivial.
> I don't understand the line you've quoted from the article regarding why this code is slower than csv2. The csv2 code seemingly does copy from the original buffer rather than manipulating in place. Indeed, the csv2 parser copies data from a buffer into a C++ STL container object, which I would expect to be quite an expensive operation. Something does not add up here, either in my understanding or in the mechanics/description of the benchmark.
I'm not familiar with the csv2 parser, but if what you say is true, I guess the slowdown would come from doing a csv2 doing a larger copy(s) and the given code doing many smaller copies.
Assuming escapes are rare, backshift unescaping is totally an option, though it scales poorly (interpreting escapes is linear, backshifting is quadratic) and is thus susceptible to slowdowns with malicious inputs. It does need very little code and no extra memory at all, though.
dst = src = ptr;
while(*src != '\0') {
if(src[0] == '"' && src[1] == '"') {
src++;
}
*dst++ = *src++;
}You can pad, no? What comes after the NUL is ignored in C.
Thus, the code triggered in the benchmark path is on: https://github.com/p-ranav/csv2/blob/master/include/csv2/rea... and https://github.com/p-ranav/csv2/blob/master/include/csv2/rea...
It doesn't access any of the cell content during the benchmark.
The other bit to mention is `xsv index`. If you can afford to make one single threaded pass over the CSV data, then future operations truly do become embarrassingly parallel. It's a simple low-tech solution, although the UX is worse because you need to take a manual step.
As for zero copy, could you say where you saw that? I don't think it makes sense to describe xsv that way. The csv parser does have a lower level zero allocation API, but there is no zero copy API.
Parsing is hard folks. All parsers should be analyzed statically and fuzzed at a bare minimum.
Mantle, followed by Vulkan and all, threw out the assumptions about cpu/gpu starving that were made in the 90s to adapt to modern hardware, extracting sometime massive performance boost "simply" by changing the API.
Right now you have RTX IO (nvidia) and DirectStorage (direct x) who aime to extract massive disk IO performance boost from modern NVMe drive for gaming by throwing away assumptions made about them in pre-ssd times and providing a new API.
These change often come down to the same thing: the way we call them, the amount of locking and round trips we do, synchronous or not, the connecting interfaces and their own speed limits, ... Rules made in an era were drive were X orders of magnitude slower than RAM start limiting us when X has been divided by two or three.
"raw csv processing at ~20GB/s" has been demonstrated in one my project as the tooling byproduct[1] based on Rayon(great Rust data parallelism library)[2] and wrapping of modified simdcsv[3] into one simple .rs.
just a little more:
1. simdcsv has severe bugs, so do not use it beyond demo.
2. the processing model of simdcsv still has rooms to good improvements(estimated 2x more, a.k.a. ~40GB+/s in memory in single modern socket should be achievable in some scenarios(no heavy string to complex language object conversions)).
[1] https://tensorbase.io/2020/08/04/hello-base.html#benchmark
Are these bugs documented anywhere? A glance at the issue tracker doesn't seem to reflect this claim.
https://github.com/gpapilion/pgrep
So in order to make it work I used temp files to paper over some correctness and ordering issues.
So the initial tests showed a 2-3x speed up on a crappy laptop ssd. Of course my usage of temp files made this much slower on multiple runs because of the page cache where traditional grep beat the approach I took because of memory speed. It wasn’t able to beat pre spout files though.
Most of these tools were designed expecting disks to roughly behave serially. With Ssds and raid devices you can achieve more parallelism than was imagined in the 70s 80s and 90s.
I think it’s an area that is ripe for more innovation.
What am I missing?
The article shows that he's getting half the throughput of parsing a CSV that's already in RAM. But: he's using RAID0 of two SSDs and only getting a little more than half the throughput of one of those SSDs. As currently written, this program might not be giving the SSDs a high enough queue depth to hit their full read throughput. I'd like to see what throughput is like with an explicit attempt to prefetch data into RAM (either with a thread manually touching all the necessary pages, or maybe with a madvise call). That could drastically reduce the number of page faults and context switches affecting the OpenMP worker threads, and yield much better CPU utilization.
Put another way, what would you do to read in the CSV serially to increase speed that would push the queue depth above 1?
However, optimal utilization of the drive(s) will always require a queue depth of more than one request, because you don't want the drive to be idle after signalling completion of its only queued command and waiting for the CPU to produce a new read request. In a RAID0 setup like the author describes, you need to also ensure that you're generating enough IO to keep both drives busy, and the minimum prefetch window size that can accomplish this will usually be at least one full stripe.
As for how you accomplish the prefetching: the madvise system call sounds like a good choice, with the MADV_SEQUENTIAL or MADV_WILLNEED options. But how much prefetching that actually causes is up to the OS and the local system's settings. On my system, /sys/block/$DISK/queue/read_ahead_kb defaults to 128, which is definitely insufficient for at least some drives but might only apply to read-ahead triggered by the filesystem's heuristics rather than more explicitly requested by a madvise. So manually touching pages from a userspace thread is probably the safer way to guarantee the OS pages in data ahead of time—as long as it doesn't run so far ahead of the actual use of the data that it creates memory pressure that might get unused pages evicted.
Isn't this a bit misleading? mmaping a file doesn't cause the kernel to start loading the whole thing into RAM, it just sets things up for the kernel to later transparently load pages of it on demand, possibly with some prefetching.
While absolutely true, I've found that fact to be very surprising to a lot of engineers.
There is literally no io being done on your data access paths. Synchronising mapped pages with file contents happens in background write back threads.
Edit: just seen this which kind of touches on the same https://news.ycombinator.com/item?id=24737186
I think most people don't appreciate that CSV parsing is actually a compute-bound problem because the rows and column positions are known in advance.
Overall, I'm not sure the complexity justifies the performance gains unless parsing is in your critical path.
You might be interested in DuckDB though which trying to create a new standard for passing datasets: https://duckdb.org/