Repository navigation
Perf Real Network
Decision: measure Felix on real Azure networks, from release artifacts, with a real IdP on the hot path — and treat every localhost number as a statement about the software, never about a deployment.
Everything published so far (docs/storage-performance.md, the benchmarks
page, scripts/perf/presets.yml runs) was measured over loopback, most of it
against an in-process broker. Loopback is the right harness for what it
measures — regressions in Felix's own code, isolated from the network — and
the wrong instrument for what users will actually see. It hides RTT (loopback
is ~50µs; an availability zone pair is 1–2ms; a WAN path is tens), hides
congestion control entirely (cwnd never matters when bandwidth is infinite and
loss is zero), hides the cost of TLS and connection establishment at real
distance, and flatters quorum: a Quorum acknowledgement over loopback costs
almost nothing extra, which is not the promise a cross-zone deployment is
buying.
This document is the design for closing that gap. The budget is an Azure subscription's $200/month of Azure credit, which shapes several decisions below — this suite provisions, runs, and tears down; nothing idles.
The first results are published. The T1 session's analysis — ~1.09 GB/s / 3.68 M msg/s aggregate ingest (73% of raw line rate), ~181 µs acknowledged-publish latency, durability free for throughput, and every semantic measured against the real Entra IdP — is the "Real-Network Performance (Azure)" page on the docs site (
docs-site/src/content/docs/features/real-network-performance.md). Raw data lives underscripts/perf/azure/sessions/.
The deliverable is a comprehensive performance-analysis page on the docs
site, of publication quality: every scenario's aggregates and spread,
each row stamped with the environment that produced it (tier, VM SKUs, zones,
release tag, measured RTT baselines), fed through the existing
charts-and-snippets pipeline so page assembly is mechanical — with a written
analysis on top that says what the numbers mean, where they beat or trail a
coordination store, and where the design's costs show up. The LOADGEN_JSON
rows and the per-session session.json are shaped for exactly that: the
analysis reads structured data, never scraped prose.
Three disciplines carry over from the local suite, and two are new:
-
Release artifacts, never source builds. Test machines download the
GitHub release tarball (
felix-<tag>-linux-x86_64.tar.gz, produced byrelease.yml) and run those bytes. Building on the target machine wastes budget, varies with the toolchain, and — the local lesson — compiling during a run corrupts the measurement. It also makes every result attributable to a tag a reader can download. - Nothing else runs on the machines. The same hygiene as local runs, now enforced by construction: the VMs exist only for the run.
-
throughputvsdelivered_throughputkeep their meanings, and batched runs still measure a throughput profile, not request latency. - Compare within a session, never across sessions. Cloud VMs are a hardware lottery (host generation, neighbours). An A/B comparison — leader vs quorum, JSON vs binary, watch fanout 1 vs 50 — is valid inside one provisioned session on the same VMs, and suspect across sessions. Publish per-session baselines beside every delta.
- Report variance, not just medians. Real networks jitter. Every scenario runs its trials (5, as locally) and publishes p50/p99/p999 with spread; a run whose trials disagree wildly is a finding about the environment, and is labelled as such rather than averaged into a fiction.
-
Linux host tuning, applied identically by cloud-init. The local numbers
were macOS; the VMs are Ubuntu, and Linux needs two things macOS does not,
both held constant across every session so they never become a hidden
variable. (1)
net.core.{r,w}mem_max = 26 MiB: Linux silently clamps a socket'sSO_RCVBUF/SO_SNDBUFto these maxima, and the stock ~208 KB throttles QUIC throughput (docs/storage-performance.md,docs/perf-investigation-throughput.mdround 18). (2)FELIX_MTU_UPPER_BOUND = 4096on every QUIC endpoint: Azure's VNet path MTU is ≤1500, so 4096 never caps the achievable size, but it keeps MTU discovery under the Linux UDP-GSO ceiling (mtu × 10 ≤ 65535; an MTU ≥8192 hard-stalls sustained throughput with a spuriousEMSGSIZEquinn never recovers from) and converges faster.FELIX_INITIAL_MTUstays unset — pinning it is unsafe before PMTUD on a real path. The Linux build already picks the rightFELIX_IO_RUNTIME_THREADS=0and core-pinning defaults, so no override is needed there.
Three tiers, provisioned by the same scripts with a flag. Each answers a question the previous one cannot.
The cluster as a latency-sensitive user would deploy it: everything in one availability zone, in a proximity placement group, accelerated networking on.
| Role | Count | Size | Why |
|---|---|---|---|
| Broker | 3 |
Standard_D4as_v5 (4 vCPU, 16 GiB) |
Fixed-CPU (no burstable B-series in a measurement), AMD v5 with accelerated networking; 4 vCPU matches the local runs' scale so numbers are comparable |
| Control plane | 1 | Standard_D2as_v5 |
Off the data path; serves auth, exchange, assignments, watches |
| Load generator | 1 | Standard_D4as_v5 |
The generator must never be the bottleneck; watch it for CPU-bound cases and raise it (and the quota) if it saturates |
| Broker data disk | 3 | Premium SSD (Premium_LRS), 128 GiB |
Real durable fsync latency on premium storage |
The topology is sized to 18 vCPU (3×4 + 2 + 4) so it fits the Azure subscription default
20-vCPU Total Regional / DASv5-family quota without a quota request. The
brokers are the system under test and stay at 4 vCPU to match the local runs;
the load generator took the cut from 8 to 4. Raise loadgenVmSize (and request
more quota) if the fanout/throughput cases show it CPU-bound.
What T1 answers: publish/subscribe/cache/counter latency and throughput over a real NIC and switch, fsync against real premium storage, fanout curves, queue drain and retained join, and the JWT flow — the honest replacements for every published localhost number.
Identical, but the three brokers pinned to zones 1/2/3 (no placement group —
the zones are the point) and the stream matrix run twice: Leader vs
Quorum. Inter-zone RTT (~1–2ms) is exactly the price a quorum
acknowledgement pays per publish, and nobody has measured Felix paying it.
What T2 answers: the Leader/Quorum delta on real inter-zone paths; failover blackout duration when a zone's broker dies under load; replication lag under sustained throughput; whether group-commit amortises the quorum wait.
T1's cluster, plus a load generator in a second region (e.g. cluster in
eastus2, client in westus2, 60ms RTT). Latency-focused and
small-payload by design: inter-region egress is billed ($0.09/GB), so
WAN throughput sweeps are bounded explicitly (see budget) rather than run
open-ended.
What T3 answers: client-perceived latency at WAN distance, connection/stream establishment cost, how the client's pooling and pipelining behave when RTT is real, and watch/retained delivery lag to a remote subscriber.
The control plane's token exchange verifies IdP tokens against a configured
allowlist — ES256 by default, optional RS/PS** (auth/exchange.rs), which
is exactly what Entra ID issues. The Azure subscription's tenant provides it
for free: an app registration, client-credentials flow, and the exchange
endpoint turns Entra's RS256 access token into a Felix EdDSA token, which is
what brokers verify per request.
Measured, not just wired:
- Exchange latency — Entra token → Felix token, cold and warm (JWKS cached), as its own scenario.
- Steady-state cost — the data-plane numbers are collected with real Felix tokens minted through this flow and refreshed at realistic intervals, so verification sits on the hot path exactly as deployed.
- Fallback if the Entra tenant fights back: a Keycloak container on the control-plane VM speaks ES256, the default allowlist. The scripts take the issuer/JWKS as parameters either way.
The local presets.yml dimensions carry over (fanout × batch × payload ×
encoding × broker profile), thinned where a dimension is known-flat, plus the
scenarios only a real network can ask:
| Scenario | Tier | What it answers |
|---|---|---|
| Publish latency (batch 1, per-message ack) — fanout {1, 10, 50} × payload {0, 256B, 4KiB} | T1 | The headline numbers, honestly |
| Throughput profile (batch 64) × payload | T1 | Sustained delivery over a real NIC; delivered vs published |
| Cache put/get, counter add/get latency | T1 | Request/response path incl. routed forwards |
| Keyed watch: change-to-delivery latency at watcher fanout {1, 50, 500} | T1 | The fanout claim of the composed semantics |
| Queue drain: enqueue-to-delivery latency and drain throughput through one consumer group | T1 | What the queue semantics cost, with redeliveries counted rather than folded in |
| Retained join: time-to-complete-state vs roster size {10², 10³, 10⁴} | T1 | What "join and hold the roster" costs |
| Leader vs Quorum publish latency, same stream shape | T2 | What the acknowledgement guarantee costs across zones |
| Failover blackout: kill the leader mid-load, measure publish gap and watch re-establishment | T2 | The ~1s local failover claim, on real infrastructure |
| Replication lag under sustained load | T2 | The Leader loss-window, observed not asserted |
| All latency scenarios, small payloads only | T3 | WAN client experience |
| Token exchange latency; authorized vs pre-authorized publish | T1 | What the IdP flow adds |
Each scenario emits the same JSONL the local pipeline consumes, so
normalize_and_aggregate.py → make_charts.py →
render_markdown_snippets.py work unchanged, and results land on the
benchmarks page in the same shape with an environment column (loopback |
azure-t1 | azure-t2 | azure-t3).
-
felix-loadgen— the one real gap. Every existing driver is loopback-bound:latency-demoruns an in-process broker,soakspawns its own child. The generator this suite needs is the measurement core oflatency-demopointed at remote addresses throughfelix-client/ClusterClient: takes cluster endpoints, tenant/token (or the Entra client-credential parameters and does the exchange itself), a scenario spec, emits the standard JSONL. Ships in the release tarball so the load-gen VM downloads the same artifact as the brokers. -
scripts/perf/azure/— reproducible IaC in Bicep (main.bicep: one resource group per session — VNet + NSG, a proximity placement group for T1 or per-zone pinning for T2, broker/control-plane/ load-gen VMs with cloud-init) plus the session lifecycle scripts (session.shdeploys and seeds,run.shdrives the matrix on the load-gen VM and pulls artifacts back,teardown.shdeletes the group). Bicep over Terraform because there is no long-lived state to manage in a create-run-destroy session, and over rawaz createcalls because a declarative template is what makes a session reproducible — the same parameters yield the same cluster, which is the whole point of a published benchmark. cloud-init installs the release artifacts on the brokers and control plane, buildsfelix-loadgenon the generator, and applies the Linux host tuning above; the IdP bootstrap, tenant seeding, and stream/cache registration run through the real REST + token-exchange flow inseed.sh. Orchestration isaz vm run-command, not SSH: the operator drives every VM over the Azure control plane's HTTPS, which needs no inbound port and sidesteps two real failures the first live run hit — networks that deep-packet-inspect and reset outbound:22to arbitrary cloud IPs, and Ubuntu 24.04's socket-activated sshd failing its first start. The VNet-private control plane is reached by running the seed on the loadgen, which is inside the VNet. - A release to point at — see below.
Costed at pay-as-you-go eastus2 list prices; Azure credit covers it several times over.
| Item | $/hour |
|---|---|
| 3 × D4as_v5 brokers | 0.52 |
| 1 × D2as_v5 control plane | 0.09 |
| 1 × D8as_v5 load generator | 0.34 |
| 3 × Premium SSD v2 64 GiB | ~0.05 |
| T1/T2 session total | ~$1.00 |
A full T1 matrix session is ~4 hours ≈ $4. A T2 quorum/failover session ≈ $3.
T3 adds a second-region generator (+$0.34/hr) and egress: at ~$0.09/GB, a
throughput sweep pushing 100 GB would cost more than the VMs — which is why
T3 is latency-scoped and its total transfer is computed and capped in the
runner before a run starts. The whole initial program — T1 twice (repeat to
observe session variance), T2 twice, T3 once, plus debugging headroom — fits
in under $40 of the $200. The guardrails that keep it there: everything
lives in one resource group per session, teardown.sh is the last line of
run.sh, and a scheduled az group delete at +8 hours backstops a wedged
session.
-
Cut the release first (Gabriel's call, and the right one): the ladder
(#348/#349/#350) plus dead-letter replication merge, the workspace version
bumps to 0.3.0, tag
v0.3.0triggersrelease.yml, and every VM in this suite downloads that tarball.felix-loadgenshould be in the same release, which makes it the one code change gating the suite. - Build
felix-loadgen+scripts/perf/azure/, validated against a local cluster (the felix-cluster harness) before any Azure spend. - T1 session, twice. Publish the first real-network numbers beside the loopback ones — expect them to be worse, and say so plainly; that is the point.
- T2, then T3. Each produces a results section on the benchmarks page and updates any claim the numbers contradict.
- No standing environment. $200/month buys ~8 always-on days of this cluster; a permanent testbed would eat the budget and rot besides.
- No Kubernetes. #131 tracks packaging for k8s; this suite measures Felix, not an orchestrator's networking. VMs + systemd keep the path from NIC to broker legible.
- No cross-cloud, no multi-region clusters. The broker's replication is zone-scale today; measuring a topology the design does not target yet would publish numbers with no owner.
Generated from docs/perf-real-network.md in the repository. That file is the source of truth; this page may be behind it.
Architecture & Design
- Broker-internal forwarding protocol
- Felix Architecture
- Felix Wire Protocol (v1)
- Felix semantics
- Felix – Comprehensive Design Document
Storage & Replication
- Durable Storage
- Durable Storage Performance
- Felix Durable Segment Format (v2)
- One core log, many semantics
- Plan: group-commit the cache write path
- Shard replication and leader failover
- The cache is a projection over the log
Control Plane & Cluster
- A formal model of one shard
- Control Plane + RAFT Plan (Draft)
- Metadata Raft
- Subscribe routing: redirect, not proxy
- The Postgres the control plane requires
- The local cluster harness
Clients & Configuration
- Auth Design: OIDC Exchange + Felix Tokens
- Broker Config Examples
- Client Config
- Running a client against a cluster
Security
- Bootstrap security
- Panic and abort audit — broker data path
- RBAC Model (Control Plane)
- Soak and resource-leak report — broker
- Threat model: the broker-internal surface
Performance Investigations
- Real-network performance suite
- Sharding investigation: keyed publishes, io_uring, and a ceiling that would not move
- The 0.4.0 performance matrix
- Throughput investigation: why Felix is slower than raw QUIC
Explorations & History