引擎怎样加速代码:形状、反馈与去优化
在语言层面,item.price 只是属性读取;执行器却可能反复观察这行代码,寻找更快的处理方式。本篇研究优化为什么可能成功、为什么也可能失效。先理解第四章的属性查找,再来认识这位忙着做笔记的引擎。
同一条语言规则,可以有不同实现
规范要求 JavaScript 数值操作符合相应语义,但不要求所有数字永远用同一种物理布局保存。类似地,属性查找必须给出正确结果,内部可以使用对象形状、缓存、机器码或其他方式加快处理。
以 V8 的分层执行思路为例,解析之后可以生成字节码;解释执行和不同层级的编译相互配合。Ignition、Sparkplug、Maglev、TurboFan 是理解这条技术路线时会遇到的名字,具体版本还会继续演进。
源代码 → 解析与字节码 → 执行、收集反馈
↓
基于假设的优化
↓
假设成立:更快执行 / 失效:退回通用路径这张图表达因果关系,不意味着每个函数必须按固定顺序经过每个编译器。冷代码可能根本不值得投入昂贵优化,短命页面也未必有足够时间收回编译成本。
对象形状:给相似对象安排相似路线
考虑两种构造方式:
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 属性。
下面两对象拥有同名属性,添加顺序却不同:
const a = { id: 1, price: 12 }
const b = { price: 18, id: 2 }
console.log(a.price + b.price) // 30语言结果完全正常。某些引擎在这样的访问点可能需要处理更多形状。由此合理得到的建议是保持数据结构清楚、初始化方式一致;不能直接得到“第二段一定慢几倍”。是否热路径、引擎版本、对象数量和其他工作都会影响结果。
delete、动态增加字段等操作也不是禁止使用的语法。真实需求需要删除属性时应当删除,再依据测量判断是否改变数据结构。为了传说中的优化保留错误数据,得不偿失。
为什么优化需要先辨认接收者
如果引擎已经知道某类对象的 price 位于某个布局位置,就有机会把通用属性查找缩短为“检查结构,再读取对应位置”。布局位置只是实现模型,下面是解释思路的伪代码,不是 JavaScript 引擎公开接口:
收到 item
├─ 与已知布局及相关假设相符 → 读取已知位置
└─ 不相符 → 使用通用属性读取,更新反馈或进入其他路径检查不可省略。price 可能是自有字段,也可能在原型上;原型属性可能后来发生变化;访问器还会执行任意用户代码。只有证明这些差异不会改变本次读取,才能使用同一条快速路径。
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。
内联缓存与类型反馈在记录什么
一个属性读取位置第一次遇到对象时,需要走相应查找过程。引擎可以记住此前处理过的形状及快速访问方式;下次形状匹配就可能走更短的路径。它可以记一种情况,也可以处理多种情况。
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 认定自己永远在读取一个数字字段。
反馈是过去发生过什么,假设是未来可能还会如此。 引擎必须安排检查或失效机制,保证猜错时仍符合语言语义。去优化是这个保证的一部分,并非程序发生业务错误。
猜错以后,必须从正确的位置继续
去优化不能简单理解成“把整个函数从头执行一遍”。如果前半段已经写入状态、打印日志或调用了外部函数,重新执行会重复副作用。优化后的执行状态需要能对应回更通用的执行表示,才能在正确的位置继续。具体恢复机制随引擎和版本变化,正确性要求却不会改变。
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 中运行:
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)每轮总和应该相同,时间会波动。后几轮可能更快,但这不足以证明某次特定优化一定发生了;系统负载、垃圾回收和计时粒度也会变化。
严谨比较需要同样输入、独立重复、多种数据规模和代表真实需求的工作负载。不能把一次计时当定论,也不能只挑最漂亮的一轮。页面速度还可能主要耗在网络、布局或渲染上,优化上述求和循环对用户没有可见价值。
先确认两个“优化版本”计算的是同一件事
一种写法变快,也可能只是少做了原本必须做的事。下面这个比较器试图用真假代替三种顺序:
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,对反向关系却不会给出负值,不能表达正常的全序关系。比较两个实现的耗时前,应先核对普通输入、相等值、空输入、缺失项与异常路径下的结果。否则测量的是两个不同程序,不是同一工作量的两种实现。
基准还要分开准备成本与核心操作成本:把数组构造算进一次、却在另一次复用现成数组,得到的差异就包含了分配成本。是否应该包含分配,要由真实调用方式决定。稳定数据模型可能减少实现负担,但没有经过语义核验与代表性测量,不应把这种可能性写成确定的性能收益。
给一次改写建立行为对照
假设有人看到前面的重复读取,想主动缓存属性值。先把原实现和候选实现放在一起,暂时不计时:
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 的规则。
在本书采用的现代语义中,排序稳定性有规范保证:比较器认为相等的元素应保持原相对顺序。具体算法与性能仍属于实现选择。用业务数据观察稳定性,比背某个旧阈值更可靠:
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 的甲、丙保持顺序。这个结论的前提包括比较器行为一致;若比较器时而随机返回正负值,就破坏了排序所需条件。数组方法与比较器细节见数组专题。
怎样读源码才不会把实现误当语言
先写一个能观察行为的最小例子,再找到规范对应的规则;如果问题是性能或内部布局,才定位目标引擎的对应实现与测试。阅读时记录版本或提交,并保留这些问题:
| 层面 | 你可以断言什么 | 不能顺手扩大成什么 |
|---|---|---|
| 规范 | 符合规范的实现应具有某种语义 | 所有实现使用同一种数据结构 |
| 具体源码 | 这个版本走某条实现路径 | 所有浏览器都如此 |
| 实验 | 当前条件下观察到这些结果 | 任意输入下固定快多少 |
源码中的阈值是可以变的,规范中的可观察约束更适合作为程序正确性的依靠。先把程序写对,再用证据找到真正值得优化的地方。
练习
- 某文章称“数组超过十项会使用快速排序”,你应如何判断其适用范围?
- 把计时实验的数组长度改成 20 和 200000;为什么需要多次运行,为什么要保留总和?
- 一个属性先保存数字,再保存字符串,为什么优化失效后仍必须得到正确结果?
- 对带副作用的 getter 连续读取两次,引擎能否总是合并成一次?去优化时为什么不能简单从函数开头重跑?
- 编写一个测试输入:第一次读取 price 返回 10,第二次抛出同一个 Error 对象。分别交给 original 和 cached,验证哪一个抛错。若接口允许这种输入,怎样修复 cached?修复后至少检查普通数据字段和这项失败输入,再决定是否计时。