Skip to content
Yii3-BenchmarksPublic

Latest commit

 

History

64 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Yii3 application-server benchmarks

This repository measures the same Yii3 API application on several PHP application servers under repeatable, constant-throughput HTTP load. All runtime implementations live together on master, use the same application code, database seed, benchmark client, and report generator, and can be run individually or as one batch.

The suite is intended for comparing runtime behavior—not for declaring a universally fastest server. Results depend on the host, Docker version, CPU scheduling, runtime configuration, request rate, and benchmark duration. Compare runs made on the same machine with the same settings and minimal background activity.

The published benchmark report combines the October 5, 2026 full rerun of ten runtimes, the October 7 Swoole measurements, and the October 8 Apache + mod_php and Workerman measurements, plus a separate October 8 session for ReactPHP and Amp. All sessions used PHP 8.5.11, OPcache file override, PostgreSQL's 2,000-connection limit, and the same load settings on the same host. They are separate measurement sessions; existing runtimes were not rerun when the additions were measured. Raw results and run context are in results/samdark_2026-10-05-pg2000/, results/samdark_2026-10-07-swoole/, results/samdark_2026-10-08-apache-workerman/, and results/samdark_2026-10-08-reactphp-amphp/; the combined report is saved as results/report.html. The earlier 200-connection run remains in results/samdark_2026-10-05/ for comparison; older measurements remain in Git history.

What is included

Runtime key Application server Execution model
frankenphp-classic FrankenPHP A normal PHP application bootstrap for each request
frankenphp-worker FrankenPHP A persistent Yii worker
oxphp-classic OxPHP A normal PHP application bootstrap for each request
oxphp-worker OxPHP A persistent Yii worker with per-request state reset
apache-mod-php Apache + mod_php A normal PHP application bootstrap in each prefork process
reactphp ReactPHP HTTP A persistent Yii worker using the Event loop
amphp Amp HTTP Server A persistent Yii worker using Revolt’s Event driver
workerman Workerman A persistent Yii worker using the Event loop
swoole Swoole A persistent Yii worker with coroutine request handling disabled
roadrunner RoadRunner A persistent Yii worker managed by RoadRunner
php-fpm PHP-FPM + Nginx Traditional FastCGI processes behind Nginx
freeunit FreeUnit PHP application hosted by FreeUnit; grouped with non-worker runtimes
rapira Rapira worker A persistent Yii worker using yii-runner-rapira
rapira-classic Rapira classic A fresh Yii application per request; grouped with non-worker runtimes
rapira-dispatcher Rapira dispatcher A persistent Yii application using Rapira exchanges; grouped with worker runtimes

Every runtime is an isolated Docker Compose profile defined in docker/benchmarks.compose.yml. Each run receives its own PostgreSQL and Valkey containers and uses the same source tree mounted at /app. The PostgreSQL database is seeded from docker/postgres/initdb.d/10-benchmark.sql.

The runtime configs use a production-oriented benchmark baseline:

  • All runtimes start 20 PHP execution workers. FrankenPHP worker mode reserves one additional thread for non-worker requests. PHP-FPM uses a static pool, avoiding worker ramp-up during measurement.
  • Every PHP image loads php.ini-production plus docker/runtimes/php-production.ini: OPcache is enabled for web and CLI SAPIs, JIT is disabled, errors go to stderr, and PHP memory is limited to 256 MiB.
  • OPcache timestamp validation is disabled. Restart the runtime after changing PHP files, including files in the mounted source tree. The benchmark suite rebuilds and restarts each runtime automatically.
  • FPM, FreeUnit, Rapira, OxPHP, Swoole, Workerman, ReactPHP, Amp, Apache and FrankenPHP workers recycle after 10,000 requests; RoadRunner uses its memory supervisor. API body limits are 8 MiB, and request/queue timeouts are configured where supported.
  • HTTP readiness checks gate benchmark startup. Containers have a 45-second shutdown grace period, bounded Docker logs, and an increased open-file limit. Nginx access logging is disabled to match the other servers, while errors remain logged. FastCGI keepalive is intentionally disabled so idle Nginx connections cannot reserve the smaller FPM worker pool.
  • Each endpoint receives a separate unmeasured warm-up before load and resource samples are recorded.
  • PostgreSQL allows 2,000 connections to leave headroom above the default 256 HTTP connections and worker recycling. The earlier October 5 run used a 200-connection limit; the published rerun uses 2,000.

These are production-like application-server settings for a controlled local benchmark. HTTP on port 9991, bind-mounted application code, disposable database storage and benchmark credentials remain intentional; a deployed service still needs its own TLS ingress, secrets and persistent database storage. Worker counts must be sized for the deployment's CPU and memory budget. Historical results use the configs in effect when they were recorded and must be rerun to compare this baseline.

Server releases checked on 2026-09-22 are pinned in the benchmark Dockerfile and Compose file, except Rapira, which uses nightly builds:

Component Version
PHP / PHP-FPM 8.5.11 (updated 2026-10-05)
FrankenPHP 1.13.0 (updated 2026-10-05)
Apache / mod_php 2.4.68 / PHP 8.5.11 (added 2026-10-08)
ReactPHP HTTP 1.11.1, with the isolated PSR-7 v2 return-type patch described below
Amp HTTP Server / Revolt 3.4.6 / 1.0.9
Workerman / Event 5.2.2 / 3.1.6 (added 2026-10-08)
Swoole 6.2.3 (added 2026-10-07)
RoadRunner 2025.1.15
FreeUnit 1.37.0 (updated 2026-10-05)
Rapira (all modes) Nightly for PHP 8.5 (nightly-php8.5)
OxPHP (both modes) 0.12.0 (added 2026-10-02)
Nginx 1.31.6 (mainline)
PostgreSQL 18.6
Valkey 9.1.2

FreeUnit's build target compiles the checksummed 1.37.0 release source against PHP 8.5.11, with TLS and compression support. The Docker entrypoint remains pinned to 1.36.1 because the 1.37.0 source archive no longer includes it. Optional JavaScript routing and OpenTelemetry modules are not built; the benchmark does not use them. Version pins should be refreshed from upstream releases when updating the benchmark baseline.

Swoole uses process mode with 20 persistent worker processes and recycles each after 10,000 requests. Each worker boots Yii after forking and resets container state after every request. Coroutine request handling and HTTP compression are disabled, so requests do not concurrently share a Yii container or PDO connection. worker-swoole.php and src/SwooleRequestFactory.php provide the adapter for the benchmark's GET endpoints; this is not a general-purpose upload-capable Swoole runner. The shared CLI PHP time limit is not a wall-clock request deadline; Swoole's 30-second max_wait_time bounds worker shutdown, and idle connections are checked every 30 seconds with a 60-second idle limit.

Run only the Swoole benchmarks with:

make bench-all RUNTIMES=swoole MODE=ramp THREADS=32 CONNECTIONS=256

Apache uses the official PHP 8.5.11 Apache image with mod_php and a fixed 20-process prefork pool. Keepalive is disabled so the 256 benchmark connections cannot reserve the 20 request processes; access logging is disabled, and processes recycle after 10,000 connections. All non-file paths route to the shared public/index.php front controller.

Workerman 5.2.2 uses 20 persistent workers with the PECL Event 3.1.6 loop. Each worker boots Yii after forking, handles requests synchronously, resets container state after each response, and recycles after 10,000 requests. worker-workerman.php and src/WorkermanRequestFactory.php adapt the benchmark's GET endpoints; uploads and static-file serving are outside this adapter's scope. The shared CLI PHP time limit is not a wall-clock deadline.

Run both additions with:

make bench-all RUNTIMES="apache-mod-php workerman" MODE=ramp THREADS=32 CONNECTIONS=256

ReactPHP and Amp each run 20 independent PHP processes under Supervisor. They use Linux SO_REUSEPORT on port 8080, TCP_NODELAY, Event 3.1.6, persistent Yii applications, and recycling after 10,000 requests. ReactPHP uses ExtEventLoop; Amp uses Revolt's EventDriver. Both limit request-handler concurrency to one per process, detach responses before resetting Yii state, and use the same synchronous PDO database code as the other runtimes. These runs compare application-server overhead; they do not measure asynchronous database clients. Amp's compression and HTTP/2 support are disabled. The adapters support the benchmark endpoints; they are not general-purpose framework runners.

React HTTP 1.11.1 requires PSR-7 v1, while Yii's installed emitter requires v2. Its image downloads the checksummed upstream release into /opt/react-http and applies docker/runtimes/react-http-psr7-v2.patch, which only adds the interface return-type declarations. The server and HTTP parsing logic are unchanged. The patched source is loaded only by worker-reactphp.php; its compatible supporting packages use the shared Composer dependencies. The report identifies this as a patched React HTTP release, not an unmodified upstream configuration.

make bench-all RUNTIMES="reactphp amphp" MODE=ramp THREADS=32 CONNECTIONS=256

Two endpoints are benchmarked:

  • / measures framework and runtime overhead with a minimal response.
  • /postgres/orders measures a database-backed request that reads joined orders and customers rows through yiisoft/db-pgsql and persistent PDO connections.

Load is generated by a pinned build of wrkx, a maintained wrk2 derivative with constant-throughput load and coordinated-omission-aware latency recording. Docker CPU and memory usage are sampled alongside the HTTP results.

Requirements

  • Linux with Docker Engine and the Docker Compose v2 plugin.
  • GNU Make and Bash.
  • Git for contributing.
  • Enough available CPU, memory, disk space, and time to build all runtime images.
  • Port 9991 available on the host.

PHP, Composer, wrkx, PostgreSQL, and Valkey do not need to be installed on the host for benchmark runs. The first run is slower because Docker must download and build the runtime images; later runs reuse cached layers.

Quick start

Clone the repository and run the complete matrix:

git clone git@github.com:Yii3-Benchmarks/app-api.git
cd app-api
make bench-all

This command sequentially:

  1. Builds and starts each runtime with isolated PostgreSQL and Valkey services.
  2. Waits for the stack and endpoint preflight check to succeed.
  3. Benchmarks / and /postgres/orders with wrkx.
  4. Captures application, database, and cache resource usage.
  5. Stops the stack and removes its volumes before moving to the next runtime.
  6. Generates one self-contained HTML report for the complete suite.

Results are written to a timestamped directory:

runtime/benchmarks/<timestamp>-suite/
├── <timestamp>-<runtime>-<target>-<mode>/
│   ├── metadata.env
│   ├── summary.json
│   ├── wrkx-timeseries.json
│   ├── wrkx-*.log
│   └── docker-stats.csv
└── report.html

Running selected benchmarks

Benchmark one runtime and the minimal endpoint:

make bench RUNTIME=roadrunner MODE=steady RATE=8000 DURATION=60s

Benchmark its PostgreSQL endpoint:

make bench-db RUNTIME=php-fpm MODE=steady RATE=4000 DURATION=60s

Benchmark all Rapira modes on both endpoints:

make bench-all RUNTIMES="rapira rapira-classic rapira-dispatcher"

All Rapira modes use the nightly-php8.5 server image and the same worker-rapira.php entry point. The nightly CLI uses rapira serve <config> and configures a fixed process count under [http.pool]. The Yii runner detects the configured mode: classic handles one request per application bootstrap, while worker and dispatcher keep the application in memory. rapira continues to select worker mode. Its Yii runner and PHP contract currently require development packages; Composer records their exact revisions in the local lock file.

Run both OxPHP modes through both endpoints:

make bench-all RUNTIMES="oxphp-classic oxphp-worker"

OxPHP follows the official Yii3 recipe, using PHP 8.5 ZTS with native mbstring, intl, and pdo_pgsql extensions. Classic mode uses public/index.php; worker mode bootstraps Yii once in worker-oxphp.php, resets container state after each response, and schedules worker recycling after 10,000 requests. Both modes use 20 PHP workers and the shared production PHP settings. Access logging is disabled. OxPHP reports the cli-server SAPI, so the front controller excludes its extension from PHP's built-in development-server routing.

Run a subset of runtimes through both endpoints:

make bench-all RUNTIMES="frankenphp-worker roadrunner freeunit"

The underlying suite script also accepts a target subset:

RUNTIMES="roadrunner freeunit" TARGETS="home" MODE=steady RATE=5000 DURATION=60s \
    ./tools/run-benchmark-suite.sh

To start a runtime without benchmarking it:

make runtime-up RUNTIME=frankenphp-worker
curl http://localhost:9991/
curl http://localhost:9991/postgres/orders
make runtime-down RUNTIME=frankenphp-worker

runtime-down removes the selected runtime's database and cache volumes. Do not use it if you need to preserve manual changes made inside those benchmark containers.

Benchmark configuration

The default mode is ramp. Configuration is passed as Make variables or environment variables.

Variable Default Meaning
RUNTIME frankenphp-classic Runtime used by make bench and make bench-db
RUNTIMES all fifteen runtimes Space-separated runtimes used by make bench-all
TARGETS home postgres-orders Space-separated endpoint keys for the suite script
MODE ramp steady for one rate or ramp for sequential rate stages
RATE 10000 Requests per second in steady mode
DURATION 160s Steady-mode duration
THREADS host CPU count wrkx worker threads
CONNECTIONS 256 Concurrent HTTP connections
WARMUP_DURATION 10s Unmeasured warm-up per endpoint; 0s disables it
WARMUP_RATE 1000 Requests per second during warm-up
STAGES thirteen stages from 2.5k to 200k RPS JSON stage list for ramp mode
OUTPUT_ROOT timestamped suite directory Result destination

Example custom ramp:

STAGES='[{"target":1000,"duration":"30s"},{"target":3000,"duration":"30s"}]' \
    make bench RUNTIME=freeunit MODE=ramp

wrkx does not change rate continuously during a run. Ramp mode executes each STAGES entry as a separate constant-rate run and records one aggregate point per stage. wrkx uses an initial calibration period, so stages shorter than 20 seconds are not recommended.

Reports

The generated HTML report compares issued and successful RPS, errors, average and p95 latency, application CPU, and application memory. Charts are grouped into worker/non-worker and DB/non-DB comparisons. DB and non-DB summary tables show Successful RPS and Target RPS at the cap in separate sortable columns, sorted by Successful RPS descending by default, with unreached caps last. Stage-based runs mark the first stage more than 5% below target as the cap; this can reflect server or load-generator saturation. The default ramp extends to 200k RPS to test beyond the old 50k ceiling. It is self-contained and can be opened directly in a browser or attached to an issue.

Regenerate a report from existing results:

make bench-report INPUT=runtime/benchmarks/<suite-directory>

Combine explicitly selected runs:

make bench-report INPUT="runtime/benchmarks/<run-1> runtime/benchmarks/<run-2>"

Rebuild the published comparison from the recorded full runs:

make bench-report INPUT="results/samdark_2026-10-05-pg2000 results/samdark_2026-10-07-swoole results/samdark_2026-10-08-apache-workerman results/samdark_2026-10-08-reactphp-amphp" OUTPUT=results/report.html

Raw wrkx output and exact run settings are retained next to the compact data. Include them when reporting unexpected results; an HTML chart alone is usually insufficient to reproduce a finding.

Repository structure

benchmark/                      wrkx image and Lua result adapter
config/, public/, src/          shared Yii3 API application
docker/benchmarks.compose.yml   isolated benchmark services and runtime profiles
docker/runtimes/                runtime images and server configuration
docker/postgres/initdb.d/       reproducible PostgreSQL benchmark data
tools/run-benchmark-suite.sh    multi-runtime orchestration and cleanup
tools/run-wrkx-benchmark.sh     one endpoint/stage benchmark runner
tools/compile-wrkx-results.php  wrkx output normalization
tools/render-benchmark-report.* HTML report generator
worker-oxphp.php                OxPHP persistent worker entry point
worker-frankenphp.php           FrankenPHP persistent worker entry point
worker-roadrunner.php           RoadRunner persistent worker entry point
worker-swoole.php               Swoole persistent worker entry point
worker-workerman.php            Workerman persistent worker entry point
worker-reactphp.php             ReactPHP persistent worker entry point
worker-amphp.php                Amp persistent worker entry point
worker-rapira.php               Rapira entry point for all three modes

The remaining application-template Docker files support development and tests. The benchmark matrix specifically uses docker/benchmarks.compose.yml and docker/runtimes/.

Testing changes

Install or update project dependencies through the development container when needed:

make composer-update

Run the automated checks relevant to your change:

make test
docker compose -f docker/benchmarks.compose.yml --profile roadrunner config --quiet
bash -n tools/run-benchmark-suite.sh tools/run-wrkx-benchmark.sh

Before submitting benchmark-related changes, run at least one short steady benchmark for the affected runtime. Use a duration of 20 seconds or more so wrkx calibration is meaningful:

make bench RUNTIME=roadrunner MODE=steady RATE=100 DURATION=20s THREADS=2 CONNECTIONS=8

Changes that affect shared application behavior should be checked against every runtime with make bench-all when practical.

Contributing

Contributions are welcome for runtime upgrades, new application servers, benchmark correctness, reporting, and reproducibility improvements.

  1. Create a branch from master.
  2. Keep shared application behavior identical across runtimes. Runtime-specific code belongs in docker/runtimes/ or a clearly named worker entry point.
  3. Add or update tests and documentation with the implementation.
  4. Run the checks above and record the exact smoke benchmark command you used.
  5. Open a pull request describing the motivation, affected runtimes, validation performed, and any compatibility or performance tradeoffs. Do not present performance changes without the host and benchmark configuration.

Adding a runtime

To add another application server:

  1. Add a named build target to docker/runtimes/Dockerfile and its configuration under docker/runtimes/.
  2. Add a matching profile and service to docker/benchmarks.compose.yml, exposing the application on host port 9991.
  3. Add the runtime key, readable label, and resource-sampled service names to tools/run-benchmark-suite.sh.
  4. Add required PHP packages to composer.json; keep one shared lock file.
  5. Add the runtime to the RUNTIMES default in Makefile and to the table in this README.
  6. Verify / and /postgres/orders, run a short steady benchmark, and confirm report generation and automatic teardown.

Avoid committing generated benchmark output unless it is intentionally used as a published reference result. Never change only one runtime's application logic to improve its score—the suite must compare equivalent work.

License

The project is released under the BSD-3-Clause License. See LICENSE.md.

Contributors

Languages

Generated from yiisoft/app-api