SuperAPI AI 工具集

performance

编程与开发

查看项目

更新于 10-15 10:05

技能介绍

用途

本技能用于优化 @adobe/data 中的热点路径,具体做法是把“工厂函数返回闭包对象”的写法重构为类(class)。它针对的是热分发点(hot dispatch sites)上的闭包→类重构。

问题描述

形如下面的工厂函数:

function createColumn(array) {
  return {
    get(i)    { return array[i]; },
    set(i, v) { array[i] = v; },
  };
}

每次调用都会创建一个新的隐藏类以及每个实例各自的方法。当紧凑循环在同一调用点看到多个此类实例(如 positionX.get(i)、positionY.get(i)、velocityX.get(i) 等)时,V8 的内联缓存会从单态变为多态,进而变为巨态,方法无法被内联。每次 .get 调用都需要解引用闭包上下文来读取 array。数据流相同,但循环比应有的速度慢约 5 倍。

改用类后:

class Column {
  array;
  constructor(a) { this.array = a; }
  get(i) { return this.array[i]; }
}

每个实例共享同一个隐藏类和同一组原型方法。内联缓存恢复单态,V8 可以内联 .get,array 变为快速属性加载。

何时寻找此优化

必须同时满足三个条件:

  1. 大量实例:工厂函数按列、按原型(archetype)、按缓冲区各调用一次,而不是每个应用只调用一次。
  2. 热点逐行循环:通过返回对象的方法读写。ECS 系统、渲染循环、序列化扫描——任何调用次数为 N × 元素数量的地方。
  3. 同一调用点看到多个此类实例:例如 positionX.set(...); positionY.set(...); positionZ.set(...); 在同一个 .set 内联缓存上有三个不同接收者。

如果缺少任何一个条件,就保持闭包写法——它更清晰。

何时不适用

  • 一次性配置、插件描述符、选项包。形状不重要,清晰度才重要。
  • 冷路径:初始化、清理、迁移、持久化。如果每个会话只运行一次,不要归类。
  • 单实例服务,调用点只看到一种形状——V8 的内联缓存已经是单态的。
  • 闭包携带非平凡捕获状态,作为私有字段会显得别扭的地方。为了 V8 的好处强行把状态塞到 this 上,如果损害可读性,就是错误的取舍。

重构步骤

参照现有先例:

  • packages/data/src/cache/managed-array.ts → ManagedTypedArrayColumn
  • packages/data/src/typed-buffer/create-number-buffer.ts → NumberTypedBuffer

具体做法:

  • 方法放在类主体上(这样它们位于原型上,而不是每个实例上)。
  • 状态放在 this 上;在所有实例的构造函数中按相同顺序赋值每个字段。字段从不重新赋值时使用 readonly。
  • 将内部辅助函数提升为私有方法,而不是嵌套闭包或箭头函数字段。箭头函数类字段会放在每个实例上,而不是原型上,会重新引入慢形状。
  • 构造后不要添加或删除属性——那会改变隐藏类。
  • 工厂函数(createXxx(...))保持导出且对调用者不变;只把函数体改为 return new XxxClass(...)。

公共 API 约束

  • 接口保持结构化类型——Column<T>、TypedBuffer<T>、ManagedArray<T>。实现可以是类,但消费者看到的仍应是普通接口对象。
  • 不要导出类。仅限模块作用域。导出工厂函数和类型。
  • 不要添加接口中没有的方法。使用公共类型的消费者绝不应需要 instanceof MyClass。
  • 不要在边界上依赖原型技巧。克隆、持久化、规范化、JSON.stringify——无论值是类实例还是普通对象,都应正常工作。

这保护了边界处的面向数据承诺。盒子内部:按性能分析结果行事。盒子边界:值就是数据。

验证收益

按顺序做三件事:

  1. 聚焦微基准:使用 perftest 测试框架(packages/data/src/perftest/perf-test.ts)——直接调用 test.run(),使用框架自动调优的 N 和内循环采样。记录重构前后的 ms/iter。
  2. 完整 perftest:在无头 Chromium 中对本地 dist 运行 packages/data/src/perftest/index.ts;逐行检查相关行。确认无关行没有回归。
  3. 可选健全性检查:启动 Chromium 时加 --js-flags="--allow-natives-syntax",并 console.log(%HaveSameMap(positionX, velocityX))——重构后应为 true,重构前为 false。

拒绝阈值:仅当聚焦基准快 ≥25% 且相关完整 perftest 行快 ≥10%,并且 pnpm test 和 npx tsc -b 干净通过时,才保留更改。低于此标准都是噪声;回退。

不属于此优化的情形

不要把闭包→类修复与相邻的性能问题混为一谈——它们有不同的解法。

  • 热点读取中的逐元素分配(例如 struct buffer 的 get(i) 返回新的 {x,y,z})。解法是批量 API(forEach、getInto(target, i))或直接操作底层类型化数组,而不是把读取结果类化。经验证:在本仓库中对 struct 读取({x: f32, y: f32, z: f32},N=1M)生成的类做基准测试,得到 10.90 ms → 10.60 ms(−2.8%)——远低于 25% 阈值。V8 对属性按固定顺序赋值的对象字面量已经收敛到稳定的隐藏类,所以类在这里没有收益。除非先改变接口表面(例如 getInto(target, i) 修改调用者拥有的对象,完全消除分配),否则不要重试。
  • 混合值类型导致的多态内联缓存(数字和字符串流经同一列)。解法:类型专用列。
  • 被重新赋值的闭包捕获绑定(array = allocator.refresh(array) 等)。隐藏类从实例 #1 起就是错的——类形式无论实例数量如何都是正确的。

仓库内先例

  • packages/data/src/cache/managed-array.ts — ManagedTypedArrayColumn。原始案例研究;聚焦 profile 上 5.4×,ec2s:move 列上 30%。
  • packages/data/src/typed-buffer/create-number-buffer.ts — NumberTypedBuffer。TypedBuffer 家族(number、struct、const、enum、array)统一基于类;这是应遵循的标准。
  • packages/data/src/perftest/perf-test.ts — 带预热 + 自动调优 N + 内循环采样的测试框架。用于任何新基准测试。