Skip to content

引擎怎样加速代码:形状、反馈与去优化 ​

在语言层面,item.price 只是属性读取;执行器却可能反复观察这行代码,寻找更快的处理方式。本篇研究优化为什么可能成功、为什么也可能失效。先理解第四章的属性查找,再来认识这位忙着做笔记的引擎。

同一条语言规则,可以有不同实现 ​

规范要求 JavaScript 数值操作符合相应语义,但不要求所有数字永远用同一种物理布局保存。类似地,属性查找必须给出正确结果,内部可以使用对象形状、缓存、机器码或其他方式加快处理。

以 V8 的分层执行思路为例,解析之后可以生成字节码;解释执行和不同层级的编译相互配合。Ignition、Sparkplug、Maglev、TurboFan 是理解这条技术路线时会遇到的名字,具体版本还会继续演进。

text
源代码 → 解析与字节码 → 执行、收集反馈
                            ↓
                      基于假设的优化
                            ↓
              假设成立:更快执行 / 失效:退回通用路径

这张图表达因果关系,不意味着每个函数必须按固定顺序经过每个编译器。冷代码可能根本不值得投入昂贵优化,短命页面也未必有足够时间收回编译成本。

对象形状:给相似对象安排相似路线 ​

考虑两种构造方式:

js
function createRow(id, price) {
  return { id, price }
}
const rows = [createRow(1, 12), createRow(2, 18)]
console.log(rows.map(row => row.price)) // [12, 18]

两个对象属性组织相似,读取 price 时可能获得稳定反馈。所谓形状,可以粗略理解为引擎记录的属性布局信息,而不是额外给你暴露的 shape 属性。

下面两对象拥有同名属性,添加顺序却不同:

js
const a = { id: 1, price: 12 }
const b = { price: 18, id: 2 }
console.log(a.price + b.price) // 30

语言结果完全正常。某些引擎在这样的访问点可能需要处理更多形状。由此合理得到的建议是保持数据结构清楚、初始化方式一致;不能直接得到“第二段一定慢几倍”。是否热路径、引擎版本、对象数量和其他工作都会影响结果。

delete、动态增加字段等操作也不是禁止使用的语法。真实需求需要删除属性时应当删除,再依据测量判断是否改变数据结构。为了传说中的优化保留错误数据,得不偿失。

为什么优化需要先辨认接收者 ​

如果引擎已经知道某类对象的 price 位于某个布局位置,就有机会把通用属性查找缩短为“检查结构,再读取对应位置”。布局位置只是实现模型,下面是解释思路的伪代码,不是 JavaScript 引擎公开接口:

text
收到 item
  ├─ 与已知布局及相关假设相符 → 读取已知位置
  └─ 不相符 → 使用通用属性读取,更新反馈或进入其他路径

检查不可省略。price 可能是自有字段,也可能在原型上;原型属性可能后来发生变化;访问器还会执行任意用户代码。只有证明这些差异不会改变本次读取,才能使用同一条快速路径。

js
function quote(item) { return item.price }
const base = { price: 10 }
const inherited = Object.create(base)
const own = { price: 10 }
console.log(quote(inherited), quote(own)) // 10 10
base.price = 15
console.log(quote(inherited), quote(own)) // 15 10

前两次读到相同结果,不能推出两个对象的读取机制相同。后面的原型修改暴露了区别。对于依赖原型的优化,实现必须检查或追踪相关依赖,不能把首次结果永久缓存下来。理解这点,才能区分“缓存怎样访问”与“缓存上次返回的值”。

V8 的某些表示还区分命名属性和索引元素,区分紧凑数组与带空槽的数组。它们是为访问模式服务的内部安排;array[3] 是否需要考虑原型、字段是否存在,仍受语言规则约束。因此不能把“空槽”简单理解成存了一份 undefined,也不能把内部形状等同于源码中的 class。

内联缓存与类型反馈在记录什么 ​

一个属性读取位置第一次遇到对象时,需要走相应查找过程。引擎可以记住此前处理过的形状及快速访问方式;下次形状匹配就可能走更短的路径。它可以记一种情况,也可以处理多种情况。

js
function readPrice(item) {
  return item.price
}
console.log(readPrice({ price: 3 }))          // 3
console.log(readPrice({ price: '待定' }))     // 待定
console.log(readPrice({ get price() { return 8 } })) // 8

这三次访问都合法,但返回类型与访问行为不同。getter 是用户代码,还可能产生副作用。优化器不能仅凭函数名字 readPrice 认定自己永远在读取一个数字字段。

反馈是过去发生过什么,假设是未来可能还会如此。 引擎必须安排检查或失效机制,保证猜错时仍符合语言语义。去优化是这个保证的一部分,并非程序发生业务错误。

猜错以后,必须从正确的位置继续 ​

去优化不能简单理解成“把整个函数从头执行一遍”。如果前半段已经写入状态、打印日志或调用了外部函数,重新执行会重复副作用。优化后的执行状态需要能对应回更通用的执行表示,才能在正确的位置继续。具体恢复机制随引擎和版本变化,正确性要求却不会改变。

js
let reads = 0
const sample = {
  get price() {
    reads += 1
    return 10
  }
}
function total(item) {
  return item.price + item.price
}
console.log(total(sample), reads) // 20 2

两次语法上独立的属性读取可能有两次副作用,优化器不能只因表达式写得一样就任意合并。只有证明相关读取可安全复用,才可以省略工作。这也是为什么“我把函数多跑几遍,它应该就会变快”无法替代对实际热点的观察。

反馈通常与具体执行位置相关。同一个对象在不同代码位置被使用,面对的历史输入可能不同;同一个函数先后接收不同数据,也可能使某处反馈从单一情况变成多种情况。单态、多态等术语描述这类观察模式,不是代码正确与否的等级。

为什么一段循环的成绩不能代表整个页面 ​

下面的完整实验可以在 Node .mjs 中运行:

js
import { performance } from 'node:perf_hooks'
const values = Array.from({ length: 20000 }, (_, index) => index % 97)
let checksum = 0
for (let round = 0; round < 5; round++) {
  const start = performance.now()
  let total = 0
  for (let repeat = 0; repeat < 100; repeat++) {
    for (const value of values) total += value
  }
  const elapsed = performance.now() - start
  checksum += total
  console.log(round, elapsed.toFixed(2), total)
}
console.log('保留结果', checksum)

每轮总和应该相同,时间会波动。后几轮可能更快,但这不足以证明某次特定优化一定发生了;系统负载、垃圾回收和计时粒度也会变化。

严谨比较需要同样输入、独立重复、多种数据规模和代表真实需求的工作负载。不能把一次计时当定论,也不能只挑最漂亮的一轮。页面速度还可能主要耗在网络、布局或渲染上,优化上述求和循环对用户没有可见价值。

先确认两个“优化版本”计算的是同一件事 ​

一种写法变快,也可能只是少做了原本必须做的事。下面这个比较器试图用真假代替三种顺序:

js
const values = [9, 2, 7]
const incorrect = values.toSorted((a, b) => a > b)
const correct = values.toSorted((a, b) => a - b)
console.log(correct.join(',')) // 2,7,9
console.log(incorrect)         // 不为不一致比较器承诺固定结果

第一个比较器可能给出 0 或 1,对反向关系却不会给出负值,不能表达正常的全序关系。比较两个实现的耗时前,应先核对普通输入、相等值、空输入、缺失项与异常路径下的结果。否则测量的是两个不同程序,不是同一工作量的两种实现。

基准还要分开准备成本与核心操作成本:把数组构造算进一次、却在另一次复用现成数组,得到的差异就包含了分配成本。是否应该包含分配,要由真实调用方式决定。稳定数据模型可能减少实现负担,但没有经过语义核验与代表性测量,不应把这种可能性写成确定的性能收益。

给一次改写建立行为对照 ​

假设有人看到前面的重复读取,想主动缓存属性值。先把原实现和候选实现放在一起,暂时不计时:

js
function original(item) {
  return item.price + item.price
}
function cached(item) {
  const price = item.price
  return price + price
}
function sample() {
  let reads = 0
  return {
    item: { get price() { return ++reads } },
    readCount() { return reads }
  }
}
for (const implementation of [original, cached]) {
  const input = sample()
  console.log(implementation(input.item), input.readCount())
}
// 3 2
// 2 1

两次测试各自创建输入,避免第一次测试改变第二次的起点。原实现读取到 1 和 2,返回 3;候选只读取到一次 1,返回 2。缓存节省了一次访问,却改变了值和读取次数。这里已经能判定它不是对所有对象都等价的改写,继续比较耗时没有意义。

如果接口明确限定输入是读取期间保持稳定的普通数据字段,缓存才可能符合这个更窄的契约。不能因为当前样例恰好没有 getter,就把这个限制当成已有保证。异常也属于契约:第二次读取可能抛错,而第一次成功,省去第二次就会让原本失败的操作成功。

把行为验证和性能验证分开进行:先对相同起始条件比较返回值、输入变化、调用次数及异常;通过后再给相同工作计时,并保留全部轮次。对照程序用于检验这次具体改写,不需要猜测内部某一条机器指令是否生成。

数组排序:规范保证与源码年代 ​

旧文章可能展示 V8 用插入排序或快速排序的实现,并以某个长度作切换边界。那能解释文章对应年代的源码,不能作为今天所有 JavaScript 的规则。

在本书采用的现代语义中,排序稳定性有规范保证:比较器认为相等的元素应保持原相对顺序。具体算法与性能仍属于实现选择。用业务数据观察稳定性,比背某个旧阈值更可靠:

js
const records = [
  { group: 2, name: '甲' },
  { group: 1, name: '乙' },
  { group: 2, name: '丙' },
]
records.sort((a, b) => a.group - b.group)
console.log(records.map(item => item.name).join('')) // 乙甲丙

相同 group 的甲、丙保持顺序。这个结论的前提包括比较器行为一致;若比较器时而随机返回正负值,就破坏了排序所需条件。数组方法与比较器细节见数组专题。

怎样读源码才不会把实现误当语言 ​

先写一个能观察行为的最小例子,再找到规范对应的规则;如果问题是性能或内部布局,才定位目标引擎的对应实现与测试。阅读时记录版本或提交,并保留这些问题:

层面你可以断言什么不能顺手扩大成什么
规范符合规范的实现应具有某种语义所有实现使用同一种数据结构
具体源码这个版本走某条实现路径所有浏览器都如此
实验当前条件下观察到这些结果任意输入下固定快多少

源码中的阈值是可以变的,规范中的可观察约束更适合作为程序正确性的依靠。先把程序写对,再用证据找到真正值得优化的地方。

练习 ​

  1. 某文章称“数组超过十项会使用快速排序”,你应如何判断其适用范围?
  2. 把计时实验的数组长度改成 20 和 200000;为什么需要多次运行,为什么要保留总和?
  3. 一个属性先保存数字,再保存字符串,为什么优化失效后仍必须得到正确结果?
  4. 对带副作用的 getter 连续读取两次,引擎能否总是合并成一次?去优化时为什么不能简单从函数开头重跑?
  5. 编写一个测试输入:第一次读取 price 返回 10,第二次抛出同一个 Error 对象。分别交给 original 和 cached,验证哪一个抛错。若接口允许这种输入,怎样修复 cached?修复后至少检查普通数据字段和这项失败输入,再决定是否计时。

查看本篇答案

用心记录每一步成长