bc8a72e5cc
## 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: #123 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|