Performance#
inito’s core design constraint is that decoration-time work (reflection,
metadata extraction, code generation) happens exactly once, and generated
methods perform like handwritten ones — never worse, and never with
per-call or per-instance overhead. This page documents how that’s measured
and what the numbers actually show.
Methodology#
Every comparison uses the same Point-shaped class (two required fields,
one defaulted) implemented four ways:
handwritten — a manually written class, the baseline.
inito — the same class decorated with
@Data(and@Builderwhere noted).dataclass —
@dataclasses.dataclass.attrs —
@attrs.define(slotted by default).
Two tools are used, per inito.md’s benchmarking requirements:
pytest-benchmark (
benchmarks/test_*_benchmark.py) — convenient for local/CI regression tracking across construction, attribute access,__repr__,__eq__,__hash__, decoration time, and@Builder’s fluent chain vs. a direct constructor call.pyperf (
benchmarks/pyperf_suite.py) — process-isolated, statistically rigorous construction benchmark, closer to what you’d cite in a real comparison.
Memory footprint (benchmarks/memory_profile.py, tracemalloc-based) and
cold-import overhead (benchmarks/import_time.py, subprocess-based) are
measured separately as standalone scripts, since both need process-level
isolation that doesn’t fit pytest-benchmark’s per-call timing model.
Reproducing these numbers#
pytest benchmarks/ --benchmark-only # construction/access/repr/eq/hash/decoration/builder
python benchmarks/pyperf_suite.py # process-isolated construction comparison
python benchmarks/memory_profile.py # per-instance memory footprint
python benchmarks/import_time.py # cold-import overhead
Results#
Measured on Apple M5 (macOS 26.3.1), CPython 3.12.13, single run. These are indicative, single-machine numbers, not hardware-normalized or CI-verified — treat relative ordering as meaningful, absolute nanosecond values as illustrative only. Re-run locally for numbers that matter to your decision.
pytest-benchmark (mean, nanoseconds unless noted)#
Operation |
handwritten |
inito |
dataclass |
attrs |
|---|---|---|---|---|
construction |
66 |
71 |
71 |
62 |
attribute access |
13.6 |
13.6 |
13.6 |
13.5 |
|
104 |
107 |
197 |
204 |
|
62 |
67 |
67 |
49 |
|
58 |
62 |
61 |
62 |
decoration (µs) |
~2 |
~100 |
~78 |
~92 |
inito is at parity with handwritten/dataclasses across every runtime
operation. For an ordinary (non-frozen) class, generated constructors
assign fields via plain self.x = x — the fastest option, and the one that
keeps CPython’s key-sharing instance dict (PEP 412) intact so attribute
reads and __eq__/__hash__/__repr__ stay at handwritten speed. When a
class is immutable — @Value, @Data(frozen=True), or stacked on top of
@dataclass(frozen=True) (innermost) — the constructor assigns via a
once-bound object.__setattr__ to bypass the blocking __setattr__. That
costs more to construct (~130ns, roughly 2x — a cold, once-per-object path)
but still keeps reads fast, because object.__setattr__ also preserves
the key-sharing dict. __repr__ is the fastest of the three codegen flavors
(single unrolled f-string). (An earlier 0.0.11 experiment wrote fields via
self.__dict__["x"] = x; it was slightly faster to construct but broke
key-sharing, silently regressing every attribute read — reverted here.)
@Builder fluent chain vs. a direct constructor call (both on the same
@Data-equipped class): the direct call took ~80ns; the four-method fluent
chain (.builder().x().y().label().build()) took ~250ns — expected, since
it does five method calls and allocates a Builder instance instead of one.
pyperf construction (mean ± stddev)#
Representative single-machine run: handwritten and dataclass land in the
same ~70-75ns band as inito; attrs is a few ns faster. Run
python benchmarks/pyperf_suite.py locally for numbers with pyperf’s full
statistical rigor (the quick sanity-check run used here used --fast,
which pyperf itself flags as insufficient for a stable result).
Memory footprint (bytes/instance, 100k instances)#
Flavor |
Bytes/instance |
|---|---|
handwritten |
96.0 |
inito |
96.0 |
dataclass |
96.0 |
attrs (slotted) |
80.0 |
inito matches handwritten/dataclass exactly — all three use ordinary
__dict__-based instances. attrs is smaller here because attrs.define
opts into __slots__ by default; inito doesn’t generate slotted classes
today (tracked as a possible future enhancement, not required by the
current spec).
Cold-import overhead (mean ± stddev, 15 runs)#
Import |
Time |
|---|---|
baseline (no import) |
0.00 ms |
|
4.46 ms |
|
9.31 ms |
|
8.74 ms |
Takeaways#
Construction, attribute access,
__eq__,__hash__: inito is at or within a few percent of handwritten/dataclasses — those generators emit exactly what you’d write by hand, and non-frozen construction uses a plainself.x = x, so the “generated code performs like handwritten code” goal holds up in measurement, not just in design intent.Immutable classes (
@Value/@Data(frozen=True)/frozen-innermost) pay ~2x on construction only (theobject.__setattr__bypass, a cold once-per-object path); their attribute reads and eq/hash/repr stay at handwritten speed.__repr__: inito’s single unrolled f-string is the fastest generated repr among the three codegen-based flavors, and close to handwritten.Decoration time: meaningfully higher than dataclasses (both are one-time, at-import costs, not per-instance) — reasonable given inito’s extra indirection (registry lookup,
exec()-based method generation vs. dataclasses’ more specialized C-accelerated path), and irrelevant to steady-state performance since it happens once per class, not per object.Memory: identical to handwritten/dataclasses; attrs’ default slotted classes have a real, expected edge here.
Dependency injection (@Service/@Singleton/@Inject)#
benchmarks/test_di_benchmark.py compares the DI container against
hand-written equivalents at four points. Measured on the same machine as
above, after 0.0.10-beta added double-checked per-class locking around
singleton construction (see the Dependency injection guide):
Operation |
inito (DI) |
hand-written |
Verdict |
|---|---|---|---|
attribute access on a resolved instance |
12 ns |
12 ns |
at parity — zero DI-related overhead once an object is built |
|
45 ns |
22 ns |
small — a single dict lookup (fast-pathed ahead of registration/scope/cycle checks); no lock is touched once a singleton is cached |
cold full-graph resolution (3-level) |
~900 ns |
~140 ns |
real, one-time-per-resolution cost — quantified, not hidden |
|
202 ns |
25 ns |
real, every call — but ~4x cheaper than before (see below) |
Unlike every other decorator in this library, @Inject and cold
container.get() calls are not claimed to be zero-overhead — only
post-construction attribute/method access on an already-resolved object
is. @Inject wraps a function (typically a composition-root entry point),
and resolving its container-registered parameters happens on every call.
It still inspects the wrapped function’s signature and type hints exactly
once, at decoration time; the per-call path only checks which parameters
the caller already supplied (a name/positional-index check — no
Signature.bind_partial per call) and resolves the rest. That is what
brought the call cost from ~830ns to ~200ns. See
the Dependency injection guide for why this
boundary is architecturally different from every other generated member.
Concurrent first-access to a singleton from multiple threads is safe
(verified with a real threading reproduction: 20 threads racing a cold
get() construct the service exactly once and all receive the same
instance). Dependencies are resolved before a service’s construction lock
is taken, so no thread ever holds two locks at once — a cyclic graph
resolved concurrently from opposite ends raises CircularDependencyError
cleanly rather than deadlocking. The lock only runs on the cold path; warm
get() is lock-free.