Skip to content

[PoC] Parallel PHPUnit runner in Go (~4x faster) + fix phpVersion leak between test classes - #8349

Merged
TomasVotruba merged 4 commits into
mainfrom
poc-fast-phpunit-go
Aug 14, 2026
Merged

[PoC] Parallel PHPUnit runner in Go (~4x faster) + fix phpVersion leak between test classes#8349
TomasVotruba merged 4 commits into
mainfrom
poc-fast-phpunit-go

Conversation

@TomasVotruba

@TomasVotruba TomasVotruba commented Aug 14, 2026

Copy link
Copy Markdown
Member

Parallel PHPUnit runner in Go — measured ~4x local

Proof of concept: run the suite faster by splitting test classes across parallel workers that each boot PHP once and run many classes in a single process.

CI-measured (GitHub runners, 4 vCPU, full suite)

Same suite, serial vendor/bin/phpunit vs the Go runner, measured on the run step of this PR:

Platform Serial Go runner Speedup
ubuntu-latest 31s 16s ~1.9x

Full suite (685 classes / 5833 fixtures) runs and passes. A fast_tests CI job (added here) runs the runner so these numbers are reproducible on every push.

Local speed (24-core host, full suite)

Mode Wall time vs serial
Serial vendor/bin/phpunit (1 process) 38.3s 1.0x
fast-phpunit -p 8 ~11s ~3.5x
fast-phpunit -p 12 ~9s ~4.2x
fast-phpunit -p 24 ~9.8s ~3.9x

Speedup scales with cores; it flattens once the heaviest chunk bounds wall time.

Why it is faster

Bootstrap dominates a Rector test class, not the assertions:

Step Time
Boot only (container build, 0 tests) ~0.23s — fixed, per process
One class, 27 fixtures (warm) 0.92s → ~26ms/fixture

Average class has ~7 fixtures, so bootstrap is ~56% of an average class's run time. Any runner that spawns a fresh process per class pays that 0.23s boot 685 times — which is why process-per-class parallelism is actually slower than serial (5.71s serial vs 8.55s xargs -P8 on the CodeQuality subset). This runner splits classes into N chunks balanced by fixture count, and each worker runs its whole chunk in one warm process, so the container is built N times, not 685 times.

Isolation — required to make it correct

Two shared-state issues surface when many classes share a process; both handled so any chunking is safe.

  1. Shared temp cache (cross-process). Rector caches parsed files in sys_get_temp_dir()/rector_cached_files; parallel processes racing that dir throw Failed to open directory / Directory not empty. Each worker gets its own temp dir.

  2. Leaked phpVersion() (in-process) — a latent bug, fixed here. phpVersion(...) is stored in the static SimpleParameterProvider and was never reset between classes. A version-bound class leaked its version into the next class in the same process:

 // NullToStrictIntPregSlitFuncCallLimitArgRector fixture — expected UNCHANGED,
 // but ran under a leaked PHP 8.1 instead of the test default (PhpVersion::PHP_10)
-preg_split('/\s/', $output, 0, PREG_SPLIT_NO_EMPTY);
+preg_split('/\s/', $output, NULL, PREG_SPLIT_NO_EMPTY);

The serial suite passes only because of its class ordering; any reshuffle — this tool or paratest — can trigger it. Fixed in AbstractRectorTestCase::tearDownAfterClass() by resetting PHP_VERSION_FEATURES to the test default. Useful on its own, independent of the runner.

Scope

  • utils-tests-runner/ is a standalone Go helper that shells out to php vendor/phpunit/phpunit/phpunit. It does not change how tests are written; it is invoked via one extra CI job. Pure Go, no .php, so it is invisible to ECS / PHPStan / Rector.
  • Only production change: the phpVersion reset in AbstractRectorTestCase. ECS + PHPStan level 8 pass; full suite passes under -p 24 locally and on CI.

Open questions

  • Keep the Go tool in-repo, or split the phpVersion reset into its own PR and drop the tool?
  • Replace the serial tests job with the runner once trusted, or keep both for a while?

@TomasVotruba
TomasVotruba marked this pull request as ready for review August 14, 2026 09:49
…k between test classes

Add utils-tests-runner/: a Go orchestrator that splits test classes into N
balanced warm chunks (one PHP boot per chunk, not per class), giving ~4x over
serial (38s -> ~9s full suite).

Fix a latent order-dependent leak in AbstractRectorTestCase: phpVersion() was
stored in static SimpleParameterProvider and never reset between classes, so a
version-bound class leaked its version into the next class in the same process.
Reset PHP_VERSION_FEATURES to the test default in tearDownAfterClass.
go build -o writes the exact name (no auto .exe), so the run step could not
find the binary on Windows. Suffix both build and run consistently.
@TomasVotruba
TomasVotruba merged commit d2eb79d into main Aug 14, 2026
50 checks passed
@TomasVotruba
TomasVotruba deleted the poc-fast-phpunit-go branch August 14, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant