Skip to content

性能

记录结果

2026-08-17 记录的公开测试中,默认 checked 模式解析热门单例的中位数为 6.233 ns/op。只针对这一场景和记录中的包版本,其他参测容器的中位数是它的 1.76 到 13.05 倍。这些数字衡量容器开销,不代表 HTTP 吞吐量或整个应用的性能。原始结果包含全部八轮数据和环境元数据。

一次热路径解析会读取 Map.get(key),并在需要构造时直接调用 new Ctor(...)。基准测试场景覆盖以下运行时选择:

运行时选择效果
显式注册容器构建就是为每个服务执行一次扁平的 Map.set。没有装饰器的副作用、构造函数名称解析器,也没有需要预先准备的元数据表。
缓存的单例和作用域级服务一次热路径解析会先从 cache.get(key) 读取,然后才运行循环检测和生命周期记账。显式 undefined 会存为内部 UNDEFINED_MARKER,所以命中缓存仍只需一次查询。
直接调用构造函数具有 0-7 个依赖的类使用直接的 new Ctor(...) 路径。更大的构造函数则回退到 Reflect.construct
异步工厂工厂返回的 Promise 会被原样缓存,因此并发调用者共享同一个进行中的初始化,而 .get() 仍保持同步。
运行时契约默认值/fast: false 保留运行时检查和精确的可变父链。fast: true 关闭检查并启用固定拓扑的作用域查找。

基准测试套件

对比工作区同时测量 InferDI (fast)InferDI (default)、InversifyJS v8、PROXY 和 CLASSIC 模式下的 Awilix v13、TSyringe v4、TypeDI v0.10,以及 Typed Inject v5。原始结果记录了本次运行使用的包版本和机器环境。

计时前的正确性验证

适配器契约测试在 Tinybench 之前运行。每个适配器必须提供相同的可观察依赖图:

  • LoggerConfigRepoService 是根单例
  • 瞬态图节点会生成新实例
  • 每个 scope 缓存自己的 ScopedService,同时共享根 logger
  • lazy 访问会推迟目标服务的解析
  • 每个声明的 teardown API 都会调用 scoped 服务的 disposer

这些检查防止生命周期或实例身份差异进入计时比较。适配器通过契约测试后,计时场景才开始测量成本。

测量设计

公开运行器使用 frozen lockfile 安装依赖,对基准测试工作区执行类型检查,构建 InferDI 的生产 ESM 产物,并针对该产物运行契约测试。随后按以下条件测量各个测试对象:

  1. 一个平衡拉丁方区组包含八轮,与八个测试对象对应。
  2. 每个测试对象在每个进程启动位置出现一次,并在其他每个测试对象之后出现一次。InferDI (fast) 在四轮中先于 InferDI (default),在另外四轮中后于它。
  3. 每个测试对象都在新的 Node 进程中运行,因此不同实现不会共享 JIT 反馈、内联缓存或 GC 历史。
  4. Tinybench 为每个场景预热 50 ms,再测量 100 ms。每个样本执行一组与该操作成本相适应的批量调用。
  5. 对于每个测试对象、场景和轮次,运行器把 Tinybench 批次耗时的中位数除以批量大小,记录标准化的 ns/op
  6. 报告器使用中位数和中位绝对偏差(MAD)聚合八轮数据。

ns/op 越低越好。MAD 描述中位数周围的离散程度,不是置信区间。进程隔离和平衡启动顺序可以降低测量偏差,但无法消除操作系统调度、CPU 频率变化、JIT 决策和 GC 时机的影响。

场景边界

每个场景只隔离容器工作的一部分。生产代码会根据生命周期模型组合其中若干阶段。

工作负载组场景对应的容器工作
热访问热单例解析、热 scoped 解析、lazy 解析读取缓存服务,或通过已有的 lazy 包装器访问服务
对象构造瞬态解析、深层图、宽图缓存未命中、依赖遍历、参数组装和构造函数调用
启动与冷访问注册、首次解析配置依赖图并首次填充缓存
Scope 生命周期Scope 创建、首次 scoped 解析、同步 teardown、异步 teardown从创建到清理,为一次请求或任务持有 scope

Tinybench 的 setup hook 在计时区间之外准备冷图和 scope。注册及解析场景的清理也在计时区间之外执行。Teardown 场景则以清理本身作为测量目标。

TypeDI 在 Registration 中显示 N/A,因为可比的服务定义会在模块求值期间作为装饰器副作用执行,而注册计时器不包含这一步。Teardown 行只包含具有等价公开契约的库:InversifyJS 显示 N/A,TypeDI 参加同步 teardown,Awilix、TSyringe 和 Typed Inject 参加异步 teardown。InferDI 同时提供两种清理契约。

公开结果

每个单元格显示以 ns/op 为单位的 中位数 ± MAD。括号内的系数将该中位数与同一行的最低中位数比较。

基准测试结果

场景InferDI (fast)InferDI (default)InversifyJSAwilix PROXYAwilix CLASSICTSyringeTypeDITyped Inject
热单例解析6.233 ± 0.091 (1.00×)6.233 ± 0.000 (1.00×)10.954 ± 0.321 (1.76×)40.425 ± 0.596 (6.49×)41.525 ± 0.367 (6.66×)81.354 ± 3.254 (13.05×)71.729 ± 0.962 (11.51×)48.354 ± 1.374 (7.76×)
瞬态解析41.798 ± 2.196 (1.00×)45.834 ± 1.284 (1.10×)79.566 ± 3.850 (1.90×)219.82 ± 8.250 (5.26×)227.15 ± 8.068 (5.43×)301.40 ± 16.682 (7.21×)519.75 ± 5.498 (12.43×)138.05 ± 1.464 (3.30×)
深层图(10 层)344.21 ± 11.460 (1.00×)445.50 ± 10.085 (1.29×)397.37 ± 7.795 (1.15×)1306.3 ± 16.500 (3.79×)1190.8 ± 12.375 (3.46×)1463.0 ± 34.835 (4.25×)4478.8 ± 26.582 (13.01×)717.75 ± 3.670 (2.09×)
宽图(4 个依赖)59.584 ± 1.282 (1.00×)71.316 ± 1.284 (1.20×)103.22 ± 2.932 (1.73×)331.10 ± 6.416 (5.56×)302.69 ± 5.498 (5.08×)457.97 ± 18.150 (7.69×)863.32 ± 12.832 (14.49×)197.63 ± 1.468 (3.32×)
宽图(10 个依赖)188.37 ± 4.125 (1.00×)200.74 ± 4.130 (1.07×)394.63 ± 4.580 (2.09×)681.08 ± 21.080 (3.62×)571.54 ± 12.830 (3.03×)979.46 ± 15.125 (5.20×)2234.1 ± 25.888 (11.86×)321.29 ± 4.585 (1.71×)
注册2007.5 ± 27.500 (1.00×)2158.8 ± 13.750 (1.08×)61274.6 ± 1537.7 (30.52×)74763.4 ± 1024.4 (37.24×)95225.6 ± 467.45 (47.43×)3762.9 ± 36.650 (1.87×)N/A3593.3 ± 18.300 (1.79×)
首次解析653.13 ± 24.745 (1.00×)794.98 ± 6.190 (1.22×)9967.4 ± 551.61 (15.26×)2434.9 ± 96.250 (3.73×)3044.2 ± 70.360 (4.66×)1236.8 ± 25.440 (1.89×)3322.9 ± 22.455 (5.09×)1275.3 ± 22.917 (1.95×)
Scope 创建79.750 ± 2.290 (1.00×)83.415 ± 5.505 (1.05×)6194.4 ± 167.98 (77.67×)1054.4 ± 16.960 (13.22×)1031.2 ± 7.790 (12.93×)503.26 ± 10.540 (6.31×)419.38 ± 3.210 (5.26×)164.08 ± 1.835 (2.06×)
首次 scoped 解析115.05 ± 1.830 (1.00×)128.34 ± 1.835 (1.12×)3048.6 ± 32.765 (26.50×)352.92 ± 1.835 (3.07×)362.54 ± 4.590 (3.15×)373.54 ± 11.915 (3.25×)296.08 ± 4.125 (2.57×)203.05 ± 0.920 (1.76×)
热 scoped 解析8.387 ± 0.092 (1.05×)8.250 ± 0.046 (1.03×)30.204 ± 0.367 (3.79×)90.841 ± 1.421 (11.39×)95.242 ± 1.421 (11.94×)78.375 ± 2.429 (9.83×)92.630 ± 0.458 (11.61×)7.975 ± 0.092 (1.00×)
同步 teardown99.455 ± 1.370 (1.01×)98.085 ± 0.925 (1.00×)N/AN/AN/AN/A124.21 ± 3.665 (1.27×)N/A
异步 teardown242.91 ± 3.210 (1.00×)249.34 ± 1.840 (1.03×)N/A860.52 ± 20.170 (3.54×)886.87 ± 13.750 (3.65×)1311.8 ± 10.080 (5.40×)N/A721.42 ± 19.935 (2.97×)
Lazy 解析18.837 ± 0.458 (1.00×)19.020 ± 0.274 (1.01×)64.212 ± 3.621 (3.41×)115.68 ± 0.825 (6.14×)124.09 ± 3.231 (6.59×)155.01 ± 5.271 (8.23×)271.06 ± 3.941 (14.39×)76.496 ± 0.962 (4.06×)

源数据

图表使用 public-2026-08-17T16-46-00-483Z.json 中的数据。原始结果包含八轮数据、标准化测量值、Tinybench 统计信息、环境元数据和依赖版本。基准测试工作区 README 说明了每个场景、对应的生产用例和报告计算方式。

结果解读

在 13 个场景中的 12 个里,至少一个 InferDI 模式取得最低中位数或并列最低中位数。InferDI (fast) 在 11 个场景中取得最低或并列最低中位数。注册、首次解析、瞬态和依赖图构造、scope 创建以及首次 scoped 解析中的优势最明显。这些操作会进入 InferDI 的扁平注册表、直接构造函数调用和精简缓存未命中路径所影响的代码段。

Warm scoped resolve 是前一统计的例外。Typed Inject 为 7.975 ns,InferDI default 为 8.250 ns,InferDI fast 为 8.387 ns。该行测量从现有 scope 中读取一次缓存值,不包含 scope 创建、首次 scoped 缓存未命中或 teardown,而同一个生产请求可能还会分别承担这些成本。

为什么 fast 没有在每一行都更低

fast: true 会改变受保护的缓存未命中、对象构造、注册失效处理,以及固定 scope 树中的父级查找。预热后,有些场景不会执行这些分支:

  • 热单例解析在读取 fast 标志前就从 cache.get(key) 返回。两个模式的中位数都是 6.233 ns。
  • 热 scoped 解析使用相同的缓存命中返回路径。Fast 为 8.387 ns,default 为 8.250 ns;同一实现路径上的差距为 0.137 ns,与报告的 MAD 数值处于同一量级。
  • 同步 teardown 在两个模式中使用相同的 disposal 实现。本次运行中,两者中位数相差 1.370 ns,恰好等于 fast 结果的 MAD。
  • 在异步 teardown 和 lazy 解析中,fast 有小幅优势,但其稳态工作也不会从关闭解析 guard 中直接受益。这些小差距也需要谨慎解读。

独立的 Node 进程可能让相同代码路径得到不同的 JIT 代码,或遇到不同的调度器和 GC 时机。拉丁方顺序和八轮中位数会减小这些影响,但无法消除它们。热 scoped 解析和同步 teardown 中的小幅反转不足以证明 fast 模式出现性能回退。对于亚纳秒和低个位数百分比的差距,在受控机器上重复运行能提供更强的证据。

当实现路径发生变化时,fast 与 default 的差距更大。十层瞬态图中,default 中位数是 fast 的 1.29×;首次解析中为 1.22×;四依赖宽图中为 1.20×;首次 scoped 解析中为 1.12×。这些方向与移除循环和生命周期记账以及采用固定拓扑 scope 查找相符。

让场景匹配应用

报告器不计算总分,因为应用会承担不同组合的容器工作。长时间运行的进程可能只注册一次,随后大部分时间读取热单例。HTTP 适配器可能为每个请求创建 scope、解析一次 scoped 图、读取缓存的 scoped 值,然后释放 scope。Worker 也可能只构造瞬态图而不创建 scope。

不要对各行的相对系数求平均值。场景使用不同的批量大小,并隔离不同的计时区间。请选择与应用实测解析模式相符的行,再结合框架和 I/O 对完整应用进行性能分析。

该比较只适用于记录的包版本、适配器、fixture 和机器。每个库保留自己的公开生命周期模型,因此 N/A 表示该行没有等价操作。该基准测试测量的是容器开销,不是端到端请求延迟。

fast: true

构造函数参数的参考说明见容器选项。本节说明这一选择 对性能的影响。

new Container({ fast: true }) 移除了运行时循环记账、单例栈跟踪,以及守卫解析路径周围的 try/finally。固定 scope 会直接读取 registry owner,不再遍历父链,并把委托解析的 singleton 镜像到 scope 缓存中。默认 scope 在每次本地未命中时遍历精确的父链,因此依赖树变更仍然可见。fast 容器会跳过注册期间的防御性失效处理。owned 实例的身份去重仍在 disposal 阶段执行。

默认的 new Container() 和显式的 {fast: false} 会保留运行时安全检查和可变依赖图。

只有在测试已使用 fast: false 充分演练过依赖图之后,才使用 fast: true。TypeScript 无法看到单例循环、瞬态循环、动态键、as 类型断言,或闭包捕获了更外层容器的工厂。请通过单一线性 fluent 链对每个运行时键只注册一次,在首次解析或创建 scope 前完成所有注册,激活后保持依赖树不可变,并先释放子 scope,再释放其祖先。

完成上述验证后,对于经过性能分析的生产依赖图,{fast: true} 可以降低注册、缓存未命中、依赖图构造和固定 scope 查找的成本。热缓存命中和 disposal 使用共同路径,因此应先测量应用中占主要成本的阶段,再选择运行时契约。开发、测试、热重载以及任何激活后仍会变更的依赖树都应保留 fast: false

热路径的小细节

transient 服务的构造

registerClass 是 transient 服务的默认选择。只有性能分析确认同一依赖图频繁解析许多依赖数量相同的 transient 类时,才需要改用工厂。

针对这一特定的 V8 热点,显式工厂可以为每项服务保留独立的构造调用点:

ts
const container = new Container()
  .declareScopeInputs<{ context: RequestContext }>()
  .registerClass('schema', Schema, [])
  .registerFactory(
    'parseRequest',
    (c) => new ParseRequest(c.get('context'), c.get('schema')),
    ['context', 'schema'],
    'transient'
  )

工厂会重复依赖列表,因此只应在实际应用中测得收益后使用。共享的泛型构造辅助函数会合并调用点,使这项优化失效。

键的表示方式

Symbol 键在密集的解析循环中可能有所帮助,因为 Map 按身份比较它们。字符串键需要哈希计算,且在冲突时还需逐字符比较。大多数应用不会测量出差异,因此应将 symbol 键视为由性能剖析驱动的改动。

在本地复现

bash
cd benchmarks
pnpm install --frozen-lockfile
pnpm run bench:quick   # 本地源码检查
pnpm run bench:public  # 生产产物,每个测试对象使用全新进程

基准测试工作区有意与根 pnpm 工作区隔离,并拥有自己的 lockfile。有关方法论、公平性说明和 fixture 来源,请参阅 benchmarks/README.md