2025-03-06 08:00:00
在 V8 Ignition 解释器的内部实现探究 中探究了 JavaScript 引擎 V8 的解释器的实现,接下来分析一下 Android Runtime (ART) 的解释器,其原理也是类似的。本博客在 ARM64 Ubuntu 24.04 平台上针对 Android Runtime (ART) 15.0.0 r1 版本进行分析。
在分析解释器的代码前,需要先了解一下解释器的输入,也就是它执行的字节码格式是什么。Android Runtime 继承和发展了 Dalvik VM 的字节码 Dalvik Bytecode 格式,因此在打包 Android 应用的时候,Java 代码最终会被翻译成 Dalvik Bytecode。
接下来来实践一下这个过程,从 Java 代码到 Dalvik Bytecode:
第一步是编写一个简单的 Java 程序如下,保存到 MainActivity.java
:
public class MainActivity { public static void main(String[] args) { System.out.println("Hello, world!"); } public static int add(int a, int b) { return a + b; }}
首先用 javac MainActivity.java
命令把源码编译到 Java Bytecode。可以用 javap -c MainActivity.class
查看生成的 Java Bytecode:
Compiled from "MainActivity.java"public class MainActivity { public MainActivity(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #13 // String Hello, world! 5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return public static int add(int, int); Code: 0: iload_0 1: iload_1 2: iadd 3: ireturn}
Java Bytecode 是个典型的栈式字节码,因此从 int add(int, int)
函数可以看到,它分别压栈第零个和第一个局部遍变量(即参数 a
和 b
),然后用 iadd
指令从栈顶弹出两个元素,求和后再把结果压栈。
接着,用 Android SDK 的 Build Tools 提供的命令 d8
来把它转换为 Dalvik Bytecode。如果你还没有安装 Android SDK,可以按照 sdkmanager 文档 来安装 sdkmanager,再用 sdkmanager 安装较新版本的 build-tools
。转换的命令为 $ANDROID_HOME/build-tools/$VERSION/d8 MainActivity.class
,结果会保存在当前目录的 classes.dex
文件内。接着可以用 $ANDROID_HOME/build-tools/$VERSION/dexdump -d classes.dex
来查看 Dalvik Bytecode:
Processing 'classes.dex'...Opened 'classes.dex', DEX version '035'Class #0 - Class descriptor : 'LMainActivity;' Access flags : 0x0001 (PUBLIC) Superclass : 'Ljava/lang/Object;' Interfaces - Static fields - Instance fields - Direct methods - #0 : (in LMainActivity;) name : '<init>' type : '()V' access : 0x10001 (PUBLIC CONSTRUCTOR) code - registers : 1 ins : 1 outs : 1 insns size : 4 16-bit code units00016c: |[00016c] MainActivity.<init>:()V00017c: 7010 0400 0000 |0000: invoke-direct {v0}, Ljava/lang/Object;.<init>:()V // method@0004000182: 0e00 |0003: return-void catches : (none) positions : 0x0000 line=1 locals : 0x0000 - 0x0004 reg=0 this LMainActivity; #1 : (in LMainActivity;) name : 'add' type : '(II)I' access : 0x0009 (PUBLIC STATIC) code - registers : 2 ins : 2 outs : 0 insns size : 2 16-bit code units000158: |[000158] MainActivity.add:(II)I000168: b010 |0000: add-int/2addr v0, v100016a: 0f00 |0001: return v0 catches : (none) positions : 0x0000 line=7 locals : 0x0000 - 0x0002 reg=0 (null) I 0x0000 - 0x0002 reg=1 (null) I #2 : (in LMainActivity;) name : 'main' type : '([Ljava/lang/String;)V' access : 0x0009 (PUBLIC STATIC) code - registers : 2 ins : 1 outs : 2 insns size : 8 16-bit code units000184: |[000184] MainActivity.main:([Ljava/lang/String;)V000194: 6201 0000 |0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0000000198: 1a00 0100 |0002: const-string v0, "Hello, world!" // string@000100019c: 6e20 0300 0100 |0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@00030001a2: 0e00 |0007: return-void catches : (none) positions : 0x0000 line=3 0x0007 line=4 locals : 0x0000 - 0x0008 reg=1 (null) [Ljava/lang/String; Virtual methods - source_file_idx : 9 (MainActivity.java)
对比 Java Bytecode,在 Dalvik Bytecode 里的 add
函数的实现就大不相同了:
add-int/2addr v0, v1
: 求寄存器 v1
和寄存器 v0
之和,在这里就对应 a
和 b
两个参数,结果写到 v0
寄存器当中return v0
: 以寄存器 v0
为返回值,结束当前函数可见 Dalvik Bytecode 采用的是类似 V8 的基于寄存器的字节码,不过没有 V8 的 accumulator
。
Dalvik Bytecode 的完整列表见 Dalvik bytecode format,它的格式基本上是两个字节为一组,每组里第一个字节代表 Op 类型,第二个字节代表参数,有一些 Op 后面还会带有多组参数。
例如上面的 add-int/2addr vA, vB
指令的编码是:
0xb0
,表示这是一个 add-int/2addr
OpvA
的寄存器编号 A
,高 4 位编码了 vB
的寄存器编号 B
所以 add-int/2addr v0, v1
的编码就是 0xb0, 0 | (1 << 4)
即 0xb0, 0x10
。因为存得很紧凑,寄存器编号只有 4 位,所以这个 Op 的操作数不能访问 v16 或更高的寄存器。
return vAA
指令的编码类似,不过因为只需要编码一个操作数,所以有 8 位可以编码返回值用哪个寄存器;为了区分是 4 位的编码还是 8 位的编码,这里用 vAA
表示可以用 8 位来记录寄存器编号。return vAA
的第一个字节是 0x0f
表示 Op 类型,第二个字节就是寄存器编号 A
。上面出现的 return v0
的编码就是 0x0f, 0x00
。
一些比较复杂的 Op 会附带更多的参数,此时编码就可能涉及到更多的字节。比如 invoke-virtual {vC, vD, vE, vF, vG}, meth@BBBB
,可以携带可变个寄存器参数,在编码的时候,格式如下:
0x6e
表示这是一个 invoke-virtual
Op因此在上面的代码中,invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0003
被编码为:0x6e, 0x20, 0x03, 0x00, 0x01, 0x00
。另外构造了一个例子,把五个参数都用上:invoke-virtual {v1, v4, v0, v2, v3}, LMainActivity;.add4:(IIII)I // method@0002
被编码为 0x6e, 0x53, 0x02, 0x00, 0x41, 0x20
,可以看到五个参数的编码顺序是第五个字节的低 4 位(v1
)和高 4 位(v4
),第六个字节的低 4 位(v0
)和高 4 位(v2
),最后是第二个字节的低 4 位(v3
)。
了解了 Dalvik Bytecode 的结构,接下来观察它是怎么被解释执行的。
Android Runtime (ART) 的解释器放在 runtime/interpreter
目录下。如果进行一些考古,可以看到这个解释器的实现是从更早的 Dalvik VM 来的。它有两种不同的解释器实现:
第一个解释器基于 switch-case 的 C++ 代码实现,其逐个遍历 Op,根据 Op 的类型 Opcode 执行相应的操作,类似下面的代码:
for (each op of current function) { switch (op.opcode) { case op_add: // implement add here break; // ... other opcode handlers }}
第二个解释器以 Token threading 的方式实现,每种 Op 对应一段代码。这段代码在完成 Op 的操作后,读取下一个 Op,再间接跳转到下一个 Op 对应的代码。其工作原理类似下面的代码,这里 goto *
是 GNU C 的扩展,对应间接跳转指令,其目的地址取决于 handlers[next_opcode]
的值,意思是根据下一个 op 的 Opcode,找到对应的 handler,直接跳转过去:
// op handlers array handlers = {&op_add, &op_sub};op_add: // implement add here // read next opcode here goto *handlers[next_opcode];
实际实现的时候更进一步,用汇编实现各个 op handler,并把 handler 放在了 128 字节对齐的位置,保证每个 handler 不超过 128 个字节,从而把读取 handlers
数组再跳转的 goto *
改成了用乘法和加法计算出 handler 的地址再跳转(computed goto):
handlers_begin:op_add: .balign 128 # implement add here # read next opcode here jmp to (handlers_begin + 128 * next_opcode);op_sub: .balign 128 # implement add here # read next opcode here jmp to (handlers_begin + 128 * next_opcode);
下面结合源码来具体分析这两种解释器的实现。
目前 Android Runtime 包括一个基于 switch-case 的解释器,实现在 runtime/interpreter/interpreter_switch_impl-inl.h
文件当中,它的核心逻辑就是一个循环套 switch-case:
while (true) { const Instruction* const inst = next; dex_pc = inst->GetDexPc(insns); shadow_frame.SetDexPC(dex_pc); TraceExecution(shadow_frame, inst, dex_pc); uint16_t inst_data = inst->Fetch16(0); bool exit = false; bool success; // Moved outside to keep frames small under asan. if (InstructionHandler<transaction_active, Instruction::kInvalidFormat>( ctx, instrumentation, self, shadow_frame, dex_pc, inst, inst_data, next, exit). Preamble()) { DCHECK_EQ(self->IsExceptionPending(), inst->Opcode(inst_data) == Instruction::MOVE_EXCEPTION); switch (inst->Opcode(inst_data)) {#define OPCODE_CASE(OPCODE, OPCODE_NAME, NAME, FORMAT, i, a, e, v) \ case OPCODE: { \ next = inst->RelativeAt(Instruction::SizeInCodeUnits(Instruction::FORMAT)); \ success = OP_##OPCODE_NAME<transaction_active>( \ ctx, instrumentation, self, shadow_frame, dex_pc, inst, inst_data, next, exit); \ if (success && LIKELY(!interpret_one_instruction)) { \ continue; \ } \ break; \ } DEX_INSTRUCTION_LIST(OPCODE_CASE)#undef OPCODE_CASE } } // exit condition handling omitted }
代码中使用了 X macro 的编程技巧:如果你需要在不同的地方重复出现同一个 list,比如在这里,就是所有可能的 Opcode 类型,你可以在一个头文件中用一个宏,以另一个宏为参数去列出来:
// V(opcode, instruction_code, name, format, index, flags, extended_flags, verifier_flags);#define DEX_INSTRUCTION_LIST(V) \ V(0x00, NOP, "nop", k10x, kIndexNone, kContinue, 0, kVerifyNothing) \ V(0x01, MOVE, "move", k12x, kIndexNone, kContinue, 0, kVerifyRegA | kVerifyRegB) \ // omitted
这个宏定义在 libdexfile/dex/dex_instruction_list.h
头文件当中。在使用的时候,临时定义一个宏,然后把新定义的宏传入 DEX_INSTRUCTION_LIST
的参数即可。例如要生成一个数组,记录所有的 op 名字,可以:
// taken from libdexfile/dex/dex_instruction.ccconst char* const Instruction::kInstructionNames[] = {#define INSTRUCTION_NAME(o, c, pname, f, i, a, e, v) pname,#include "dex_instruction_list.h" DEX_INSTRUCTION_LIST(INSTRUCTION_NAME)#undef DEX_INSTRUCTION_LIST#undef INSTRUCTION_NAME};
这段代码经过 C 预处理器,首先会被展开为:
const char* const Instruction::kInstructionNames[] = {#define INSTRUCTION_NAME(o, c, pname, f, i, a, e, v) pname, INSTRUCTION_NAME(0x00, NOP, "nop", k10x, kIndexNone, kContinue, 0, kVerifyNothing) \ INSTRUCTION_NAME(0x01, MOVE, "move", k12x, kIndexNone, kContinue, 0, kVerifyRegA | kVerifyRegB) \ // omitted#undef INSTRUCTION_NAME};
继续展开,就得到了想要留下的内容:
回到 switch-case 的地方,可以预见到,预处理会生成的代码大概是:
switch (inst->Opcode(inst_data)) { case 0x00: { \ next = inst->RelativeAt(Instruction::SizeInCodeUnits(Instruction::k10x)); \ success = OP_NOP<transaction_active>( \ ctx, instrumentation, self, shadow_frame, dex_pc, inst, inst_data, next, exit); \ if (success && LIKELY(!interpret_one_instruction)) { \ continue; \ } \ break; \ } // omitted}
其中 next = inst->RelativeAt(Instruction::SizeInCodeUnits(Instruction::k10x));
语句根据当前 Op 类型计算出它会占用多少个字节,从而得到下一个 Op 的地址。之后就是调用 OP_NOP
函数来进行实际的操作了。当然了,这个实际的操作,还是需要开发者去手动实现(OP_NOP
函数会调用下面的 NOP
函数):
HANDLER_ATTRIBUTES bool NOP() { return true;}HANDLER_ATTRIBUTES bool MOVE() { SetVReg(A(), GetVReg(B())); return true;}HANDLER_ATTRIBUTES bool ADD_INT() { SetVReg(A(), SafeAdd(GetVReg(B()), GetVReg(C()))); return true;}
第二个解释器则是基于 token threading 的解释器,它的源码在 runtime/interpreter/mterp
目录下。由于这些代码是用汇编写的,直接写会有很多重复的部分。为了避免重复的代码,目前的解释器 mterp (现在叫 nterp) 用 Python 脚本来生成最终的汇编代码。要生成它,也很简单:
这个脚本是平台无关的,例如如果要生成 amd64 平台的汇编,只需要:
这样就可以看到完整的汇编代码了,后续的分析都会基于这份汇编代码。如果读者对 amd64 汇编比较熟悉,也可以在本地生成一份 amd64 的汇编再结合本文进行理解。
上面提到过 add-int/2addr vA, vB
这个做整数加法的 Op,直接在生成的汇编中,找到它对应的代码:
.balign NTERP_HANDLER_SIZE.L_op_add_int_2addr: /* 0xb0 */ /* omitted */ /* binop/2addr vA, vB */ lsr w3, wINST, #12 // w3<- B ubfx w9, wINST, #8, #4 // w9<- A GET_VREG w1, w3 // w1<- vB GET_VREG w0, w9 // w0<- vA FETCH_ADVANCE_INST 1 // advance rPC, load rINST add w0, w0, w1 // w0<- op, w0-w3 changed GET_INST_OPCODE ip // extract opcode from rINST SET_VREG w0, w9 // vAA<- w0 GOTO_OPCODE ip // jump to next instruction /* omitted */
其中 wINST
表示当前 Op 的前两个字节的内容,前面提到,add-int/2addr vA, vB
编码为两个字节,第一个字节是固定的 0xb0
,第二个字节共 8 位,低 4 位编码了 vA
的寄存器编号 A
,高 4 位编码了 vB
的寄存器编号 B
。由于这是小端序的处理器,那么解释为 16 位整数,从高位到低位依次是:4 位的 B
,4 位的 A
和 8 位的 0xb0
。知道这个背景以后,再来分析每条指令做的事情,就很清晰:
lsr w3, wINST, #12
:求 wINST
右移动 12 位,得到了 B
ubfx w9, wINST, #8, #4
: ubfx
是 Bit Extract 指令,这里的意思是从 wINST
第 8 位开始取 4 位数据出来,也就是 A
GET_VREG w1, w3
: 读取寄存器编号为 w3
的值,写到 w1
当中,结合第一条指令,可知此时 w1
等于 B
寄存器的值GET_VREG w0, w9
: 读取寄存器编号为 w9
的值,写到 w0
当中,结合第二条指令,可知此时 w0
等于 A
寄存器的值FETCH_ADVANCE_INST 1
: 把 "PC" 往前移动 1 个单位的距离,也就是两个字节,并读取下一个 Op 到 rINST
当中add w0, w0, w1
: 进行实际的整数加法运算,结果保存在 w0
当中GET_INST_OPCODE ip
: 根据第五条指令读取的下一个 Op 的值 rINST
,解析出它的 OpcodeSET_VREG w0, w9
: 把整数加法的结果写回到寄存器编号为 w9
的寄存器当中,结合第二条指令,可知写入的是 A
寄存器GOTO_OPCODE ip
: 跳转到下一个 Op 对应的 handler整体代码还是比较清晰的,只是说把计算 A + B
写入 A
的过程和读取下一个 Op 并跳转的逻辑穿插了起来,手动做了一次寄存器调度。那么这些 GET_REG
和 FETCH_ADVANCE_INST
等等具体又是怎么实现的呢?下面把宏展开后的代码贴出来:
.balign NTERP_HANDLER_SIZE.L_op_add_int_2addr: /* 0xb0 */ /* omitted */ /* binop/2addr vA, vB */ // wINST is w23, the first 16-bit code unit of current instruction // lsr w3, wINST, #12 // w3<- B lsr w3, w23, #12 // ubfx w9, wINST, #8, #4 // w9<- A ubfx w9, w23, #8, #4 // virtual registers are stored relative to xFP(x29), the interpreted frame pointer, used for accessing locals and args // GET_VREG w1, w3 // w1<- vB ldr w1, [x29, w3, uxtw #2] // GET_VREG w0, w9 // w0<- vA ldr w0, [x29, w9, uxtw #2] // xPC(x22) is the interpreted program counter, used for fetching instructions // FETCH_ADVANCE_INST 1 // advance rPC, load rINST // a pre-index load instruction that both reads wINST from memory and increments xPC(x22) by 2 ldrh w23, [x22, #2]! // add w0, w0, w1 // w0<- op, w0-w3 changed add w0, w0, w1 // ip(x16) is a scratch register, used to store the first byte (opcode) of wINST // GET_INST_OPCODE ip // extract opcode from rINST and x16, x23, 0xff // save addition result to virtual register, which is relative to xFP(x29) // also set its object references to zero, which is relative to xREFS(x25) // SET_VREG w0, w9 // vAA<- w0 str w0, [x29, w9, uxtw #2] str wzr, [x25, w9, uxtw #2] // now x16 saves the opcode, and xIBASE(x24) interpreted instruction base pointer, used for computed goto // for opcode #k, the handler address of it would be `xIBASE + k * 128` // GOTO_OPCODE ip // jump to next instruction add x16, x24, x16, lsl #7 br x16 /* omitted */
各个寄存器的含义已经在上面的注释中写出,比如 w23
记录了当前 Op 的前 16 位的内容,x29
记录了当前的 frame pointer,通过它可以访问各个 virtual register;x11
是 PC,记录了正在执行的 Op 的地址;x24
记录了这些 op handler 的起始地址,由于每个 handler 都不超过 128 字节,且都对齐到 128 字节边界(.balign NTERP_HANDLER_SIZE
),所以只需要简单的运算 xIBASE + opcode * 128
即可找到下一个 op 的 handler 地址,不需要再进行一次访存。
如果要比较一下 Android Runtime 的 mterp (nterp) 和 V8 的 Ignition 解释器的实现,有如下几点相同与不同:
mksnapshot
阶段),长度没有限制,允许生成比较复杂的汇编,但如果汇编比较短(比如 release 模式下),也可以节省一些内存;代价是需要一次额外的对 dispatch table 的访存,来找到 opcode 对应的 handler除了以上列举的不同的地方以外,其实整体来看是十分类似的,下面是二者实现把整数加载到寄存器(const/4 vA, #+B
和 LdaSmi
)的汇编的对比:
Operation | mterp (nterp) | Ignition |
---|---|---|
Extract Dest Register | ubfx w0, w23, #8, #4 |
N/A (destination is always the accumulator) |
Extract Const Integer | sbfx w1, w23, #12, #4 |
add x1, x19, #1; ldrsb w1, [x20, x1] |
Read Next Op | ldrh w23, [x22, #2]! |
add x19, x19, #2; ldrb w3, [x20, x19] |
Save Result | str w1, [x29, w0, uxtw #2]; str wzr, [x25, w0, uxtw #2] |
add w0, w1, w1 |
Computed Goto | and x16, x23, 0xff; add x16, x24, x16, lsl #7; br x16 |
ldr x2, [x21, x3, lsl #3]; mov x17, x2; br x2 |
在寄存器的约定和使用上的区别:
Purpose | mterp (nterp) | Ignition |
---|---|---|
Intepreter PC | base + offset in x22
|
base in x20 , offset in x19
|
Virtual Register | relative to x29
|
relative to fp
|
Dispatch Table | computed from x24
|
saved in x21
|
既然已经分析了 V8 和 Android Runtime 的解释器,也来简单看一下 Lua 的解释器实现。它写的非常简单,核心代码就在 lvm.c
当中:
vmdispatch (GET_OPCODE(i)) { vmcase(OP_MOVE) { StkId ra = RA(i); setobjs2s(L, ra, RB(i)); vmbreak; } vmcase(OP_LOADI) { StkId ra = RA(i); lua_Integer b = GETARG_sBx(i); setivalue(s2v(ra), b); vmbreak; } // ...}
可以看到,它把 switch
、case
和 break
替换成了三个宏 vmdispatch
、vmcase
和 vmbreak
。接下来看它可能的定义,第一种情况是编译器不支持 goto *
语法,此时就直接展开:
如果编译器支持 goto *
语法,则展开成对应的 computed goto
形式:
#define vmdispatch(x) goto *disptab[x];#define vmcase(l) L_##l:#define vmbreak mfetch(); vmdispatch(GET_OPCODE(i));static const void *const disptab[NUM_OPCODES] = { &&L_OP_MOVE, &&L_OP_LOADI, // ...};
和之前写的解释器的不同实现方法对应,就不多阐述了。
2025-03-01 08:00:00
V8 是一个很常见的 JavaScript 引擎,运行在很多的设备上,因此想探究一下它内部的部分实现。本博客在 ARM64 Ubuntu 24.04 平台上针对 V8 12.8.374.31 版本进行分析。本博客主要分析了 V8 的 Ignition 解释器的解释执行部分。
首先简单过一下 v8 的源码获取以及编译流程,主要参考了 Checking out the V8 source code 和 Compiling on Arm64 Linux:
# setup depot_toolscd ~git clone https://chromium.googlesource.com/chromium/tools/depot_tools.gitexport PATH=$PWD/depot_tools:$PATH# clone v8 reposmkdir ~/v8cd ~/v8fetch v8cd v8# switch to specified taggit checkout 12.8.374.31gclient sync --verbose# install dependencies./build/install-build-deps.sh# install llvm 19wget https://mirrors.tuna.tsinghua.edu.cn/llvm-apt/llvm.shchmod +x llvm.shsudo ./llvm.sh 19 -m https://mirrors.tuna.tsinghua.edu.cn/llvm-aptrm llvm.shsudo apt install -y lld clang-19# fix incompatibilities with system clang 19sed -i "s/-Wno-missing-template-arg-list-after-template-kw//" build/config/compiler/BUILD.gn# compile v8 using system clang 19# for amd64: use x64.optdebug instead of arm64.optdebugtools/dev/gm.py arm64.optdebug --progress=verbose# d8 is compiled successfully at./out/arm64.optdebug/d8
如果不想编译 V8,也可以直接用 Node.JS 来代替 d8
。不过 Node.JS 会加载很多 JS 代码,使得输出更加复杂,此时就需要手动过滤一些输出,或者通过命令行设置一些打印日志的过滤器。另外,后面有一些深入的调试信息,需要手动编译 V8 才能打开,因此还是建议读者上手自己编译一个 V8。
在 AMD64 上,默认会使用 V8 自带的 LLVM 版本来编译,此时就不需要额外安装 LLVM 19,也不需要修改 v8/build/config/compiler/BUILD.gn
。
通过 V8 的文档可以看到,V8 一共有这些解释器或编译器,按照其优化等级从小到大的顺序:
在 JS 的使用场景,不同代码被调用的次数以及对及时性的需求差别很大,为了适应不同的场景,V8 设计了这些解释器和编译器来提升整体的性能:执行次数少的代码,倾向于用更低优化等级的解释器或编译器,追求更低的优化开销;执行次数多的代码,当编译优化时间不再成为瓶颈,则倾向于用更高优化等级的编译器,追求更高的执行性能。
首先来观察一下 Ignition 解释器的工作流程。写一段简单的 JS 代码:
保存为 test.js
,运行 ./out/arm64.optdebug/d8 --print-ast --print-bytecode test.js
以打印它的 AST 以及 Bytecode:
首先开始的是 top level 的 AST 以及 Bytecode,它做的事情就是:声明一个函数 add,然后以参数 (1, 2)
来调用它。
top level AST:
[generating bytecode for function: ]--- AST ---FUNC at 0. KIND 0. LITERAL ID 0. SUSPEND COUNT 0. NAME "". INFERRED NAME "". DECLS. . FUNCTION "add" = function add. EXPRESSION STATEMENT at 42. . kAssign at -1. . . VAR PROXY local[0] (0xc28698556308) (mode = TEMPORARY, assigned = true) ".result". . . CALL. . . . VAR PROXY unallocated (0xc28698556200) (mode = VAR, assigned = true) "add". . . . LITERAL 1. . . . LITERAL 2. RETURN at -1. . VAR PROXY local[0] (0xc28698556308) (mode = TEMPORARY, assigned = true) ".result"
首先声明了一个 add
函数,然后以 1
和 2
两个参数调用 add
函数,把结果绑定给局部变量 .result
,最后以 .result
为结果返回。接下来看它会被翻译成什么字节码:
[generated bytecode for function: (0x1f8e002988f5 <SharedFunctionInfo>)]Bytecode length: 28Parameter count 1Register count 4Frame size 32 0x304700040048 @ 0 : 13 00 LdaConstant [0] 0x30470004004a @ 2 : c9 Star1 0x30470004004b @ 3 : 19 fe f7 Mov <closure>, r2 0x30470004004e @ 6 : 68 63 01 f8 02 CallRuntime [DeclareGlobals], r1-r2 0x304700040053 @ 11 : 21 01 00 LdaGlobal [1], [0] 0x304700040056 @ 14 : c9 Star1 0x304700040057 @ 15 : 0d 01 LdaSmi [1] 0x304700040059 @ 17 : c8 Star2 0x30470004005a @ 18 : 0d 02 LdaSmi [2] 0x30470004005c @ 20 : c7 Star3 0x30470004005d @ 21 : 66 f8 f7 f6 02 CallUndefinedReceiver2 r1, r2, r3, [2] 0x304700040062 @ 26 : ca Star0 0x304700040063 @ 27 : af ReturnConstant pool (size = 2)0x304700040011: [TrustedFixedArray] - map: 0x1f8e00000595 <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2 0: 0x1f8e00298945 <FixedArray[2]> 1: 0x1f8e000041dd <String[3]: #add>Handler Table (size = 0)Source Position Table (size = 0)
V8 的字节码采用的是基于寄存器的执行模型,而非其他很多字节码会采用的栈式。换句话说,每个函数有自己的若干个寄存器可供操作。每条字节码分为 Opcode(表示这条字节码要进行的操作)和操作数两部分。函数开头的 Register count 4
表明该函数有四个寄存器:r0-r3
,此外还有一个特殊的 accumulator
寄存器,它一般不会出现在操作数列表中,而是隐含在 Opcode 内(Lda/Sta
)。
完整的 Opcode 列表可以在 v8/src/interpreter/bytecodes.h
中找到,对应的实现可以在 v8/src/interpreter/interpreter-generator.cc
中找到。
上述字节码分为两部分,第一部分是声明 add
函数:
LdaConstant [0]
: 把 Constant Pool 的第 0 项也就是 FixedArray[2]
写入 accumulator
寄存器当中Star1
: 把 accumulator
寄存器的值拷贝到 r1
寄存器,结合上一条字节码,就是设置 r1 = FixedArray[2]
Mov <closure>, r2
: 把 <closure>
拷贝到 r2
寄存器,猜测这里的 <closure>
对应的是 add
函数CallRuntime [DeclareGlobals], r1-r2
: 调用运行时的 DeclareGlobals
函数,并传递两个参数,分别是 r1
和 r2
;有意思的是,CallRuntime
的参数必须保存在连续的寄存器当中,猜测是为了节省编码空间至此,add
函数就声明完成了。接下来,就要实现 add(1, 2)
的调用:
LdaGlobal [1], [0]
: 把 Constant Pool 的第 1 项也就是 "add"
这个字符串写入 accumulator
,最后的 [0]
和 FeedBackVector 有关,目前先忽略Star1
: 把 accumulator
寄存器的值拷贝到 r1
寄存器,结合上一条字节码,就是设置 r1 = "add"
LdaSmi [1]
: 把小整数(Small integer, Smi)1
写入 accumulator
Star2
: 把 accumulator
寄存器的值拷贝到 r2
寄存器,结合上一条字节码,就是设置 r2 = 1
LdaSmi [2]
: 把小整数(Small integer, Smi)2
写入 accumulator
Star3
: 把 accumulator
寄存器的值拷贝到 r3
寄存器,结合上一条字节码,就是设置 r3 = 2
CallUndefinedReceiver2 r1, r2, r3, [2]
: 根据 r1
调用一个函数,并传递两个参数 r2, r3
(函数名称最后的 2
表示有两个参数),最后的 [2]
也和 FeedBackVector 有关这样就完成了函数调用。
接下来观察 add
函数的 AST:
[generating bytecode for function: add]--- AST ---FUNC at 12. KIND 0. LITERAL ID 1. SUSPEND COUNT 0. NAME "add". INFERRED NAME "". PARAMS. . VAR (0xc50512445280) (mode = VAR, assigned = false) "a". . VAR (0xc50512445300) (mode = VAR, assigned = false) "b". DECLS. . VARIABLE (0xc50512445280) (mode = VAR, assigned = false) "a". . VARIABLE (0xc50512445300) (mode = VAR, assigned = false) "b". RETURN at 25. . kAdd at 34. . . VAR PROXY parameter[0] (0xc50512445280) (mode = VAR, assigned = false) "a". . . VAR PROXY parameter[1] (0xc50512445300) (mode = VAR, assigned = false) "b"
add
函数的 AST 比较直接,a + b
直接对应了 kAdd
结点,直接作为返回值。
接下来观察 add
的 Bytecode:
[generated bytecode for function: add (0x10c700298955 <SharedFunctionInfo add>)]Bytecode length: 6Parameter count 3Register count 0Frame size 0 0x6ed0004008c @ 0 : 0b 04 Ldar a1 0x6ed0004008e @ 2 : 3b 03 00 Add a0, [0] 0x6ed00040091 @ 5 : af ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 0)
Ldar a1
: 把第二个参数 a1
也就是 b
写入 accumulator
寄存器Add a0, [0]
: 求第一个参数 a0
也就是 a
与 accumulator
寄存器的和,写入到 accumulator
寄存器当中,结合上一条 Bytecode,就是 accumulator = a0 + a1
;[0]
和 FeedBackVector 有关Return
: 把 accumulator
中的值作为返回值,结束函数调用简单小结一下 V8 的字节码:
rn
的形式出现,n
是寄存器编号accumulator
局部寄存器,作为部分字节码的隐含输入或输出(Add
)an
的形式出现,n
是参数编号[imm]
,可能是整数字面量(LdaSmi
),可能是下标(LdaConstant
),也可能是 FeedBackVector 的 slot有了字节码以后,接下来观察 Ignition 具体是怎么解释执行这些字节码的。
为了实际执行这些字节码,Ignition 的做法是:
由于 Opcode 的种类是固定的,所以实际运行 V8 的时候,这些代码已经编译好了,只需要在运行时初始化对应的数据结构即可。这个代码的生成和编译过程,也不是由 C++ 编译器做的,而是有一个 mksnapshot
命令来完成初始化,你可以认为它把这些 Opcode 对应的汇编指令都预先生成好,运行时直接加载即可。
首先来看 Ignition 的怎么实现各种 Opcode 的,以 LdaSmi
为例,它的作用是小的把立即数(Smi=Small integer)写入到 accumulator
当中,这段在 v8/src/interpreter/interpreter-generator.cc
的代码实现了这个逻辑:
// LdaSmi <imm>//// Load an integer literal into the accumulator as a Smi.IGNITION_HANDLER(LdaSmi, InterpreterAssembler) { TNode<Smi> smi_int = BytecodeOperandImmSmi(0); SetAccumulator(smi_int); Dispatch();}
可以看到,逻辑并不复杂,就是取了第一个立即数操作数,设置到了 accumulator
,最后调用 Dispatch
,也就是读取下一个 Opcode 对应的汇编指令然后跳转。接下来看这几个步骤在汇编上是怎么实现的。
为了查看 Ignition 对各种 Opcode 具体生成了什么样的汇编指令,可以用 ./out/arm64.optdebug/mksnapshot --trace-ignition-codegen --code-comments
命令查看,下面列出了 LdaSmi
这个 Opcode 对应的汇编,由于这段汇编有点长,具体做的事情和对应的源码已经通过注释标注出来:
kind = BYTECODE_HANDLERname = LdaSmicompiler = turbofanaddress = 0x31a000906fdInstructions (size = 324)# 在代码的开头,检查寄存器是否正确,即 x2 是否保存了当前代码段的开始地址,对应的源码:# v8/src/compiler/backend/arm64/code-generator-arm64.cc CodeGenerator::AssembleCodeStartRegisterCheck():# UseScratchRegisterScope temps(masm());# Register scratch = temps.AcquireX();# __ ComputeCodeStartAddress(scratch); // becomes x16 in the following code# __ cmp(scratch, kJavaScriptCallCodeStartRegister);# __ Assert(eq, AbortReason::kWrongFunctionCodeStart);# 其中 kJavaScriptCallCodeStartRegister 定义在 v8/src/codegen/arm64/register-arm64.h:# constexpr Register kJavaScriptCallCodeStartRegister = x2; [ Frame: MANUAL -- Prologue: check code start register -- - AssembleCode@../../src/compiler/backend/code-generator.cc:2320xc6ccfe4a8b00 0 10000010 adr x16, #+0x0 (addr 0xc6ccfe4a8b00)0xc6ccfe4a8b04 4 eb02021f cmp x16, x20xc6ccfe4a8b08 8 54000080 b.eq #+0x10 (addr 0xc6ccfe4a8b18) [ - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4008 Abort message: - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4010 Wrong value in code start register passed - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:40110xc6ccfe4a8b0c c d2801081 movz x1, #0x84 [ Frame: NO_FRAME_TYPE [ - EntryFromBuiltinAsOperand@../../src/codegen/arm64/macro-assembler-arm64.cc:2377 ]0xc6ccfe4a8b10 10 f96a3750 ldr x16, [x26, #21608]0xc6ccfe4a8b14 14 d63f0200 blr x16 ] ]# 栈对齐检查,定义在:# v8/src/codegen/arm64/macro-assembler-arm64.cc MacroAssembler::AssertSpAligned():# if (!v8_flags.debug_code) return;# ASM_CODE_COMMENT(this);# HardAbortScope hard_abort(this); // Avoid calls to Abort.# // Arm64 requires the stack pointer to be 16-byte aligned prior to address# // calculation.# UseScratchRegisterScope scope(this);# Register temp = scope.AcquireX(); // becomes x16 in the following code# Mov(temp, sp);# Tst(temp, 15);# Check(eq, AbortReason::kUnexpectedStackPointer); -- B0 start (construct frame) -- [ - AssertSpAligned@../../src/codegen/arm64/macro-assembler-arm64.cc:15900xc6ccfe4a8b18 18 910003f0 mov x16, sp [ - LogicalMacro@../../src/codegen/arm64/macro-assembler-arm64.cc:1970xc6ccfe4a8b1c 1c f2400e1f tst x16, #0xf ]0xc6ccfe4a8b20 20 54000080 b.eq #+0x10 (addr 0xc6ccfe4a8b30) [ - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4008 Abort message: - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4010 The stack pointer is not the expected value - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4011 [ Frame: NO_FRAME_TYPE0xc6ccfe4a8b24 24 52800780 movz w0, #0x3c0xc6ccfe4a8b28 28 f94e7750 ldr x16, [x26, #7400]0xc6ccfe4a8b2c 2c d63f0200 blr x16 ] ] ]# 构建栈帧# 构建完成后会得到:sp = prev sp - 64, fp = sp + 48## 栈帧的示意图, 每一个方框表示 8 字节的内存:# sp + 64 +------------+# | lr | <= lr 是 link register 的缩写,表示返回地址# sp + 56 +------------+# | prev fp | <= 保存了调用前的 fp (frame pointer)# sp + 48 +------------+ <= 新的 fp (frame pointer) 指向这里# | x16 (0x22) |# sp + 40 +------------+# | x20 | <= bytecode array register# sp + 32 +------------+# | x21 | <= dispatch table register# sp + 24 +------------+# | x19 | <= bytecode offset register,记录当前正在执行的 bytecode 在 bytecode array 中的偏移# sp + 16 +------------+# | x0 | <= accumulator register# sp + 8 +------------+# | |# sp +------------+ <= 新的 sp (stack pointer) 指向这里## 这些寄存器定义在 v8/src/codegen/arm64/register-arm64.h 当中:# constexpr Register kInterpreterAccumulatorRegister = x0;# constexpr Register kInterpreterBytecodeOffsetRegister = x19;# constexpr Register kInterpreterBytecodeArrayRegister = x20;# constexpr Register kInterpreterDispatchTableRegister = x21;0xc6ccfe4a8b30 30 d2800450 movz x16, #0x220xc6ccfe4a8b34 34 a9be43ff stp xzr, x16, [sp, #-32]!0xc6ccfe4a8b38 38 a9017bfd stp fp, lr, [sp, #16]0xc6ccfe4a8b3c 3c 910043fd add fp, sp, #0x10 (16)0xc6ccfe4a8b40 40 d10083ff sub sp, sp, #0x20 (32)0xc6ccfe4a8b44 44 f90013f4 str x20, [sp, #32]0xc6ccfe4a8b48 48 f9000ff5 str x21, [sp, #24]0xc6ccfe4a8b4c 4c f90007e0 str x0, [sp, #8]0xc6ccfe4a8b50 50 f9000bf3 str x19, [sp, #16]# 调用了未知的 C 函数0xc6ccfe4a8b54 54 d28042c1 movz x1, #0x216 [ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166 [ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:19540xc6ccfe4a8b58 58 f94d5f42 ldr x2, [x26, #6840] ] [ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:34480xc6ccfe4a8b5c 5c d28bcef0 movz x16, #0x5e770xc6ccfe4a8b60 60 b8706842 ldr w2, [x2, x16]0xc6ccfe4a8b64 64 8b020382 add x2, x28, x2 ] ]0xc6ccfe4a8b68 68 aa1403e0 mov x0, x200xc6ccfe4a8b6c 6c f94ecf50 ldr x16, [x26, #7576] [ - CallCFunction@../../src/codegen/arm64/macro-assembler-arm64.cc:21060xc6ccfe4a8b70 70 10000068 adr x8, #+0xc (addr 0xc6ccfe4a8b7c)0xc6ccfe4a8b74 74 a93f235d stp fp, x8, [x26, #-16]0xc6ccfe4a8b78 78 d63f0200 blr x160xc6ccfe4a8b7c 7c f81f035f stur xzr, [x26, #-16] ]0xc6ccfe4a8b80 80 d2800001 movz x1, #0x0 [ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166 [ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:19540xc6ccfe4a8b84 84 f94d5f42 ldr x2, [x26, #6840] ] [ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:34480xc6ccfe4a8b88 88 d28bcf70 movz x16, #0x5e7b0xc6ccfe4a8b8c 8c b8706842 ldr w2, [x2, x16]0xc6ccfe4a8b90 90 8b020382 add x2, x28, x2 ] ]0xc6ccfe4a8b94 94 f94007e0 ldr x0, [sp, #8]0xc6ccfe4a8b98 98 f94ecf50 ldr x16, [x26, #7576] [ - CallCFunction@../../src/codegen/arm64/macro-assembler-arm64.cc:21060xc6ccfe4a8b9c 9c 10000068 adr x8, #+0xc (addr 0xc6ccfe4a8ba8)0xc6ccfe4a8ba0 a0 a93f235d stp fp, x8, [x26, #-16]0xc6ccfe4a8ba4 a4 d63f0200 blr x160xc6ccfe4a8ba8 a8 f81f035f stur xzr, [x26, #-16] ]# 从这里开始实现 LdaSmi 的语义# 从前面的分析可以看到 LdaSmi 由两个字节组成:# 1. 第一个字节是 0x0d,表示这是一条 LdaSmi# 2. 第二个字节就是要加载到 `accumulator` 的小整数# 如:0d 01 对应 LdaSmi [1],0d 02 对应 LdaSmi [2]# 所以,为了实现 LdaSmi,需要从 bytecode array 中读取 LdaSmi 字节码的第二个字节,# 保存到 `accumulator` 寄存器当中# 下面一条一条地分析指令在做的事情:# 1. 从 sp + 16 地址读取 bytecode offset 寄存器的值到 x3,# 它记录了 LdaSmi 相对 bytecode array 的偏移0xc6ccfe4a8bac ac f9400be3 ldr x3, [sp, #16]# 2. 计算 x3 + 1 的值并写入 x4,得到 LdaSmi 的第二个字节相对 bytecode array 的偏移0xc6ccfe4a8bb0 b0 91000464 add x4, x3, #0x1 (1)# 3. 从 sp + 32 地址读取 bytecode array 寄存器的值到 x200xc6ccfe4a8bb4 b4 f94013f4 ldr x20, [sp, #32]# 4. 从 x20 + x4 地址读取 LdaSmi 的第二个字节到 x4,也就是要加载到 `accumulator` 的值,# 之后 x4 的值会写入到 x0,也就是 `accumulator` 对应的寄存器0xc6ccfe4a8bb8 b8 38e46a84 ldrsb w4, [x20, x4]# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去 ========= Dispatch - Dispatch@../../src/interpreter/interpreter-assembler.cc:1278 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978# x3 是 LdaSmi 当前所在的 bytecode offset,加 2 是因为 LdaSmi 占用了两个字节# x19 = x3 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码0xc6ccfe4a8bbc bc 91000873 add x19, x3, #0x2 (2)# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器0xc6ccfe4a8bc0 c0 38736a83 ldrb w3, [x20, x19]# 接下来检查在 x3 寄存器当中的字节码的第一个字节(Opcode),如果它:# 1. 小于 187(kFirstShortStar),说明它不是特殊的 Short Star (Star0-Star15) 字节码# 2. 介于 187(kFirstShortStar) 和 202(kLastShortStar) 之间,说明它是特殊的 Short Star (Star0-Star15) 字节码# 3. 如果大于 202(kLastShortStar),说明它是非法的字节码# 如果 x3 寄存器大于或等于 187,说明这个字节码可能是 Short Star 字节码,就跳转到后面的 B20xc6ccfe4a8bc4 c4 7102ec7f cmp w3, #0xbb (187)0xc6ccfe4a8bc8 c8 54000102 b.hs #+0x20 (addr 0xc6ccfe4a8be8) -- B1 start --# 此时 x3 小于 187# 从栈上读取 x21 即 dispatch table register0xc6ccfe4a8bcc cc f9400ff5 ldr x21, [sp, #24]# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址0xc6ccfe4a8bd0 d0 f8637aa2 ldr x2, [x21, x3, lsl #3]# 把之前 LdaSmi 计算得到的 x4 寄存器写到 `accumulator` 即 x0 寄存器当中# 这里 x0 = 2 * x4,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)0xc6ccfe4a8bd4 d4 0b040080 add w0, w4, w4# 恢复调用函数前的旧 fp 和 lr0xc6ccfe4a8bd8 d8 a9407bbd ldp fp, lr, [fp]# 恢复调用函数前的旧 sp0xc6ccfe4a8bdc dc 910103ff add sp, sp, #0x40 (64)# 下一个字节码对应的代码的地址已经保存在 x2 寄存器当中,跳转过去0xc6ccfe4a8be0 e0 aa0203f1 mov x17, x20xc6ccfe4a8be4 e4 d61f0220 br x17 -- B2 start -- [ Assert: UintPtrGreaterThanOrEqual(opcode, UintPtrConstant(static_cast<int>( Bytecode::kFirstShortStar))) - StoreRegisterForShortStar@../../src/interpreter/interpreter-assembler.cc:310 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978 ] Assert - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978 [ Assert: UintPtrLessThanOrEqual( opcode, UintPtrConstant(static_cast<int>(Bytecode::kLastShortStar))) - StoreRegisterForShortStar@../../src/interpreter/interpreter-assembler.cc:314 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978# 此时 x3 大于或等于 187# 进一步判断 x3 是否大于 202,如果大于,则跳转到后面的 B30xc6ccfe4a8be8 e8 7103287f cmp w3, #0xca (202)0xc6ccfe4a8bec ec 540001c8 b.hi #+0x38 (addr 0xc6ccfe4a8c24) -- B4 start --# 此时 x3 介于 187 和 202 之间,是一个 Short Star# Short Star 做的事情就是把 `accumulator` 寄存器的值复制到 r0-r15 当中指定的寄存器# 所以直接在这里实现了 Short Star 的语义,而不是单独跑一段代码去执行它# 由于 r0-r15 寄存器保存在栈上,所以通过 x3 计算出 Short Star 要写到哪个寄存器# 进而直接计算要写到的栈的地址的偏移# 寻找一个通项公式,找到 Star0-Star15 要写入的地址:# Star0(202): r0 的位置在栈顶再往下的 8 字节,即 fp 减去 56# Star0(187): r15 的位置在栈顶再往下的 8*16 字节,即 fp 减去 176# 相对 fp 的偏移量就等于 x3 * 8 - 1672# 从而得到下面的代码:# 计算 x3 = x3 * 80xc6ccfe4a8bf0 f0 d37df063 lsl x3, x3, #3# 把之前 LdaSmi 计算得到的 x4 寄存器写到 `accumulator` 即 x0 寄存器当中# 这里 x0 = 2 * x4,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)0xc6ccfe4a8bf4 f4 0b040080 add w0, w4, w4 ] Assert - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978# 计算 x3 = x3 - 1672,就得到了相对 fp 的偏移量0xc6ccfe4a8bf8 f8 d11a2063 sub x3, x3, #0x688 (1672)# 从 fp 的地址读取函数调用前的 fp0xc6ccfe4a8bfc fc f94003a4 ldr x4, [fp]# 把 `accumulator` 写入到相对函数调用前的 fp 的对应位置0xc6ccfe4a8c00 100 f8236880 str x0, [x4, x3]# 下面就是 Dispatch 逻辑,只不过这次是执行完 Short Star 字节码后的 Dispatch# x19 = x3 + 1,就是 bytecode offset 前进一个字节,指向下一个字节码0xc6ccfe4a8c04 104 91000673 add x19, x19, #0x1 (1)# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器0xc6ccfe4a8c08 108 38736a83 ldrb w3, [x20, x19]# 从栈上读取 x21 即 dispatch table register0xc6ccfe4a8c0c 10c f9400ff5 ldr x21, [sp, #24]# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址0xc6ccfe4a8c10 110 f8637aa2 ldr x2, [x21, x3, lsl #3]# 恢复调用函数前的旧 fp 和 lr0xc6ccfe4a8c14 114 a9407bbd ldp fp, lr, [fp]# 恢复调用函数前的旧 sp0xc6ccfe4a8c18 118 910103ff add sp, sp, #0x40 (64)# 下一个字节码对应的代码的地址已经保存在 x2 寄存器当中,跳转过去0xc6ccfe4a8c1c 11c aa0203f1 mov x17, x20xc6ccfe4a8c20 120 d61f0220 br x17 -- B5 start (no frame) -- -- B3 start (deferred) --# 此时 x3 大于 202,为非法字节码,跳转到错误处理的逻辑 [ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166 [ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:19540xc6ccfe4a8c24 124 f94d5f41 ldr x1, [x26, #6840] ] [ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:34480xc6ccfe4a8c28 128 d28bd170 movz x16, #0x5e8b0xc6ccfe4a8c2c 12c b8706821 ldr w1, [x1, x16]0xc6ccfe4a8c30 130 8b010381 add x1, x28, x1 ] ] [ Frame: NO_FRAME_TYPE [ Inlined Trampoline for call to AbortCSADcheck - CallBuiltin@../../src/codegen/arm64/macro-assembler-arm64.cc:23910xc6ccfe4a8c34 134 96a4e10b bl #-0x56c7bd4 (addr 0xc6ccf8de1060) ;; code: Builtin::AbortCSADcheck ] ]0xc6ccfe4a8c38 138 d4200000 brk #0x00xc6ccfe4a8c3c 13c d4200000 brk #0x00xc6ccfe4a8c40 140 d503201f nop ;;; Safepoint table. - Emit@../../src/codegen/safepoint-table.cc:187 ]External Source positions: pc offset fileid line b4 380 72Safepoints (entries = 2, byte size = 12)0xc6ccfe4a8b7c 7c slots (sp->fp): 010010000xc6ccfe4a8ba8 a8 slots (sp->fp): 00001000RelocInfo (size = 3)0xc6ccfe4a8c34 code target (BUILTIN AbortCSADcheck) (0xc6ccf8de1060)
为了简化代码,关闭了 control flow integrity 相关的代码生成,具体方法是运行 gn args out/arm64.optdebug
,追加一行 v8_control_flow_integrity = false
,再重新 autoninja -C out/arm64.optdebug d8
。
以上是 debug 模式下生成的代码,多了很多检查;如果在 release 模式下,可以观察到更优的指令:
kind = BYTECODE_HANDLERname = LdaSmicompiler = turbofanaddress = 0x31a000462bdInstructions (size = 80)# 从这里开始实现 LdaSmi 的语义# 计算 x19 + 1 的值并写入 x1,得到 LdaSmi 的第二个字节相对 bytecode array 的偏移0xc903f8193400 0 91000661 add x1, x19, #0x1 (1)# 从 x20 + x1 地址读取 LdaSmi 的第二个字节到 x1,也就是要加载到 `accumulator` 的值,# 之后 x1 的值会写入到 x0,也就是 `accumulator` 对应的寄存器0xc903f8193404 4 38e16a81 ldrsb w1, [x20, x1]# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去# x19 = x19 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码0xc903f8193408 8 91000a73 add x19, x19, #0x2 (2)# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器0xc903f819340c c 38736a83 ldrb w3, [x20, x19]# 计算 x4 = x3 * 8,也就是 dispatch table 中下一个字节码对应的代码地址的字节偏移0xc903f8193410 10 d37df064 lsl x4, x3, #3# 把之前 LdaSmi 计算得到的 x1 寄存器写到 `accumulator` 即 x0 寄存器当中# 这里 x0 = 2 * x1,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)0xc903f8193414 14 0b010020 add w0, w1, w1# 如果 x3 寄存器大于或等于 187,说明这个字节码可能是 Short Star 字节码,就跳转到后面的 0xc903f819342c 地址0xc903f8193418 18 7102ec7f cmp w3, #0xbb (187)0xc903f819341c 1c 54000082 b.hs #+0x10 (addr 0xc903f819342c)# 如果没有跳转,此时 x3 寄存器小于 187# 从 dispatch table,以 x3 为下标(x4 = x3 * 8),读取下一个字节码对应的代码的地址0xc903f8193420 20 f8646aa2 ldr x2, [x21, x4]# 跳转到下一个字节码对应的代码的地址0xc903f8193424 24 aa0203f1 mov x17, x20xc903f8193428 28 d61f0220 br x17# 实现 Short Star 字节码# 计算出要写入的 r0-r15 寄存器相对 fp 的偏移量 x3 * 8 - 1672# 这个偏移量的计算公式在前面推导过,此时 x4 等于 x3 * 80xc903f819342c 2c d11a2081 sub x1, x4, #0x688 (1672)0xc903f8193430 30 aa1d03e3 mov x3, fp# 把 `accumulator` 写入到相对 fp 的对应位置0xc903f8193434 34 f8216860 str x0, [x3, x1]# 下面就是 Dispatch 逻辑,只不过这次是执行完 Short Star 字节码后的 Dispatch# x19 = x19 + 1,就是 bytecode offset 前进一个字节,指向下一个字节码0xc903f8193438 38 91000673 add x19, x19, #0x1 (1)# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x1 寄存器0xc903f819343c 3c 38736a81 ldrb w1, [x20, x19]# 从 dispatch table,以 x1 为下标,读取下一个字节码对应的代码的地址0xc903f8193440 40 f8617aa2 ldr x2, [x21, x1, lsl #3]# 跳转到下一个字节码对应的代码的地址0xc903f8193444 44 aa0203f1 mov x17, x20xc903f8193448 48 d61f0220 br x170xc903f819344c 4c d503201f nop
可见 release 模式下的代码还是简单了许多,保证了性能。
有的 Opcode 后面不会紧接着出现 Short Star,此时 Dispatch 会减少一次特判,代码更加简单,以 Ldar
为例:
kind = BYTECODE_HANDLERname = Ldarcompiler = turbofanaddress = 0x31a00046245Instructions (size = 44)# Ldar 的语义是,把指定参数寄存器的值写入到 `accumulator` 当中# 参数寄存器的位置记录在 Ldar 字节码的第二个字节中# 如:0b 04 对应 Ldar a1# 计算 x19 + 1 的值并写入 x1,得到 Ldar 的第二个字节相对 bytecode array 的偏移0xc903f8193320 0 91000661 add x1, x19, #0x1 (1)# 从 x20 + x1 地址读取 Ldar 的第二个字节到 x1,也就是参数寄存器相对 fp 的下标0xc903f8193324 4 38a16a81 ldrsb x1, [x20, x1]# 相对 fp 以 x1 为下标,读取参数寄存器的值到 x1 寄存器0xc903f8193328 8 aa1d03e3 mov x3, fp0xc903f819332c c f8617861 ldr x1, [x3, x1, lsl #3]# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去# x19 = x19 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码0xc903f8193330 10 91000a73 add x19, x19, #0x2 (2)# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器0xc903f8193334 14 38736a83 ldrb w3, [x20, x19]# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址0xc903f8193338 18 f8637aa2 ldr x2, [x21, x3, lsl #3]# 把参数寄存器的值写入到 `accumulator` 也就是 x0 当中0xc903f819333c 1c aa0103e0 mov x0, x1# 跳转到下一个字节码对应的代码的地址0xc903f8193340 20 aa0203f1 mov x17, x20xc903f8193344 24 d61f0220 br x170xc903f8193348 28 d503201f nop
小结一下:
mksnapshot
命令一次性生成好,运行时直接复用,不用重新生成2025-02-04 08:00:00
之前用了一段时间飞书日历,想要把日历里的事件导出来备份,但是发现飞书自己的导出功能太弱,因此参考 从飞书导出日历到 Fastmail - Xuanwo's Blog 进行了导出的尝试。
上面提到的文章,是通过 CalDAV 的方式进行的日历同步。因此我第一步也是配置飞书的 CalDAV 服务:
按照界面所示,配置 CalDAV 同步,就可以得到用于 CalDAV 的域名、用户名和密码了。如果只是要订阅,那么到这一步,就可以直接用 CalDAV 客户端来同步了。但我想进一步得到 iCalendar 格式的日历文件。
于是我参考了上述文章的评论区的做法:
@jason5ng32 jason5ng32Oct 28, 2024分享一下我的方法:1. 在服务器上安装 vdirsyncer ,这个工具可以同步 CalDAV 的内容,在同步设置里,不需要先找到 UUID,可以直接用飞书提供的 URL。2. 写一个 Python 脚本,将 vdirsyncer 同步的内容合并成单一的 ics 文件。3. 将 ics 文件放到一个地址稍微复杂一点的 http 目录里,可以外部访问。4. 写一个 run.sh 脚本,通过 crontab 每 10 分钟执行一次 vdirsyncer 同步和日历文件合成。
也就是说,用 vdirsyncer 把日历同步到本地,再转换为 iCalendar 格式的日历文件。参考 vdirsyncer 文档,这件事情并不复杂:
pip3 install vdirsyncer
~/.vdirsyncer/config
,填入在飞书处得到的用户密码: [general]status_path = "~/.vdirsyncer/status/"[pair my_contacts]a = "my_contacts_local"b = "my_contacts_remote"collections = ["from a", "from b"][storage my_contacts_local]type = "filesystem"path = "~/.contacts/"fileext = ".ics"[storage my_contacts_remote]type = "caldav"url = "https://caldav.feishu.cn"username = "REDACTED"password = "REDACTED"
vdirsyncer discover && vdirsyncer sync
此时在 ~/.contacts
目录下,已经能看到很多个 ics 文件了,每个 ics 文件对应了日历中的一个事件。实际上,这些文件就已经是 iCalendar 格式了,只不过每个文件只有一个事件。
为了让一个 .ics
文件包括日历的所有事件,写了一个脚本,实际上就是处理每个 ics 文件,去掉每个文件开头结尾的 BEGIN:VCALENDAR
和 END:VCALENDAR
,把中间的部分拼起来,最后再加上开头结尾:
import sysall_lines = []all_lines += ["BEGIN:VCALENDAR"]for f in sys.argv[1:]: content = open(f).read().strip() lines = content.splitlines() all_lines += lines[1:-1]all_lines += ["END:VCALENDAR"]print("\n".join(all_lines))
运行上述脚本:python3 dump.py ~/.contacts/*/*.ics > dump.ics
,这样得到的 .ics
文件就可以直接导入到日历软件了。
UPDATE: 我在之前写的飞书文档备份工具 feishu-backup 的基础上,添加了飞书日历的导出功能,把原始的 json 保存下来,并转换得到 iCalendar 格式的 .ics
文件。
除了导出飞书的日历,也可以用类似的方法导出 iCloud 国区的日历:把 url 改成 "https://caldav.icloud.com.cn"
,在 Apple ID 上生成 App 密码,填入上面的 password,再把邮箱写到 username 即可。
更进一步,也可以导出 iCloud 国区的联系人:
fileext = ".ics"
改成 fileext = ".vcf"
,因为联系人的格式是 vCard,其文件名后缀是 .vcf
type = "caldav"
改成 type = "carddav"
,把 url = "https://caldav.icloud.com.cn
改成 url = "https://contacts.icloud.com.cn"
,表示同步联系人而不是日历如果既要同步日历,又要同步联系人,或者需要同步来自不同来源的日历,建议把 status 和 storage local 放到不同的目录下,避免出现冲突。
2025-01-12 08:00:00
之前 测试了 Intel Alder Lake 的 P 核微架构,这次就来测一下 Alder Lake 的 E 核微架构 Gracemont。
Intel 关于 Gracemont 微架构有这些官方的信息:
网上已经有较多针对 Gracemont 微架构的评测和分析,建议阅读:
下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。
Intel Gracemont 的性能测试结果见 SPEC。
官方信息:
Gracemont 的 Clustered Decode 架构比较特别,目前没有找到方法去证实它的 Fetch 带宽,后续如果找到了更好的方法,再测这个特性。
官方信息:
Gracemont 的 Clustered Decode 架构比较特别,目前没有找到方法去确认它 2x 3-wide 的 Decode 带宽,后续如果找到了更好的方法,再测这个特性。
官方信息:
为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 4 字节 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:
可以看到 footprint 在 64 KB 之前时可以达到 5 IPC,之后则降到 3.25 IPC,这里的 64 KB 就对应了 L1 ICache 的容量。
官方信息:
构造一系列的 jmp 指令,使得 jmp 指令分布在不同的 page 上,使得 ITLB 成为瓶颈:
可以看到 64 个 Page 出现了明显的拐点,对应的就是 64 的 L1 ITLB 容量。过了拐点后,每次 jmp 的时间延长到了 16 个周期左右,包括了 L2 TLB 到 L1 ITLB 的 refill 时间。
用之前设计的 Return Stack 测试代码来测试 Gracemont,它的 call/ret 是成对的,也就是 ret 的返回地址不变,称这个版本为 A。此时发现不同调用深度下,都能做到 2 cycle 每 call/ret 对,没有观察到性能的下降,说明此时 Return Stack 并没有介入,应该是由 BTB 提供了预测。下面是 A 版本代码在 Gracemont 上的测试结果:
为了解决这个问题,修改代码,在函数里构造两个 call 去调用同一个函数,这样 ret 的返回地址就会变化了,称这个版本为 B。这时候跑出来的结果比较奇怪,周期数快速上升:
同样的 B 版本代码在 AMD Zen3 和 Apple Firestorm 的处理器上,可以观察到在符合预期的 Return Stack 大小处出现性能拐点,和 A 版本代码得到的结论一致。而 B 版本代码在 Golden Cove 上,会观察到在 6 的附近有一个性能下降如下图,但之前用 A 版本代码测得的拐点为 20:
这个区别背后的原因还需要进一步的分析。下面是两个版本的汇编代码的对比:
# version Afunc_n: call func_{n-1} ret# version B, generate two alternating call sitesfunc_n: mov %rdi, %rsi and $1, %rsi je 2f call func_{n-1} jmp 3f2: call func_{n-1} jmp 3f3: ret
官方信息:
在先前测试 L1 ICache 容量的时候,观察到的最大的 IPC 就是 5,此时测得的瓶颈在于 Rename 宽度,对应 5-wide Rename。
官方信息:
实测各类指令的吞吐:
官方信息:
针对 Load Store 带宽,实测每个周期可以完成:
最大的读带宽是 32B/cyc,最大的写带宽是 32B/cyc,二者可以同时达到。
官方信息:
经过实际测试,Gracemont 上如下的情况可以成功转发,对地址 x 的 Store 转发到对地址 y 的 Load 成功时 y-x 的取值范围:
Store\Load | 8b Load | 16b Load | 32b Load | 64b Load |
---|---|---|---|---|
8b Store | {0} | {} | {} | {} |
16b Store | {0} | {0} | {} | {} |
32b Store | {0} | {0} | {0} | {} |
64b Store | {0} | {0} | {0,4} | {0} |
可以看到,Gracemont 在 Store 包含 Load 且地址相同时可以转发。特别地,针对 64b Store 到 32b Load 转发还允许 y-x=4。各种情况下的 CPI:
不支持多个 Store 对同一个 Load 的转发。跨缓存行时不能转发。
即使 Load 和 Store 不重合,但在一定情况下,也会出现 CPI 等于 11 的情况,例如:
在以上几种情况下,Load 和 Store 访问范围并不重合,但性能和访问范围重合且转发失败时相同(CPI 等于 11),由此猜测 Gracemont 判断是否重合是以对齐的 4B 为粒度,如果 Load 和 Store 访问了相同的对齐的 4B 块,即使不重合,一定情况下也可能会被当成重合的情况来处理,但由于实际上并没有重合,就没法转发,性能就比较差。
小结:Gracemont 的 Store to Load Forwarding:
测试不同场景下的 Load to Use Latency:
mov 0(%rsi), %rsi
: 3 cycle,但在跨越 64B 缓存行边界时退化到 11 cyclemov 8(%rsi), %rsi
: 3 cyclemov 0(%rsp, %rsi, 8), %rsi
: 4 cyclemov 0(%rsi, %rdx, 8), %rsi
: 4 cycle官方信息:
官方信息:
用类似测 L1 DCache 的方法测试 L1 DTLB 容量,只不过把 pointer chasing 链的指针分布在不同的 page 上,使得 DTLB 成为瓶颈。奇怪的是,虽然官方信息写的是 32-entry 的 L1 DTLB,但是实测它有 48-entry:
这个观察和 Meteor Lake’s E-Cores: Crestmont Makes Incremental Progress 是一致的,怀疑是 Intel 写错了数据。
官方信息:
使用类似 L1 DTLB 的方式去测试 L2 TLB,在 2048 附近观察到了拐点:
这个结果和官方数据是吻合的。
官方信息:
官方信息:
为了测试 ROB 的大小,设计了一个循环,循环开始和结束是长延迟的 long latency load。中间是若干条 NOP 指令,当 NOP 指令比较少时,循环的时候取决于 load 指令的时间;当 NOP 指令数量过多,填满了 ROB 以后,就会导致 ROB 无法保存循环末尾的 load 指令,性能出现下降。测试结果如下:
当 NOP 数量达到 256 时,性能开始急剧下滑,说明 Golden Cove 的 ROB 大小是 256。
2025-01-10 08:00:00
前段时间测试了 AMD/Apple/Qualcomm/ARM 的处理器的微架构,自然不能漏了 Intel。虽然 Intel 已经出了 Redwood Cove 和 Lion Cove,但手上没有设备,而且 Golden Cove 也是“相对比较成功”(“缩缸的是 Raptor Cove,和我 Golden Cove 有什么关系,虽然其实 Raptor Cove 是 Golden Cove Refresh”)的一代微架构,用在了 Alder Lake 和 Sapphire Rapids 上,因此就来分析它,后续有机会也会分析一下对应的 E 核架构 Gracemont。
Intel 关于 Golden Cove 微架构有这些官方的信息:
网上已经有较多针对 Golden Cove 微架构的评测和分析,建议阅读:
下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。
Intel Golden Cove 的性能测试结果见 SPEC。
官方信息:
官方信息:
官方信息:
Intel 的 uOP(Micro-OP) Cache 称为 Decode Stream Buffer (DSB): Decode Stream Buffer (DSB) is a Uop-cache that holds translations of previously fetched instructions that were decoded by the legacy x86 decode pipeline (MITE).
。
uOP Cache 的组织方式通常是组相连,每个 entry 保存了几条 uOP,这些 uOP 对应了原来指令流中连续的几条指令。
为了测试 uOP Cache 的大小,构造不同大小的循环,循环体是复制若干份的 add %%rsi, %%rdx
指令,最后是 dec + jnz
作为循环结尾,通过 IDQ.DSB_UOPS 性能计数器统计每次循环有多少个 uOP 来自于 DSB 也就是 uOP Cache,发现其最大值为 2800 左右,距离 4K 还有一定的距离。目前还没有找到一个可以稳定跑出 4K uOP 的指令模式,不知道遇到了什么瓶颈。
考虑到 taken branch 在典型的 uOP Cache 设计中会结束一个 entry,把循环体改成若干条 jmp
指令,并且每个 64B 缓存行只有一条 jmp
指令,此时每个 uOP entry 只记录一条 jmp
指令。观察到每次循环最多 512 个 uOP 来自 uOP Cache,那么 Golden Cove 的 uOP Cache 大概就是 512 个 entry。如果改成每 128B 缓存行只有一条 jmp
指令,uOP Cache 容量减少到 256 个 entry;继续增加间距,256B 间距对应 128 个 entry,512B 间距对应 64 个 entry,1024B 间距对应 32 个 entry,2048B 间距对应 16 个 entry,4096B 间距对应 8 个 entry,继续增大间距后,entry 数维持中 8 不再减少,意味着 Golden Cove 的 uOP Cache 是 8 Way 64 Set 一共 512 Entry,Index 是 PC[11:6]。
那么按照官方信息所说的 4K 容量,一共 512 个 Entry,那么每个 Entry 应该能够记录最多 8 个 uOP,这正好也对应上了 8 uOP 的吞吐。
根据前人在 Intel 比较老的微架构上的测试结果(见 The microarchitecture of Intel, AMD, and VIA CPUs)以及 Intel 的官方文档 Software Optimization Manual(这个文档把 uOP Cache 叫做 Decoded ICache),Intel 之前很多代微架构的 uOP Cache Entry 的构造条件是:
3*6=18
个 uOP(The Decoded ICache can hold only up to 18 micro-ops per each 32 byte aligned memory chunk
);如果指令跨了 32B 边界,它被算在后面那个 32B 里面each unconditional branch is the last micro-op occupying a Decoded ICache Way
)下面来分析 Golden Cove 上这些构造条件是否有变化。参考 I See Dead µops: Leaking Secrets via Intel/AMD Micro-Op Caches 的方法,构造了一个循环,循环体由 4x 15-byte-nop + 1x 4-byte-nop
组成,这样的 5 条指令填满了对齐的 64B。在 Golden Cove 上测试,发现依然可以用满 512 个 Entry,假如 Entry 不能跨越 32B 边界,那么这 5 条指令至少就要 2 个 Entry,但实际上只用了 1 个 Entry。这说明 Golden Cove 上 uOP Cache Entry 的第一条限制中,Entry 不能跨越的边界,从 32B 扩大到了 64B,毕竟每个 Entry 能存的 uOP 数量也增多了,如果继续限制 32B,每个 Entry 就很难存满 8 个 uOP 了。接下来测试对齐的 64B 内可以最多有多少个 entry。
把循环体改成每对齐的 64B 就有四条 jmp 指令,前一条 jmp 指令跳转到后一条 jmp 指令,模拟每 64B 有四个 Entry 的情况:
测试发现这个情况下能达到 512 个 Entry。说明对齐的 64B 内至少可以存 4 个 Entry。
进一步测试,如果每对齐的 64B 有五条 jmp 指令,模拟每 64B 有五个 Entry 的情况:
发现最高的 Entry 数只有 480 左右,不确定是遇到了什么限制,如果对齐的 64B 内不能存 5 个 Entry,也不应该得到 480 这个结果。
如果单独去测试每个对齐的 64B 能缓存多少个 uOP,比如每个对齐的 64B 里由若干条 nop 加最后一条跳到下一个 64B 开头的 jmp 指令组成,会发现当对齐的 64B 内的 uOP 个数从 36 个变成 37 个时,uOP Cache 命中率急剧下降。这意味着,每对齐的 64B 内依然不能存超过 36 个 uOP。这类似于原来的每对齐的 32B 内不能存超过 18 个 uOP,但粒度更粗,实际上更加宽松,比如对齐的 64B 内的前 32B 可以全是 NOP 指令,只要 64B 内总数不超过 36 就可以。但比较奇怪的是,36 uOP per 64B 不能整除 8 uOP/Entry,不像原来的 18 per 32B 可以整除 6 uOP/Entry。
官方信息:
构造一系列的 jmp 指令,使得 jmp 指令分布在不同的 page 上,使得 ITLB 成为瓶颈:
可以看到 256 个 Page 出现了明显的拐点,对应的就是 256 的 L1 ITLB 容量。注意要避免 ICache 和 BTB 的容量成为瓶颈,把 jmp 指令分布在不同的 Cache Line 和 BTB entry 上。
超过 256 个 Page 以后,如图有周期数突然下降后缓慢上升的情况(例如横坐标 288->289、320->321、352->353、384->385 等,以 32 为周期),背后的原理需要进一步分析。
扩大 jmp 指令的距离再测试:
从这个结果来看,L1 ITLB 对于 4K 页应该是 32 Set 8 Way。
官方信息:
为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 4 字节 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:
可以看到 footprint 在 32 KB 之前时可以达到 6 IPC,之后则降到 4 IPC,这里的 32 KB 就对应了 L1 ICache 的容量。
构造不同深度的调用链,测试每次调用花费的平均时间,得到下面的图:
可以看到调用链深度为 20 时性能突然变差,因此 Return Stack 深度为 20。
官方信息:
Golden Cove 架构针对循环做了优化,Loop Stream Detector(简称 LSD)会检测当前指令流是否在一个循环当中,并且循环的 uop 不超出 Instruction Decode Queue(IDQ) 的容量,那么 LSD 会把 Legacy decode pipeline(MITE) 和 Decode stream buffer(DSB) 关掉,不再让 IDQ 的指令出队,而是直接在 IDQ 的内部循环提供指令,这个时候就节省了很多处理器前端的功耗。
为了测试 Instruction Decode Queue 的大小,构造不同大小的循环,循环体是复制若干份的 inc %rsi
指令,最后是 dec + jnz
作为循环结尾,通过 LSD.UOPS 性能计数器统计每次循环有多少个 UOP 来自于 Loop Stream Detector 机制,发现其最大值为 144,说明 Golden Cove 的 Loop Stream Detector 可以识别最多 144 个 uop 的循环。此时每个循环要执行 145 条指令,最后的 dec + jnz
被融合成了一个 uop。
循环体中,如果用 nop
指令来填充,会得到 40 左右的小得多的容量,猜测是进入了低功耗模式。
官方信息:
官方信息:
官方信息:
针对 Load Store 带宽,实测每个周期可以完成:
因为测试环境是 Client 而非 Server,所以 AVX512 被屏蔽了,无法测试 AVX512 的读写带宽。此时最大的读带宽是 96B/cyc,最大的写带宽是 64B/cyc,二者不能同时达到。
官方信息:
经过实际测试,Golden Cove 上如下的情况可以成功转发,对地址 x 的 Store 转发到对地址 y 的 Load 成功时 y-x 的取值范围:
Store\Load | 8b Load | 16b Load | 32b Load | 64b Load |
---|---|---|---|---|
8b Store | {0} | {} | {} | {} |
16b Store | [0,1] | {0} | {} | {} |
32b Store | [0,3] | [0,2] | {0} | {} |
64b Store | [0,7] | [0,6] | [0,4] | {0} |
可以看到,Golden Cove 在 Store 完全包含 Load 的情况下都可以转发,没有额外的对齐要求。但当 Load 和 Store 只有部分重合时,就无法转发,这和官方信息有所冲突。两个连续的 32 位的 Store 和一个 64 位的 Load 重合也不能转发。
比较有意思的是,在 y=x 且不跨越缓存行边界且满足下列要求的情况下,Store Forwarding 不会带来性能损失,就好像 Load Store 访问的是不同的没有 Overlap 的地址一样:
考虑到 y 必须等于 x,也就是地址要一样,并且没有带来性能损失,猜测 Golden Cove 使用了类似 Memory Renaming 的技术来实现这个效果。如果是连续两个对同一个地址的 Store 对一个 Load 的转发,效果和只有一个 Store 是一样的。
除了上述情况以外,Store Forwarding 成功时的延迟在 5 个周期,失败则要 19 个周期。
小结:Golden Cove 的 Store to Load Forwarding:
官方信息:
构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间:
可以看到 48KB 出现了明显的拐点,对应的就是 48KB 的 L1 DCache 容量。第二个拐点在 384KB,对应的是 L1 DTLB 的容量。
官方信息:
用类似测 L1 DCache 的方法测试 L1 DTLB 容量,只不过这次 pointer chasing 链的指针分布在不同的 page 上,使得 DTLB 成为瓶颈:
可以看到 96 Page 出现了明显的拐点,对应的就是 96 的 L1 DTLB 容量。没有超出 L1 DTLB 容量前,Load to use latency 是 5 cycle;超出 L1 DTLB 容量后,Load to use latency 是 12 cycle,说明 L1 DTLB miss 带来了 7 cycle 的损失。
官方信息:
沿用之前测试 L1 DTLB 的方法,把规模扩大到 L2 Unified TLB 的范围,就可以测出来 L2 Unified TLB 的容量,下面是 Golden Cove 上的测试结果:
第一个拐点是 96 个 Page,对应 L1 DTLB,此时 CPI 从 5 提升到 12;第二个拐点是 768,对应 L1 DCache,此时 CPI 从 12 提升到 23;第三个拐点是 1600 左右,而没有到 2048,猜测有 QoS 限制了数据对 L2 TLB 的占用。
官方信息:
构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间:
Intel Golden Cove 的处理器通过 MSR 1A4H 可以配置各个预取器(来源:Software Developers Manual,MSRs Supported by 12th and 13th Generation Intel® Core™ Processor P-core):
在 Golden Cove 上按 64B 的跳步进行访存,测量每次访存的延迟,得到如下结果:
可以观察到在 48KB 之内是 5 cycle latency,在 L2 Cache 范围内是 5-8 cycle latency。
如果通过 wrmsr -p 0 0x1a4 0x8
把 DCU_IP_PREFETCHER_DISABLE
设为 1,即关闭 L1 data cache IP prefetcher,再在 0 号核心上重新跑上面的测试,得到如下结果:
就可以看到 L2 Cache 的范围内的性能退化到了 16 Cycle,和随机 pointer chasing 一样。关闭其他的 prefetcher 则没有这个现象,说明正是 L1 data cache IP prefetcher 实现了针对 L1 的 Stride Prefetcher。
更进一步,参考 Battling the Prefetcher: Exploring Coffee Lake (Part 1) 的方式,研究 Stride 预取器的行为:分配一片内存,把数据从缓存中 flush 掉,再按照特定的访存模式访问,触发预取器,最后测量访问每个缓存行的时间,从而得到预取器预取了哪些缓存行的信息。
首先是只访问一个 cache line 的时候,可以看到,除了已经访问过的 cache line,其他 cache line 都出现了缓存缺失,说明此时预取器没有在工作:
接下来,按照固定的 stride 访问各个缓存行,发现当访问了五个 cache line 时,预取器会比较稳定地预取第六个 cache line:
继续增加访问次数,可以看到预取器总是会预取将要访问的下一个 cache line:
如果通过 wrmsr -p 0 0x1a4 0x8
把 DCU_IP_PREFETCHER_DISABLE
设为 1,即关闭 L1 data cache IP prefetcher,就会观察到上述 Stride 预取的行为消失,不会预取将要访问的下一个 cache line。
把相同的代码放到 Gracemont 上运行,会看到它的预取器会预取将要访问的未来两个 cache line:
可见不同微架构的预取器的策略是不同的。
官方信息:
为了测试 ROB 的大小,设计了一个循环,循环开始和结束是长延迟的 long latency load。中间是若干条 NOP 指令,当 NOP 指令比较少时,循环的时候取决于 load 指令的时间;当 NOP 指令数量过多,填满了 ROB 以后,就会导致 ROB 无法保存循环末尾的 load 指令,性能出现下降。测试结果如下:
当 NOP 数量达到 512 时,性能开始急剧下滑,说明 Golden Cove 的 ROB 大小是 512。
2024-12-27 08:00:00
最近做了不少微架构的评测,其中涉及到了很多的 CPU 微架构的逆向:
因此总结一下 CPU 微架构逆向方法学。
首先定义一下:什么是 CPU 微架构逆向,我认为 CPU 微架构逆向包括两部分含义:
举一个例子,已经知道某 CPU 微架构有一个组相连的 L1 DCache,但不知道它的容量,几路组相连,此时通过微架构逆向的方法,可以得到它的容量,具体是几路组相连,进一步可能把它的 Index 函数也逆向出来。这是第一部分含义。
再举一个例子,已经知道某 CPU 微架构有一个分支预测器,但不知道它使用了什么信息来做预测,可能用了分支的地址,可能用了分支要跳转的目的地址,可能用了分支的方向,这时候通过微架构逆向的方法,对不同的可能性做排除,找到真正的那一个。如果不能排除到只剩一个可能,或者全部可能都被排除掉,说明实际的微架构设计和预期不相符。
第一部分含义,目前已经有大量的成熟的 Microbenchmark(针对微架构 Microarchitecture 设计的 Benchmark,叫做 Microbenchmark)来解决,它们针对常见的微架构设计,实现了对相应设计参数的逆向的 Microbenchmark,可以在很多平台上直接使用。第二部分含义,目前还只能逐个分析,去猜测背后的设计,再根据设计去构造对应该设计的 Microbenchmark。
下面主要来介绍,设计和实现 Microbenchmark 的方法学。
首先要了解 Microbenchmark 的原理,它的核心思路就是,通过构造程序,让某个微架构部件成为瓶颈,接着在想要逆向的设计参数的维度上进行扫描,通过某种指标来反映是否出现了瓶颈,通过瓶颈对应的设计参数,就可以逆向出来设计参数的取值。这一段有点难理解,下面给一个例子:
比如要测试的是 L1 DCache 的容量,那就希望 L1 DCache 的容量变成瓶颈。为了让它成为瓶颈,那就需要不断地访问一片内存,它的大小比 L1 DCache 要更大,让 L1 DCache 无法完整保存下来,出现缓存缺失。为了判断缓存缺失是否出现,可以通过时间或周期,因为缓存缺失肯定会带来性能损失,也可以直接通过缓存缺失的性能计数器。既然要逆向的设计参数是 L1 DCache 的容量,那就在容量上进行一个扫描:在内存中开辟不同大小的数组,比如一个是 32KB,另一个是 64KB,每次测试的时候只访问其中一个数组。每个数组扫描访问若干次,然后统计总时间或周期数或缓存缺失次数。假如实际 L1 DCache 容量介于 32KB 和 64KB 之间,那么应该可以观察到 64KB 数组大小测得的性能相比 32KB 有明显下降。如果把测试粒度变细,每 1KB 设置一个数组大小,最终就可以确定实际的 L1 DCache 容量。
在上面这个例子里,成为瓶颈的微架构部件是 L1 DCache,想要逆向的设计参数是它的容量,反映是否出现瓶颈的指标是性能或缓存缺失次数,构造的程序做的事情是不断地访问一个可变大小的数组,其中数组大小和想要逆向的设计参数是挂钩的。
因此可以总结出 Microbenchmark 设计的几个要素:
比如上面的 L1 DCache 容量的测试上,这几个要素的回答是:
假如要设计一个针对 ROB(ReOrder Buffer) 容量的测试,思考同样的要素:
思考明白这些要素,就可以知道怎么设计出一个 Microbenchmark 了。
原理介绍完了,下面介绍一些常用的方法。
上面提到,为了反映出瓶颈,需要有一个指标,它最好能够精确地反映出瓶颈的发生与否,同时也尽量要减少噪声。能用的指标不多,只有两类:
虽然测时间最简单也最通用,但它会受到频率波动的限制,如果在运行测试的时候,频率剧烈变化(特别是手机平台),引入了大量噪声,就会导致有效信息被淹没在噪声当中。
其中性能计数器是最为精确的,虽然使用起来较为麻烦,但也确实支撑了很多更深入的 CPU 微架构的逆向。希望硬件厂商看到这篇文章,不要为了避免逆向把性能计数器藏起来:因为它对于应用的性能分析真的很有用。具体怎么用性能计数器,可以参考一些现成的 Microbenchmark 框架。
在有异构核的处理器上,为了保证测试的是预期微架构的核心,一般还会配合绑核,绑核在除了 macOS 和 iOS 以外的系统都可以直接指定绑哪个核心,而 macOS 和 iOS 只能通过指定 QoS 来建议调度器调度到 P 核还是 E 核,首先是不能确定是哪个 P 核或哪个 E 核,其次这只是个建议,并非强制。
接下来介绍一些构造瓶颈的一些常见套路:
再介绍一些常见的坑:
实际上,现在已经有很多现成的 Microbenchmark,以及一些记录了 Microbenchmark 的文档:
以及一些用 Microbenchmark 做逆向并公开的网站:
如果你想要去逆向某个微架构的某个部件,但不知道怎么做,不妨在上面这些网站上寻找一下,是不是已经有现成的实现了。
如果你对如何编写这些 Microbenchmark 不感兴趣,也可以试试在自己电脑上运行这些程序,或者直接阅读已有的分析。