krabby: for context


by arya dradjica on

Continuing on from the proof of concept, I'm making good progress on Krabby! There are three major steps I've accomplished in the last two weeks: a faster TOML parser, a custom lexer, and most importantly, the completion of the task architecture. I will need all of this infrastructure before I can tackle name resolution, and it's coming together very well.

parsing TOML

The proof of concept implementation would print trace logs of all the tasks it was executing, along with helpful latency measurements. During my testing, I noticed something a bit strange: the first page of trace logs was filled with threads not having enough work to do. This is somewhat expected: until the Cargo manifest of the project to be checked is parsed, there is no work to parallelize. It was still a bit frustrating, and it seemed to go on for too long, so I checked the latency of the parsing task.

It turned out that parsing a simple 80-line Cargo manifest was taking Krabby about 250-300 microseconds. This was significantly longer than I expected. The latency of opening and reading the file into memory was only 10 microseconds. 250 microseconds—about 750,000 cycles—is an absurd amount of time to spend parsing just 2,500 bytes. My implementation was deferring to the cargo_toml crate to parse the text, and then extracting some fields from the parsed manifest data. I was fairly sure the extraction process was not taking time, so that just left the TOML parsing.

A quick peek at the code showed that cargo_toml was simply relying on serde to do the actual parsing, via the standard toml crate. I've heard that serde / some serde formats could be somewhat inefficient at times, but I was incredibly surprised by the slowdown. While I could peek through the source code of serde and toml to try to identify the performance killer, I thought it would be easier to just write a new TOML parser from scratch. I estimated that a custom TOML parser could run 10 to 20 times faster.

I implemented a scheme I've seen referred to as "pull parsing", where every unit of information in the parsed file (e.g. a table section or a key-value entry) is represented as an "event" produced by the parser. I took some care to avoid unnecessary allocations and copies -- when an unquoted key or a string literal without escapes was used, I would reference it directly from the source file. The parser ended up being about 1,500 lines of code—not bad at all.

I ended up with an interesting architecture for consuming parse events. I defined some "Cargo manifest specification" types, whose fields and layout corresponded with the Cargo manifest format exactly. These types then implemented a ConsumeTOML trait, through which they could consume parse events and update their data. If a parse event corresponded to an unexpected location, or used the wrong type (e.g. introduced an array where a table was expected), an error would be returned. When an event had to be delegated to a sub-table, the sub-table's event-consuming functions could be called directly. The whole system composed really well, and I should probably set up some derive macros and publish the whole thing as a standalone crate.

The end result was perfect—the manifest loading task dropped from 250-300 microseconds down to just 30 microseconds. I did have some further optimization ideas, but I don't think they're going to be worthwhile.

the new lexer

Lexing is the only computationally intensive task the compiler is capable of right now, so we may as well focus on it while we can. While there are plenty of interesting computational optimizations I could perform (I'm especially excited about SIMD approaches), I decided to start light and use some more efficient data structures. Until now, I was just using proc-macro2, and had copied and modified its data structures in order to make it thread-safe. Now I've introduced a "token buffer" representation that is about 5 times more compact, and that should provide much better cache locality. There are a number of specific optimizations and design choices here:

Initially, I would simply call proc-macro2's lexer, traverse the resulting token stream, and build up the token buffer from it. I have now inlined that lexing code and modified it to build up the token buffer directly. I'm also making some more adjustments to make the lexer implementation more readable, rather than necessarily faster; I will provide separate performance-focused implementations later.

the task architecture

While I was able to demonstrate a proof of concept of Krabby in action, there was something missing. Running krabby build would certainly try to build the packages you threw at it, but I neglected to mention that the command would never terminate. Krabby was simply unable to tell when it was out of work, and its worker threads simply poll for tasks in a hot loop. After finishing the requested tasks, it would max out on CPU usage and output an infinite stream of meaningless logs. Oops!

The individual tasks in the compiler were already associated with some concept of context, which would represent why the task was being executed, and would route its outputs towards the next compilation step. I've now built a hierarchy around this context, so that tasks know who called them. Most importantly, this adds an event hook that is called when a context is finished (i.e. when its last child is finished). This allows me to determine when a high-level task (e.g. "check a crate") is finished, and whether it was successful. And krabby build correctly terminates now!

what's next

Now that the compilation terminates on time, it's significantly easier to benchmark and profile. Rather than moving forward into parsing, I'm going to take some time to optimize the lexer. It turns out less than 50% of the runtime is spent actually lexing; a lot of time is lost in the work-stealing architecture and in some concurrent data structures I neglected to optimize. I've been dreaming about vectorized lexing for about three years now, but I'm going to address my data structures first. It'll be fun!