From bc8a72e5cce6368a8359a5cfa581942c54fe1088 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Thu, 13 Aug 2026 17:40:22 +1000 Subject: [PATCH] proxy: add mirror-selection benchmarks (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why We added a mirror load-balancing strategy (round_robin / least_conn) in #122 and need hard numbers on whether mirror *selection* adds meaningful request latency. This PR adds microbenchmarks that isolate the selection cost (no network/DB/redis) and commits the results as a permanent regression guard. ## How `internal/proxy/selection_bench_test.go` builds a zero-value `Engine` (same setup as `leastconn_test.go` / `multibaseurl_test.go`) and calls the selection functions directly: - `BenchmarkBaseURLAttemptOrder_SingleURL` — the `len<=1` early-return no-op path - `BenchmarkBaseURLAttemptOrder_RoundRobin` — pools of 3 and 8 - `BenchmarkBaseURLAttemptOrder_LeastConn` — pools of 3 and 8, with in-flight skew preloaded on the gauges - `BenchmarkBeginEndAttempt` — the gauge inc/dec pair - `_Parallel` (`RunParallel`) variants of RR / least_conn / begin-end to surface sync.Map + atomic contention Run: `go test -run=^$ -bench='BaseURLAttemptOrder|BeginEndAttempt' -benchmem -benchtime=1s -count=6 -cpu=8 ./internal/proxy/` `internal/proxy/BENCHMARKS.md` has the median-of-6 table (Ryzen 7 4700U, go1.26.5) plus the raw runs. ## Headline numbers (median of 6, ns/op — sequential | parallel@8) | selection | seq ns/op | parallel ns/op | allocs/op | |---|---:|---:|---:| | single-URL | ~133 | — | 1 | | round_robin (3) | ~462 | ~67 | 4 | | round_robin (8) | ~682 | ~130 | 4 | | least_conn (3) | ~2690 | ~292 | 22 | | least_conn (8) | ~15200 | ~1673 | 132 | | beginAttempt+endAttempt | ~514 | ~72 | 4 | **Key caveat:** `baseURLAttemptOrder` is only called from `headUpstream` / `fetchFromUpstream` / `checkUpstream` — the cache-miss/upstream path. A cache hit returns `Source: "cache"` before any selection runs, so the **cache-hit hot path pays zero** selection cost regardless of strategy. **Verdict:** even the worst case (least_conn across 8 mirrors, ~15 µs) is <1% of the multi-millisecond upstream round-trip it accompanies; round_robin (~0.5 µs) is negligible. The strategy adds no meaningful latency. least_conn's cost scales super-linearly because the `sort.SliceStable` comparator re-derives each mirror's gauge via `inflightCounter` (string-concat key + speculative `new(atomic.Int64)`) O(n·log n) times — a possible future micro-opt (snapshot loads before sorting), out of scope here. Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/123 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- internal/proxy/BENCHMARKS.md | 162 +++++++++++++++++++++++++ internal/proxy/selection_bench_test.go | 156 ++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 internal/proxy/BENCHMARKS.md create mode 100644 internal/proxy/selection_bench_test.go diff --git a/internal/proxy/BENCHMARKS.md b/internal/proxy/BENCHMARKS.md new file mode 100644 index 0000000..c09ac64 --- /dev/null +++ b/internal/proxy/BENCHMARKS.md @@ -0,0 +1,162 @@ +# Mirror-selection benchmarks + +These benchmarks (`selection_bench_test.go`) isolate the **mirror load-balancing +selection overhead** — no network, no DB, no Redis. They build a zero-value +`Engine` and call `baseURLAttemptOrder` / `beginAttempt` / `endAttempt` +directly, the same way `leastconn_test.go` and `multibaseurl_test.go` do. + +Goal: quantify how much latency the load-balancing strategy (`round_robin` vs +`least_conn`) adds versus a plain single-URL remote, and give a permanent +regression guard. + +## Key context: selection is cache-miss-only + +`baseURLAttemptOrder` is called from exactly three places — `headUpstream`, +`fetchFromUpstream`, and `checkUpstream` — all on the **upstream / cache-miss +path**. A cache hit returns `Source: "cache"` from `GetArtifact` / `store.Stat` +*before* any selection code runs. So none of the numbers below apply to the hot +cache-hit path: cache hits pay **zero** selection cost regardless of strategy. +The overhead here is paid once per upstream fetch, alongside a network round-trip +measured in milliseconds. + +## How to run + +``` +go test -run=^$ -bench='BaseURLAttemptOrder|BeginEndAttempt' -benchmem \ + -benchtime=1s -count=6 -cpu=8 ./internal/proxy/ +``` + +## Results + +Machine: AMD Ryzen 7 4700U (8 threads), linux/amd64, go1.26.5. +`-benchtime=1s -count=6`; figures below are the **median of 6 runs**. + +### Sequential (single-goroutine) + +| Benchmark | ns/op | B/op | allocs/op | +|----------------------------------|-------:|-----:|----------:| +| BaseURLAttemptOrder_SingleURL | ~133 | 16 | 1 | +| BaseURLAttemptOrder_RoundRobin/3 | ~462 | 120 | 4 | +| BaseURLAttemptOrder_RoundRobin/8 | ~682 | 280 | 4 | +| BaseURLAttemptOrder_LeastConn/3 | ~2690 | 474 | 22 | +| BaseURLAttemptOrder_LeastConn/8 | ~15200 | 2688 | 132 | +| BeginEndAttempt (gauge inc/dec) | ~514 | 104 | 4 | + +### Parallel (`RunParallel`, GOMAXPROCS=8) — ns/op is wall-time across 8 cores + +| Benchmark | ns/op | B/op | allocs/op | +|-------------------------------------------|------:|-----:|----------:| +| BaseURLAttemptOrder_RoundRobin_Parallel/3 | ~67.5 | 120 | 4 | +| BaseURLAttemptOrder_RoundRobin_Parallel/8 | ~130 | 280 | 4 | +| BaseURLAttemptOrder_LeastConn_Parallel/3 | ~292 | 474 | 22 | +| BaseURLAttemptOrder_LeastConn_Parallel/8 | ~1673 | 2688 | 132 | +| BeginEndAttempt_Parallel | ~71.5 | 104 | 4 | + +## Reading the numbers + +- **Single-URL is a near-no-op** (~133 ns, 1 alloc): the `len(urls) <= 1` + early return just returns the pool slice. Every non-mirrored remote takes this + path. +- **round_robin is cheap**: ~462 ns for a 3-mirror pool, ~682 ns for 8. Cost is + one atomic cursor increment plus building the rotated `[]string`. Allocs are + constant at 4 (the ordered slice + its backing string headers), size grows + with pool length. +- **least_conn is more expensive and scales super-linearly**: ~2.7 µs / 22 + allocs at 3 mirrors, ~15 µs / 132 allocs at 8. The cost is the per-call + `sort.SliceStable`, whose comparator calls `inflightCounter` (a + `sync.Map.LoadOrStore` with a `remoteName\x00url` string-concat key plus a + speculative `new(atomic.Int64)`) O(n·log n) times. That is where the alloc + count and the time come from — not the sort itself. A future optimization + could snapshot each mirror's load once before sorting; out of scope for this + measurement PR. +- **beginAttempt/endAttempt** (~514 ns seq, ~72 ns parallel) is one + `LoadOrStore` + two atomic adds; it only runs for least_conn multi-mirror + remotes, once per upstream attempt. +- **Under concurrency the atomics/sync.Map do not collapse**: every parallel + variant reports *lower* ns/op than its sequential twin because work spreads + across 8 cores (RunParallel reports aggregate wall-time-per-op). No contention + cliff on the shared rrCounters cursor, the inflight `sync.Map`, or the + per-mirror `atomic.Int64` gauges. + +## Verdict + +At the per-request scale that matters (a cache-miss that is *already* doing a +multi-millisecond network fetch), even the worst case here — least_conn across 8 +mirrors at ~15 µs — is <1% of a single upstream round-trip, and round_robin +(~0.5 µs) is negligible. The strategy adds no meaningful latency, and it adds +**exactly zero** to the cache-hit hot path because selection never runs there. + +## Raw output (all 6 runs) + +``` +goos: linux +goarch: amd64 +pkg: git.unkin.net/unkin/artifactapi/internal/proxy +cpu: AMD Ryzen 7 4700U with Radeon Graphics +BenchmarkBaseURLAttemptOrder_SingleURL-8 8635875 138.4 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_SingleURL-8 9673108 126.7 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_SingleURL-8 8001002 147.3 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_SingleURL-8 11303490 135.0 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_SingleURL-8 10125138 132.0 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_SingleURL-8 8025687 130.1 ns/op 16 B/op 1 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2706013 453.2 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2498718 444.4 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2605516 471.6 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2799928 487.6 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2463375 418.6 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool3-8 2472265 474.3 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1741789 689.4 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1775467 602.1 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1829398 688.4 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1781149 679.3 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1795680 599.4 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin/pool8-8 1739122 684.5 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 424184 2675 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 426796 2498 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 427116 2716 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 430540 2681 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 418156 2700 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool3-8 423009 2711 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 163642 14007 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 78501 15457 ns/op 2688 B/op 131 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 76380 14928 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 183634 16091 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 73809 15561 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn/pool8-8 74546 13962 ns/op 2688 B/op 132 allocs/op +BenchmarkBeginEndAttempt-8 2350348 519.7 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt-8 2321659 514.0 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt-8 2284635 438.0 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt-8 2287051 513.9 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt-8 2286481 520.3 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt-8 2837775 512.9 ns/op 104 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 16052568 67.78 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 17452791 66.07 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 17549858 69.25 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 18845167 64.55 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 16285608 69.81 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool3-8 17382639 67.12 ns/op 120 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 9734368 120.6 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 10154736 133.3 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 10061422 131.8 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 10212364 127.4 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 10259030 132.5 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel/pool8-8 10069576 122.4 ns/op 280 B/op 4 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4288112 292.7 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4009249 295.9 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4176378 291.3 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4104871 289.9 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4245262 296.4 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool3-8 4079778 290.3 ns/op 474 B/op 22 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 748200 1636 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 763029 1653 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 663717 1772 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 739677 1676 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 763148 1669 ns/op 2688 B/op 132 allocs/op +BenchmarkBaseURLAttemptOrder_LeastConn_Parallel/pool8-8 610597 1684 ns/op 2688 B/op 132 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 17266058 69.46 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 17151303 72.05 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 16919542 74.17 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 16948015 72.49 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 16918693 69.52 ns/op 104 B/op 4 allocs/op +BenchmarkBeginEndAttempt_Parallel-8 17376012 70.99 ns/op 104 B/op 4 allocs/op +``` diff --git a/internal/proxy/selection_bench_test.go b/internal/proxy/selection_bench_test.go new file mode 100644 index 0000000..d83b73d --- /dev/null +++ b/internal/proxy/selection_bench_test.go @@ -0,0 +1,156 @@ +package proxy + +import ( + "fmt" + "testing" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// These benchmarks isolate the mirror-selection overhead only: they construct a +// zero-value Engine (no DB/S3/redis) and call baseURLAttemptOrder / +// beginAttempt / endAttempt directly, mirroring leastconn_test.go and +// multibaseurl_test.go. This quantifies how much latency the load-balancing +// strategy (round_robin vs least_conn) adds versus a single-URL remote. Note +// that in the live proxy this selection runs only on the cache-miss/upstream +// path; a cache hit never calls it. + +// mirrorPool builds a remote with n upstreams (base_url + n-1 mirrorlist +// entries) under the given strategy. +func mirrorPool(name, strategy string, n int) models.Remote { + r := models.Remote{ + Name: name, + BaseURL: "https://mirror0.example/repo", + MirrorStrategy: strategy, + } + for i := 1; i < n; i++ { + r.Mirrorlist = append(r.Mirrorlist, fmt.Sprintf("https://mirror%d.example/repo", i)) + } + return r +} + +// skewInflight sets an ascending in-flight load across the pool so least_conn's +// stable sort has real work to do (mirror0 busiest, last mirror idle). +func skewInflight(e *Engine, r models.Remote) { + pool := r.UpstreamPool() + for i, u := range pool { + e.inflightCounter(r.Name, u).Add(int64(len(pool) - i)) + } +} + +// BenchmarkBaseURLAttemptOrder_SingleURL measures the early-return no-op path +// (pool of 1): the branch that preserves original single-attempt behavior and +// must add effectively zero overhead. This is the same code the cache-miss path +// takes for every non-mirrored remote. +func BenchmarkBaseURLAttemptOrder_SingleURL(b *testing.B) { + e := &Engine{} + r := models.Remote{Name: "solo", BaseURL: "https://mirror0.example/repo"} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = e.baseURLAttemptOrder(r) + } +} + +// BenchmarkBaseURLAttemptOrder_RoundRobin measures the default strategy: rotate +// the starting mirror by an atomic cursor and materialize the ordered slice. No +// in-flight sort. +func BenchmarkBaseURLAttemptOrder_RoundRobin(b *testing.B) { + for _, n := range []int{3, 8} { + b.Run(fmt.Sprintf("pool%d", n), func(b *testing.B) { + e := &Engine{} + r := mirrorPool("rr", models.MirrorStrategyRoundRobin, n) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = e.baseURLAttemptOrder(r) + } + }) + } +} + +// BenchmarkBaseURLAttemptOrder_LeastConn measures the least_conn strategy: RR +// rotation plus a stable sort of the pool by the atomic in-flight gauges. Skew +// is preloaded so the sort compares distinct loads. +func BenchmarkBaseURLAttemptOrder_LeastConn(b *testing.B) { + for _, n := range []int{3, 8} { + b.Run(fmt.Sprintf("pool%d", n), func(b *testing.B) { + e := &Engine{} + r := mirrorPool("lc", models.MirrorStrategyLeastConn, n) + skewInflight(e, r) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = e.baseURLAttemptOrder(r) + } + }) + } +} + +// BenchmarkBeginEndAttempt measures the gauge inc/dec pair that brackets each +// least_conn upstream attempt (LoadOrStore + atomic add, then atomic add back). +func BenchmarkBeginEndAttempt(b *testing.B) { + e := &Engine{} + r := mirrorPool("g", models.MirrorStrategyLeastConn, 3) + url := r.UpstreamPool()[0] + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctr := e.beginAttempt(r, url) + endAttempt(ctr) + } +} + +// BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel surfaces atomic-cursor +// contention on the shared rrCounters entry under concurrent selection. +func BenchmarkBaseURLAttemptOrder_RoundRobin_Parallel(b *testing.B) { + for _, n := range []int{3, 8} { + b.Run(fmt.Sprintf("pool%d", n), func(b *testing.B) { + e := &Engine{} + r := mirrorPool("rrp", models.MirrorStrategyRoundRobin, n) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = e.baseURLAttemptOrder(r) + } + }) + }) + } +} + +// BenchmarkBaseURLAttemptOrder_LeastConn_Parallel surfaces sync.Map read +// contention on the in-flight gauges plus the per-call sort under concurrency. +func BenchmarkBaseURLAttemptOrder_LeastConn_Parallel(b *testing.B) { + for _, n := range []int{3, 8} { + b.Run(fmt.Sprintf("pool%d", n), func(b *testing.B) { + e := &Engine{} + r := mirrorPool("lcp", models.MirrorStrategyLeastConn, n) + skewInflight(e, r) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = e.baseURLAttemptOrder(r) + } + }) + }) + } +} + +// BenchmarkBeginEndAttempt_Parallel exercises the gauge inc/dec pair under +// concurrency: all goroutines hammer the same atomic.Int64, the realistic +// hot-mirror case, to surface counter contention. +func BenchmarkBeginEndAttempt_Parallel(b *testing.B) { + e := &Engine{} + r := mirrorPool("gp", models.MirrorStrategyLeastConn, 3) + url := r.UpstreamPool()[0] + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + ctr := e.beginAttempt(r, url) + endAttempt(ctr) + } + }) +}