Performance under load¶
What a real ag-server does when many clients talk to it at once: throughput and latency of integrations on
different streams and on one stream, event fan-out, the rate limit, and what happens when the server is told it is
saturated. The cost of the engine itself, by repository size, is in
Performance of the engine.
These are measurements with their conditions, not service-level objectives. AgentGit promises none of these numbers.
How it is measured¶
ag-bench load (crates/ag-bench) starts a real ag-server process for each scenario — fresh data directory,
SQLite control database, SQLite metadata and one file per object, synchronous=FULL, the server's default
configuration except the setting a scenario varies — creates users and credentials through the admin API, and drives
it with real clients from threads of its own process:
cargo build --release -p agentgit-server -p agentgit-bench
target/release/ag-bench load # every scenario, both protocols, 20 s each
target/release/ag-bench load --profile smoke # 3 s each, 4 writers: what CI runs
target/release/ag-bench load --scenarios streams,sse --protocols http --workers 32 --duration-secs 60
target/release/ag-bench load --data-parent /dev/shm # keep the servers' data on a memory-backed file system
target/release/ag-bench load --json out.json --markdown out.md
A cycle is what an agent does to land a change: build a changeset of two files, upload its objects, open a
proposal, integrate it. HTTP/JSON uploads each object with its own PUT; gRPC sends them as one client
stream, so the upload step differs between the protocols by design. A unit completed is a cycle that reached an
answer from the server; what counts is stated per scenario. Latency percentiles are nearest-rank over all
operations of the run (a reported p99 is a latency that happened); the clients are closed-loop (a client sends its
next request when the previous one is answered), so throughput and latency are not independent: more clients on a
saturated server raise latency, not throughput.
The generator and the server share the machine, so the numbers include the generator's own CPU use, and the
server's CPU seconds (from /proc/<pid>/stat) are shown next to them. Retries are not hidden: a failed cycle is
counted as a failure and the client starts a new one after the server's retry hint.
Conditions of the numbers below: commit ff68edb plus the change that introduced ag-bench; Intel Xeon @ 2.80 GHz,
4 logical CPUs, 16 GiB, Linux 6.18, rustc 1.97, release builds of both; 16 writers (one thread and one credential
each, plus one integrator credential each), 20 s per scenario. The data lived on the VM's virtual disk. It is a shared
VM: the load average was 2.4 when the run began and 13 when it ended, because other jobs were running; the numbers are
pessimistic and noisy, and differences below roughly 30 % mean nothing. Re-run on the hardware you care about.
Results¶
Reads: the ceiling¶
32 clients reading a stream head (credential check, authorization, one metadata read), no rate limit:
| protocol | requests/s | p50 | p90 | p99 | max | server CPU |
|---|---|---|---|---|---|---|
| HTTP/JSON | 5 100 | 5.0 ms | 11.9 ms | 21.3 ms | 47.9 ms | 159 % of one core |
| gRPC | 5 062 | 5.9 ms | 9.7 ms | 15.1 ms | 71.8 ms | 172 % of one core |
Both protocols are at the same ceiling, which is mostly the shared four cores (the generator takes the rest).
Integrations on different streams¶
16 clients, each with its own stream of one repository; every cycle is a fast-forward.
| protocol | integrations/s | upload p50 / p99 | propose p50 / p99 | integrate p50 / p99 | whole cycle p50 / p99 / max | failures |
|---|---|---|---|---|---|---|
| HTTP/JSON | 79.5 | 12.5 / 45.6 ms | 41.5 / 396 ms | 75.4 / 482 ms | 162 / 657 / 1191 ms | 0 |
| gRPC | 72.3 | 12.0 / 94.3 ms | 38.9 / 429 ms | 96.0 / 611 ms | 177 / 914 / 1825 ms | 0 |
Different streams do not conflict, but they do not run in parallel either: the server used 88 % of one core
while 16 clients waited, i.e. it was not CPU-bound. The same scenario with the servers' data on a memory-backed file
system (--data-parent /dev/shm, HTTP/JSON, same VM, run afterwards) did 231 integrations/s (propose p50 12 ms,
integrate p50 25 ms, cycle p50 55 ms): about 2.9 times more. On this VM, therefore, most of the write latency is
the disk: every write transaction is an fsync (an integration needed 7 of them when these runs were taken and needs 3
now, numbers; synchronous=FULL, a deliberate durability choice documented in
Durability), and writers to one repository's SQLite database take turns. That is a statement about
this VM's disk; on a disk with cheap fsyncs the ratio will differ. PostgreSQL metadata ([repos] storage, see
PostgreSQL & S3) was not measured.
Integrations on one stream¶
16 clients on the stream main, each reading the head, changing its own two files and integrating. A change built
on a head that has moved by the time it is integrated is merged by the server (three-way merge).
| protocol | answered/s | integrated as | integrate p50 / p99 | whole cycle p50 / p99 / max | aborted |
|---|---|---|---|---|---|
| HTTP/JSON | 44.1 | 95 fast-forward, 807 merge | 43.5 / 810 ms | 116 / 1145 / 2004 ms | 112 (11 %) |
| gRPC | 48.8 | 98 fast-forward, 895 merge | 58.9 / 655 ms | 99 / 681 / 1101 ms | 159 (14 %) |
These runs are from before the bounded retry described in the engine page
(24 attempts with a spread wait, repos.integrate_attempts); with it aborted fell to 3-4 % at 16 writers on one
stream (HTTP/JSON, two runs). Failure mode: when the stream moves again and again while a merge is being prepared, the
server gives up after its attempts and answers aborted — "stream changed 8 times while integrating; retry" (8 was
the limit when these runs were taken; HTTP 409, retryable). It lost
nothing: the proposal is untouched and the client integrates it again. At 16 writers on one stream, roughly one
integration in nine needed that second try. Clients should treat aborted as "retry" (the error is marked retryable in the protocol); the generator here does not retry inside a cycle, it counts the failure and starts a new cycle.
Same file, same stream (every client changes shared.txt on the head it read), 16 clients:
| protocol | answered/s | integrated | conflict outcomes |
integrate p50 / p99 | failures |
|---|---|---|---|---|---|
| HTTP/JSON | 91.6 | 329 | 1 512 | 57.6 / 386 ms | 0 |
| gRPC | 93.3 | 319 | 1 559 | 60.4 / 341 ms | 0 |
Nothing is lost or merged silently: the first change on a head wins, the others are told which paths conflict. A conflict is answered faster than a successful merge because no merge changeset is written.
Event fan-out¶
100 subscribers (50 SSE over HTTP/JSON, 50 gRPC server streams) follow one repository while 16 writers integrate on their own streams. Delivery lag is the client's receive time minus the event's server timestamp (same host; 1 ms resolution).
| value | |
|---|---|
| integrations during the run | 1 049 (51.5/s; 79.5/s in the otherwise identical streams run without subscribers, on a machine whose load varied) |
stream.integrate events delivered |
1 049 to every one of the 100 subscribers; 0 duplicates; 0 feeds failed |
| lag, SSE | p50 37 ms, p90 136 ms, p99 287 ms, max 622 ms |
| lag, gRPC | p50 37 ms, p90 133 ms, p99 281 ms, max 584 ms |
| server | 215 % of one core, 81 MiB peak RSS |
Every event reached every subscriber, once. The lag is dominated by the machine being saturated (116 threads of load generator on four cores), not by the feed; it should be read as an upper bound for this setup.
The rate limit¶
The default limit is per credential (limits.requests_per_second = 200, burst = 400); this scenario uses 50 and
100. One credential is hammered by 8 closed-loop clients for 20 s while a second credential makes one call every
200 ms.
| protocol | requests sent | admitted | the configuration allows | rejected | second credential |
|---|---|---|---|---|---|
| HTTP/JSON | 160 256 | 1 100 (55.0/s) | burst + rate × time = 100 + 50 × 20 = 1 100 | rate_limited, HTTP 429, every one |
100 of 100 answered |
| gRPC | 172 682 | 1 100 (55.0/s) | 1 100 | rate_limited (gRPC RESOURCE_EXHAUSTED) |
100 of 100 answered |
The limiter admits exactly what the token bucket allows, rejections cost the server less than a served request
(p50 0.87 ms against 0.95 ms) and do not disturb other credentials. The retry hint: the error body carries
retry_after_ms (1–20 ms here); the HTTP Retry-After header is in whole seconds rounded up (every rejection said
1), as the header's format requires, so a client that honours the header waits much longer than the bucket needs
— clients that can read the JSON hint should prefer it. One second after the hammering stopped the credential was
served again. A rejection is cheap but not free: 8 clients rejected 8 000 times a second used about 150 % of one core.
Saturation: load shedding¶
The server refuses requests beyond limits.max_concurrent_requests at once instead of queuing them. With the cap
set to 4 (the default is 512) and 64 clients reading a stream head:
| protocol | requests/s answered | served | refused (unavailable, HTTP 503) |
refusal p50 | served p50 |
|---|---|---|---|---|---|
| HTTP/JSON | 5 104 | 45 639 | 56 525 | 9.4 ms | 12.8 ms |
| gRPC | 15 140 | 56 578 | 246 387 | 3.7 ms | 5.0 ms |
The server stayed up and ready (/readyz answered 200 after the run). Refusals carry unavailable and are
retryable. The latency of a refusal is mostly the time to get scheduled on a saturated machine, not the server's work.
gRPC clients got through far more refused requests per second in this run; this was not investigated further.
What these numbers do not say¶
- No deployment is measured. One VM, loopback networking, the generator on the same cores, a virtual disk.
- PostgreSQL metadata, S3 objects, several replicas, TLS were not exercised.
- Small repositories. Each scenario starts from an empty repository and a run adds a few thousand small changesets. Behaviour on a 100 000-file repository under load follows from the engine numbers (an integration there costs 0.3–1 s of CPU) but was not measured end to end.
- Latency of a single idle request is not what a user sees: closed-loop clients on a saturated server measure queueing. The idle cost is the engine table plus a few milliseconds.
- Long runs. 20 s per scenario shows no failure over time, memory growth or compaction effects.
- The Web UI, MCP server and workers are not driven.
Finding the bottleneck of your own setup¶
- The server's request and integration metrics and its CPU are in Observability; if the server is far from one core busy and integrations are slow, look at the disk (fsync latency) before the CPU.
- Same-stream
abortedanswers mean many writers integrate to one stream; give agents their own streams and integrate them in turn, or retry. - To see how much of your write latency is the disk, run the
streamsscenario twice, once with--data-parenton a memory-backed file system.
In CI¶
perf.yml runs on changes to the server, the engine and the tool:
- smoke (every push to
maintouching those paths and every pull request touching the tool): the engine benchmark at 1 000 and 10 000 files andag-bench load --profile smoke, release builds, non-gating (continue-on-error); the results are in the job summary and uploaded as theperf-smokeartifact. A runner's numbers are not compared with anything: the point is that the tools still run end to end. - full (weekly, and by hand): the sizes in this page, the artifact is
perf-full.
The functional behaviour that the load run exercises — rate_limited rejections never exceeding the bucket, shedding
that leaves the server ready, every subscriber receiving every stream.integrate event once, same-file changes
producing conflict — is also asserted, with small numbers, by cargo test -p agentgit-bench
(crates/ag-bench/tests/smoke.rs), which starts a real ag-server process.