Skip to content

Latest commit

 

History

History
245 lines (182 loc) · 15.6 KB

File metadata and controls

245 lines (182 loc) · 15.6 KB

OPcache / JIT Benchmark Results — php-via

Environment

Item Value
Date 2026-05-11
PHP 8.4.20
OpenSwoole v25.2.0
OS Linux 6.6.87.2-microsoft-standard-WSL2
Host WSL2 (single NUMA node, coroutine scheduler)

Test Methodology

Tool: tests/Load/bench_opcache.php orchestrates tests/Load/action_hammer.php

Parameter Value
Actions per pass 5,000 (bench_app) / 2,000 (website)
Concurrency 200 (bench_app) / 100 (website)
Per-request timeout 5 s
Passes cold (fresh OPcache) + warm (JIT fully ramped)
HTTP OK requirement 100%

Cold pass — server just started, OPcache empty, JIT profiling hasn't begun.
Warm pass — immediately after cold, OPcache is hot, JIT has compiled inner loops.

Workloads

Name Route Description
counter /bench/counter Trivial integer increment. Measures framework + SSE overhead with zero application logic.
cpu /bench/cpu Mandelbrot set on a 50×50 grid, max 100 iterations/pixel. ~250k float ops per action call. Canonical JIT benchmark.
io /bench/io usleep(2_000) per action (2 ms simulated DB latency). OPcache/JIT should have no effect here — bottleneck is coroutine scheduling.
spreadsheet /bench/spreadsheet SQLite range query (20×10 viewport) + build viewport HTML cell-by-cell. Mirrors the real SpreadsheetExample render path on every keystroke.
spreadsheet-raw-live /examples/spreadsheet-raw Same as spreadsheet-live but uses raw PHP string building instead of Twig for the SSE update — isolates Twig template overhead.

Profiles

Profile Notable flags
no-opcache opcache.enable=0 — baseline, full interpretation
opcache-default-cli opcache.enable_cli=1, all defaults
opcache-tuned memory=256M, interned=64M, max_files=100000, validate_timestamps=0
jit-function tuned + opcache.jit=function, jit_buffer_size=128M
jit-tracing tuned + opcache.jit=tracing, jit_buffer_size=128M
opcache-preload tuned + opcache.preloadSKIPPED (see below)
multi-worker-4w jit-tracing flags + VIA_BENCH_WORKERS=4, SwooleBroker — UNRELIABLE (see below)

Results

All throughput values in req/s. Δ is warm req/s vs no-opcache warm.

Counter (trivial increment)

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 4,073 4,107 100.0 100.0 +0.8%
opcache-default-cli 4,354 3,872 −5.7% 100.0 100.0 −11.1%
opcache-tuned 4,392 4,815 +17.2% 100.0 100.0 +9.6%
jit-function 4,309 4,519 +10.0% 100.0 100.0 +4.9%
jit-tracing 4,846 4,980 +21.3% 100.0 100.0 +2.8%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

CPU (Mandelbrot 50×50)

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 444 366 100.0 100.0 −17.6%
opcache-default-cli 827 642 +75.4% 100.0 100.0 −22.4%
opcache-tuned 810 817 +123.2% 100.0 100.0 +0.9%
jit-function 2,713 2,608 +612.6% 100.0 100.0 −3.9%
jit-tracing 3,036 2,875 +685.5% 100.0 100.0 −5.3%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

IO (usleep 2 ms)

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 4,797 4,009 100.0 100.0 −16.4%
opcache-default-cli 4,724 3,846 −4.1% 100.0 100.0 −18.6%
opcache-tuned 4,758 4,449 +11.0% 100.0 100.0 −6.5%
jit-function 374 431 −89.2% ⚠️ 100.0 100.0 +15.2%
jit-tracing 5,265 4,522 +12.8% 100.0 100.0 −14.1%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

Spreadsheet (SQLite query + 20×10 viewport build)

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 3,037 3,416 100.0 100.0 +12.5%
opcache-default-cli 3,519 3,544 +3.7% 100.0 100.0 +0.7%
opcache-tuned 3,504 3,468 +1.5% 100.0 100.0 −1.0%
jit-function 3,441 3,618 +5.9% 100.0 100.0 +5.1%
jit-tracing 3,783 3,688 +8.0% 100.0 100.0 −2.5%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

Spreadsheet live (Twig + SQLite + virtual scroll — website app)

2,000 actions, concurrency=100, navigate (ArrowDown) against /examples/spreadsheet. Full php-via stack: framework routing, session auth, SQLite range query, Twig renderBlock, SSE patch queue. Updated after Step 6 (grid extent cache) — eliminates the full-table MAX scan on every render. Only no-opcache and jit-tracing were re-measured; Δ for other profiles is recalculated against the new no-opcache warm baseline (254 req/s).

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 214 254 100.0 100.0 +18.7%
opcache-default-cli 247 265 +4.3% ¹ 100.0 100.0 +7.3%
opcache-tuned 314 260 +2.4% ¹ 100.0 100.0 −17.2%
jit-function 299 247 −2.8% ¹ 100.0 100.0 −17.4%
jit-tracing 412 346 +36.2% 100.0 100.0 −16.0%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

¹ Not re-measured after Step 6; absolute warm req/s unchanged from original run. Δ recalculated against the new no-opcache warm baseline (254 req/s).

Spreadsheet raw live (raw PHP SSE render — no Twig on hot path)

2,000 actions, concurrency=100, navigate (ArrowDown) against /examples/spreadsheet-raw. Same stack as spreadsheet-live but the SSE update patch is built with raw PHP string concatenation — Twig is only used for the initial page render, not the hot update path. Updated after Step 6 (grid extent cache). Re-measured profiles: no-opcache (confirmed twice), opcache-tuned, jit-tracing. Remaining profiles use original warm req/s with Δ recalculated against the updated no-opcache warm baseline (827 req/s).

Profile cold r/s warm r/s Δ cold OK% warm OK% cold→warm
no-opcache 1,149 827 100.0 100.0 −29.9%
opcache-default-cli 911 919 +11.1% ¹ 100.0 100.0 +0.9%
opcache-tuned 1,187 1,082 +30.8% 100.0 100.0 −8.8%
jit-function 1,108 969 +17.2% ¹ 100.0 100.0 −12.5%
jit-tracing 1,316 1,143 +38.2% 100.0 100.0 −13.1%
opcache-preload SKIPPED
multi-worker-4w SKIPPED

¹ Not re-measured; absolute warm req/s from original run. Δ recalculated against the updated no-opcache warm baseline (827 req/s).

no-opcache warm drop at c=100 (~−30%)
Consistently reproducible across runs (827 vs 983 at c=50). The cold pass runs at ~1,150–1,180 req/s (100 coroutines, no OPcache overhead), which floods the SQLite queue before the warm pass starts. This is normal SQLite lock-queue pressure at high concurrency without JIT throttling. jit-tracing warm is stable at 1,143 req/s across c=10–100 — the documented c=100 collapse (552 req/s prior to Step 6) is resolved.

CPU workload: JIT is transformative

The Mandelbrot workload shows the largest gain:

  • OPcache only (opcache-default-cli): +75% over interpreted — bytecode caching already roughly doubles CPU throughput.
  • JIT (jit-function / jit-tracing): +613–686% — ~7.9× faster than no-opcache. The tight while loop over floats is exactly what tracing JIT compiles best.
  • jit-tracing slightly edges out jit-function (2,875 vs 2,608 req/s). The Mandelbrot loop is simple enough that both modes fully compile it, but tracing's code-path analysis is marginally more effective.
  • The cold→warm delta is small (~4–5%) for JIT on the CPU workload — JIT warms up within the first few hundred actions.

Counter workload: JIT helps moderately, within noise

Framework overhead (context lookup, signal mutation, SSE patch queuing) is not dominated by tight loops. Gains are modest:

  • Most profiles within ±10% of baseline — largely measurement noise on WSL2.
  • jit-tracing warm shows +21.3% — plausibly real; tracing JIT compiles the signal dispatch path.
  • jit-function shows +10.0% — within noise; cold start overhead of JIT buffer allocation is visible in the cold pass.

IO workload: JIT has no effect — except for a critical anomaly

Expected: IO-bound workload, bottleneck is the 2 ms coroutine sleep. All profiles hover near 4,000 req/s — except one:

jit-function IO: 431 req/s warm (−89.2%) ⚠️

This is not noise — it's a real regression. The likely cause: opcache.jit=function compiles usleep() as a regular function call, bypassing OpenSwoole's SWOOLE_HOOK_ALL coroutine hook that makes usleep() yield the coroutine instead of blocking the thread. With blocking usleep(2000), max throughput = 1/(0.002s) = 500 req/s — which matches the 431 req/s observed.

jit-tracing does not exhibit this regression (4,522 req/s). Tracing mode compiles hot paths but preserves the hook for usleep().

Recommendation: Do not use opcache.jit=function in OpenSwoole applications that rely on SWOOLE_HOOK_ALL for coroutine-safe blocking functions.

Spreadsheet workload: SQLite is the bottleneck, JIT helps modestly

The bench_app spreadsheet workload (SQLite range query + 200-cell viewport HTML build) shows modest JIT gains (+8%) compared to Mandelbrot's +686%. The gains are real but the ceiling is the SQLite round-trip, not the PHP bytecode.

Key observations:

  • jit=tracing warm: 3,688 req/s (+8.0%) — JIT compiles the htmlspecialchars loop and cell-building iteration, which are the tightest PHP loops here.
  • jit=function warm: 3,618 req/s (+5.9%) — slightly lower than tracing; function-mode doesn't compile the inner loop as aggressively.
  • This mirrors real-world spreadsheet behaviour: each keystroke does a DB query, so the DB remains the limit regardless of OPcache tuning.

Spreadsheet live (full stack): OPcache gains are clear; JIT advantage compresses at higher concurrency

The website spreadsheet-live workload runs the complete php-via stack per action: framework routing, session auth, SQLite range query, Twig renderBlock('spreadsheet_update'), SSE patch queuing. Numbers are significantly higher than the May 2026-05-08 baseline (101 req/s) because Twig file caching and partial block rendering were applied in the interim.

Key observations:

  • All OPcache profiles deliver ~+30–40% over no-opcache warm — bytecode caching eliminates Twig template parse overhead on every action.
  • jit-tracing cold: 385 req/s is the highest single-pass number, confirming JIT warms up fast on the Twig dispatch path. However warm: 242 req/s falls below opcache-tuned (260) at concurrency=100 — at this load SQLite I/O saturation masks the JIT advantage.
  • jit-function warm (247) and jit-tracing warm (242) are nearly identical, unlike the CPU workload where tracing is clearly better. SQLite dominates here.
  • The large cold→warm drop on jit-tracing (−37.1%) reflects the JIT compilation burst boosting the cold pass; the warm pass baseline is higher but the burst is gone.
  • The dominant cost is still SQLite I/O, confirmed by the raw comparison below.

Spreadsheet raw live: Twig costs ~4–5× throughput on the SSE update path

Removing Twig from the hot SSE path (raw PHP string building) reveals the true cost of template rendering per action:

Profile Twig warm r/s Raw warm r/s Raw / Twig
no-opcache 254 827 3.3×
opcache-default-cli 265 919 3.5×
opcache-tuned 260 1,082 4.2×
jit-function 247 969 3.9×
jit-tracing 346 1,143 3.3×

The raw workload is pure SQLite + PHP string building. The ~3.3–4.2× gap is entirely Twig: executing the compiled block, output buffering, and CoreExtension attribute lookups per cell add significant overhead even with file-cached templates.

opcache-preload: SKIPPED

OPcache preloading (opcache.preload) causes a SIGSEGV (signal 11) in OpenSwoole workers. The preload script runs correctly in the master process (classes compile and link against vendor), but when workers fork from the master, the preloaded class table causes worker crashes on the first request.

This is a known incompatibility between PHP's preloading mechanism and OpenSwoole's POOL_MODE worker fork model on WSL2 Linux kernel 6.6. Preloading is designed for PHP-FPM (stateless fork-on-request) and is not reliably compatible with long-lived async worker processes.

multi-worker-4w: Low HTTP OK% — results unreliable

The 4-worker profile is SKIPPED in this run. Previous runs showed only 9–20% HTTP OK. Root cause: the benchmark hammer loads the page once (hitting one random worker), then fires all action POSTs without sticky routing. With 4 workers in POOL_MODE, ~75% of POST requests hit workers that don’t have the context registered — resulting in 403/404 failures. This is a benchmark design limitation, not a framework bug.


Recommendations

Scenario Recommended config
Pure CPU work (computations, transformations) jit=tracing, jit_buffer_size=64M+~7.9× speedup
Real application with Twig rendering jit=tracing+36.2% on full-stack spreadsheet workload
Largest single optimization (Twig SSE path) Replace Twig renderBlock with raw PHP on the hot SSE update — ~3–4× throughput gain
Framework-heavy, mostly IO opcache-tuned (no JIT) — safe +19% with zero risk
IO-bound (DB, network, file) OPcache only; skip JIT — bottleneck is not bytecode
SQLite-bound actions Cache per-server-lifetime query results (e.g. extent/schema) — halves blocking call count per render
Avoid jit=function in OpenSwoole — breaks usleep() hook, destroys IO concurrency

Known Caveats

  • WSL2 environment noise: WSL2 virtualisation adds scheduling jitter. Counter and IO results within ±10% should be treated as equivalent.
  • Single-run measurements: Each cold+warm pair is one run. Results would stabilise further with 3+ runs averaged.
  • Negative cold→warm on spreadsheet-live: The cold pass benefits from JIT compilation burst; the warm pass starts from a higher OPcache baseline, so the ratio inverts. This is expected and not a regression.
  • opcache-tuned raw-live warm anomaly: Consistently reproducible across runs (−43 to −68% warm drop while cold is stable at ~1,040 req/s). Likely caused by interned string buffer pressure on the string-heavy raw path under OPcache's aggressive interning settings. jit-function and jit-tracing (same base flags) are unaffected, suggesting the anomaly is specific to the non-JIT tuned profile under sustained warm-pass load. Use jit-function or jit-tracing for the raw path in production.
  • preload NunoMaduro warnings:** Can't preload unlinked class NunoMaduro\Collision\... are benign — these are test-only dev dependencies that use anonymous class patterns incompatible with preloading. They do not affect runtime.