Skip to content

Performance roadmap

What we shipped under Track D, what's next, and the items we intentionally deferred. Numbers in parentheses are the headroom estimates from the original gap analysis — measure, don't trust.

Shipped

Sovereign log: zero-alloc hot path

Previously src/dispatch.rs called format!("ip={…} class={…}") on every classified request when [sovereign].log_classification = true. That's one heap allocation per request, on the hot path, with no opt-out short of disabling the log.

The new path:

  1. Always-on: bumps a per-class atomic counter (sovereign::record_classification). Exposed on /metrics as zion_sovereign_classifications_total{class="…"}.
  2. Opt-in: emits tracing::info! with &'static str label + Display for the IP. Zero allocation when the subscriber's filter rejects the event; format-direct-to-buffer when the subscriber accepts.

Streaming WAF body scan (API ready, dispatch wiring opt-in)

waf::StreamingScanner is the chunk-by-chunk counterpart of validate_request. Each call to feed(chunk) runs the shared Aho-Corasick automaton over previous_overlap || chunk and keeps the trailing MAX_PATTERN_LEN - 1 bytes for the next call. Properties:

  • Early exit: a payload whose first chunk contains an attack pattern denies as soon as that chunk is scanned, regardless of total body size.
  • Bounded peak memory: at most one chunk + 64 bytes of overlap is held at a time.
  • Boundary correctness: a pattern straddling two chunks is matched via the overlap buffer. Verified by unit test streaming_catches_pattern_split_across_chunks.

Wiring into dispatch.rs::process_request is gated behind a future [waf_profile.X] streaming = true config flag. The default path stays buffered until benchmarks on representative workloads validate the streaming costs.

TLS resumption: explicit failure mode

tls.rs previously panicked at boot when the platform CSPRNG refused to deliver entropy for the session ticketer. That's now a ZionError::Tls with a clean exit code. The rest of the resumption stack (ServerSessionMemoryCache::new(16384), 4 TLS-1.3 tickets per connection, 0-RTT with 425-Too-Early gating, half-RTT data, ignore_client_order) was already in place from v0.1.7 and is documented inline in the source.

Next (ready to land)

Streaming WAF dispatch integration

Plumb StreamingScanner::feed into the body validation branch of dispatch.rs. Today process_request calls Limited::new(body, max_body_bytes).collect().await and then runs the buffered validate_request. Replace with a BodyExt::frame() loop that feeds each frame to StreamingScanner and rebuilds the body for the proxy stage on Allow.

Trade-off: the streaming variant must rebuild the body for the proxy. Two implementation paths:

  1. Tee: keep both the original frames and the scanner's view. Zero re-copy, but holds the body in memory (same peak as today). Wins on latency (early exit) but not memory.
  2. Re-emit: store frames in a Vec<Bytes> and reconstruct BodyExt::Full from them on Allow. One extra hop on the happy path; saves real memory only if the scan denies.

Recommendation: ship (1) first behind streaming = true, measure, then decide.

Per-class metric labels for zion_sovereign_classifications_total

The renderer emits one bucket{class="…"} line per enabled class (see src/sovereign/mod.rs). The current classification_counts() iterator is conditional on geo-eu to elide the EU labels when the feature is off. A future classified_at exemplar would link a request's W3C trace ID to the bucket — symmetric with the latency-histogram exemplars we already emit.

Deferred (research-level)

These items were on the original Track D list but didn't ship in this commit. Each one has a non-trivial complexity bill that has to be paid before the perf gain materialises.

NUMA-aware sharding for DashMap (estimate: +5–10% on dual-socket)

core_affinity already pins worker threads to physical cores. The remaining headroom is cross-socket cache traffic on shared DashMapsstate.rate_map, state.inflight, and the L2 cache shards.

Shipped (scaffold): the numa-aware feature (src/numa.rs, NumaAwareMap) splits those maps into per-NUMA-node shards routed by the calling thread's node. It reads /sys/devices/system/node/ directly (no libnuma dep) and degrades transparently to a single shard on single-socket boxes, macOS, and Windows — so the code path is always compiled and correct; bootstrap surfaces the detected node count on /metrics.

Still WIP: the measured dual-socket win. dashmap::DashMap doesn't expose per-shard memory placement, so the current wrapper shards by node but doesn't yet pin each shard's allocation to that node's memory. Path forward: run benches/numa.rs on bare-metal Linux dual-socket hardware to confirm the +5–10%, then, if the shard-routing alone doesn't land it, reach for papaya or a custom node-pinned sharded map.

io_uring read/write vectored, splice/sendfile (estimate: 1–2% on large payloads)

src/uring.rs currently uses io_uring only for single-shot accept. Read/write vectored would reduce syscall count for large bodies; splice / sendfile would zero-copy cacheable static responses from the file system to the socket.

Why deferred:

  • Linux-only; we need a cfg-gated path that doesn't break the macOS development experience.
  • io_uring read/write needs careful integration with hyper's tokio::io::AsyncRead/AsyncWrite impls — not a drop-in replacement.
  • The win is 1–2% on large payloads; small JSON / form bodies see no benefit. Premature without a workload that targets that regime.

Path forward: add a --features io-uring-rw flag, build a benchmark that proxies 1 MB+ payloads and measures p99 latency, then ship if the regression suite stays clean.

kTLS / sendfile for TLS-terminated cacheable responses

Linux 4.13+ supports kernel TLS, allowing sendfile() to write encrypted bytes straight from page cache to the socket. rustls 0.23 exposes the secret-extraction API (rustls::ServerConfig::enable_secret_extraction = true), but the plumbing into hyper and into a Linux-specific kTLS::send_file syscall is non-trivial.

Why deferred: same as io_uring rw — Linux-only kernel ABI, complex integration, win materialises only on large cacheable static content. Documented as the natural follow-up once io_uring rw lands.

SO_REUSEPORT + BPF demux for unified TCP/QUIC port

Today HTTP/3 (QUIC, UDP) and HTTPS (TCP) bind separate listeners on the same :443. That's two kernel structures, two accept loops, two metrics paths.

A BPF program attached via SO_ATTACH_REUSEPORT_EBPF can demux by the L4 protocol byte and route to the correct listener thread. Reduces bind overhead and aligns connection→worker affinity.

Why deferred:

  • Requires writing eBPF C / Rust, loading via bpf(BPF_PROG_LOAD), and shipping a runtime kernel-version check.
  • The bind overhead is < 1% of request cost; the optimisation is mostly about cleaner architecture, not raw throughput.

Path forward: track upstream Cloudflare's Pingora demux patterns and lift the design once it settles in the broader Rust ecosystem.

Microbenchmark hygiene

Adding micro-bench targets is a Track-D-adjacent task: it's the only way the deferred items above can be ranked objectively. benchmarks/ currently runs end-to-end wrk suites. We need a sibling cargo bench setup with criterion for:

  • validate_request (buffered) vs StreamingScanner::feed on representative payloads (1 KB JSON, 1 MB form, 10 MB upload).
  • record_classification vs the previous format! baseline.
  • parse_traceparent on RFC vectors and on garbage.

This is the next-but-one Track D commit.

Released under the MIT License.