Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

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.

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.md says 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.

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.

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.

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
  • 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.md explains 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.

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.

Terminal window
# All of them
dotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- --filter '*'
# One group
dotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- --filter '*Traversal*'

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.

Terminal window
docker compose up -d --wait
export STRATA_TEST_POSTGRES_URL=postgres://strata:strata@localhost:5432/strata
export STRATA_TEST_REDIS_URL=redis://localhost:6379
# The whole profile
dotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- profile --out dotnet-profile.json
# One scenario
dotnet run -c Release --project dotnet/benchmarks/Datalium.Strata.Benchmarks -- profile --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.

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.

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. EveryNode costs 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/41 describes 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.