Problem/Motivation
Fresh D9 git clone, with only composer install and a core/phpunit.xml added:
$ time vendor/bin/phpunit --configuration core/phpunit.xml core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php
PHPUnit 8.5.21 by Sebastian Bergmann and contributors.
Testing Drupal\Tests\ckeditor5\Kernel\ValidatorsTest
.............................. 30 / 30 (100%)
Time: 20.33 seconds, Memory: 8.00 MB
OK (30 tests, 50 assertions)
real 0m20.421s
user 0m8.002s
sys 0m11.805s
versus in my dev environment, where I've got 57 contrib modules installed, with most of them git clones (for development), it takes 3.6 times longer:
$ time vendor/bin/phpunit --configuration core/phpunit.xml core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php
PHPUnit 8.5.21 by Sebastian Bergmann and contributors.
Testing Drupal\Tests\ckeditor5\Kernel\ValidatorsTest
.............................. 30 / 30 (100%)
Time: 1.2 minutes, Memory: 8.00 MB
OK (30 tests, 50 assertions)
real 1m12.087s
user 0m17.227s
sys 0m53.724s
Steps to reproduce
- Install many modules, preferably using
git clone - Do development on them, and have a few modules with a
node_modulesdirectory (find them usingfind . -name 'node_modules' -type d -prunefrom your Druapl root)
Proposed resolution
-
drupal_phpunit_contrib_extension_directory_roots()lists every directory that could contain Drupal extensions. Each of those is passed todrupal_phpunit_find_extension_directories(), which scans them recursively … INFINITELY!.Ironically, @sun warned all of us about this 7 years ago: https://www.php.net/manual/en/class.recursivedirectoryiterator.php#114504
Search results only turned up #2900208: Improve performance of functional tests by caching Drupal installations — but that's about speeding up the DB installation part of functional tests. This is about discovering the tests in the first place.
Fixing this brings it down to:
$ time vendor/bin/phpunit --configuration core/phpunit.xml core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php PHPUnit 8.5.21 by Sebastian Bergmann and contributors. Testing Drupal\Tests\ckeditor5\Kernel\ValidatorsTest .............................. 30 / 30 (100%) Time: 56 seconds, Memory: 8.00 MB OK (30 tests, 50 assertions) real 0m56.051s user 0m15.277s sys 0m40.484sNow it's "only" 2.8 times slower.
- This still leaves ~36 seconds performance delta. Still unacceptable. There must be some directory scanning somewhere else.
Found root cause; fixed in #5, cleaned up in #7. Now only ~2 times slower.
- The remaining ~20 second performance delta can only be optimized away in one of two ways: a new caching mechanism (do we really want that in tests?) or tweaking Drupal's PHPUnit
bootstrap.phpto not construct a fresh autoloader on every run.Why does this matter? Because there are two factors that make this very noticeable in day-to-day (core) development:
- Worse:
drupal_phpunit_populate_class_loader()is called every single time you run tests. So it does all this work every time you run tests 🙃 - Worst: as part of debugging this, I discovered that for each returned value by a
@dataProvider, PHPUnit ends up callingdrupal_phpunit_populate_class_loader()again. So one test class with a single test method will call it once, but if that single test has a data provider with 10 cases, then the class loader will be computed eleven times. → this part is not addressed in this issue, but it makes the impact of this improvement bigger still…
I tried two dead-simple caching approaches in #8. First using APCu: it gets stored correctly, but gets wiped immediately. Not sure by what yet. But it's probably going to be hard to get rid of. Second using
mtimeof the Drupal root. While not perfect, this would allow caching the autoloader (which BTW only needs to be changed when modules are added or removed from disk!), and you'd only have to dotouch .in the Drupal root (we could make it listen in more places) to get it to rebuild it.Still, this seems to add too much risk and contentiousness to the proposed patch, which now hopefully remains RTBC'able.
- Worse:
🤓 Bonus: I suspect this will also speed up DrupalCI…
→ turned out to be true possible but uncertain — see #25 and #28.
Remaining tasks
None.
User interface changes
None.
API changes
API addition: \Drupal\Core\Extension\Discovery\RecursiveGenericExtensionFilterIterator extracted from \Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator, to keep the complexity of the code for low-level test infrastructure to a minimum. The pre-existing RecursiveExtensionFilterIterator now extends the simpler/more generic RecursiveGenericExtensionFilterIterator.
Data model changes
None.
Release notes snippet
TBD
| Comment | File | Size | Author |
|---|---|---|---|
| #43 | 3249443-d-9-5-43.patch | 7.73 KB | alex.skrypnyk |
| #42 | 3249443-nr-bot.txt | 144 bytes | needs-review-queue-bot |
| #36 | 3249443-37.patch | 7.47 KB | alexpott |
| #26 | 3249443-26.patch | 12.44 KB | wim leers |
Issue fork drupal-3249443
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:
Comments
Comment #2
wim leersComment #3
longwaveDuplicate of #3223881: Speed up test discovery with \RecursiveCallbackFilterIterator?
edit: and you even came up with basically the same approach :)
Comment #5
wim leers#2 passed tests! It finished testing in 59 minutes, versus 60 minutes for
9.3.x— too little a difference to be confident it is helping.Explain the remaining performance delta
Looks like this is mighty difficult to analyze because it requires cross-process PHP debugging … since the PHPUnit process is triggering individual requests to be built.
I suspect that it's simply because there are still far more directories to analyze in the "dev" vs "fresh" envs. So let's observe the performance delta for a simpler (faster) test:
vs
… yep, still ~3x slower! So it really is still just that. What I did in #2 doesn't go far enough.
Dig more
I did some more digging, because if tests are slow then surely Drupal itself must be slow too, right?
That's how I ended up finding
RecursiveExtensionFilterIteratorintroduced by #2188661: Extension System, Part II: ExtensionDiscovery, which is where @sun actually did the work that led to him posting https://www.php.net/manual/en/class.recursivedirectoryiterator.php#114504 🤪Why … reinvent the wheel and not just reuse that? Simply doing that gets us to a point where the number of modules installed and complexity of directory trees used in development (git/npm/…) does not matter at all anymore, because it brings performance to 2x:
+
Comment #6
wim leersphpcs
Comment #7
wim leers#3#5 makes things more complicated. It's overriding a class in a special place. It'd be far better and less risky if the test suite didn't reuse the more complex pieces ofRecursiveExtensionFilterIterator.To address that, I extracted the simple foundations of that class into
RecursiveGenericExtensionFilterIteratorand made that the base class for the pre-existing logic in core. That allows the test to use as little code from Drupal core as possible.Comment #8
wim leersIt is so unfortunate that the remaining ~20 second performance delta is still there, because it really is extremely silly: all that time is going to rebuilding exactly the same class autoloader again and again and again … even though it will only be different when modules are added/removed from disk! 😮💨
I tried two dead-simple caching approaches:
mtimeof the Drupal root. While not perfect, this would allow caching the autoloader (which BTW only needs to be changed when modules are added or removed from disk!), and you'd only have to dotouch .in the Drupal root (we could make it listen in more places) to get it to rebuild it.Still, this seems to add too much risk and contentiousness to the proposed patch, which now hopefully remains RTBC'able. So only adding these as do-not-test patches.
Comment #9
wim leersUgh, d.o ate my patches… and it broke the "assigned" state change and also didn't mention I updated the issue summary 😬
EDIT: nope, it didn't eat them, it just didn't list them. I'll add manual links to #8…
Comment #10
wim leers@longwave in #3: 😬 wow, I spent 30 mins searching and couldn't find anything, couldn't believe it. I guess my search-fu is weak :| None of them mention
drupal_phpunit_find_extension_directories(), that's probably why.Comment #11
wim leers#7 finished testing in 55 minutes, versus 60 minutes for
9.3.x— now that is a pretty sizable difference!Queued more tests to see if this delta is reproducible. 🤞
Comment #13
wim leersCommented at #3223881-27: Speed up test discovery with \RecursiveCallbackFilterIterator why I believe it may be best to continue in this issue. I'm happy to transfer issue credits of course. In any case already crediting @larowlan because the fact that he built essentially the same technical solution but in a different shape already is an important piece of validation :)
Comment #14
larowlanthis class name now needs updating
This looks good to me - I'm happy either way.
I've been running my own phpunit.xml for at least 3 years to bypass this problem (and we have an even more optimized one on client projects where we know where tests will live). That's probably why this hasn't been fixed, because phpunit.xml.dist can be freely modified as phpunit.xml - so thank you for also coming back to try and improve this for everyone.
Comment #20
larowlanCrediting participants in the other issue and marking it as a duplicate
Comment #22
larowlanComment #23
andypostComment #24
quietone commentedI only read the comments and have some suggestions.
This would be clearer if 'root' was 'Drupal root'.
If this is saying see some documentation should there be an @see below to that class. At least, I was looking for it when I read this.
I have trouble following this paragraph. Maybe it is just me. But it appears to be a duplicate of the comment that is below the if statement. So there are not two copies of this comment, one above the if and one inside.
Let's not use possessive on implementation. 'Otherwise, respect the implementation of the parent.'
Can we have the first sentence start with a capital letter and remove the hyphen in special-cased. And there are extensions that do not have a config directory.
'The 'config' directories are a special case because extensions may have a directory named 'config'.
Clarify the name of the module and the machine name.
"because the Configuration Manager module in core has the machine name 'config'."
Not sure what this phrase is telling me. It 'it' the core config module?
Does this phrase really need to be in parenthesis?
edit: grammar fixes
Comment #25
wim leersI wrote this in #11:
That was using PHP 8.0 + MySQL 5.7.
Full list of results:
HEAD9.3.x+ PHP 8.0 + MySQL 5.79.4.x+ PHP 8.0 + MySQL 5.79.3.x+ PHP 7.4 + MySQL 5.79.3.x+ PHP 7.4 + SQLite 3.27We can run more tests, but the benefits surely seem to hold up. It's interesting that for SQLite there's less impact. The same set of tests runs, so you'd think that the same absolute savings apply there. This seems to suggest that the DrupalCI servers are disk I/O-constrained for MySQL testing; it would seem that the MySQL disk activity (for parallel test jobs: concurrency is set to 15 in the current Drupal core
drupalci.yml) slows down the constructing of the class autoloader for every test?In any case, there seems to be too much data showing a benefit for this to be a coincidence. Saving 8% on the most common test environment (the ) is quite a nice win I think? 😊
Comment #26
wim leerss/root folders/Drupal root/— hope you like that.RecursiveExtensionFilterIterator. Did not make changes.RecursiveExtensionFilterIterator. Did not make changes.RecursiveExtensionFilterIterator. Did not make changes.\Drupal\Core\Extension\ExtensionDiscovery::scanDirectory(). Did not make changes.Comment #27
wim leersCleaned up & updated issue summary.
Comment #28
wim leersHm … I see that the tests in #26 took just as long as the
HEADtest: 59 minutes. Maybe there is just too much fluctuation to make these numbers comparable? 😬 Somebody with DrupalCI superpowers could run it on the exact same machine to determine whether this is actually helping Drupal core test runs. Meanwhile, updating the issue summary — I don't want to make false claims!Comment #29
longwaveThere is a lot of variation in DrupalCI runs. I also wonder if disk is even the issue here, the instances have plenty of RAM and have checked out the entire repository shortly before running the tests; to me this would imply that all the directories should be in cache and so while we are saving a bit of CPU time here, it is probably not all that much in terms of wall clock time compared to running the actual test suite.
However as this *does* markedly speed up runs on developer machines where the whole project is not necessarily in RAM already, this is still a good thing to fix.
Comment #30
longwaveMaybe this should be deferred to a followup but it feels like we should be able to combine a bit more of the test bootstrap and the iterator:
RecursiveExtensionFilterIteratorstores this list:drupal_phpunit_contrib_extension_directory_roots()builds a lot of lists containing these items:I wonder if
drupal_phpunit_contrib_extension_directory_roots()should start returning the top level directories instead and letRecursiveExtensionFilterIteratorhandle the modules, profiles and themes subdirectories?Comment #31
wim leers#30: I tried to keep changes to a minimum, but there's definitely more we could do.
Comment #32
longwaveI added some extra logging inside the iterators and the final loop in
drupal_phpunit_find_extension_directories(). Before this patch there were 22,156 possible extensions naively returned by the iterator to consider. With the patch applied, there are only 547 entries which exactly matches the set of .info.yml files.Testing this locally on a core checkout alone there is still an significant improvement. Before the patch:
After the patch:
This patch will speed up test runs for any developer using PHPUnit with Drupal. There is nothing left to do here, #30 can be tackled in a followup as it's a cleanup rather than any further performance improvement and so to me this is RTBC.
Comment #33
alexpottThe improvements to local testing look nice. I think though we should ask ourselves about the naming / class structure. I'm concerned about
\Drupal\Core\Extension\Discovery\RecursiveGenericExtensionFilterIteratormissingI think that we can do this all with less new code and different code paths (eventually).
If
drupal_phpunit_contrib_extension_directory_rootsreturned:Then we would need two Iterators.
Comment #34
alexpottAlso I think we can improve things for contrib and custom by doing what ExtensionDiscovery does and passing $ignore_directories into the \Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator::__construct... so we skip node_modules by default.
Comment #35
alexpottSomething like this.
No interdiff because it's a different approach.
Comment #36
alexpottFixing coding standards.
Comment #39
borisson_Reading this issue summary is great! So much detailed information.
I would love to see performance differences between #26 and #36 because they are different approaches. So story in the IS is probably not exactly right anymore if we decided to follow that approach?
Comment #40
alex.skrypnykTested #37 with 9.4 - works great.
The speed improvement on large codebase with multiple `node_modules` directories is significant (30s vs 10s).
One issue is that when running Unit tests settings are not picked up and this line
$ignore_directories = Settings::get('file_scan_ignore_directories', []);will always return empty array. But maybe I ran it incorrectly. Manually populating that return value with['node_modules']did the trick.Comment #42
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It either no longer applies to Drupal core, or fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
Apart from a re-roll or rebase, this issue may need more work to address feedback in the issue or MR comments. To progress an issue, incorporate this feedback as part of the process of updating the issue. This helps other contributors to know what is outstanding.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #43
alex.skrypnykRe-rolled #37 for 9.5.x with the following changes:
1. Fixed
$ignore_directories = Settings::get('file_scan_ignore_directories', []);not working as settings has not been initialised yet.2. Updated deprecation messages to point to 9.6.x
Comment #44
alex.skrypnykWhen testing custom modules or themes using Drupal Core's PHPUnit configuration file,
core/phpunit.xml.dist, note that thecore/tests/bootstrap.phpfile scans all extension roots (core, modules, themes, etc.), and there is currently no way to opt out of this behavior. While we may want to use Drupal Core's bootstrap file to reuse PHPUnit behavior when testing custom modules or themes within real sites, we may not want to use the same targets.In practice, the current implementation of
core/tests/bootstrap.phpcan lead to longer test execution times that scale proportionally with the number of installed modules. For instance, if you have a site with 100 contributed modules and one custom module with 1 unit test, running that test will scan through core and 100s of contributed modules to then only run one test in your custom module.And if you run these tests in a Docker container, the performance degradation can be even worse due to PHP's constant checking for directory and file existence while performing such a lookup.
UPDATE: Found https://www.drupal.org/project/drupal/issues/2943172
Comment #45
larowlanWe've been running the version from #3223881: Speed up test discovery with \RecursiveCallbackFilterIterator with our phpunit.xml referencing a custom bootstrap.php for ~4 years. You just need to update it from time to time as the major phpunit version changes.
Comment #46
mxr576@alex.skrypnyk thanks for finding the other issue where I was also involved, or made an attempt to make a related change...
+ $ignore_directories = Settings::get('file_scan_ignore_directories', []);I did the some, but @Lendude might have a valid point on the other issue
(Source https://www.drupal.org/project/drupal/issues/2943172#comment-13216764)
Comment #48
wim leers@larowlan just reminded me of this. Still would be very nice to land this.
P.S.: I forgot I even created this. @larowlan talked about #3050881: Use RecursiveExtensionFilterIterator in drupal_phpunit_find_extension_directories for performance and then told me it's a duplicate of "mine" — this issue 🤣
Comment #49
mfbI noticed that it recurses through .git directories too - that wouldn't be as bad as node_modules but, still seems like a waste
Comment #51
catchComment #54
longwaveLet's try and revive this, opened an MR with the simplest change that should work.
Comment #55
longwaveAdded a static method to ExtensionDiscovery so we can reuse some code.
Comment #56
nicxvan commentedThis is a good start, I wonder if this should be in the extension system since it's about extension discovery.
I think we'd want to patch this in contrib before we release this to make sure tests work properly still.
Comment #57
smustgrave commentedAny recommendation for testing this one?
Is the summary still accurate?