Problem/Motivation
Every PHPUnit test method in core runs in its own PHP process (#[RunTestsInSeparateProcesses], enforced in BrowserTestBase and KernelTestBase). Each process compiles about 2,300 PHP files into a private opcache segment and throws it away when it exits. CLI processes cannot share opcache shared memory, so a CI job compiles Drupal thousands of times.
opcache.file_cache lets separate CLI processes share compiled scripts through a directory on disk. Only the first process compiles a file; later processes load it.
Proposed resolution
Enable opcache.file_cache for the PHP CLI only, in the CI test jobs:
mkdir -p /tmp/opcache-cli && chown www-data:www-data /tmp/opcache-cli printf '\nopcache.file_cache=/tmp/opcache-cli\n' >> "$(php -r 'echo PHP_CONFIG_FILE_PATH;')/php-cli.ini"
in .gitlab-ci/scripts/server-setup.sh. Only php-cli.ini changes: Apache (mod_php) reads php.ini, so the web server is unchanged. A longer-term alternative is to set it in the DrupalCI images' php-cli.ini.
Measurements
Ratio = with cache / without cache, per test class, paired, with 95% confidence intervals.
| Setup | Kernel | Functional |
|---|---|---|
| Dedicated host (Xeon E3-1245 v5), 3 interleaved rounds, 40 Kernel and 39 Functional classes, run-tests.sh concurrency 9 | 0.715 [0.699, 0.731]; wall 79.8 s to 58.3 s; job CPU -28% | 0.870 [0.858, 0.883]; wall 104.4 s to 93.7 s; job CPU -14% |
| Second host, same hardware, some background load | 0.729 [0.694, 0.764] | 0.910 [0.888, 0.933] |
| GitLab CI, both arms in one pipeline, shard pairs on the same CPU model | all 4 shards faster (0.49 to 0.83); no same-model pair | class time 0.77 to 0.92 (about 0.87); job CPU 0.77 to 0.89 |
Pairing by CPU model matters: CI runners mix Xeon 8124M/8275CL/8375C/8488C and EPYC 7R13/7R32 nodes, and node placement alone moved Kernel timings by up to 25% between pipelines.
Tested and rejected: opcache.jit=tracing made tests 12% to 36% slower; a larger opcache.interned_strings_buffer (4 to 32 MB) made no difference; a tmpfs cache directory gave no measurable gain over the container filesystem.
Correctness and risks
- The full test suite passed with the cache in GitLab CI. PHPStan produced byte-identical results with and without it.
validate_timestampsstays on, and opcache does not cache a file modified in the last 2 seconds (opcache.file_update_protection), so settings files and generated PHP that tests write are not served stale.- If the directory is missing or not writable, PHP aborts every CLI run ("opcache.file_cache must be a full path of an accessible directory"). The script must create it.
- opcache creates its cache subdirectory with mode 0700 for the first user. If root compiles a PHP file first, www-data silently runs uncached. In the test jobs www-data is the first user:
server-setup.shruns no PHP as root, and the first PHP the jobs run iscomposer run-scriptas www-data. - About 70 to 225 MB of disk per job.
Remaining tasks
- Merge request with the setup script change.
- Confirm in a pipeline that the cache fills (file count in the job log) and compare job durations.
User interface changes
Introduced terminology
API changes
Data model changes
Release notes snippet
Issue fork drupal-3628411
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3628411-ab-bench
changes, plain diff MR !17363
- 3628411-ci-share-compiled
changes, plain diff MR !17339
Comments
Comment #3
andypostComment #5
longwave@andypost as we discussed in Slack I think we should just configure this in php.ini in the CI images instead, then it makes all test runs faster for everyone.
Comment #6
andypost@longwave Agreed, the setting is moved into the 8.5/8.6 images. Pushed to drupalci_environments
devand cherry-picked toproductionIn median it shows 17% faster but kernel tests benefits the most
Two things came up while measuring the MR that the image has to handle as well:
opcache.record_warnings=1is required. Without it, a compile-time warning, such as a PHP 8.6 deprecation, is raised only by the first process that compiles the file. That process may be run-tests.sh rather than a test. With PHPUnit 12.5--fail-on-deprecationon 8.5.11 and 8.6.0RC2, a warm cache without it reportedOK. With it, the results match a run with no cache: each deprecation is reported once, and@/error_reporting()masks still apply.composer installas root first. A core pipeline on the newdevimages confirmed it (997577): every test job got the cache as www-data, while Unit (Core) ended up with a root-owned cache holding only composer's files. Contrib's gitlab_templates has the same problem: it runsphpunit --versionas root. So one line in core stays: achownafter composer.Comment #8
longwaveLet's do it. We can try to solve composer as root elsewhere if we want to, not sure it's worth doing though.
Comment #9
catchThe most recent job here is 4m19s which until two days ago would have equalled the record for fastest job. Very rare we get runs that fast so this is encouraging for a general improvement.
Do we need a gitlab_templates issue to add creating the directory there too?
Wanted to commit this but
Drupal\Tests\views\FunctionalJavascript\Plugin\views\Handler\FieldTestis consistently failing on the MR. I can't reproduce it locally on either main or the MR branch.Comment #10
andyposttested locally and still can't reproduce, the strange is that
FieldTest, the repeated failure istestModalDialogTitleat line 115 expected "Content", actual empty string.The only difference is that the test passed on shard 2 but last runs were in shard 3, will dig deeper
PS: https://git.drupalcode.org/project/gitlab_templates/-/work_items/3572454
Comment #11
longwaveI assume this is just making a race condition that was always there much easier to hit now things are faster. But we should try to solve it or it will just become another annoying random fail...
Comment #12
andypostDebugged and fixed test - as it running faster now the browser have less time to position element so
waitForElementVisible()solved the issueComment #13
longwaveI came to the same conclusion, that we are racing the 20ms debounce delay and that waiting for the element to actually become visible is the correct fix.
@catch the test already calls assertWaitOnAjaxRequest so I think that part should be fine?
Comment #16
catch@longwave ::assertWaitOnAjaxRequest() is the old buggy method that is unfortunately not deprecated yet, ::assertExpectedAjaxRequest() is the new robust method. But let's let the issue changing all occurrences address that since the race condition is not the fault of this issue at all - it's just exposing it.
Committed/pushed to main and 12.0.x, thanks!
Comment #18
andypostThank you, gonna explore the related issue