Client-side code paths that need no server, measured in release mode on macOS and Linux
不需要伺服器的客戶端程式路徑,在 macOS 與 Linux 上以 release 模式量測
Every push to main reruns the suite on Linux and records one data point per commit
每次 push 到 main 都會在 Linux 重跑一次,每個 commit 記錄一個資料點
Live charts from Bencher: p50 wall clock per iteration on the main branch, ubuntu-24.04 testbed, log scale. The workflow moved to that image on 2026-10-26; earlier runs stay on the ubuntu-latest testbed in Bencher. Click a chart to open the interactive view with allocation counts and per-commit details.
來自 Bencher 的即時圖表:main 分支在 ubuntu-24.04 testbed 上每次迭代的 p50 wall clock,對數軸。workflow 在 2026-10-26 改用這個 image,之前的執行仍留在 Bencher 的 ubuntu-latest testbed。點擊圖表可開啟互動頁面,查看記憶體配置次數與每個 commit 的細節。
Both columns below were measured on the same commit on 2026-10-25; the client-stack section on 2026-10-26, on later code. Linux numbers come from a shared GitHub-hosted runner and vary between runs; treat differences under about 10% as noise.
下表兩欄在同一個 commit 於 2026-10-25 量測;client stack 一節是 2026-10-26 在之後的程式碼上量的。Linux 數字來自共用的 GitHub-hosted runner,每次會有浮動,10% 以內的差異視為雜訊。
| macOS | Linux | |
|---|---|---|
| Machine機器 | Apple M1 Max (arm64), 10 cores, 64 GB | GitHub ubuntu-latest, AMD EPYC 9V45 (x86_64), 4 vCPU, 16 GB |
| OS作業系統 | macOS 27.2 (Darwin 27.2.0) | Ubuntu 24.04.5 LTS (6.17.0-1022-azure) |
| Swift | 6.4 (swiftlang-6.4.0.34.1) | 6.4.1 (swift-6.4.1-RELEASE) |
| Build建置模式 | Release | Release |
| Source來源 | local run本機執行 | Benchmarks workflow, run 40121083776 |
p50 is the median wall clock per iteration; Malloc the heap allocations per iteration; Instructions the CPU instructions per iteration (macOS only: Linux needs perf_event, which GitHub runners do not expose). Lower is better everywhere. On Linux the benchmark runtime interposer adds a fixed floor of 16 allocations to every benchmark, so a zero-allocation benchmark reports 16 there.
p50 是每次迭代 wall clock 的中位數;Malloc 是每次迭代的堆積配置次數;Instructions 是每次迭代的 CPU 指令數(僅 macOS:Linux 需要 perf_event,GitHub runner 不開放)。所有數字都是越低越好。Linux 上 benchmark runtime 的 interposer 會對每個 benchmark 固定多算 16 次配置,所以零配置的 benchmark 在 Linux 會顯示 16。
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
EventData/create-raw-data | 1,167 ns | 1 | 5,935 | 3,555 ns | 17 |
EventData/create-codable-model | 1,208 ns | 2 | 6,799 | 3,545 ns | 18 |
EventData/encode-codable-payload | 3,751 ns | 13 | 34 K | 5,011 ns | 29 |
EventData/create-batch-100-raw | 78 µs | 501 | 911 K | 357 µs | 517 |
EventData/create-batch-100-codable | 52 µs | 201 | 582 K | 342 µs | 217 |
EventData from Data costs about 1.2 µs on macOS with 1 allocation (the internal Data copy).Codable model adds 1 allocation to box the model; encoding is deferred until .data is read, so construction cost is almost the same as the raw case.encode-codable-payload is the full JSONEncoder().encode() path and the hot path when serialising events to the wire.EventData construction is about 3× slower on Linux for one event and 4.5–6.5× for a batch of 100. Allocation counts match, so this is the cost of swift-foundation's Data and UUID on that runner, not extra work in the SDK.Data 建立 EventData 在 macOS 約 1.2 µs、1 次配置(內部的 Data 複製)。Codable model 多 1 次配置來裝箱 model;編碼延後到讀取 .data 時才發生,所以建構成本跟 raw 幾乎相同。encode-codable-payload 是完整的 JSONEncoder().encode() 路徑,也是事件送上線路時的熱路徑。EventData 建構在 Linux 上,單筆慢約 3 倍,100 筆的批次慢 4.5–6.5 倍。配置次數一致,所以差在該 runner 上 swift-foundation 的 Data 與 UUID,不是 SDK 多做了事。| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
ClientSettings/parse-single-node | 9,007 ns | 11 | 118 K | 8,927 ns | 44 |
ClientSettings/parse-cluster-seeds | 20 µs | 29 | 272 K | 18 µs | 70 |
ClientSettings; there is no reason to parse in a hot path.ClientSettings,沒有理由在熱路徑上解析。Append/encode-request/<events>x<payload> builds the request messages of one append (requestMessages()) and serialises each to bytes, as the gRPC serializer does before writing them to the stream. The events carry raw Data payloads, so JSON encoding is not included.
Append/encode-request/<筆數>x<payload> 建出一次 append 的請求訊息(requestMessages()),再把每則序列化成 bytes,跟 gRPC serializer 寫進 stream 前做的事一樣。事件用的是原始 Data payload,所以不含 JSON 編碼。
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
Append/encode-request/1x100B | 2,751 ns | 6 | 24 K | 2,153 ns | 24 |
Append/encode-request/1x4KB | 2,835 ns | 6 | 25 K | 2,263 ns | 24 |
Append/encode-request/1x64KB | 5,043 ns | 6 | 42 K | 3,085 ns | 24 |
Append/encode-request/100x100B | 89 µs | 204 | 1,156 K | 112 µs | 222 |
Append/encode-request/100x4KB | 104 µs | 204 | 1,292 K | 122 µs | 222 |
Append/encode-request/100x64KB | 284 µs | 204 | 2,939 K | 206 µs | 222 |
Append/encode-request/1000x100B | 878 µs | 2,004 | 11.5 M | 1.23 ms | 2,022 |
Append/encode-request/1000x4KB | 1.01 ms | 2,004 | 12.9 M | 1.32 ms | 2,022 |
Append/encode-request/1000x64KB | 2.19 ms | 2,004 | 29.4 M | 2.14 ms | 2,022 |
Codable models add EventData/encode-codable-payload per event: JSON encoding costs about four times as much as the protobuf encoding around it.Codable model 的話,每筆再加上 EventData/encode-codable-payload:JSON 編碼的成本約是外層 protobuf 編碼的四倍。Read/decode-response/<events>x<payload> parses one serialised ReadResp per event, as the server streams them, and turns each into a Streams.ReadResponse. Read/decode-model/<events> then decodes the RecordedEvents of such a read into a three-field Codable struct with decode(to:).
Read/decode-response/<筆數>x<payload> 依 server 串流的方式,每筆事件解析一則序列化的 ReadResp,再轉成 Streams.ReadResponse。Read/decode-model/<筆數> 接著用 decode(to:) 把這些 RecordedEvent 解碼成三個欄位的 Codable struct。
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
Read/decode-response/1x100B | 4,667 ns | 9 | 46 K | 3,135 ns | 27 |
Read/decode-response/1x4KB | 4,583 ns | 9 | 47 K | 3,225 ns | 27 |
Read/decode-response/1x64KB | 5,751 ns | 9 | 60 K | 3,685 ns | 27 |
Read/decode-response/100x100B | 321 µs | 900 | 4,329 K | 289 µs | 1,116 |
Read/decode-response/100x4KB | 338 µs | 900 | 4,436 K | 298 µs | 1,116 |
Read/decode-response/100x64KB | 574 µs | 900 | 5,722 K | 380 µs | 1,116 |
Read/decode-response/1000x100B | 3.19 ms | 9,000 | 43.2 M | 2.96 ms | 11,020 |
Read/decode-response/1000x4KB | 3.41 ms | 9,000 | 44.4 M | 3.02 ms | 11,020 |
Read/decode-response/1000x64KB | 5.57 ms | 9,000 | 57.3 M | 4.92 ms | 11,020 |
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
Read/decode-model/1 | 3,417 ns | 12 | 33 K | 2,183 ns | 28 |
Read/decode-model/100 | 231 µs | 1,200 | 3,021 K | 183 µs | 1,216 |
Read/decode-model/1000 | 2.37 ms | 12,000 | 30.0 M | 1.82 ms | 12,020 |
decode(to:) adds about 2.4 µs and 12 allocations per event on macOS (1.8 µs on Linux).decode(to:) 每筆在 macOS 再加約 2.4 µs、12 次配置(Linux 1.8 µs)。Client/* runs a real KurrentDBClient against an in-process server (Benchmarks/OfflineBenchmarks/InProcessServer.swift), plugged in through the package-level ClientSettings.transportProvider. Node selection, the retry wrapper, the shared connection, the generated gRPC client and the response sequences run as they do against a node. The server answers from responses serialised in advance and never parses a request, but like a node it takes each request whole before answering, and it fails a call whose request does not carry the expected number of messages. There are no sockets and no HTTP/2 framing, so what is left is the cost of the client and of grpc-swift. GRPC/* makes the same calls with the generated gRPC client alone, on the same kind of connection behind the same AnyClientTransport: Client/* minus GRPC/* is what the SDK adds above that transport, whose own forwarding both include; it forwards nothing per message. Both also include one copy of every message, each way, between the in-process transport's [UInt8] and the Posix transport's bytes type, which a real connection does not make. Measured on 2026-10-26 on the code of PR #182: macOS in two runs with the same allocation counts and p50s at most 9.4% apart (the second run against the first; the tables show the first); Linux in run 40282079929 (AMD EPYC 7763, 4 vCPU, Ubuntu 26.04).
Client/* 用真的 KurrentDBClient 接 in-process server(Benchmarks/OfflineBenchmarks/InProcessServer.swift),透過 package 層級的 ClientSettings.transportProvider 接上。node 選擇、retry、共用連線、生成的 gRPC client 與回應序列都跟接真 node 時一樣在跑。server 用事先序列化好的回應作答,從不解析請求,但會像真 node 一樣先收完整個請求再回應,請求的訊息數不對就讓呼叫失敗;沒有 socket、也沒有 HTTP/2 framing,所以剩下的就是 client 與 grpc-swift 本身的成本。GRPC/* 在同樣的連線、同樣的 AnyClientTransport 後面,只用生成的 gRPC client 做同樣的呼叫:Client/* 減掉 GRPC/* 就是 SDK 在這個 transport 之上多出來的部分,transport 本身的轉發兩邊都有,而且每則訊息不會另外轉發。兩者也都包含每則訊息在 in-process transport 的 [UInt8] 與 Posix transport 的 bytes 型別之間來回各複製一次,這是真連線不會有的。量測於 2026-10-26,使用 PR #182 的程式碼:macOS 跑了兩次,配置次數相同、p50 最多相差 9.4%(第二次對第一次;表中是第一次);Linux 為 run 40282079929(AMD EPYC 7763, 4 vCPU, Ubuntu 26.04)。
Two things these numbers do not show. Memory: the in-process transport queues messages without a limit, so the server can run ahead of a slow reader; against a node, the HTTP/2 flow-control window limits how far it can. The server's share of a deadline: requests reach the in-process server without their grpc-timeout header, so only the client's timer runs; a node runs its own on its own machine. Dropping the header copies the request metadata of a call with a deadline, which a real connection does not do.
這些數字不包含兩件事。記憶體:in-process transport 的佇列沒有上限,server 可以跑在讀得慢的一方前面;接真 node 時,HTTP/2 的 flow-control window 會限制 server 能超前多少。deadline 在 server 端的成本:請求送到 in-process server 時已拿掉 grpc-timeout header,所以只有 client 的計時 task 在跑;真 node 的計時在它自己的機器上。拿掉 header 會複製一次有 deadline 的呼叫的請求 metadata,這是真連線不會做的。
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
Client/append/1x100B | 58 µs | 110 | 623 K | 142 µs | 122 |
Client/append/100x100B | 299 µs | 1,107 | 4,841 K | 576 µs | 823 |
Client/append-with-deadline/1x100B | 70 µs | 129 | 709 K | 208 µs | 141 |
Client/read-buffered/1x100B | 62 µs | 107 | 655 K | 160 µs | 130 |
Client/read-buffered/100x100B | 730 µs | 1,610 | 10.2 M | 1.23 ms | 1,636 |
Client/read-buffered/1000x100B | 6.54 ms | 15,119 | 95.8 M | 10.97 ms | 15,151 |
Client/read-lazy/1x100B | 68 µs | 117 | 755 K | 199 µs | 139 |
Client/read-lazy/100x100B | 996 µs | 1,808 | 12.7 M | 2.62 ms | 1,836 |
Client/read-lazy/1000x100B | 9.27 ms | 17,114 | 122.2 M | 24.92 ms | 18,015 |
| Benchmark | macOS p50 | macOS Malloc | macOS Instructions | Linux p50 | Linux Malloc |
|---|---|---|---|---|---|
GRPC/append/1x100B | 52 µs | 98 | 593 K | 123 µs | 105 |
GRPC/append/100x100B | 284 µs | 996 | 4,571 K | 479 µs | 708 |
GRPC/read/1x100B | 50 µs | 85 | 570 K | 128 µs | 101 |
GRPC/read/100x100B | 521 µs | 1,479 | 7,721 K | 830 µs | 1,496 |
GRPC/read/1000x100B | 4.92 ms | 14,085 | 72.0 M | 6.95 ms | 14,109 |
The suite is Benchmarks/OfflineBenchmarks, built with ordo-one/benchmark in release mode. The Benchmarks workflow runs it on ubuntu-24.04 on every push to main, publishes the markdown report to the job summary, and uploads two measures per benchmark to Bencher: latency (p50 wall clock in ns, bounds p0/p90) and the p50 malloc count, as allocations or, for the client-stack benchmarks, allocations-concurrent. On a push to main two thresholds can fail the run: a t-test on latency (upper boundary 0.99 over the last 64 runs, once a benchmark has 10) when the result lands outside the band of recent history, and a percentage test on allocations (upper boundary 1% against the previous run) when the count grows by more than 1%. Allocation counts are deterministic for a given toolchain and runner image, so growth comes from a code change, a new Swift patch release or a new runner image; each alerts once, and the next run compares against the new count. The client-stack benchmarks are the exception: they allocate on several concurrent tasks, and on the 4-vCPU runner a lazy read's count per iteration spreads over about 10%, so allocations-concurrent keeps their history without a threshold and the latency test covers them.
套件是 Benchmarks/OfflineBenchmarks,用 ordo-one/benchmark 以 release 模式建置。Benchmarks workflow 每次 push 到 main 就在 ubuntu-24.04 執行,把 markdown 報表寫進 job summary,並對每個 benchmark 上傳兩個 measure 到 Bencher:latency(p50 wall clock,單位 ns,區間 p0/p90)與 p50 配置次數;配置次數一般記為 allocations,client stack 的 benchmark 記為 allocations-concurrent。push 到 main 時有兩個門檻會讓執行失敗:latency 上的 t-test(最近 64 次、上界 0.99,累積滿 10 次才開始)在結果落在歷史區間外時觸發;allocations 上的 percentage 門檻(對前一次、上界 1%)在配置次數增加超過 1% 時觸發。同一套 toolchain 與 runner image 上的配置次數是固定的,所以增加只會來自程式碼變更、新的 Swift patch 版本或新的 runner image;每種情況只會觸發一次,下一次執行就以新的次數為基準。client stack 的 benchmark 是例外:它們在多個並行的 task 上配置記憶體,在 4 vCPU 的 runner 上 lazy read 每次迭代的配置次數分散約 10%,所以 allocations-concurrent 只保留歷史、不設門檻,由 latency 的門檻把關。
How to run the suite locally on macOS or Linux, filter benchmarks and compare baselines: BENCHMARKS.md.
在 macOS 或 Linux 本機執行、篩選 benchmark 與比較 baseline 的步驟見 BENCHMARKS.md。