Skip to content

Speed up setup:di:compile by removing repeated di.xml parsing - #41243

Open
jakwinkler wants to merge 1 commit into
magento:2.4-developfrom
qoliber:qoliber/di-compile-performance
Open

Speed up setup:di:compile by removing repeated di.xml parsing#41243
jakwinkler wants to merge 1 commit into
magento:2.4-developfrom
qoliber:qoliber/di-compile-performance

Conversation

@jakwinkler

@jakwinkler jakwinkler commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description (*)

Reading a di.xml scope re-parses and DOM-merges every file that contributes to it — 254 files and about 12k nodes for global on a stock install, roughly 0.6s. During one setup:di:compile that read happens five times for global alone, from four independent call sites that never share a result: the object manager bootstrap, the interception configuration builder, the interception cache, and the area configuration reader.

ObjectManager\Config\Reader\Dom now memoizes its parsed result per scope, normalising the scope exactly as Config\Reader\Filesystem::read() does so read() and read($defaultScope) share an entry.

The cache is per instance on purpose. A result depends on the reader's file resolver, merge rules, schema and validation state, so two differently configured readers must never see each other's results, and a cached unvalidated read must never satisfy a reader that asked for validation.

app/etc/di.xml wires this reader into the configuration loader, the interception config and the plugin list, all of which outlive a single scope read — so the parsed scopes would otherwise be held for the lifetime of the process. The reader implements ResetAfterRequestInterface to drop them, for application server mode and other long-running processes.

Measurements

Clean install, 337 modules, cold generated/, best of three, idle machine:

before 13.63s
after 8.89s

Generated output is byte-identical — every metadata file and every interceptor, verified by checksum.

Related Pull Requests

The parallel-compilation half of the original change has been split into a follow-up so this one can be reviewed on its own. It is on qoliber/di-compile-parallel and adds worker processes for area configuration and interceptor generation (8.89s → 6.95s), which raises questions this PR does not — inherited connections across fork(), worker budgets under cgroup quotas, and failure reporting from a child. Those belong in their own review.

Fixed Issues (if relevant)

None linked; found by profiling.

Manual testing scenarios (*)

  1. rm -rf generated/code generated/metadata && time bin/magento setup:di:compile
    find generated -name '*.php' | sort | xargs md5sum > /tmp/before.txt
  2. Apply this branch, repeat, writing /tmp/after.txt.
  3. diff /tmp/before.txt /tmp/after.txt — expect no differences, and a faster compile.
  4. bin/magento cache:flush, then load a storefront page and the admin to confirm the compiled config is sound.

Questions or comments

  • The memoization is what makes the compile faster, and retaining the parsed scopes is inseparable from it; _resetState() caps that for long-running processes rather than removing the trade.
  • While profiling this I noticed DiCompileCommand::configureObjectManager() injects excludePatterns — containing the absolute install path — into ClassesScanner, and that argument is serialized into all seven compiled area configs. It makes the compiled output non-reproducible across build paths. Out of scope here, but happy to raise it separately.

Thanks to the Mage-OS reviewers on mage-os/mageos-magento2#338, whose review of the original combined branch prompted this split.

Contribution checklist (*)

  • Pull request has a meaningful description of its purpose
  • All commits are accompanied by meaningful commit messages
  • All new or changed code is covered with unit/integration tests (if applicable)
  • README.md files for modified modules are updated and included in the pull request if any README.md predefined sections require an update
  • All automated tests passed successfully (all builds are green)

@m2-assistant

m2-assistant Bot commented Sep 8, 2026

Copy link
Copy Markdown

Hi @jakwinkler. Thank you for your contribution!
Here are some useful tips on how you can test your changes using Magento test environment.
❗ Automated tests can be triggered manually with an appropriate comment:

  • @magento run all tests - run or re-run all required tests against the PR changes
  • @magento run <test-build(s)> - run or re-run specific test build(s)
    For example: @magento run Unit Tests

<test-build(s)> is a comma-separated list of build names.

Allowed build names are:
  1. Database Compare
  2. Functional Tests CE
  3. Functional Tests EE
  4. Functional Tests B2B
  5. Integration Tests
  6. Magento Health Index
  7. Sample Data Tests CE
  8. Sample Data Tests EE
  9. Sample Data Tests B2B
  10. Static Tests
  11. Unit Tests
  12. WebAPI Tests
  13. Semantic Version Checker

You can find more information about the builds here
ℹ️ Run only required test builds during development. Run all test builds before sending your pull request for review.


For more details, review the Code Contributions documentation.
Join Magento Community Engineering Slack and ask your questions in #github channel.

@rhoerr

rhoerr commented Sep 9, 2026

Copy link
Copy Markdown

Claude analysis — reviewed while cherry-picking this into Mage-OS release/4.x (mage-os/mageos-magento2#338). Applies cleanly; unit tests and phpcs pass. Notes below are offered as review input, not blockers.

Verified as correct:

  • The Dom memoization key normalises exactly as Config\Reader\Filesystem::read(), is per-instance, validation state is immutable, and arrays are returned by value — no cross-contamination or aliasing hazard.
  • Area's replay of applyThirdPartyInterfaces() does preserve sequential semantics; the back-fill is idempotent and the only divergence (collection key order) is neutralised by the existing ksort() calls.

Potential issues:

  1. Workers fork with the cache backend connection already open. DiCompileCommand calls App\Cache::clean() before any fork, which connects the configured backend; each worker then reaches ObjectManager\Config\Config::extend(), which does _cache->get()/_cache->save() (Config.php:306,322). Since clean() just ran, every key misses and every child writes a large payload down the inherited descriptor. Fine on a file backend — which is what the benchmarks used — but with Redis/Memcached, or any PDO handle open at fork time, N children multiplex one connection with no framing coordination, and a child's exit() runs destructors against the parent's socket. Worth closing/resetting connections right after pcntl_fork() returns 0.

  2. Parallel::each() never consults workerCount(). It forks one child per item. Interception pre-chunks so it stays bounded, but Area passes the whole area list straight in. Also cpuCount() reads /proc/cpuinfo and ignores cgroup CPU quota and affinity, so it over-reports badly in Docker/k8s. Combined with memory_limit=-1 being common in CI, this can turn one large process into N.

  3. Failure diagnostics degrade in the parallel path. The child reduces any Throwable to $e->getMessage() on STDERR — no class, no trace, no LocalizedException context — and the parent throws RuntimeException naming a reindexed numeric key, so you get "failed for: 2" with no area name. DiCompileCommand catches only OperationException, so this surfaces as an uncaught exception. Single-process mode also aborts on first failure while fork mode runs the remaining items, so MAGE_DI_COMPILE_SINGLE_PROCESS=1 isn't a faithful reproduction when something fails.

  4. Memoization is a process-wide memory trade, not just a compile-time one. app/etc/di.xml wires the same shared Dom reader into ConfigLoader, Interception\Config\Config and PluginList, so parsed scopes are now retained for the process lifetime — tens of MB more on cold-config-cache requests and CLI runs. That retention is also where the speed-up comes from, so it's a real trade; a reset() after the config is serialized would cap it.

  5. Minor: pcntl_waitpid returning ECHILD (e.g. SIGCHLD set to SIG_IGN) would mark every child failed; $failures = [] at the top of each() is dead, so failures collected in finally are discarded if the parent loop throws; and FILTER_VALIDATE_BOOLEAN means MAGE_DI_COMPILE_SINGLE_PROCESS=2 silently leaves parallelism on. A --single-process CLI flag would be a better kill switch than an undocumented env var.

@jakwinkler

Copy link
Copy Markdown
Contributor Author

@rhoerr working on updates based on your review :-)

@jakwinkler

Copy link
Copy Markdown
Contributor Author

@magento run all tests

Reading a scope re-parses and DOM-merges every di.xml that contributes to it - 254 files and
about 12k nodes for 'global' on a stock install, roughly 0.6s. During one compile that read
happens five times for 'global' alone, from four independent call sites: the object manager
bootstrap, the interception configuration builder, the interception cache and the area
configuration reader. None of them share a result.

ObjectManager\Config\Reader\Dom now memoizes its parsed result per scope, normalising the scope
exactly as Config\Reader\Filesystem::read() does so that read() and read($defaultScope) share an
entry.

The cache is per instance on purpose. A result depends on the reader's file resolver, merge
rules, schema and validation state, so two differently configured readers must never see each
other's results, and a cached unvalidated read must never satisfy a reader that asked for
validation.

app/etc/di.xml wires this reader into the configuration loader, the interception config and the
plugin list, all of which outlive a single scope read, so the parsed scopes would otherwise be
held for the lifetime of the process. The reader implements ResetAfterRequestInterface to drop
them, for application server mode and other long-running processes.

Measured on a clean install, 337 modules, cold generated/, best of three:

    before  13.63s
    after    8.89s

Generated output is byte-identical: every metadata file and every interceptor, verified by
checksum.

Co-Authored-By: Claude <noreply@anthropic.com>
@jakwinkler
jakwinkler force-pushed the qoliber/di-compile-performance branch from cb6baf5 to 799ae2d Compare September 10, 2026 12:28
@jakwinkler

Copy link
Copy Markdown
Contributor Author

@magento run all tests

@jakwinkler

Copy link
Copy Markdown
Contributor Author

if only one could run B2B and EE tests ... ;-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants