Fix tail latency by withinboredom · Pull Request #2016 · php/frankenphp
This PR adds `sync.Pool` direct handoff instead to both regular and worker threads, building on the semaphore-based FIFO approach in `fix/latency`. The pool eliminates the remaining channel contention and provides significant tail latency improvements, particularly for workers. By using `sync.Pool`, we achieve a lock-free per-P dispatch, reducing contention dramatically. The dispatcher tries the pool first and then falls back to the semaphore implementation. Benchmark Results Workers (8 threads, 500 connections, 15s) | Configuration | Req/sec | P50 | P75 | P90 | P99 | Threads | Global RQ | |----------------|---------|--------|--------|---------|---------|---------|-----------| | latency branch | 78,161 | 6.03ms | 8.08ms | 11.19ms | 18.38ms | 45 | 145 | | pool (this PR) | 76,259 | 6.06ms | 7.45ms | 9.03ms | 12.99ms | 46 | 217 | | Improvement | -2.4% | +0.5% | -7.8% | -19.3% | -29.3% | +1 | +49.7% | Regular Threads (8 threads, 500 connections, 15s) | Configuration | Req/sec | P50 | P75 | P90 | P99 | Threads | Global RQ | |----------------|---------|---------|---------|---------|---------|---------|-----------| | latency branch | 42,096 | 11.46ms | 12.35ms | 13.35ms | 17.18ms | 62 | 3 | | pool (this PR) | 42,592 | 11.40ms | 12.26ms | 13.27ms | 15.95ms | 67 | 11 | | Improvement | +1.2% | -0.5% | -0.7% | -0.6% | -7.2% | +5 | +267% | Low Load (10 connections, 1 thread, 15s) - Regular Threads Only to show no difference | Configuration | Req/sec | P50 | P99 | |----------------|---------|-------|-------| | baseline (main) | 38,354 | 222µs | 650µs | | latency branch | 37,977 | 225µs | 657µs | | pool (this PR) | 38,584 | 222µs | 643µs | | Improvement | +1.6% | -1.3% | -2.1% | ## Thread Affinity Tradeoff Workers previously had a low-index affinity and would target the same threads under a low load (t[0], t[1], etc.). This minimised resource initialisation when frameworks lazily created resources (e.g. database connections). The new behaviour in this PR uses `sync.Pool` which uses per-P (processor) locality, distributing requests across multiple threads even under low loads. I think this is actually a good thing, as the previous behaviour is actively dangerous in production scenarios. Consider a scenario with 120 worker threads for an i/o heavy workload (this is actually our advice in multiple issues). Under normal load, maybe only t[0-80] are usually active, and thus only 80 database connections are open. On a Black Friday, the load spikes, and we activate t[101] for the first time, but it exceeds a database connection limit of 100, causing cascading failures during peak loads. This is the worst possible time to discover resource limits. With `sync.Pool`, a normal load eventually cycles through all 120 workers, ensuring no surprises under load. It’s worth noting that with per-P locality there’s a high probability you’ll get the same worker on the same connection, keeping various caches (CPU L1/L2/L3, etc.). This is actually probably better than the low-index affinity for cache efficiency. ## Further improvements Further improvements will result in diminishing returns. Based on eBPF profiling of the pool implementation under load, the remaining overhead is well-distributed: Syscall profile: - futex: 902K calls, 211s total: significantly reduced from baseline’s 1.1M+ calls - sched_yield: 58K calls, 5.3s: intentional scheduler balancing (kept from earlier work) - File I/O (openat/newfstatat/close): ~2.8M operations, 11s: PHP script execution - nanosleep: 177K calls, 47s: timing operations Off-CPU profile shows time is now spent primarily on: - PHP memory allocation (_emalloc_192) and hash operations (zend_hash_index_find) - Go work-stealing and scheduling (normal runtime overhead) The Go-side dispatch optimisation in this PR has eliminated the primary bottleneck (futex contention on channels). The remaining time is spent on productive work (PHP execution) and unavoidable overhead (file I/O, timing). Future optimisation would need to focus on PHP internals, which are outside the scope of FrankenPHP’s Go dispatcher. * All benchmarks are done with significant tracing enabled and thus may be exaggerated under real workloads. --------- Signed-off-by: Robert Landers <landers.robert@gmail.com>