Performance#
The short answer: resolution is linear in the size of your project, and the constants are small enough that it is not the thing you will be waiting for.
A 500-registration project with 1,000 dependency edges resolves in about 60 ms.
Importing scikit-learn on the same machine takes 564 ms. Building the registry is
not what costs you time — which is the point of binding components by import path
rather than importing them, and why cmn-check can validate a whole project
without loading a single component.
The cost model#
Three things drive the cost, and each contributes independently:
resolution ≈ 0.034 ms × registrations
+ 0.045 ms × dependency edges
+ 0.113 ms × variant configurations
A dependency edge is one RegistrationKey reachable from one field —
a list of five keys is five edges. A variant configuration is one derived
by expansion, so a field with variants=[a, b] on a configuration contributes
two.
Variant configurations are the expensive term, by roughly three to one, because each one is copied, has its dependencies resolved, and has its conditions validated. If a project is slow to resolve, that is where the time is, and the number to look at is how many configurations the sweep expands to rather than how many you wrote.
The constants belong to one machine. Do not trust them; reproduce them:
python benchmarks/dag_scaling.py
which prints the measured time for thirteen shapes alongside what the model predicts. On the machine it was fitted on — Python 3.13, Ryzen 9 5900 — the worst disagreement is 16%.
What it looks like in practice#
Project |
Registrations |
Edges |
Resolution |
|---|---|---|---|
|
9 |
11 |
1.0 ms |
A large research project |
500 |
1,000 |
~62 ms |
Deliberately unreasonable |
2,000 |
4,000 |
~250 ms |
The first row is measured; the other two are the model, which the benchmark confirms to within 16%.
For comparison, on the same machine: import pandas costs 171 ms and
import sklearn 564 ms. A project big enough for resolution to be noticeable
is one where importing a single component costs more than resolving everything.
How resolution is ordered#
There is one strategy, not a choice of several. dag_resolution walks the
dependency graph in reverse topological order — every configuration’s
children are expanded before it is:
order = list(nx.topological_sort(cls._DEPENDENCY_DAG))
for key in reversed(order):
...
The order comes from the graph, so it does not depend on the sequence your modules happen to register in. That is worth stating because it was not always true: resolution used to expand from the roots downwards, taking its order from registration — something no user controls and nothing reports.
tests/test_scaling.py pins the guarantee by resolving the same five
registrations in all 120 permutations and requiring the valid keys, the invalid
keys and the number of expansions to match. The third is the one that has
teeth: against the old resolver the keys agreed in every order, and only the
work moved, between 6 and 10 expansions. Order sensitivity surfaced as depth
rather than as wrong answers, which is why a RecursionError on a long chain
was the first anyone saw of it.
Why it stays linear#
Every configuration is expanded exactly once. Resolution walks the dependency graph in reverse topological order, so a configuration’s children are always expanded before it is, and the second visit — the one that arrives along a dependency edge — returns a memoized answer instead of recursing.
That gives an exact invariant rather than an estimate:
expand_configuration calls == registrations + dependency edges
which tests/test_scaling.py asserts as an equality on every graph shape. It
is a deterministic count rather than a duration, so it holds on a busy CI runner
and fails the moment anything starts revisiting nodes. A quadratic regression
cannot pass it.
Depth#
Dependency chains are not limited by Python’s recursion limit. A chain of 1,500 configurations resolves, with the interpreter default of 1,000, because expansion never recurses more than a step or two deep whatever the chain length.
This was not always true. Resolution used to expand from the roots downwards, so
a long chain recursed its whole length and raised RecursionError somewhere
past 400. It also depended on the order you happened to register things in —
registering children before parents attached every node directly to the root and
hid the problem completely, which is exactly what an early benchmark here did.
Both orders are now tested.
Things that are not the bottleneck#
Measured, then left alone:
Container dependencies are cheaper than scalar ones. 160 parents with one
list[RegistrationKey]field of ten members resolve in 31 ms; the same graph spelled as ten separate key fields takes 68 ms.listanddictwere added for expressiveness and happen to be more than twice as fast.Defining configuration classes costs more than resolving them. A
Configurationsubclass costs about 0.32 ms to define, half of which is plain pydantic building its validator. 500 distinct configuration classes therefore cost ~160 ms to import, against ~17 ms to resolve. The import is the bill, and most of it is not cinnamon’s.Parallel resolution does not help. Resolution is bound by the interpreter lock, not by anything a thread pool improves; measured at 0.84× — slower than doing it serially.