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 @Builder where 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

__repr__

104

107

197

204

__eq__

62

67

67

49

__hash__

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

dataclasses (stdlib)

4.46 ms

attrs

9.31 ms

inito

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 plain self.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 (the object.__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

container.get(), warm/singleton-cached

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

@Inject-wrapped function call

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.