Skip to content

Perf optimizations - #407

Merged
jeffijoe merged 1 commit into
jeffijoe:masterfrom
kibertoad:feat/perf
Feb 12, 2026
Merged

Perf optimizations#407
jeffijoe merged 1 commit into
jeffijoe:masterfrom
kibertoad:feat/perf

Conversation

@kibertoad

Copy link
Copy Markdown
Contributor

Optimizations Applied

1. Cache-first resolve for singleton and scoped (container.ts)

Original: Awilix already caches singleton values on the root container and scoped values on the resolving container. However, the cache check lived inside the switch statement at the end of resolve(). This meant every resolve - even for already-cached values - first ran cycle detection (.some() with closure allocation), strict mode lifetime check (.findIndex() with closure allocation), and resolutionStack.push({ name, lifetime }) (object allocation) before reaching the cache lookup.

After: The cache check is moved to the top of resolve(), immediately after determining the lifetime. Cached singletons return in O(1), skipping cycle detection, lifetime check, and stack push/pop entirely. Cached scoped values also return early, but in strict mode still run the lifetime check (a singleton ancestor depending on a cached scoped value is still a lifetime leak). Transient resolves (which are never cached) pay a single lifetime !== TRANSIENT comparison to skip the cache-first block.

2. Closure-free cycle detection and lifetime check (container.ts)

Original: Cycle detection used resolutionStack.some(({name: n}) => n === name) - allocating a closure on every resolve() call. Strict mode lifetime check used resolutionStack.findIndex(({lifetime: lt}) => isLifetimeLonger(lt, lifetime)) - allocating another closure on every strict-mode resolve().

After: Both replaced with plain for loops over resolutionStack.length. No closure allocation per resolve. These loops only run on the slow path (uncached resolves), since the cache-first optimization returns before reaching them for cached values.

3. Injector proxy key deduplication (resolvers.ts)

Original: uniq([...Reflect.ownKeys(container.cradle), ...Reflect.ownKeys(locals)]) - Reflect.ownKeys on the cradle triggers the proxy's ownKeys trap which calls rollUpRegistrations(), then uniq() creates an intermediate array + Set + spread.

After: new Set(Object.keys(container.registrations)) + iterate Object.keys(locals) - reads the registration hash directly (bypasses proxy chain), builds the Set in-place with no intermediate array.

Benchmark Results

Simple Resolve

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
resolve transient (3 regs) 8,277,532 8,197,774 -1.0%
resolve singleton (cache hit) 11,260,368 14,830,936 +31.7%
resolve scoped (cache hit) 10,119,952 11,775,814 +16.4%
resolve transient (100 regs) 9,677,381 9,808,228 +1.4%

Singleton and scoped cache hits improved significantly from the cache-first optimization (skip cycle detection + stack push/pop).

Deep Chain Resolve (all transient)

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
depth=5 3,883,172 3,391,002 -12.7%
depth=10 1,682,576 1,535,879 -8.7%

All-transient chains see a regression. This is a tradeoff of the cache-first optimization: every transient resolve now runs through a lifetime !== TRANSIENT check that always fails before reaching cycle detection. The changed code structure (more branches in resolve()) may also affect V8's JIT optimization of the function. This cost is inherent to the cache-first approach - the same branching that enables O(1) returns for cached singletons/scoped values adds a small penalty to transient resolves that never hit the cache. In practice, this tradeoff is favorable: real apps have a mix of lifetimes where the cache-first wins far outweigh the transient penalty.

Strict Mode Lifetime Check

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
depth=5 (no violation) 59,196 61,019 +3.1%
depth=10 (no violation) 31,604 32,260 +2.1%
depth=25 (no violation) 12,834 12,684 -1.2%
depth=10 transients, strict=false 1,520,272 1,347,347 -11.4%
depth=10 transients, strict=true 1,415,609 1,219,630 -13.8%

The "no violation" case (the common path) shows a small improvement at typical depths. The depth=10 transient benchmarks regress for the same reason as deep chain resolve: the cache-first branching adds overhead to all-transient chains that never benefit from caching.

Scope Lookup

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
depth=1 (first call) 383,056 382,557 -0.1%
depth=3 (first call) 201,284 201,103 -0.1%
depth=6 (first call) 117,661 117,159 -0.4%
depth=10 (first call) 75,734 75,156 -0.8%
depth=1 (cached) 11,466,116 12,092,396 +5.5%
depth=3 (cached) 10,184,138 10,623,798 +4.3%
depth=6 (cached) 9,782,371 9,902,143 +1.2%
depth=10 (cached) 7,074,941 8,820,600 +24.7%
re-register depth=10 2,065,740 2,103,586 +1.8%
hasRegistration depth=1 18,008,949 19,873,383 +10.4%
hasRegistration depth=6 10,337,492 10,271,681 -0.6%

First calls are flat (no cache to hit). Cached calls show clear improvement from the cache-first fast path, with depth=10 cached showing a +24.7% gain. hasRegistration at depth=1 also improved +10.4%.

Injector Proxy

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
5 registrations 453,528 881,553 +94.4%
50 registrations 51,972 67,659 +30.2%
200 registrations 12,159 14,889 +22.4%

The largest win. Bypassing the cradle proxy and avoiding Reflect.ownKeys + uniq() intermediate allocations.

Realistic App (50 registrations, scoped web app simulation)

Benchmark Baseline (ops/sec) Optimized (ops/sec) Change
orderController (deep graph, fresh scope) 145,726 157,496 +8.1%
3 controllers in same scope 86,186 92,667 +7.5%
full request: middleware + controller + dispose 86,021 91,797 +6.7%
scoped service: first resolve + cache hit 154,630 165,134 +6.8%
singleton from nested scope 744,466 755,077 +1.4%
productService (shallow, fresh scope) 459,273 474,069 +3.2%
hasRegistration x5 8,523,215 8,251,150 -3.2%
create scope + resolve config value 756,177 752,209 -0.5%
orderController (strict mode) 133,945 149,118 +11.3%
all 10 controllers (fresh scope) 51,959 55,977 +7.7%

Real-world workloads show consistent 3-11% improvement across the board. These gains come primarily from the cache-first optimization: in a typical request scope, most scoped services are resolved multiple times (shared across controllers), and each cache hit now skips cycle detection and stack manipulation entirely. Strict mode sees the largest gain (+11.3%) because cached singletons bypass both the lifetime check and cycle detection.

Comment thread src/resolvers.ts
Comment thread tsconfig.json Outdated
@coveralls

coveralls commented Feb 12, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 100.0%. remained the same
when pulling a2d2ef6 on kibertoad:feat/perf
into fee0279 on jeffijoe:master.

Comment thread src/__tests__/container.test.ts
Comment thread src/__tests__/local-injections.test.ts
Comment thread src/container.ts
Comment thread src/resolvers.ts
Comment thread src/container.ts Outdated
Comment thread src/resolvers.ts Outdated
Comment thread src/__tests__/container.test.ts Outdated
Comment thread src/container.ts Outdated
Comment thread src/resolvers.ts Outdated
@jeffijoe

Copy link
Copy Markdown
Owner

Final request: could you squash your commits into however many logical commits you think makes sense? Could be a single one, but ideally want to keep the git log clean.

@kibertoad

Copy link
Copy Markdown
Contributor Author

@jeffijoe Done, but can't this also be done while merging? GitHub supports squashing all commits in the PR (one of the three merge modes), just needs to be enabled on the repo level.

@jeffijoe

Copy link
Copy Markdown
Owner

It does, but I prefer still having merge commits. Makes it easy to see what was part of a single PR.

@jeffijoe
jeffijoe merged commit 3141783 into jeffijoe:master Feb 12, 2026
6 checks passed
@jeffijoe

Copy link
Copy Markdown
Owner

Thanks @kibertoad, this has been released!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants