Two tiers, one scenario vocabulary. Numbers here are guidance, not published claims; result files are generated artifacts and are never committed.
Decomposes modern-di's resolve hot path so a regression points at a specific
cost. Runs in CI (informational, non-gating) and locally via just bench.
| ID | Scenario | Isolates |
|---|---|---|
| G1 | Transient resolve, single dep, warm container | pure wiring cost |
| G2 | Cached resolve, warm cache | cache-hit lookup |
| G3 | Deep chain, depth 6, uncached | per-edge wiring |
| G4 | Wide, one object with 10 sibling deps | fan-out |
| G5 | Cross-scope resolve, REQUEST -> APP dep | find_container traversal |
| G6 | build_child_container(REQUEST) |
per-request setup |
| G7 | Full lifecycle batch: K=100 x (build REQUEST -> sync-init cached resolve -> await close_async()) |
real per-request cost incl. async teardown |
| G7c | Control: K=100 empty awaits in one loop entry | residual event-loop floor inside G7 |
| G8 | Cold first-resolve: build root container + compile + resolve, depth 6 | construction + first-compile cost |
| G8b | G8 with every provider cache=True |
_compile_cached_factory's cold-miss builders, read against G8 |
| G9 | Context resolve: request value by type + APP dep, warm child | non-pure context-folding path |
| G10 | validate() on a depth-6 chain (isolated via pedantic) |
graph-validation traversal, deep |
| G11 | validate() on a wide 10-sibling graph (isolated via pedantic) |
graph-validation traversal, fan-out |
| G12 | Resolve a depth-6 chain with one unrelated override active | override front-guard (fetch_override) tax |
| G13 | Per-request cycle finalizing 10 cached resources (close_sync) |
LIFO teardown at scale |
| G14 | Concurrent cached-hit throughput, N threads (lock-free read) | free-threaded read scaling |
| G15 | Concurrent first-resolve, N threads (double-checked creation lock) | free-threaded creation-lock contention |
| G16 | Warm by-type resolve(SomeType), small graph |
find_provider lookup on the integration/@inject path |
| G17 | Warm by-type resolve(SomeType), 200-provider registry |
lookup cost at realistic registry scale |
| G18 | Warm resolve through an Alias to a cached source |
the alias hop, read against G2 |
Rules. Containers are built/warmed in setup, never inside the timed call —
except G8, which builds the root container inside the timed call on
purpose, measuring the one-time construction + graph compile the other
scenarios amortize away.
Cold-resolve scenarios (G1, G3, G4) use transient (uncached) providers so each
timed call does the full wiring. G10/G11 use benchmark.pedantic with a
per-round setup that builds a fresh unvalidated container (untimed), so they
isolate validate() from construction — a fresh registry each round means every
round runs the full graph walk. Every benchmark asserts the resolved graph is
correct. G7 is wall-clock only — instruction-count tooling cannot measure the awaited teardown.
It times a batch of K=100 request cycles inside a single run_until_complete: one loop
entry costs ~27us on any body, which previously swamped the ~2us of real work when each
iteration entered the loop separately. Divide the G7 number by 100 for per-request cost, and
read G7c — the same batch shape with an empty body — as the residual floor still inside it
(~15% at K=100).
Scenarios costing under ~2 us are pinned to a fixed rounds x iterations
(benchmarks/_pinned.py), so one round spans 50-150 us and the time.perf_counter pair is
under 0.2% of the value. Everything at or above ~10 us keeps pytest-benchmark's calibration --
the timer is already under 0.5% there -- as do the scenarios needing per-round setup, which
cannot raise iterations without timing a warm repeat instead of the cold case they exist for.
This was not always so, and the reason it changed is worth keeping. Unpinned, the short
scenarios calibrate to iterations=1 and their medians quantize to one tick -- ~41 ns on an
Apple M4, which was 23% of G2 and 16-18% of the by-type scenarios. Two runs of an unchanged G6
would report 541 ns and 584 ns, exactly one tick apart, reading as an 8% regression that is
nothing at all. That was tolerable while the alert threshold sat at 150%, far above any tick
noise. It stopped being tolerable when individual changes started being worth 20-33% each: a
complete revert of the arity-specialised creator call reads as 149.3%, which the old threshold
would not have caught.
Pinning changes the reported statistic from a median of single calls to a median of
per-round means -- the same statistic the comparative tier reports. Pre-pinning numbers are
therefore not comparable to post-pinning ones, which is why the stored CI baseline was reset
(the cache key carries a -v2- prefix; the old entries are orphaned rather than deleted).
Expect apparent one-off "improvements" across that boundary: G16 moved 250 -> 168 ns purely by
coming off the grid.
The alert threshold is 120% and the job stays non-gating. 120% catches a full revert of three of the four optimizations landed on 2026-08-03 (arity ladder 149.3%, alias hop 127.8%, by-type inline 124.5%) and misses the fourth (the context fold, ~106%), which no threshold that survives shared-runner variance would catch.
Measured headroom, four consecutive full-tier runs on a quiet machine after pinning: every scenario within 3.3%, except G2 -- the smallest at ~156 ns -- which produced one run at 135 ns, a 17.9% spread. That outlier read faster, so it would not trip a regression alert, but it is the reason G2 is the scenario to distrust first. Before pinning, a single tick alone was 23% of G2.
The number is still provisional: shared ubuntu-latest runners are noisier than this, and it
should be revisited once there is CI history to measure. Because fail-on-alert is false, a
false positive costs a comment rather than a red build -- which is the trade that makes a
threshold this low workable at all.
G14/G15 use a custom N-thread harness (test_guard_concurrency.py) — pytest-benchmark
times a parallel batch of worker threads released together behind a barrier,
parametrized over thread count {1, 2, 4} so the scaling trend shows within one run.
The GIL vs free-threaded (PEP 703) comparison comes from running the file under each
build (same version/arch):
uv run --python 3.14t --with pytest-benchmark pytest benchmarks/test_guard_concurrency.py
CI's guard-bench runs one GIL interpreter, so the free-threaded numbers are a manual run. Finding: resolution is thread-safe but its throughput does not scale with threads on a free-threaded build — cached-hit batch time is flat-to-worse as threads rise and matches the GIL, while a pure-compute control on the same harness scales ~3.5x at 4 threads. The bottleneck is concurrent access to shared hot-path objects (registry resolver/cache dicts, container, cached value), not the GIL; first-resolve additionally serializes on the double-checked creation lock. Read the thread-count trend, not absolutes — throughput benches are noisy and guard-bench is non-gating.
modern-di vs dishka, that-depends, dependency-injector, wireup on the same
graph shape. Local-only (just bench-compare); never in CI. Deps are pinned in
benchmarks/comparative/pyproject.toml; the env is git-ignored.
| ID | Scenario | Guard equiv |
|---|---|---|
| C1 | Transient resolve, single dep | G1 |
| C2 | Singleton resolve, warm | G2 |
| C3 | Deep chain, depth 6 | G3 |
| C4 | Request lifecycle: enter request scope -> sync-init resolve -> async-finalize on exit | G7 |
| C5 | Cold build + first resolve, depth 6 | G8 |
| C6 | Context: per-request runtime value by type + app dep | G9 |
Each framework uses its natural request-scope + resource-teardown idiom (not modern-di's
scope names forced onto it). C1-C3 are synchronous resolves for every framework, and exist in
two variants for modern-di: by-reference (resolve_provider) and by-type (resolve).
dishka and wireup expose only by-type lookup; that-depends and dependency-injector only
by-reference. Each published row therefore compares one modern-di variant against the rivals
whose API matches it -- a single modern-di column would be unfair to one half of the set.
By-type resolution adds a fixed dict-lookup cost of roughly 40 ns on top of resolve_provider;
while small in absolute terms, the percentage this represents depends on the baseline, not a
framework constant. C4 is not sync-vs-sync: modern-di resolves synchronously under an
async finalizer, while dishka / that-depends / dependency-injector / wireup all force an
awaited resolve -- so C4 measures the whole request lifecycle, not an isolated resolve, and is
timed as a batch of K=100 cycles per loop entry.
| Framework (pin) | C1 transient | C2 singleton | C4 scoped + async teardown | C4 resolve |
|---|---|---|---|---|
| modern-di | Factory (uncached) |
Factory(cache=True) |
REQUEST Factory(cache=CacheSettings(finalizer=async)), await close_async() |
sync |
| dishka 1.10.1 | provide(cache=False) |
provide (cache default) |
async-gen @provide(REQUEST), async with container() |
await |
| that-depends 4.0.2 | Factory |
Singleton |
async-gen ContextResource, container_context |
await |
| dependency-injector 4.49.1 | Factory |
Singleton |
async-gen Resource, init/shutdown_resources |
await |
| wireup 2.12.0 | injectable(transient) + scope |
injectable (singleton default) |
async-gen injectable(scoped), async container |
await |
Fixed timing shape for the published scenarios (C1-C4, C6). pytest-benchmark auto-calibrates
iterations per benchmark per run, which in practice left some cells at iterations=1 -- each
median carrying a whole per-round timer pair and snapped to the platform timer's ~42 ns grid --
while others ran at 20-100 and amortized both away. A published ratio must not divide a
grid-snapped number by an unsnapped one, so C1-C4 and C6 use benchmark.pedantic at a shape
pinned identically in all five files: C1-C3 and C6 at rounds=200, iterations=1000, C4 at
rounds=100, iterations=3 (C4's callable is already a batch of K = 100 cycles, so a few
iterations put the timer pair below 0.01% of the cell). warmup_rounds=1 replaces the warm-up
calibration used to provide; every other setup stays outside the timed call exactly as before. (C6's
later promotion did change what its timed call contains — see its caveat below.)
tests/test_bench_report.py parses the five files and fails if a published scenario drops off
the pinned shape or the numbers drift apart. C5 is not published on the page and stays on
auto-calibration.
Levelling this axis moved published cells in both directions, and the shift is not uniform:
cells that had been at iterations=1 shed anywhere from ~10 ns (dependency-injector C1) to
~50 ns (dishka C1, C3), and one -- modern-di's by-reference C3 -- rose by ~12 ns. Because dishka
shed proportionally more than modern-di did, modern-di's C1 and C3 ratios against dishka got
worse, not better. Netted over the sixteen ratio cells published at the time, eight moved against
modern-di, seven for it and one was unchanged. (C6 was promoted later, so the table now
carries twenty.)
C4's estimator also shifted: a round is now the mean of iterations batches, so a right-skewed
distribution reports nearer its mean -- modern-di's C4 mean was 24% above its median at
iterations=1, and its published per-request figure rose accordingly. The mean itself did not
move (245.9 us before, 248.0 us after on the same machine), so that is a change of estimator,
not of measured work.
Thread-safety configuration differs, at each framework's default. dishka's make_container
defaults to lock_factory=<class '_thread.lock'>, so every get() in C1-C3 acquires a lock;
make_async_container defaults to asyncio.Lock. modern-di's cached-read path is lock-free by
design (see docs/introduction/design-decisions.md), and its creation lock is double-checked. Every
framework here runs at its default, which is the comparison a user gets out of the box -- but a
dishka user targeting single-threaded work can pass lock_factory=None, and that would move
dishka's C1-C3 cells. The axis is disclosed rather than normalized away.
Caveat — C4 is not sync-vs-sync. modern-di is the only framework that resolves
the connection synchronously while finalizing asynchronously; the other four
force an awaited resolve once the finalizer is async. C4 therefore measures the
whole request lifecycle (enter scope -> resolve -> async finalize) as wall-clock
under a shared event loop, not an isolated resolve. C1-C3 are true synchronous
resolves for every framework. wireup's transient/scoped resolves require an active
scope, entered once in setup so C1/C3 time only scope.get.
| Framework | C5 cold (build + first resolve) | C6 context (request value by type + app dep) |
|---|---|---|
| modern-di | Container(groups=[...]) + resolve |
ContextProvider + build_child(context=) |
| dishka | Provider + provide×6 + make_container + get |
from_context + container(context=) |
| that-depends | rebuild 6 Factory + resolve_sync |
fetch_context_item_by_type + container_context(global_context=) |
| dependency-injector | ChainContainer() + c0() |
providers.Dependency + .override() |
| wireup | create_sync_container + enter_scope + get |
enter_scope({RequestObj: value}), registered placeholder |
Caveat — C5 is not one axis. The frameworks front-load wiring at different
points, so "cold" measures different things and the cells are not comparable
one-to-one: modern-di / dishka / wireup time a real per-container build (dishka
builds the graph; wireup exec-codegens a factory per provider);
dependency-injector's number is ~98% provider-graph deepcopy on
instantiation, not resolution; that-depends wires at import and has no
per-call build, so its cell is a Factory-reconstruction analog (6× Factory.__init__
- resolve), not a container build. dishka's cell additionally includes per-call provider registration --
Provider(scope=...)plus sixprovide()calls, measured at 121.3us of its 908.1us (13%) -- which modern-di hoists to import time in theChainGroupclass body. The two build cells are therefore close but not strictly like-for-like. The honest reading is modern-di vs the build-time codegen frameworks (dishka/wireup), where stayingexec-free wins by a wide margin. C5 aligns validation off (modern-di never validates unlessvalidate()is called explicitly, dishkaskip_validation=True) so it isolates build+compile.
Caveat — C6 is sync for all five (a clean sync-vs-sync comparison, unlike C4), timing the per-request "supply value + resolve" cycle. It is published, against all four rivals in one table, for C4's reason alone: modern-di resolves by reference throughout the body and has no by-type C6 variant, so a split would leave that half mixed-basis. The rivals do line up with the C1-C3 grouping here — that-depends resolves its C6 handler by reference, as it does on C1-C3; only the supply of the request value is type-keyed, which is not the axis the tables split on.
modern-di's C6 body was corrected in two ways when it was published, and they pull against each
other. It calls no open() — a freshly built child is already open as of 3.1, so timing one
charged a redundant lock acquire (81 ns, ~6% of the cell) with no counterpart in any rival body.
It now does close the child, because all four rivals exit a scope inside their timed bodies and
this one did not: that teardown is ~110 ns, larger than the open() removed, so on net the
correction moved modern-di's C6 cells against it, not for it. Two structural
notes: dependency-injector injects by reference (providers.Dependency +
.override()), not by type — a structural analog, not an equivalent; wireup
requires the runtime type registered as a scoped injectable with a raising
placeholder factory (its own integration idiom), the value then supplied via
enter_scope.
No comparative validate() row. validate() (G10/G11 in the guard tier) has
no comparative equivalent: dependency-injector and that-depends run no build-time
graph-validation pass, and for dishka and wireup validation is folded inside
make_container / create_sync_container with no isolation seam (and their C5
cold build already includes it). A cross-framework "validation cost" row would be
n/a for two frameworks and redundant for the others, so it is omitted.
just bench— guard tier (this repo's env).just bench-compare— comparative tier (isolated env; first run resolves deps).just bench-report [runs]— runs the comparative tierrunstimes (default 5) and prints the markdown ratio table published indocs/introduction/performance.md. The published table is generated by this command, never hand-assembled. C4 is timed as a batch of K=100 request cycles per event-loop entry; the report divides by K so the published cell is per request.