Performance
Strata ships a benchmark suite that measures the engine’s own cost: what a node, a run, a parallel branch and a checkpoint cost when the handlers do nothing. It also measures what each built-in store costs to save and load a checkpoint. It doesn’t measure model or tool latency. In a real agent, a node that calls a model spends far longer in that call than in the engine, so these numbers matter most for graphs of many cheap nodes and for durable runs that checkpoint often.
This page summarizes the results. The full tables, the method and every caveat are in benchmarks/RESULTS.md. The code is in the benchmarks folder, and the design behind it is design/37-Benchmarks.md.
The machine and the method
Section titled “The machine and the method”Every number on this page comes from one machine:
- Version:
0.9.0-preview.1. - Machine: Intel Core i9-12900KF (8 performance and 8 efficiency cores, 24 logical), Windows 11.
- Runtimes: .NET 10.0.8 (x64 RyuJIT, server GC). Rust 1.91.1,
--release, tokio’s multi-threaded runtime. - Stores: SQLite as a file on the local disk. Postgres 16 and Redis 7 in Docker Desktop on the same machine, with Postgres on
tmpfs. - When: measured between 2026-10-11 and 2026-10-15.
RESULTS.mdsays which figures come from which day and commit. - Not a quiet machine. A development session and an editor were running throughout. The slowest percentiles and the maxima carry that noise.
Each engine has two tools that answer different questions:
- Microbenchmarks, with BenchmarkDotNet in .NET and criterion in Rust. They report the mean time of one run against a store that costs nothing, so the time is the engine’s. In .NET they also report what a run allocates.
- A profile, a program in the repository. It times every operation itself and reports latency percentiles up to P99.9, throughput under concurrency, memory per suspended thread, and what each store does.
Both engines run the same scenarios: the same graphs, the same state and the same parameters. A number in one engine is about the same thing as the number beside it. The aim is to explain differences, not to race the engines.
What the results show
Section titled “What the results show”These follow the numbered findings in RESULTS.md.
- The engine’s share of a node is about 0.4 µs in both engines. That is the median gap between one async handler returning and the next one starting. The P99.9 is 1.5 µs in .NET and 1.0 µs in Rust. In Rust it was 1.8 µs before the work in
design/38. - Traversals are sub-millisecond at every size measured. A hundred async nodes take 50 µs in .NET and 41 µs in Rust. The slowest case, a hundred sync nodes in Rust, takes 0.46 ms.
- Rust is level with .NET on a line of async nodes, and slower than .NET elsewhere. Where it is slower, the cost is outside the per-node path:
- A run’s own setup is about 0.9 µs in Rust and 0.2 µs in .NET. That is the whole difference on a one-node run.
- A branch of a parallel group costs about 1.5 µs in Rust and 0.6 µs in .NET. An item of a map costs about 2.1 µs and 0.9 µs.
- A streamed token costs 352 ns in Rust and 111 ns in .NET, nearly all of it the channel.
- Rust clones the state into every async handler, so a 16 KiB state adds 54% to a ten-node run. In .NET it adds nothing.
- A sync handler in Rust costs a thread hand-off each way, and its cost moves with that. When both threads stay awake, a sync node costs about 1.1 µs. When one sleeps, it costs 4 to 5.5 µs. Sync throughput in Rust stops at about 0.4 million transitions a second however many runs are going. Async reaches 11 million a second in Rust and 20 million in .NET.
- A suspended thread holds nothing in the process. Against SQLite, ten thousand frozen threads grew the heap by 0 bytes each in Rust and by nothing measurable in .NET, and took 1.6 KB each on disk. The in-memory store holds 1.7 KB (.NET) or 3.6 KB (Rust) per thread, its copy of a 1.25 KB checkpoint.
- A checkpoint per node costs a store round trip per node. A ten-node run under
EveryNode(eleven saves) takes 3 ms on Redis, 12 to 14 ms on Postgres and 25 ms on SQLite. With a store that costs nothing, the same run takes 12 µs in .NET and 20 µs in Rust. - SQLite saves are bound by the disk. Without group commit, each save takes about 2.3 ms, one disk sync per save, about 415 a second however many writers there are. Postgres stops scaling at about 7,000 saves a second. Sixteen Postgres writers save 6,849 checkpoints a second in Rust and 6,339 in .NET.
- Group commit multiplies what many writers save. With 64 writers, SQLite saves about 15 times as many (6,433 a second in Rust, 6,362 in .NET, against 415 and 410). Postgres saves 2.5 times as many in Rust (17,882 against 7,097) and 1.8 times in .NET (12,619 against 7,092). A single writer gains nothing and pays a little: 773 saves a second against 1,010 in Rust, and 733 against 868 in .NET.
- The .NET group commit varies from run to run, and the cause isn’t known. Six runs of .NET Postgres with 64 writers gave 5,900 to 16,200 saves a second. Four Rust runs gave 17,900 to 21,600.
- Deltas and offload shrink a large state’s checkpoints. With a 64 KiB value that never changes, a save stores 65.6 KB whole, 6.6 KB on average with deltas, and 3.4 to 3.7 KB with the value offloaded. Loads read more: a chain of deltas, or the blob. State describes both options.
Selected numbers
Section titled “Selected numbers”Engine cost per run
Section titled “Engine cost per run”The mean time of one run against a store that costs nothing, from the microbenchmarks. Measured on the machine above.
| Benchmark | Rust | .NET |
|---|---|---|
| Traversal, async, 1 node | 1.28 µs | 0.71 µs |
| Traversal, async, 10 nodes | 5.22 µs | 4.11 µs |
| Traversal, async, 100 nodes | 41.0 µs | 49.9 µs |
| Traversal, sync, 1 node | 4.49 µs | 2.06 µs |
| Traversal, sync, 10 nodes | 13.2 µs | 14.7 µs |
| Traversal, sync, 100 nodes | 459 µs | 143 µs |
| Parallel group, 8 branches | 15.1 µs | 5.65 µs |
| Map, 64 items | 144 µs | 61.1 µs |
10 nodes, 1 KiB state, Boundaries |
5.60 µs | 4.11 µs |
10 nodes, 1 KiB state, EveryNode |
20.5 µs | 12.3 µs |
The Rust sync rows are one run of five that differed widely. Five runs gave 4.3 to 7.0 µs for one node, 13 to 54 µs for ten and 134 to 539 µs for a hundred. Run them more than once before you trust one.
A durable run on a real store
Section titled “A durable run on a real store”The ten-node line with a 1 KiB state under EveryNode, eleven checkpoints a run. Measured on the machine above, with Postgres and Redis in a container on the same machine.
| Store | Engine | Mean | P99 |
|---|---|---|---|
| SQLite | Rust | 25.7 ms | 36.6 ms |
| SQLite | .NET | 25.4 ms | 35.6 ms |
| Postgres | Rust | 12.4 ms | 14.8 ms |
| Postgres | .NET | 13.9 ms | 16.3 ms |
| Redis | Rust | 2.82 ms | 3.72 ms |
| Redis | .NET | 2.87 ms | 4.13 ms |
What the numbers don’t tell you
Section titled “What the numbers don’t tell you”- They are one machine’s. Compare your own run with another run on your machine, not with these numbers.
- They were measured on Windows only. CI runs every benchmark once on Linux on each pull request, to prove the benchmarks still run. It records nothing, and nothing fails a build when performance regresses.
- The stores had no network in front of them. Postgres and Redis ran in a container on the same machine, so their numbers are a floor for the store’s own cost. Docker Desktop also made a 64 KB command to either container slow on this machine, which affects the large-state rows.
RESULTS.mdexplains this in each section it touches. - They aren’t a load test of a real application. A load report from a real application is one of the open items for 0.9.0. See Why Strata is a preview.
- Some things aren’t measured yet. Tool dispatch, subgraphs, retries and the drivers have no benchmarks of their own.
Known limitations lists these with the rest of what this version doesn’t do yet.
Run them yourself
Section titled “Run them yourself”You need a clone of datalium/strata, and the .NET SDK for the C# benchmarks or the Rust toolchain for the Rust ones. Installation lists the versions. Run every command from the repository root. Close what you can first: a benchmark measures the machine as much as the code.
The microbenchmarks
Section titled “The microbenchmarks”# All of themdotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- --filter '*'
# One groupdotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- --filter '*Traversal*'# All of them(cd rust && cargo bench -p strata-bench)
# One group(cd rust && cargo bench -p strata-bench -- traversal)The profile
Section titled “The profile”The profile needs Docker for the Postgres and Redis rows. The repository’s docker-compose.yml starts both, and two variables tell the profile where they are. Without the variables, the profile leaves those rows out and says so. It works in a schema and under a key prefix of its own, and removes them when it finishes.
docker compose up -d --waitexport STRATA_TEST_POSTGRES_URL=postgres://strata:strata@localhost:5432/strataexport STRATA_TEST_REDIS_URL=redis://localhost:6379
# The whole profiledotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- profile --out dotnet-profile.json
# One scenariodotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- profile --only throughputdocker compose up -d --waitexport STRATA_TEST_POSTGRES_URL=postgres://strata:strata@localhost:5432/strataexport STRATA_TEST_REDIS_URL=redis://localhost:6379
# The whole profile(cd rust && cargo run --release -p strata-bench -- --out ../rust-profile.json)
# One scenario(cd rust && cargo run --release -p strata-bench -- --only throughput)--only takes transition_latency, run_latency, throughput, suspended_memory, stores and large_state, separated by commas. The profile prints a Markdown table, and with --out it also writes the results as JSON, in the same shape from both engines.
--quick runs the profile once with small counts. That proves it runs, which is what CI does. It measures nothing.
The compose file now starts Redis 8, where the published results used Redis 7.
Share your results
Section titled “Share your results”Numbers from other machines, and from Linux in particular, help. Open an issue at github.com/datalium/strata/issues with your hardware, OS, runtimes, the commit you measured and the profile’s JSON.
What the numbers suggest
Section titled “What the numbers suggest”Each of these follows from a measured number above. Check it against your own graph.
- In Rust, prefer async handlers on hot paths. An async node costs about 0.4 µs. A sync node costs 1.1 to 5.5 µs, because it runs on tokio’s blocking pool. For a node that calls a model, the difference doesn’t matter.
- Choose the checkpoint policy for the durability you need.
EveryNodecosts one store round trip per node: about 3 ms on Redis and 25 ms on SQLite for a ten-node run.Boundaries, the default, writes only where a run stops. See Durable execution. - Keep state small. In Rust, a 16 KiB state adds 54% to a ten-node run. For a large value that rarely changes, use deltas or offload, as State describes.
- With many writers, consider group commit. At 64 writers it multiplies SQLite saves about 15 times and Postgres saves 1.8 to 2.5 times. With one writer it costs a little. The SQLite and Postgres stores have it;
design/41describes it. - Waiting threads cost the store, not the process. Against SQLite, a thread frozen at a boundary held nothing on the heap and took 1.6 KB on disk. The in-memory store keeps every waiting thread’s checkpoint in memory. See Human-in-the-loop.