Performance of coroutine-style lexers in Go
eli.thegreenplace.net
eli.thegreenplace.net
The coroutine style of lexer is probably bound to not have such a bad slow down on larger stuff, but it depends. The amount of contention and locking introduced by adding a channel in such a hot path seems like it is probably not going to be worth it, especially if you are likely to do many parsing tasks in parallel and can simply make the boundary for parallelism further down the line.
edit: Somehow while writing this, I managed to forget that I also wrote a JS parser, albeit I never finished it. It uses a typical manually written lexer + parser, too, although it's probably pretty slow from being written stupidly. It lacks the ability to deal with a couple constructs due to ambiguity (most notably, I didn't get past the part where you parse fat arrow lambdas; it is ridiculously hard to tell them apart from other grammar elements.)
https://github.com/jchv/cleansheets / https://cleansheets.io/parser/
https://github.com/lpereira/lwan/blob/master/src/lib/lwan-te...
If you watch the talk carefully, Rob Pike himself mentions this near the end of the talk.
>I find the design to be elegant for sure, but I also don’t mind handwriting parsers in general.
But this is a hand-written parser! Just using function pointers to store the state instead of using an enum and switch statements.
I regularly use one of them to parse multi-gigabyte files for work and have been mildly disappointed with the results.
I'd be curious the performance implications of straight up serializing the process with maybe an RWMutex wrapping the token handoff to see what amount of that is the channel itself?
One of the lexers/parser based on the talk
I think goroutines were intended more for i/o concurrency than as a control flow mechanism, so the performance issues aren't surprising. I wonder how the benchmark would look with C++20 coroutines. I was going to say protothreads but I think those are not allowed to be recursive.
As for listening to your professors, well, Knuth vol 1 all the way back in 1968 advocated coroutines as a more powerful generalization of subroutines ;).
The ease of implementing very lightweight and fast multitaskers is one of the traditional attractions of Forth, if that matters.
I always use golang.org/x/tools/cmd/goyacc to generate my lexers and parsers. It's a Go port of Lex/Yacc (Flex/Bison) that works pretty well and is very fast.
It basically works by writing your lexes and parser in a Domain Specific Language (DSL) and compiling into go. It's pretty fast.
Those people wil get things done, but often in a roundabout way, reinventing the wheel multiple times along the way. Meanwhile the fortunate ones, like you, will pick the right tool, apply it, get state of the art results on their first try and move on.
but your argument that the academy is center of knowledge about the pragmatics of software development falls a bit flat. if you've been around enough you know 'grad student code' when you see it.
Academia doesn't necessarily teach you the most useful way to do things.
[1] https://notes.eatonphil.com/parser-generators-vs-handwritten...
Coroutines were invented surprisingly enough, for lexing and parsing[0].
[0] http://melconway.com/Home/pdf/compiler.pdf
Edit1: added source. I'm surprised Melvin Conway is still alive
Though, the "in practice" part is the thing that gets me. I can see how the code might be easier to read but performance wise it seems like a lot of overhead since the coroutines are unlikely to be doing things like waiting for I/O.
So, there’s room on the CPU for multithreading to improve performance, but it must be done carefully to minimize both contention and cache evictions.
IIRC/IIUC, Go in 2011 only supported a single logical processor (P) in its runtime model of (G)oroutines, Logical (P)rocessors, and (M)achine threads. It's possible channels didn't require any atomic operations at all. I'd be surprised if a coroutine style implementation would have been faster, as resuming and yielding coroutines typically involve more operations then entering into and returning from a function[1], but the overhead might have been low-enough to be negligible relative to elegance of the lexer code. (Inverting consumer/producer calling patterns is one of the areas at which coroutines excel, especially stackful coroutines where you can make full use of the stack as an implicit data structure.)
[1] Coroutines in Lua are relatively cheap, but because they have to do exception handling bookkeeping (i.e. setjmp) they're still more expensive than a regular function call. This is specific to Lua and not a necessary part of implementing coroutines. However, I can't imagine a situation where coroutines would be faster than a regular function call, and difficult to imagine them having the same cost unless a language uses a completely discontiguous stack for all call frames. In general coroutines have the same cost as a function call plus the need to save/restore at least some additional stack information.
A problem
Can't run a goroutine to completion during initialization.
Forbidden by the language specification.
(Raises awful issues about order of init, best avoided.)
That means we can't lex & parse a template during init.
The goroutine is a problem....
https://talks.golang.org/2011/lex.slide#39Removing the use of goroutines seems to have been simple. Not too surprised as lexers don't usually need very deep call stacks, anyhow. (A single loop and switch can suffice in most situations. Or a single level of calls when one is inclined to break things out into a function dispatch table.) In Lua I've found coroutines more useful for writing iterators over trees, where there's mutual recursion.
That smells strongly like an optimization opportunity waiting to happen, and here we have a profile that can be used to test it. chanrecv seems to have no fast-path for buffered channels with data and always locks. Has nobody thought of making this at least partially lock-free?
If you want performance, find parallelism and exploit it.
The input file the author uses is about 1M in size, and most of my inputs are much smaller (which is realistic for my use case); my feeling is that it's due to this, but I didn't verify; I'll have to check later and maybe set a more dynamic size based on the size of the input.
The lesson here is what we should (hopefully!) all know already: benchmark these changes because what works brilliant in situation A may be detrimental in situation B.
I actually added to my “some day list” an item to benchmark both versions as you did but I never got around to it.
Now that I’ve read your article I can remove that from my list.
My hope is that some motivated smart person uses your benchmarks to optimize the Go channel system. Often having a specific use-case is exact what’s needed.