V8 引擎是 JS 运行引擎中的一种,在浏览器和node中比较常见。新开V8引擎系列文章,研究探索引擎背后的调用流程和设计思路。V8引擎系列文章主要参考V8引擎开源源码,辅助开源社区资料(部分资料已经过时)进行总结。如有总结错误之处,望不吝赐教,在此拜谢。
前置-背景什么是 V8引擎V8 是一个C++编写的程序,它用于编译执行JavaScript代码。提供以下基础能力:
💡1、编译并执行JS代码
2、以某种顺序执行函数,处理调用栈
3、管理对象的内存分配,堆内存
4、垃圾回收
5、提供所有 JS 语言支持的数据类型、运算符、API 和公共函数
V8 提供可选事件循环(浏览器中运行JS时,事件循环由浏览器提供)。V8 是一个单线程运行JS代码的多线程应用,V8引擎本身是多线程程序,V8采用单线程运行JS代码。
💡V8运行JS代码是是单线程。一个V8实例,运行一个单独的JS执行上下文。在浏览器或者 node.js 开发的进程中可以同时存在多个V8线程实例来实现多线程并发,比如:通过WebWorker等技术开辟新的执行JS上下文并调用V8线程执行。
V8 引擎内部多线程大体划分为几类:
💡1、主线程:负责执行 JS 代码解析、编译、运行。处理所有关于 JS 代码执行相关的任务,包括调用栈管理和事件循环。
2、垃圾回收线程:负责自动管理内存。
3、TurboFan 编译线程: V8 执行JS代码过程中会标记 hot 代码,TurboFan 线程会将 hot 代码编译优化,通常hot代码会被转换为机器码。
4、I/O线程:node.js 环境中与 libuv 集成。负责文件系统操作,网络请求等等I/O操作。
5、其他辅助线程:debug、性能分析等等
V8 引擎运行 JS 代码流程大体如下:
V8 只负责执行 JS,部分运行环境需要调用宿主提供。
Host Env 运行环境V8的运行宿主环境有多种,最为常见的是浏览器 Browser 和 NodeJS 。Browser 环境和 NodeJS 环境有相同的成员如:ECMAScript standard (JavaScript核心内置API)、函数调用栈、Heap 内存、垃圾回收等等。不一致的在于特殊 API + 事件循环。
特殊点如下:
Browser 环境:
💡Web API: 浏览器提供的接口,用于程序和浏览器交互 Canvas、ServiceWorker、WebStorage、Fetch、 Audio/Video、Geolocation等等
DOM API:文档对象模型,用于对 HTML 进行操作。主要包含以下方面 API 功能Element CRUD、Event Handing 事件监听处理、CSSOM 样式表修改
Event Loop: 浏览器单独提供的时间循环与渲染周期结合
NodeJS 环境:
💡文件系统 API: fs 模块,解决 i/o 读写
网络 API:http、https、net等模块
环境变量 proces :环境变量维护
Cluster集群和子线程:多线程并发API
其他模块: C++ 模块
Event Loop:NodeJS 的 Event Loop采用了多阶段设计,允许更细粒度的控制
事件驱动模型中唤起的回调,经过事件循环调用V8引擎执行。页面事件/浏览器生命周期/WebAPI 等等来源触发的回调函数,被添加到事件循环的任务队列中【这里也可称之为宏任务队列】。事件循环与微任务队列的关系在本文中不会深入,会单开一文。
以浏览器环境调用V8运行JS为例
到此,正式进入 JS 代码如何在引擎中运行过程。第一步是:解释执行编译优化
编译 & 运行过程V8 引擎将JS代码编译转换成字节码和部分机器码。步骤如下:
💡
解析器将 JS Code 解析转换成 AST 并分析出关联的 Scopes 作用域, 构建AST 的过程称之为解析 Parse。
随后进行第一次编译 AST + Scopes 编译转换成字节码, 第一个编译器名字叫做 Ignition ,ignition 将 AST 转变成 bytecode 字节码。
解释执行字节码。字节码被解释器 inceptor 解释执行,解释执行过程中会进行代码优化标记,。
IC 优化。
Hot 代码三级优化,转成机器码。针对Hot代码,V8引擎目前采用三级优化模式。分别是:Sparkplug、Maglev、TurboFan 。三位大师负责将标记为 Hot 代码进一步编译成为机器码,以此提升代码运行速度。机器码被 CPU 直接运行。
到了这里会有两个疑问,为什么不全部编译成为机器码?第二什么情况下会进行优化,什么情况下会退出优化?
💡
最快的启动运行和不太大的内存。
JIT 编译成为字节码能够节约时间,直接编译成机器码需要分析整个JS脚本推断所有对象类型和优化条件。Web 站点快速加载、响应流畅需要最快启动JS脚本执行。
大部分JS代码不会重复运行,运行初始编译成机器码耗时多且内存占用非常大。
JS 语言特性:动态类型不确定性
运行时频繁修改变量类型,未运行前无法推断准确的类型。
通过解释器 Ignition 运行字节码时,标记变量类型结构固定且频繁执行的 代码为 Hot,将Hot代编译成码机器码提升运行速率。
跨平台兼容性
bytecode 与平台无关,可以在不同的硬件架构上运行。机器码由 CPU 直接运行,与 CPU 架构以及支持的编码强相关。
字节码 VS 机器码编译有两种常见的模式 JIT (just in time)即时编译和 AOT(Ahead of time)提前编译。JIT 是在代码运行期间动态编译,编译-运行-编译-运行。AOT 一般常见于C++ 和 JAVA 这类强类型语言,打包构建时直接产出字节码或机器码。V8 引擎编译JS代码采用的是 JIT编译模式。
V8 执行 JS 代码过程中先翻译成 bytecode, 多次运行会标记可被优化的代码标记为 Hot TuboFan 将 Hot 代码从 bytecode 编译成机器码,加速运行【CPU直接运行机器码】。
V8 提供的字节码映射表:
https://github.com/v8/v8/blob/master/src/interpreter/bytecodes.h
10k 的 JS 代码就全编译成机器码需要 20M 的空间,1M的JS代码则需要约 2G 的内存空间。
V8采用折中方案:解释执行大部分代码,对部分高频代码进行优化。
V8 引擎运行优化V8 引擎优化 “在内存、CPU、等资源合适的情况下,优化调用频繁;类型结构稳定;执行时间长;代码规模适中;代码控制流程简单的代码” 提升运行效率。剔除死代码,死代码包括:不可达代码;无用计算;冗余的类型检查。
在分析优化逻辑之前,需要先介绍一下 feedback_vector 。JS 代码在V8引擎中运行过程中,所有的代码分析 / 运行记录 等信息都是记录在 feedback_vector 上。
在 JS source → bytecode -> intercept execute - … → Maglev … → TurboFan → MachineCode .. 过程中代码的分析结果,运行中的返回值、稳定性、耗时等信息都存储在 feedback_vector 上.
💡分类介绍 feedback_vector 存储的重要信息
1、代码编译分析信息:
2、运行时优化信息
V8 基于 各个环节提取的 feedback_vector 信息进行优化/去优化分析。包括:死代码消除、IC 内联代码优化、Hot 标记优化。
标记优化的大流程如下:
死代码消除V8 对代码进行标记,不可达的代码;无用计算的代码;冗余的类型检查等等代码会被标记为 isDead。这些代码不会被执行也不会被内联优化检查到!
内联优化 【src/compiler/js-inlining-heuristic.cc】减少函数调用开销(调用栈切换);减少局部变量访问次数来提升实现优化。函数体积小、调用频率高、参数类型稳定、没有复杂控制流。一句话,高频调用且可预测的小函数。
内联优化的小 case
123456789101112// 原代码function add(a, b) { return a + b;}function calc(x, y) { return add(x, y) * 2; }// 内联优化后function calc(x, y) { return (x + y) * 2;}
内联优化标记策略:
💡
构造函数调用或者普通函数调用
存在直接递归调用的不优化
未达到最低调用频率不优化
强制内联小函数【27字节码以内,转换JS代码大概1-2行】注意:即使是小函数不符合前三条也不会优化!
考虑内存和性能预算,当内存预算到了瓶颈停止内联,内存占用超过预算甚至会将部分内联进行退化。小函数优势在此!
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960// file: src/compiler/js-inlining-heuristic.ccReduction JSInliningHeuristic::Reduce(Node* node) { // 非构造函数调用或者普通函数调用 退出检查 if (!IrOpcode::IsInlineeOpcode(node->opcode())) return NoChange(); // 累计内联大小超过上限--退出 if (total_inlined_bytecode_size_ >= max_inlined_bytecode_size_absolute_) { return NoChange(); } ... for (int i = 0; i < candidate.num_functions; ++i) { // 直接递归调用---不优化 if (frame_info.shared_info().ToHandle(&frame_shared_info) && frame_shared_info.equals(shared.object())) { TRACE("Not considering call site #" << node->id() << ":" << node->op()->mnemonic() << ", because of recursive inlining"); candidate.can_inline_function[i] = false; } // 强制优化小函数 if (candidate.can_inline_function[i]) { can_inline_candidate = true; BytecodeArrayRef bytecode = candidate.bytecode[i].value(); candidate.total_size += bytecode.length(); unsigned inlined_bytecode_size = 0; if (OptionalJSFunctionRef function = candidate.functions[i]) { if (OptionalCodeRef code = function->code(broker())) { inlined_bytecode_size = code->GetInlinedBytecodeSize(); candidate.total_size += inlined_bytecode_size; } } candidate_is_small = candidate_is_small && IsSmall(bytecode.length() + inlined_bytecode_size); } } if (!can_inline_candidate) return NoChange(); // 未达到最低调用频率不优化:min_inlining_frequency = 0.15 if (candidate.frequency.IsKnown() && candidate.frequency.value() < v8_flags.min_inlining_frequency) { return NoChange(); } seen_.insert(node->id()); // 小函数强制内联--- if (candidate_is_small) { return InlineCandidate(candidate, true); } // In the general case we remember the candidate for later. candidates_.insert(candidate); return NoChange();}
1234// Returns true if opcode can be inlined.static bool IsInlineeOpcode(Value value) { return value == kJSConstruct || value == kJSCall;}
💡opcode 常见值如下:
kJSCall:表示一个普通的 JavaScript 函数调用。
kJSConstruct:表示一个构造函数调用,通常是通过 new 关键字调用的函数。
kJSReturn:表示返回语句,结束当前函数并返回值。
kJSLoadProperty:表示加载对象的属性。
kJSStoreProperty:表示将值存储到对象的属性中。
kJSLoadElement:表示加载数组或类数组对象的元素。
kJSStoreElement:表示将值存储到数组或类数组对象的元素中。
kJSThrow:表示抛出异常。
kJSBranch:表示条件分支或跳转。
标记完成后,需要进行优化前检查,排序。到这里的基本都是非小函数【大概是函数体超过2行,小函数字节码限制27字节】。
12345678910111213141516171819202122232425262728293031323334// file: src/compiler/js-inlining-heuristic.cc// 排序策略void JSInliningHeuristic::Finalize() {... while (!candidates_.empty()) { auto i = candidates_.begin(); Candidate candidate = *i; candidates_.erase(i); // 已经优化了的--跳过 if (!IrOpcode::IsInlineeOpcode(candidate.node->opcode())) continue; // 无效死代码,跳过 if (candidate.node->IsDead()) continue; // 预算检查--- double size_of_candidate = candidate.total_size * v8_flags.reserve_inline_budget_scale_factor; int total_size = total_inlined_bytecode_size_ + static_cast
分析函数历史执行情况!从参数格式结构稳定性、运行结果稳定性、异常错误等方面进行评估,内容来自 feedback_vector。
稳定性检查,出现过不稳定执行的函数中止优化!
函数内逐步进行内联优化!直到总内联优化占用达到上限!
123456789101112131415161718192021222324252627282930313233343536373839Reduction JSInliningHeuristic::InlineCandidate(Candidate const& candidate, bool small_function) { ... // 检查调用是否稳定--参数检查! Node* if_exception = nullptr; if (NodeProperties::IsExceptionalCall(node, &if_exception)) { Node* if_exceptions[kMaxCallPolymorphism + 1]; // 检查历史执行情况:运行稳定性、异常结果检查 for (int i = 0; i < num_calls; ++i) { if_successes[i] = graph()->NewNode(common()->IfSuccess(), calls[i]); if_exceptions[i] = graph()->NewNode(common()->IfException(), calls[i], calls[i]); } // Morph the {if_exception} projection into a join. ... }... // 稳定性检查通过后,逐步进行内联优化!直到总内联优化占用达到上限! for (int i = 0; i < num_calls && total_inlined_bytecode_size_ < max_inlined_bytecode_size_absolute_; ++i) { if (candidate.can_inline_function[i] && (small_function || total_inlined_bytecode_size_ < max_inlined_bytecode_size_cumulative_)) { Node* call = calls[i]; Reduction const reduction = inliner_.ReduceJSCall(call); if (reduction.Changed()) { total_inlined_bytecode_size_ += candidate.bytecode[i]->length(); call->Kill(); } } } return Replace(value);}
内联优化存在几个针对字节码大小的限制,
💡
小函数内联大小:27字节
单个内联最大字节码:460字节
累计内联上限:920字节
123456789101112131415161718// file : src/flags/flag-definitions.hDEFINE_INT(max_inlined_bytecode_size, 460, "maximum size of bytecode for a single inlining")DEFINE_INT(max_inlined_bytecode_size_cumulative, 920, "maximum cumulative size of bytecode considered for inlining")DEFINE_INT(max_inlined_bytecode_size_absolute, 4600, "maximum absolute size of bytecode considered for inlining")DEFINE_FLOAT( reserve_inline_budget_scale_factor, 1.2, "scale factor of bytecode size used to calculate the inlining budget")DEFINE_INT(max_inlined_bytecode_size_small, 27, "maximum size of bytecode considered for small function inlining")DEFINE_INT(max_optimized_bytecode_size, 60 * KB, "maximum bytecode size to " "be considered for turbofan optimization; too high values may cause " "the compiler to hit (release) assertions")DEFINE_FLOAT(min_inlining_frequency, 0.15, "minimum frequency for inlining")DEFINE_BOOL(polymorphic_inlining, true, "polymorphic inlining")
其他不会被优化的场景:
💡
动态的 eval
with 语句
复杂的 try catch
单个函数编译为字节码内存超过 460 字节
调用评率低:ignition 为 0.15 [单位没看懂]
动态 import
非纯函数,存在不可预测的过程
内联优化是通过对解释执行的字节码进行执行过程中分析转换的,内联优化后产物还是字节码,Hot 代码中内联优化的代码则会一同编译成机器码。Hot 代码编译机器码部分,不再追溯关于内联优化相关内容。
Hot 代码优化V8 代码在字节码解释执行阶段会附带标记Hot代码(附带信息存于feedback_vector),通过将 Hot 代码从字节码编译成机器码。机器码由CPU直接运行效率远远大于ignition解释器运行字节码。
V8 引擎采用多级优化策略(平衡编译耗时和机器码优化深度),截止24年11月,V8发布源码中采用的是三级编译器。 Maglev 编译器第一版本发布是在 chrome 117 。
先介绍一下Hot代码涉及的优化编译器:
三级优化模型,V8目的是平衡编译成本和运行速度。编译成本:编译时间 + 内存占用。编译速度Sparkplug最快。以下是编译的速度:
V8 引擎解释器+编译器优化历史组合跑分情况:
💡
1. Ignition(解释器)
阶段:Ignition 是 V8 的字节码解释器,它是 JavaScript 执行的第一个阶段。
功能:将 JavaScript 源代码编译为字节码,并逐条解释执行。这种解释执行通常适合初始化阶段或短期、低频执行的代码。
调用条件:代码首次加载时,Ignition 会将源代码转为字节码,然后解释执行。
优点:解释器生成字节码的速度较快、内存开销小,但由于每次都需要解释,性能会比编译的机器码稍慢。
2. Sparkplug(基线编译器)
阶段:在 Ignition 之后,作为快速编译阶段执行。
功能:Sparkplug 是 V8 的基线编译器,负责将字节码快速编译为机器码,来提高代码执行速度。相比解释执行,编译后的代码运行更快,但 Sparkplug 并不会进行复杂优化,因此编译时间也非常短。
调用条件:当代码被标记为“热”代码; 不存在 feedback_vector;
优点:快速生成机器码,提升代码性能的同时仍保持较低内存消耗。
3. Maglev(中层编译器)
阶段:位于 Sparkplug 和 TurboFan 之间。
功能:Maglev 是一种中层即时编译器,用于在代码“热度”增加但尚未进入深度优化阶段时提供进一步的优化。Maglev 生成的机器码质量高于 Sparkplug,但没有 TurboFan 的复杂优化。
调用条件:Sparkplug 优化代码执行优化后,代码类型允许升级到 Maglev;之前没有Maglev 编译失败记录;未启动PGO(Profile Guided Optimization),启动PGO的直奔TurboFan;
优点:提供更高质量的机器码,在提高性能的同时避免过多调用 TurboFan。
4. TurboFan(优化编译器)
阶段:在代码被频繁调用后,为性能的最后提升执行。
功能:TurboFan 是 V8 的优化编译器,对代码进行深度优化,生成高度优化的机器码。它适合执行频率非常高、长时间运行的“热”代码。
调用条件:代码执行频率极高时触发,特别是在 Maglev 生成的代码仍不够高效的情况下,才会进入 TurboFan 的深度优化阶段。
优点:TurboFan 会观察代码的执行模式,通过内联缓存和隐藏类等信息,对代码进行进一步优化,使其达到最佳性能。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144// file: src/execution/tiering-manager.ccvoid TieringManager::OnInterruptTick(DirectHandle
以上就是Hot代码分层优化的内容,什么时候去优化呢?当内存资源出现压力后、函数变冷(调用不再频繁)等情况优化会被去掉,退回字节码解释执行。
如何写好代码?思考题~
参考资料💡
Maglev - V8最快的JIT优化 https://v8.dev/blog/maglev
how-v8-javascript-engine-works https://cabulous.medium.com/how-v8-javascript-engine-works-5393832d80a7
Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU https://www.youtube.com/watch?v=p-iiEDtpy6I
V8 github.com/v8