Benchmarks
How far one engine goes
Every release, we put the systema engine alone in virtual machines of fixed size and drive it with real SDK workers until it stops keeping up. These are the results: what each size sustains, what it costs in CPU and memory on the way, and where it runs out.
- Engine
- systema 0.25.0
- Measured
- Oct 20, 2026
- Machine
- Local workstation, 16 CPUs
- Sizes
- 3 VMs and the host, 8 scenarios
Pick a machine size
Every figure and chart below follows it. The other sizes stay in the charts, behind, to compare.
- Calls per second through the engine
- 97 k/s
- Worker to engine to worker and back, 512 calls in flight. p99 17 ms.
- Engine CPU per call
- 20 µs
- About 51k calls a second for each core the engine has.
- Observer activations per second, sustained
- 50 k/s
- Every event delivered. Offer 100 k/s and 74% arrive; the rest is dropped, and counted.
- Calls a worker makes to its own functions
- 4.7 M/s
- In-process, unobserved: the engine spends 0.00 CPU on them.
- Engine memory per connected worker
- 161 KiB
- 5,000 idle workers held: 786 MiB on top of 40 MiB at rest.
- Engine memory per observer
- 10 KiB
- 64,000 observers held: 638 MiB.
AMD Ryzen 7 9800X3D 8-Core Processor · 16 CPUs. A size of N vCPU · M is the engine with N virtual CPUs and an M memory cap, the load generator beside it on 6 vCPUs of its own; how we measure.
Calls
Calls through the engine
A worker calls a function another worker hosts. The call goes to the engine, the engine routes it to the host, and the answer comes back the same way: four WebSocket frames per call.
Callers keep more and more calls in flight, from 1 to 2,048. Throughput grows while the engine has CPU to spare, up to a peak: the size’s capacity.
| Size | Peak | p99 there | CPU per call |
|---|---|---|---|
| 1 vCPU · 512 MiB | 63 k/s | 351 ms | 16 µs |
| 2 vCPU · 1 GiB | 97 k/s | 17 ms | 20 µs |
| 4 vCPU · 2 GiB | 171 k/s | 6.2 ms | 22 µs |
| Host · 8 CPUs | 424 k/s | 8.3 ms | 15 µs |
Calls per second, by calls in flight
64-byte payloads. Each size gets a fresh engine.
| Machine | Calls in flight | Calls per second |
|---|---|---|
| 1 vCPU | 1 | 4.1k |
| 1 vCPU | 8 | 15k |
| 1 vCPU | 32 | 28k |
| 1 vCPU | 128 | 46k |
| 1 vCPU | 512 | 55k |
| 1 vCPU | 2,048 | 63k |
| 2 vCPU | 1 | 3.6k |
| 2 vCPU | 8 | 28k |
| 2 vCPU | 32 | 61k |
| 2 vCPU | 128 | 85k |
| 2 vCPU | 512 | 97k |
| 2 vCPU | 2,048 | 92k |
| 4 vCPU | 1 | 3.6k |
| 4 vCPU | 8 | 27k |
| 4 vCPU | 32 | 97k |
| 4 vCPU | 128 | 160k |
| 4 vCPU | 512 | 171k |
| 4 vCPU | 2,048 | 152k |
| Host | 1 | 28k |
| Host | 8 | 119k |
| Host | 32 | 219k |
| Host | 128 | 330k |
| Host | 512 | 403k |
| Host | 2,048 | 424k |
p99 latency, by calls in flight
| Machine | Calls in flight | p99 latency |
|---|---|---|
| 1 vCPU | 1 | 289 µs |
| 1 vCPU | 8 | 769 µs |
| 1 vCPU | 32 | 2 ms |
| 1 vCPU | 128 | 4.5 ms |
| 1 vCPU | 512 | 70 ms |
| 1 vCPU | 2,048 | 351 ms |
| 2 vCPU | 1 | 313 µs |
| 2 vCPU | 8 | 401 µs |
| 2 vCPU | 32 | 878 µs |
| 2 vCPU | 128 | 2.6 ms |
| 2 vCPU | 512 | 17 ms |
| 2 vCPU | 2,048 | 66 ms |
| 4 vCPU | 1 | 313 µs |
| 4 vCPU | 8 | 416 µs |
| 4 vCPU | 32 | 574 µs |
| 4 vCPU | 128 | 1.4 ms |
| 4 vCPU | 512 | 6.2 ms |
| 4 vCPU | 2,048 | 30 ms |
| Host | 1 | 65 µs |
| Host | 8 | 146 µs |
| Host | 32 | 464 µs |
| Host | 128 | 1 ms |
| Host | 512 | 2.4 ms |
| Host | 2,048 | 8.3 ms |
Observers
What observing a function costs
Every function is observable. Here each called function gains 1 to 64 observers without a filter, so every call activates all of them, fire-and-forget, while 64 callers keep calling.
An activation is a call the engine makes on its own, and it costs about as much as one. So the engine splits the same CPU between the calls and their activations: the more observers per call, the fewer calls fit.
Activations per second, by observers per function
| Machine | Observers per function | Activations per second |
|---|---|---|
| 1 vCPU | 1 | 26k |
| 1 vCPU | 4 | 39k |
| 1 vCPU | 16 | 71k |
| 1 vCPU | 64 | 51k |
| 2 vCPU | 1 | 43k |
| 2 vCPU | 4 | 39k |
| 2 vCPU | 16 | 38k |
| 2 vCPU | 64 | 32k |
| 4 vCPU | 1 | 60k |
| 4 vCPU | 4 | 119k |
| 4 vCPU | 16 | 178k |
| 4 vCPU | 64 | 144k |
| Host | 1 | 110k |
| Host | 4 | 326k |
| Host | 16 | 308k |
| Host | 64 | 217k |
The observed calls, by observers per function
| Machine | Observers per function | Calls per second |
|---|---|---|
| 1 vCPU | 0 | 40k |
| 1 vCPU | 1 | 26k |
| 1 vCPU | 4 | 9.8k |
| 1 vCPU | 16 | 6.3k |
| 1 vCPU | 64 | 5.1k |
| 2 vCPU | 0 | 81k |
| 2 vCPU | 1 | 43k |
| 2 vCPU | 4 | 35k |
| 2 vCPU | 16 | 34k |
| 2 vCPU | 64 | 29k |
| 4 vCPU | 0 | 135k |
| 4 vCPU | 1 | 78k |
| 4 vCPU | 4 | 30k |
| 4 vCPU | 16 | 47k |
| 4 vCPU | 64 | 44k |
| Host | 0 | 251k |
| Host | 1 | 222k |
| Host | 4 | 88k |
| Host | 16 | 70k |
| Host | 64 | 58k |
Delivery
How much it delivers in full
A source like cron or a database calls its event function (cron::on-tick, database::on-row-changed) for each event, and systema delivers each call to the observers whose args and filters match. We offer a steady rate, from 1k to 500k events a second over 16 observers, and count what arrives.
Up to some rate, everything does. That rate is the size’s sustained capacity, and past it the engine does not fall over: it delivers what it can and drops the rest, counted.
| Size | Sustained | Past it |
|---|---|---|
| 1 vCPU · 512 MiB | 50 k/s | 75% of 100 k/s |
| 2 vCPU · 1 GiB | 50 k/s | 74% of 100 k/s |
| 4 vCPU · 2 GiB | 100 k/s | 61% of 200 k/s |
| Host · 8 CPUs | 100 k/s | 90% of 200 k/s |
Activations delivered, by events offered
Per second, over 16 observers, on logarithmic scales: a line on the diagonal delivers everything.
| Machine | Events offered per second | Activations delivered per second |
|---|---|---|
| 1 vCPU | 1k | 999 |
| 1 vCPU | 5k | 5k |
| 1 vCPU | 20k | 20k |
| 1 vCPU | 50k | 50k |
| 1 vCPU | 100k | 69k |
| 2 vCPU | 1k | 1000 |
| 2 vCPU | 5k | 5k |
| 2 vCPU | 20k | 20k |
| 2 vCPU | 50k | 50k |
| 2 vCPU | 100k | 68k |
| 4 vCPU | 1k | 1000 |
| 4 vCPU | 5k | 5k |
| 4 vCPU | 20k | 20k |
| 4 vCPU | 50k | 50k |
| 4 vCPU | 100k | 100k |
| 4 vCPU | 200k | 115k |
| Host | 1k | 1000 |
| Host | 5k | 5k |
| Host | 20k | 20k |
| Host | 50k | 50k |
| Host | 100k | 100k |
| Host | 200k | 177k |
Filters
Filters at scale
Observers usually filter: a cron expression, a table, a tenant. Here one function has 16 to 4,000 observers, each filtering on a different value, and every call matches exactly one of them.
What grows is the filtering, not the deliveries: the engine checks every observer’s filter on every call to the function.
Calls per second, by filtered observers on the function
| Machine | Filtered observers | Calls per second |
|---|---|---|
| 1 vCPU | 16 | 20k |
| 1 vCPU | 256 | 19k |
| 1 vCPU | 1,024 | 14k |
| 1 vCPU | 4,000 | 5.6k |
| 2 vCPU | 16 | 40k |
| 2 vCPU | 256 | 36k |
| 2 vCPU | 1,024 | 29k |
| 2 vCPU | 4,000 | 24k |
| 4 vCPU | 16 | 60k |
| 4 vCPU | 256 | 59k |
| 4 vCPU | 1,024 | 64k |
| 4 vCPU | 4,000 | 58k |
| Host | 16 | 153k |
| Host | 256 | 159k |
| Host | 1,024 | 164k |
| Host | 4,000 | 124k |
Memory
What it holds, and how much fits
The engine’s resident memory at rest as idle workers connect and observers pile up. Each VM size’s memory cap is drawn across: where a line would cross it, that size runs out.
| Size | Idle workers | Observers |
|---|---|---|
| 1 vCPU · 512 MiB | ~2,900 fit | ~46,000 fit |
| 2 vCPU · 1 GiB | ~6,300 fit | ~99,000 fit |
| 4 vCPU · 2 GiB | ~12,600 fit | ~262,000 fit |
| Host · 8 CPUs | 168 KiB each | 8 KiB each |
Fits: the size’s cap, less the engine at rest, over the measured cost of one. Real workers register more functions, and real observers carry bigger filters.
Engine memory, by idle workers connected
Each worker announces itself and registers two functions, then only answers pings.
| Machine | Idle workers | Resident memory |
|---|---|---|
| 1 vCPU | 0 | 38 MiB |
| 1 vCPU | 100 | 58 MiB |
| 1 vCPU | 1,000 | 200 MiB |
| 2 vCPU | 0 | 38 MiB |
| 2 vCPU | 100 | 58 MiB |
| 2 vCPU | 1,000 | 201 MiB |
| 2 vCPU | 5,000 | 825 MiB |
| 4 vCPU | 0 | 38 MiB |
| 4 vCPU | 100 | 59 MiB |
| 4 vCPU | 1,000 | 204 MiB |
| 4 vCPU | 5,000 | 837 MiB |
| 4 vCPU | 10k | 1.6 GiB |
| Host | 0 | 43 MiB |
| Host | 100 | 63 MiB |
| Host | 1,000 | 213 MiB |
| Host | 5,000 | 864 MiB |
| Host | 10k | 1.6 GiB |
Engine memory, by observers held
Spread over 16 functions, a small filter each, 1,000 per observing worker.
| Machine | Observers held | Resident memory |
|---|---|---|
| 1 vCPU | 0 | 39 MiB |
| 1 vCPU | 1,000 | 52 MiB |
| 1 vCPU | 4,000 | 77 MiB |
| 1 vCPU | 16k | 205 MiB |
| 2 vCPU | 0 | 40 MiB |
| 2 vCPU | 1,000 | 50 MiB |
| 2 vCPU | 4,000 | 78 MiB |
| 2 vCPU | 16k | 198 MiB |
| 2 vCPU | 64k | 678 MiB |
| 4 vCPU | 0 | 40 MiB |
| 4 vCPU | 1,000 | 50 MiB |
| 4 vCPU | 4,000 | 73 MiB |
| 4 vCPU | 16k | 183 MiB |
| 4 vCPU | 64k | 530 MiB |
| Host | 0 | 44 MiB |
| Host | 1,000 | 53 MiB |
| Host | 4,000 | 76 MiB |
| Host | 16k | 175 MiB |
| Host | 64k | 542 MiB |
Overload
When the targets are slow
Observers whose target takes 20 ms, behind calls that come far faster. Each observer runs 64 activations at a time and queues up to 1,024 more; past that, events are dropped and counted.
The calls hold at 64 in flight while slow observers are added: 4, 64, 256. Every one of them keeps a full queue. A queued event is held once, however many observers wait on it, and all the queues together are capped at 64 MiB of events.
Peak engine memory, by slow observers
| Machine | Slow observers on the function | Peak resident memory |
|---|---|---|
| 1 vCPU | 4 | 51 MiB |
| 1 vCPU | 64 | 104 MiB |
| 1 vCPU | 256 | 278 MiB |
| 2 vCPU | 4 | 52 MiB |
| 2 vCPU | 64 | 123 MiB |
| 2 vCPU | 256 | 329 MiB |
| 4 vCPU | 4 | 53 MiB |
| 4 vCPU | 64 | 137 MiB |
| 4 vCPU | 256 | 516 MiB |
| Host | 4 | 64 MiB |
| Host | 64 | 170 MiB |
| Host | 256 | 553 MiB |
Local calls
Calls that never leave the worker
A worker calling a function it hosts runs it in its own process. The engine hears about such a call only when an observer’s filter matches it, in batched reports.
16 tasks in one worker call its own function as fast as they can: unobserved, with an observer matching 1% of the calls, and with one matching them all.
Local calls per second
2 vCPU · 1 GiB
- Unobserved4.7 M/s
engine 0.00 CPU · nothing on the wire
- Observed, 1% match3.6 M/s
engine 0.95 CPU · 36 k/s activations delivered
- Observed, 100% match2.7 M/s
engine 1.12 CPU · 41 k/s activations delivered
Micro-benchmarks
Single paths, every release
Criterion timings of single code paths, in one process, on every release since v0.11.6: a standard GitHub runner, 4 cores. Lower is faster; each line is a benchmark’s history, and its last point is v0.25.0-rc.2, measured Oct 19, 2026.
WebSocket round trip
One call from a worker, through the engine, to another worker and back, over real sockets.
- invoke_echo91 µs31% faster
Concurrent calls
N calls at once to a function hosted in the engine, waiting for all of them.
- 12.2 µs19% faster
- 816 µs17% faster
- 3268 µs15% faster
- 128269 µs15% faster
Payload size
One call carrying 1 KB to 1 MB of JSON.
- 1kb1.5 µs14% faster
- 10kb2.1 µs12% faster
- 100kb7.4 µs20% faster
- 1mb68 µs20% faster
Observer fan-out
One call delivered to N observers at once.
- 122 µs32% faster
- 842 µs19% faster
- 32114 µs±3%
- 128436 µs±2%
Registration churn
Registering and removing functions, workers and observers by the hundred and the thousand.
- functions_register_remove/10042 µs24% faster
- functions_register_remove/1000416 µs24% faster
- functions_register_remove/50002.2 ms25% faster
- triggers_register_unregister/100108 µs19% faster
- triggers_register_unregister/10001.1 ms20% faster
- triggers_register_unregister/50005.7 ms21% faster
- workers_register_unregister/100214 µs13% faster
- workers_register_unregister/10007 ms±7%
- workers_register_unregister/500048 ms±8%
Core runtime1 benchmark
Calling a handler registered in the engine.
- engine_call_registered_handler1.9 µs11% faster
Key-value contention4 benchmarks
N writers on the same key at once.
- 1321 ns22% faster
- 41.2 µs26% faster
- 164.9 µs26% faster
- 6423 µs25% faster
Key-value store10 benchmarks
Reads, writes, updates and listings in the built-in store.
- delete537 ns27% faster
- get_hit254 ns23% faster
- get_miss87 ns22% faster
- list_100_items28 µs20% faster
- list_groups208 ns23% faster
- list_keys_with_prefix288 ns27% faster
- set_overwrite422 ns24% faster
- update_increment372 ns25% faster
- update_merge759 ns25% faster
- update_set_field411 ns25% faster
Protocol: decode8 benchmarks
Parsing each wire message from JSON.
- InvocationResult1 µs18% faster
- InvocationResult_Error489 ns18% faster
- InvokeFunction1 µs16% faster
- Ping95 ns±7%
- Pong93 ns10% faster
- RegisterFunction455 ns37% faster
- RegisterTrigger591 ns17% faster
- WorkerRegistered252 ns15% faster
Protocol: round trip8 benchmarks
Encoding and decoding each wire message.
- InvocationResult1.4 µs19% faster
- InvocationResult_Error669 ns17% faster
- InvokeFunction1.3 µs18% faster
- Ping107 ns±9%
- Pong107 ns±9%
- RegisterFunction544 ns41% faster
- RegisterTrigger773 ns16% faster
- WorkerRegistered394 ns±8%
Protocol: encode8 benchmarks
Turning each wire message into JSON.
- InvocationResult324 ns19% faster
- InvocationResult_Error189 ns16% faster
- InvokeFunction313 ns13% faster
- Ping22 ns27% faster
- Pong23 ns23% faster
- RegisterFunction101 ns50% faster
- RegisterTrigger169 ns18% faster
- WorkerRegistered136 ns±7%
Pub/sub fan-out4 benchmarks
One message to N subscribers.
- 122 µs33% faster
- 840 µs25% faster
- 3280 µs20% faster
- 128265 µs13% faster
Queue: concurrent producers4 benchmarks
N producers enqueueing at once.
- 14.8 µs26% faster
- 417 µs26% faster
- 1668 µs24% faster
- 64264 µs24% faster
Queue: payload size4 benchmarks
Enqueueing jobs of 1 KB to 1 MB.
- 1kb4.3 µs25% faster
- 10kb4.8 µs24% faster
- 100kb11 µs19% faster
- 1mb73 µs21% faster
Queue: enqueue1 benchmark
Putting one job on a queue.
- push_single4.9 µs25% faster
Worker cleanup9 benchmarks
Tearing down a worker that leaves with its functions and calls in flight.
- functions/105.1 µs24% faster
- functions/5016 µs28% faster
- functions/20054 µs24% faster
- invocations/102.3 µs24% faster
- invocations/503.1 µs19% faster
- invocations/2006.1 µs18% faster
- triggers/1010 µs19% faster
- triggers/5038 µs19% faster
- triggers/200133 µs20% faster
Invocation tracking, concurrent4 benchmarks
The same with N calls at once.
- 1594 ns18% faster
- 84.5 µs20% faster
- 3222 µs20% faster
- 12891 µs21% faster
Invocation tracking2 benchmarks
Recording which worker each call belongs to.
- add_remove_sequential518 ns21% faster
- add_with_200_existing520 ns22% faster
Method
How we measure
Everything on this page comes from one open-source tool that runs the same way on your machine as in CI, and every number links back to the run that produced it.
microVM · KVM
systema engine
N vCPUs, pinned
cgroup cap M, no swap
⇄ loopback
Load generator
6 vCPUs, 2 GiB
SDK workers and callers
- Machine sizes
- Each size is a microVM (libkrun on KVM, the virtualization systema's own sandboxes use). The engine runs pinned to its N virtual CPUs, in a cgroup capped at M with no swap: past it, the kernel stops it, and the run says where. The load generator runs in the same VM on 6 vCPUs and 2 GiB of its own and talks to the engine over the VM's loopback, so it never takes the engine's CPU and nothing proxies their sockets.
- The host
- The same engine as a plain process, pinned to half of the machine’s cores and not capped: the reference without a VM.
- The load
- Real SDK workers (the Rust SDK) call, host, observe and report observed calls, as an application would. Idle workers are raw WebSockets, so ten thousand fit in one process.
- A step
- 10 seconds of load after a warm-up, with the engine’s CPU, memory and threads read from /proc every 250 ms. Every scenario starts on a fresh engine.
- The engine
- As it ships: default configuration, every span recorded, logs at info. It is the Linux glibc build, the one in the Docker image.
- What it is not
- A shared CI machine, so runs differ by several percent: compare lines within a run, and runs by their trend. The numbers are for this machine; a faster CPU moves every line.
Run it yourself
x86_64 Linux with KVM, in the workers repo next to sandbox-libkrun, against an engine built from systema. The report lands in target/loadtest/.
cd sandbox-libkrun && BUILD_TARGET=x86_64-unknown-linux-gnu scripts/build-init.sh cargo build --release -p systema-loadtest target/release/systema-loadtest suite --engine <systema>/target/release/systema --profiles 2x1024 \ --init target/x86_64-unknown-linux-musl/release/systema-init --krunfw firmware/libkrunfw-linux-x86_64.so