proxy: add mirror-selection benchmarks #123
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user