Compare the concurrency benchmarks with Hopac as well as Go.
The Go table is refreshed with a new run (the numbers moved within the noise), and the section gains a Hopac part: the same rows on the same .NET runtime, plain and with every operation raced against a cancel IVar, with what the numbers show. Hopac is ahead without cancellation and far behind with one shared cancel IVar, and allocates more. 🤖 Generated with [ECA](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
parent
7fbff1f6cc
commit
60110605ec
1 changed files with 20 additions and 6 deletions
|
|
@ -789,25 +789,39 @@ Conversely, you may occasionally need to wait on a channel or event from inside
|
||||||
(sync/blocking (chan-recv ch)))
|
(sync/blocking (chan-recv ch)))
|
||||||
}|
|
}|
|
||||||
|
|
||||||
@subsubsection{Compared with Go}
|
@subsubsection{Compared with Go and Hopac}
|
||||||
|
|
||||||
Go is the obvious point of comparison: goroutines and unbuffered channels are the same model of fibers meeting at a rendezvous. The main repository has the same benchmarks written in both languages (@code{bench/bjolang/cmlbench.bjo} and @code{go run . -suite cmlbench} in @code{bench/go}). Every Bjolang channel operation races the cancellation token of its scope, so Go is measured twice: as plain channel operations, and with each one selecting against @code{ctx.Done()}, which is what a go program has to stop execution. Bjolang is measured twice as well: with events and @code{sync}, and with @code{chan-put} and @code{chan-get}, so the difference between the two bjolang columns is the cost of what building an event and syncing it costs. The best of five runs, in nanoseconds per operation, on a Ryzen 9 5900X under Linux with .NET 10 and Go 1.25:
|
Go is the obvious point of comparison: goroutines and unbuffered channels are the same model of fibers meeting at a rendezvous. The main repository has the same benchmarks written in both languages (@code{bench/bjolang/cmlbench.bjo} and @code{go run . -suite cmlbench} in @code{bench/go}). Every Bjolang channel operation races the cancellation token of its scope, so Go is measured twice: as plain channel operations, and with each one selecting against @code{ctx.Done()}, which is what a go program has to stop execution. Bjolang is measured twice as well: with events and @code{sync}, and with @code{chan-put} and @code{chan-get}, so the difference between the two bjolang columns is the cost of what building an event and syncing it costs. The best of five runs, in nanoseconds per operation, on a Ryzen 9 5900X under Linux with .NET 10 and Go 1.25:
|
||||||
|
|
||||||
@read-table{
|
@read-table{
|
||||||
| Benchmark | Bjolang, @code{sync} | Bjolang, @code{chan-put} | Go | Go, with @code{ctx.Done()} |
|
| Benchmark | Bjolang, @code{sync} | Bjolang, @code{chan-put} | Go | Go, with @code{ctx.Done()} |
|
||||||
|----------------------------------------+----------------------+--------------------------+-----+----------------------------|
|
|----------------------------------------+----------------------+--------------------------+-----+----------------------------|
|
||||||
| Ring: 1000 fibers, a million messages | 86 | 68 | 114 | 241 |
|
| Ring: 1000 fibers, a million messages | 78 | 66 | 115 | 247 |
|
||||||
| The same ring inside a nested scope | 73 | — | — | 245 |
|
| The same ring inside a nested scope | 72 | — | — | 248 |
|
||||||
| Spawning a million fibers and waiting | 84 | — | 132 | — |
|
| Spawning a million fibers and waiting | 80 | — | 136 | — |
|
||||||
| An 8-way choose where one branch fires | 152 | 153 | 400 | 443 |
|
| An 8-way choose where one branch fires | 154 | 145 | 396 | 449 |
|
||||||
}
|
}
|
||||||
|
|
||||||
In the choose row only the sender can use @code{chan-put}, since the receiving side has to use channel-receive (and thus allocates). On the ring, syncing an event costs about a fifth of each message. For some reason, thi ring inside a nested scope is faster in bjolang, and I haven't figured out why yet.
|
In the choose row only the sender can use @code{chan-put}, since the receiving side has to use channel-receive (and thus allocates). On the ring, syncing an event costs about a sixth of each message. For some reason, thi ring inside a nested scope is faster in bjolang, and I haven't figured out why yet.
|
||||||
|
|
||||||
These are small benchmarks on one machine, and they only measure the cost of the concurrency primitives, not of the work between rendevouz. Go is ahead in other ways: a Go channel operation never allocates, where a Bjolang send synced as an event allocates 32 bytes and a @code{choose} 40 (@code{chan-put} and @code{chan-get} allocate nothing). Any Go function can block without being written as a bjoroutine and the Go scheduler preempts a goroutine that computes for too long, where a fiber has to be moved off the pool with @code{spawn/thread}.
|
These are small benchmarks on one machine, and they only measure the cost of the concurrency primitives, not of the work between rendevouz. Go is ahead in other ways: a Go channel operation never allocates, where a Bjolang send synced as an event allocates 32 bytes and a @code{choose} 40 (@code{chan-put} and @code{chan-get} allocate nothing). Any Go function can block without being written as a bjoroutine and the Go scheduler preempts a goroutine that computes for too long, where a fiber has to be moved off the pool with @code{spawn/thread}.
|
||||||
|
|
||||||
What Bjolang offers that Go does not is in the CML vocabulary rather than in the numbers. A Go @code{select} is a statement, its cases are fixed in the source, and a channel operation cannot be passed to a function, wrapped, or combined with others. In Bjolang an event is a value, which is what made @code{within} above a two-line function that works on any event, and @code{choose}, @code{wrap}, @code{guard} and @code{with-nack} build new events out of old ones. Go has no structured concurrency either. Waiting for goroutines, stopping them, and reporting their failures are a @code{sync.WaitGroup}, a @code{context.Context} passed to every function and checked at every channel operation, and an @code{errgroup}, written by hand each time. A goroutine that nothing waits for leaks without a sound. In Bjolang a scope does all of that by construction: it waits for its fibers, raises their failures when it ends, releases the files they opened, and cancels them through a token that every @code{sync} watches without being asked.
|
What Bjolang offers that Go does not is in the CML vocabulary rather than in the numbers. A Go @code{select} is a statement, its cases are fixed in the source, and a channel operation cannot be passed to a function, wrapped, or combined with others. In Bjolang an event is a value, which is what made @code{within} above a two-line function that works on any event, and @code{choose}, @code{wrap}, @code{guard} and @code{with-nack} build new events out of old ones. Go has no structured concurrency either. Waiting for goroutines, stopping them, and reporting their failures are a @code{sync.WaitGroup}, a @code{context.Context} passed to every function and checked at every channel operation, and an @code{errgroup}, written by hand each time. A goroutine that nothing waits for leaks without a sound. In Bjolang a scope does all of that by construction: it waits for its fibers, raises their failures when it ends, releases the files they opened, and cancels them through a token that every @code{sync} watches without being asked.
|
||||||
|
|
||||||
|
Hopac is the tougher comparison. It is a Concurrent ML library for F#, running on the same .NET runtime with the same JIT and garbage collector, so the differences between it and Bjolang are in the design rather than in the platform. The same benchmarks are in @code{bench/hopac} (@code{dotnet run -c Release -- cmlbench}). Hopac has no cancellation built in, so like Go it is measured twice: plain, and with every operation raced against an @code{IVar} (Hopac's write-once variable) that is filled to stop the jobs. Same machine, best of five, nanoseconds per operation:
|
||||||
|
|
||||||
|
@read-table{
|
||||||
|
| Benchmark | Bjolang, @code{sync} | Hopac | Hopac, racing a cancel @code{IVar} |
|
||||||
|
|-----------------------------------------+----------------------+-------+------------------------------------|
|
||||||
|
| Ring: 1000 fibers, a million messages | 78 | 43 | 3445 |
|
||||||
|
| The same ring, one cancel IVar per job | — | — | 180 |
|
||||||
|
| The same ring inside a nested scope | 72 | — | 3383 |
|
||||||
|
| Spawning a million fibers and waiting | 80 | 75 | — |
|
||||||
|
| An 8-way choose where one branch fires | 154 | 116 | 128 |
|
||||||
|
}
|
||||||
|
|
||||||
|
Without cancellation Hopac is ahead: its ring is almost twice as fast as Bjolang's, its choose takes a quarter less time, and its spawns cost about the same. Bjolang's numbers include watching the scope's token at every operation, which Hopac's plain rows do not do. Once cancellation is added the picture turns. A thousand jobs racing one shared @code{IVar} all take that IVar's lock at every operation, and the ring slows to three and a half microseconds a message. Giving each job an IVar of its own brings it back to 180 ns, at the price of keeping track of a thousand IVars and filling all of them to stop the ring. Hopac also allocates more: 208 bytes a message on the ring and 424 a choose, against Bjolang's 32 and 72. Hopac can run jobs in parallel and wait for all of them, but a job cannot be stopped from the outside, so stopping jobs when one of them fails, or when their owner is done with them, is the same hand-written race against an IVar as in the table.
|
||||||
|
|
||||||
@subsection{Effects}
|
@subsection{Effects}
|
||||||
|
|
||||||
Our deck is shuffled using @code{shuffle-vec}, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack.
|
Our deck is shuffled using @code{shuffle-vec}, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue