|
| 1 | +# Performance Comparison: `domainfront` vs `fronted` |
| 2 | + |
| 3 | +Benchmarks run on Apple M4 Pro, Go 1.24, `go test -bench=. -benchmem -count=3`. |
| 4 | + |
| 5 | +## Goroutine Load |
| 6 | + |
| 7 | +| Metric | `fronted` | `domainfront` | Improvement | |
| 8 | +|--------|-----------|---------------|-------------| |
| 9 | +| Goroutines during operation | 15 | 3 | **5x fewer** | |
| 10 | +| Goroutines leaked after Close | 6 | 0 | **Clean shutdown** | |
| 11 | + |
| 12 | +`fronted` spawns ~15 goroutines: 1 keepcurrent runner, 1 keepcurrent data channel consumer, 10 pond workers, 1 findWorkingFronts loop, 1 maintainCache, plus any leaked from pond. `domainfront` spawns exactly **3**: crawler, cacheSaver, and (optionally) configUpdater. All exit cleanly on `Close()` with zero leaks. |
| 13 | + |
| 14 | +## Hot Path: Take/Return Cycle |
| 15 | + |
| 16 | +| Operation | `fronted` | `domainfront` | |
| 17 | +|-----------|-----------|---------------| |
| 18 | +| Take + Return | 17 ns/op, 0 allocs | 68 ns/op, 0 allocs | |
| 19 | + |
| 20 | +`fronted` is ~4x faster here because it's a raw buffered channel send/receive. `domainfront` uses the same channel internally but `ReturnSuccess` also calls `markSucceeded()` (mutex lock + `time.Now()`). This 50ns difference is **negligible** compared to the ~5-50ms TLS dial that follows every Take. |
| 21 | + |
| 22 | +## Candidates/Sort (called by crawler + cache saver) |
| 23 | + |
| 24 | +| Operation | `fronted` | `domainfront` | Improvement | |
| 25 | +|-----------|-----------|---------------|-------------| |
| 26 | +| 5000 fronts sort | 677 us, 82 KB | 359 us, 205 KB | **1.9x faster** | |
| 27 | + |
| 28 | +`domainfront` is nearly **2x faster** because it snapshots timestamps outside the lock and sorts without acquiring per-front read locks during comparison. It uses more memory (205 KB vs 82 KB) because of the parallel `indexed` struct array — a deliberate trade of ~120 KB temporary memory for halved sort time and reduced lock contention. |
| 29 | + |
| 30 | +## Provider Lookup |
| 31 | + |
| 32 | +| Operation | `fronted` | `domainfront` | Improvement | |
| 33 | +|-----------|-----------|---------------|-------------| |
| 34 | +| Exact host alias match | 37 ns/op | 34 ns/op | ~same | |
| 35 | +| Passthrough pattern match | 53 ns/op | 39 ns/op | **1.4x faster** | |
| 36 | + |
| 37 | +`domainfront` pre-lowercases passthrough patterns at config load time, saving a `strings.ToLower` on every lookup in the hot path. |
| 38 | + |
| 39 | +## SNI Generation |
| 40 | + |
| 41 | +| Operation | `fronted` | `domainfront` | Improvement | |
| 42 | +|-----------|-----------|---------------|-------------| |
| 43 | +| GenerateSNI | 49 ns/op, 1 alloc | 51 ns/op, 0 allocs | **Zero allocs** | |
| 44 | + |
| 45 | +Same speed, but `domainfront` avoids the allocation by taking `string` instead of `*Masquerade`. |
| 46 | + |
| 47 | +## Request Rewriting |
| 48 | + |
| 49 | +| Operation | `fronted` | `domainfront` | |
| 50 | +|-----------|-----------|---------------| |
| 51 | +| Rewrite request | 339 ns/op, 6 allocs | 330 ns/op, 6 allocs | |
| 52 | + |
| 53 | +Essentially identical — same fundamental work. |
| 54 | + |
| 55 | +## Structural/Memory Improvements |
| 56 | + |
| 57 | +| Concern | `fronted` | `domainfront` | |
| 58 | +|---------|-----------|---------------| |
| 59 | +| Front list growth | Unbounded append (`addFronts`) — grows forever on config updates | `Replace()` atomic swap — bounded to config size | |
| 60 | +| Sort under lock | `sortedCopy()` holds RLock during O(n log n) sort | Sort happens **outside** the lock | |
| 61 | +| Per-front locks during sort | Sort comparator calls `lastSucceeded()` = O(n log n) lock acquisitions | Timestamps snapshotted once = O(n) lock acquisitions | |
| 62 | +| Dependencies | `pond`, `keepcurrent`, `ops` + transitive deps | Only `utls` + `go-yaml` | |
| 63 | + |
| 64 | +## Speed to Working Fronts |
| 65 | + |
| 66 | +Both use the same fundamental approach (parallel vetting with POST to test URL), so time-to-first-working-front is dominated by network latency (~50-200ms per TLS dial). The key structural difference: `domainfront`'s crawler doesn't need `pond` — it uses a simple semaphore + WaitGroup with the same concurrency (10 workers), avoiding the worker pool library overhead and goroutine pool lifecycle. |
| 67 | + |
| 68 | +## Bottom Line |
| 69 | + |
| 70 | +`domainfront` is **not dramatically faster at the micro-benchmark level** — the hot paths (Take/Return, Lookup, rewrite) are in the same ballpark. The real wins are: |
| 71 | + |
| 72 | +1. **5x fewer goroutines** (3 vs 15), zero leaks on shutdown |
| 73 | +2. **Bounded memory** — no unbounded front list growth on config updates |
| 74 | +3. **2x faster sort** with less lock contention on the candidate list |
| 75 | +4. **Cleaner shutdown** — single context cancellation vs multiple stop channels |
| 76 | +5. **Fewer dependencies** — smaller binary, less supply chain surface |
0 commit comments