What compiled DI containers actually buy you

Container compilation moves reflection from request time to build time. What that removes, what it costs, and when it matters.

Alden Pike8 min read

The same application, the same wiring, the same twenty services resolved on a request. Through Symfony’s ContainerBuilder used directly that took 0.157 ms; through the container that same builder dumps to generated PHP it took 0.027 ms. Nothing about the application changed between the two — only when the wiring was worked out.

Method

PHP 8.5.9, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop running macOS, not quiesced. symfony/dependency-injection 8.1.5. Figures come from PHP’s built-in server serving one script, timed with hrtime() inside the process around two phases separately: obtaining the container, and resolving services from it. opcache.enable=1, opcache.jit=disable, opcache.file_update_protection=0. Fifteen interleaved runs per variant, three warmup rounds discarded. Tables give medians; run-to-run spread is stated with each table, and given as a range per cell where the columns are close. Every response printed its own effective configuration and OPcache counters, which is how I know the variants differed rather than all landing on the default.

The graph is one service class per repository class, each service depending on its repository and a shared logger, each repository on a shared connection. Services and repositories are non-shared, so resolving 20 services constructs 40 objects plus three shared ones. The classes a run instantiates are required before timing starts, so the figures are container work rather than autoloading, which both variants pay identically.

What a runtime container does per resolution

Asked for a service, a runtime container reads its definition, resolves each constructor argument — recursively, because arguments are mostly references to other services — and constructs the object. Symfony’s ContainerBuilder walks Definition objects to do it. Laravel’s container reflects over the constructor and resolves each type-hinted parameter. Either way the work lands on the request, and it lands again on the next one, because a PHP request inherits nothing from the last: the shared-nothing model described in the path a request takes from source to opcodes throws away the object graph along with everything else.

What compilation moves

Compiling a container performs that resolution once and writes a PHP class with one method per service. Symfony’s PhpDumper produced this for a service depending on a non-shared repository and a shared logger, rewrapped here because the generator emits the constructor call on a single line:

protected static function getSvc0Service($container)
{
    $container->factories['svc0'] = function ($container) {
        return new \Gen50\Svc0(
            new \Gen50\Repo0(($container->services['connection'] ?? self::getConnectionService($container))),
            ($container->services['logger'] ?? self::getLoggerService($container)),
        );
    };

    return $container->factories['svc0']($container);
}

There is no definition to read and no argument to resolve. The repository was inlined into the constructor call because nothing else shares it, and the two shared dependencies became an array probe with a fallback. The request-time work is a method call over code the compiler already reasoned about.

The saving has two parts and they scale differently

Holding the number of resolutions at 20 and varying how many services the container defines separates them. Medians in milliseconds:

Definitions Runtime, obtain Runtime, resolve Compiled, obtain Compiled, resolve
54 0.0448 0.0645 0.0154 0.0114
204 0.0942 0.0633 0.0152 0.0113
1,004 0.3654 0.0630 0.0152 0.0114
4,004 1.5690 0.0663 0.0158 0.0123

Spread on that table ran from 2.0% of the median to 44%, widest on the 54-definition row and on the two compiled columns at 4,004, where the quantity timed is a few microseconds and one slow run drags the range a long way. The 204 and 1,004 rows, which carry the argument, sat between 2.0% and 8.1%. Those are figures for this series: a spread is a property of one run, not a quantity a re-run reproduces the way a median is.

Resolution cost is flat across a seventy-fold change in container size, for both variants. Building the definitions is not: the runtime column rises roughly in proportion to how many services exist, reaching 1.57 ms at four thousand, where it dwarfs the resolution it exists to serve. The compiled columns do not move.

Varying the other axis — 1,004 definitions throughout, changing only how many services a request resolves — isolates the per-resolution difference:

Resolutions Runtime Compiled Ratio
1 0.0098 (0.0092–0.0145) 0.0015 (0.0014–0.0018) 6.5x
5 0.0212 (0.0209–0.0214) 0.0036 (0.0035–0.0037) 5.9x
20 0.0640 (0.0630–0.0701) 0.0114 (0.0113–0.0140) 5.6x
100 0.2833 (0.2814–0.3045) 0.0500 (0.0490–0.0553) 5.7x
300 0.8387 (0.8324–0.8550) 0.1420 (0.1408–0.1468) 5.9x

Spread does not fall cleanly with size. The single-resolution row is the loosest — 54% of the median in the runtime column, 27% in the compiled one — where a few microseconds is near what this method can resolve. But the five-resolution runtime cell is the tightest in the table at 2.4%, and the 20-resolution compiled cell is 24%. What holds on every row is the separation: no runtime range comes near its compiled counterpart, the closest approach being a factor of 4.5, so the ratio column survives the noise even where the noise is large.

Both are linear in resolutions and the ratio stays between 5.6x and 6.5x, which works out at about 2.3 µs saved per service resolved. The runtime container’s obtain phase stayed between 0.3696 and 0.3798 ms across that whole column, confirming it depends on definitions rather than resolutions.

So the sentence “the saving scales with what a request builds, not with the size of the container” is true of the per-resolution half and false of the other half. A framework that rebuilds its definitions on every request pays for its whole container whether or not the request touches it.

One result went against my expectation. A forty-line container that autowires by reflecting constructors at resolution time — the shape Laravel uses — resolved the same 20 services in 0.0162 ms against the compiled container’s 0.0113, but with no obtain phase at all, so its total of 0.0163 ms beat the compiled container’s 0.0265 ms. It is not a fair fight: mine handles type-hinted constructor parameters and nothing else, no interface bindings, no factories, no lazy services. Read it as evidence that reflection is cheaper than its reputation, not that compilation loses.

The compiled container is a file, and OPcache is why it is free

That flat obtain column depends on something. Turning OPcache off and changing nothing else:

Definitions OPcache on OPcache off
204 0.0154 (0.0148–0.0213) 1.0150 (0.9819–1.0769)
1,004 0.0154 (0.0148–0.0211) 3.4235 (3.4144–3.5583)
4,004 0.0155 (0.0148–0.0299) 12.9366 (12.7245–13.1540)

Generated code is still code. The dumped container at four thousand definitions is a 2.1 MB PHP file, and without a cache each request pays to compile it. The first request of a fresh process pays it once regardless: 2.58 ms at 204 definitions, 8.65 ms at 1,004, 35.42 ms at 4,004. Compilation trades request work for a large file, and what OPcache removes and what it does not is what makes that trade come out ahead.

The costs, and one of them is a benefit

The build step is real. Defining, compiling and dumping 4,004 services took 265 ms, of which 107 ms was the compiler passes and 153 ms the dump. That runs on deploy, not on a request, but it runs, and the generated file must be invalidated when the wiring changes — which is what a cache clear is for. Edit a service definition without regenerating and the application keeps the previous wiring, silently and correctly, until something rebuilds it.

The error class moves too. I registered a service pointing at mailerr when mailer was meant. The uncompiled container served a request that resolved mailer without noticing, because nothing asked for the broken service. Compiling refused:

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException:
The service "invoice_sender" has a dependency on a non-existent service
"mailerr". Did you mean this: "mailer"?

A wiring mistake on a path no test covers is a production incident under a runtime container and a failed build under a compiled one.

Where this is not where your time is

At 20 services a request, compilation saved about 46 µs of resolution. A single loopback round trip on this machine cost roughly 86 µs in the measurements behind the OPcache article — a different series, so compare the orders of magnitude rather than the digits. One avoidable database query outweighs the entire container. If a profiler shows the request waiting on I/O, this is not the lever, and the method for finding out is in benchmarking PHP without lying to yourself.

What to do with this

Compile the container if your framework offers it, and take the reason to be correctness rather than speed. Wiring errors surfacing at build time is worth more than 46 µs, and the performance argument only becomes interesting where a request resolves hundreds of services or the container defines thousands.

Keep OPcache on, because the compiled container’s advantage is contingent on it, and how contingent depends on size. Timing both phases together with the cache off, in a separate series of eleven runs, the compiled container still won at 204 definitions — 1.07 ms against 1.47 ms — and lost at 4,004, at 13.04 ms against 2.83 ms. Past some container size, compiling a multi-megabyte generated file on every request costs more than building the definitions it replaced.

Measure the resolution count before treating the container as a suspect. Twenty services at 2.3 µs of saving each is not where a slow request went, and the container is one of the easiest things in a framework to blame and one of the harder ones to profile honestly.

Frequently asked

Is a runtime container too slow for production?
Not automatically. It is slower per instantiation, and whether that matters depends on how many services a request actually builds.
Does autowiring disappear when the container is compiled?
No. In Symfony autowiring resolves at compile time instead of request time; the wiring is the same, the work happens earlier.
Why do container changes need a cache clear?
Because the compiled container is generated code. Until it is regenerated, the application runs the previous wiring.
Share

Related posts

Arrow keys to move, Enter to open.