MoreRSS

site iconJiaJe | 杰哥修改

清华大学计算机系博士生。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

JiaJe | 杰哥的 RSS 预览

Android Runtime 解释器的实现探究

2025-03-06 08:00:00

Android Runtime 解释器的实现探究

背景

V8 Ignition 解释器的内部实现探究 中探究了 JavaScript 引擎 V8 的解释器的实现,接下来分析一下 Android Runtime (ART) 的解释器,其原理也是类似的。本博客在 ARM64 Ubuntu 24.04 平台上针对 Android Runtime (ART) 15.0.0 r1 版本进行分析。

Dalvik Bytecode

在分析解释器的代码前,需要先了解一下解释器的输入,也就是它执行的字节码格式是什么。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) 函数可以看到,它分别压栈第零个和第一个局部遍变量(即参数 ab),然后用 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 函数的实现就大不相同了:

  1. add-int/2addr v0, v1: 求寄存器 v1 和寄存器 v0 之和,在这里就对应 ab 两个参数,结果写到 v0 寄存器当中
  2. return v0: 以寄存器 v0 为返回值,结束当前函数

可见 Dalvik Bytecode 采用的是类似 V8 的基于寄存器的字节码,不过没有 V8 的 accumulator

Dalvik Bytecode 的完整列表见 Dalvik bytecode format,它的格式基本上是两个字节为一组,每组里第一个字节代表 Op 类型,第二个字节代表参数,有一些 Op 后面还会带有多组参数。

例如上面的 add-int/2addr vA, vB 指令的编码是:

  1. 第一个字节是 0xb0,表示这是一个 add-int/2addr Op
  2. 第二个字节共 8 位,低 4 位编码了 vA 的寄存器编号 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,可以携带可变个寄存器参数,在编码的时候,格式如下:

  1. 第一个字节 0x6e 表示这是一个 invoke-virtual Op
  2. 第二个字节的高 4 位记录了参数个数
  3. 第三和第四个字节共 16 位,记录了要调用的函数的 index,这个 index 会被拿来索引 DEX 的 method_ids 表
  4. 第五和第六个字节共 16 位,配合第二个字节的低 4 位,最多可以传递 5 个寄存器参数,每个寄存器参数 4 位

因此在上面的代码中,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);

下面结合源码来具体分析这两种解释器的实现。

基于 switch-case 的解释器

目前 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};

继续展开,就得到了想要留下的内容:

const char* const Instruction::kInstructionNames[] = { "nop", "move", // omitted};

回到 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 的解释器 mterp (nterp)

第二个解释器则是基于 token threading 的解释器,它的源码在 runtime/interpreter/mterp 目录下。由于这些代码是用汇编写的,直接写会有很多重复的部分。为了避免重复的代码,目前的解释器 mterp (现在叫 nterp) 用 Python 脚本来生成最终的汇编代码。要生成它,也很简单:

cd runtime/interpreter/mterp./gen_mterp.py mterp_arm64ng.S arm64ng/*.S

这个脚本是平台无关的,例如如果要生成 amd64 平台的汇编,只需要:

cd runtime/interpreter/mterp./gen_mterp.py mterp_x86_64ng.S x86_64ng/*.S

这样就可以看到完整的汇编代码了,后续的分析都会基于这份汇编代码。如果读者对 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。知道这个背景以后,再来分析每条指令做的事情,就很清晰:

  1. lsr w3, wINST, #12:求 wINST 右移动 12 位,得到了 B
  2. ubfx w9, wINST, #8, #4: ubfx 是 Bit Extract 指令,这里的意思是从 wINST 第 8 位开始取 4 位数据出来,也就是 A
  3. GET_VREG w1, w3: 读取寄存器编号为 w3 的值,写到 w1 当中,结合第一条指令,可知此时 w1 等于 B 寄存器的值
  4. GET_VREG w0, w9: 读取寄存器编号为 w9 的值,写到 w0 当中,结合第二条指令,可知此时 w0 等于 A 寄存器的值
  5. FETCH_ADVANCE_INST 1: 把 "PC" 往前移动 1 个单位的距离,也就是两个字节,并读取下一个 Op 到 rINST 当中
  6. add w0, w0, w1: 进行实际的整数加法运算,结果保存在 w0 当中
  7. GET_INST_OPCODE ip: 根据第五条指令读取的下一个 Op 的值 rINST,解析出它的 Opcode
  8. SET_VREG w0, w9: 把整数加法的结果写回到寄存器编号为 w9 的寄存器当中,结合第二条指令,可知写入的是 A 寄存器
  9. GOTO_OPCODE ip: 跳转到下一个 Op 对应的 handler

整体代码还是比较清晰的,只是说把计算 A + B 写入 A 的过程和读取下一个 Op 并跳转的逻辑穿插了起来,手动做了一次寄存器调度。那么这些 GET_REGFETCH_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 解释器的实现,有如下几点相同与不同:

  1. 两者都采用了 token threading 的方法,即在一个 Op 处理完成以后,计算出下一个 Op 的 handler 的地址,跳转过去
  2. V8 的 op handler 是动态生成的(mksnapshot 阶段),长度没有限制,允许生成比较复杂的汇编,但如果汇编比较短(比如 release 模式下),也可以节省一些内存;代价是需要一次额外的对 dispatch table 的访存,来找到 opcode 对应的 handler
  3. mterp 的 op handler 对齐到 128B 边界,带来的好处是不需要访问 dispatch table,直接根据 opcode 计算地址即可,不过由于很多 handler 很短,可能只有十条指令左右,就会浪费了一些内存
  4. V8 没有 handler 长度的限制,所以针对一些常见的 Op 做了优化(Short Star),可以减少一些跳转的开销
  5. V8 在区分 Smi(Small integer) 和对象的时候,做法是在 LSB 上打标记:0 表示 Smi,1 表示对象;mterp 则不同,它给每个虚拟寄存器维护了两个 32 位的值:一个保存在 xFP 指向的数组当中,记录的是它的实际的值,比如 int 的值,或者对象的引用;另一个保存在 xREFS 指向的数组当中,记录的是它引用的对象,如果不是对象,则记录的是 0

除了以上列举的不同的地方以外,其实整体来看是十分类似的,下面是二者实现把整数加载到寄存器(const/4 vA, #+BLdaSmi)的汇编的对比:

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

Lua 解释器

既然已经分析了 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; } // ...}

可以看到,它把 switchcasebreak 替换成了三个宏 vmdispatchvmcasevmbreak。接下来看它可能的定义,第一种情况是编译器不支持 goto * 语法,此时就直接展开:

#define vmdispatch(o) switch(o)#define vmcase(l) case l:#define vmbreak break

如果编译器支持 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, // ...};

和之前写的解释器的不同实现方法对应,就不多阐述了。

参考

V8 Ignition 解释器的内部实现探究

2025-03-01 08:00:00

V8 Ignition 解释器的内部实现探究

背景

V8 是一个很常见的 JavaScript 引擎,运行在很多的设备上,因此想探究一下它内部的部分实现。本博客在 ARM64 Ubuntu 24.04 平台上针对 V8 12.8.374.31 版本进行分析。本博客主要分析了 V8 的 Ignition 解释器的解释执行部分。

编译 V8

首先简单过一下 v8 的源码获取以及编译流程,主要参考了 Checking out the V8 source codeCompiling 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 一共有这些解释器或编译器,按照其优化等级从小到大的顺序:

  1. Ignition: 解释器
  2. SparkPlug: 不优化的快速编译器,追求快的编译速度
  3. Maglev:做优化的编译器,寻求编译速度和编译质量的平衡
  4. TurboFan:做优化的编译器,寻求更好的编译质量

在 JS 的使用场景,不同代码被调用的次数以及对及时性的需求差别很大,为了适应不同的场景,V8 设计了这些解释器和编译器来提升整体的性能:执行次数少的代码,倾向于用更低优化等级的解释器或编译器,追求更低的优化开销;执行次数多的代码,当编译优化时间不再成为瓶颈,则倾向于用更高优化等级的编译器,追求更高的执行性能。

Ignition 解释器

分析样例 JS 代码

首先来观察一下 Ignition 解释器的工作流程。写一段简单的 JS 代码:

function add(a, b) { return a + b;}add(1, 2);

保存为 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 函数,然后以 12 两个参数调用 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 函数:

  1. LdaConstant [0]: 把 Constant Pool 的第 0 项也就是 FixedArray[2] 写入 accumulator 寄存器当中
  2. Star1: 把 accumulator 寄存器的值拷贝到 r1 寄存器,结合上一条字节码,就是设置 r1 = FixedArray[2]
  3. Mov <closure>, r2: 把 <closure> 拷贝到 r2 寄存器,猜测这里的 <closure> 对应的是 add 函数
  4. CallRuntime [DeclareGlobals], r1-r2: 调用运行时的 DeclareGlobals 函数,并传递两个参数,分别是 r1r2;有意思的是,CallRuntime 的参数必须保存在连续的寄存器当中,猜测是为了节省编码空间

至此,add 函数就声明完成了。接下来,就要实现 add(1, 2) 的调用:

  1. LdaGlobal [1], [0]: 把 Constant Pool 的第 1 项也就是 "add" 这个字符串写入 accumulator,最后的 [0] 和 FeedBackVector 有关,目前先忽略
  2. Star1: 把 accumulator 寄存器的值拷贝到 r1 寄存器,结合上一条字节码,就是设置 r1 = "add"
  3. LdaSmi [1]: 把小整数(Small integer, Smi)1 写入 accumulator
  4. Star2: 把 accumulator 寄存器的值拷贝到 r2 寄存器,结合上一条字节码,就是设置 r2 = 1
  5. LdaSmi [2]: 把小整数(Small integer, Smi)2 写入 accumulator
  6. Star3: 把 accumulator 寄存器的值拷贝到 r3 寄存器,结合上一条字节码,就是设置 r3 = 2
  7. 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)
  1. Ldar a1: 把第二个参数 a1 也就是 b 写入 accumulator 寄存器
  2. Add a0, [0]: 求第一个参数 a0 也就是 aaccumulator 寄存器的和,写入到 accumulator 寄存器当中,结合上一条 Bytecode,就是 accumulator = a0 + a1[0] 和 FeedBackVector 有关
  3. Return: 把 accumulator 中的值作为返回值,结束函数调用

简单小结一下 V8 的字节码:

  1. 有若干个局部的寄存器,在操作数中以 rn 的形式出现,n 是寄存器编号
  2. accumulator 局部寄存器,作为部分字节码的隐含输入或输出(Add
  3. 有若干个参数,在操作数中以 an 的形式出现,n 是参数编号
  4. 操作数还可以出现立即数参数 [imm],可能是整数字面量(LdaSmi),可能是下标(LdaConstant),也可能是 FeedBackVector 的 slot

有了字节码以后,接下来观察 Ignition 具体是怎么解释执行这些字节码的。

解释执行

为了实际执行这些字节码,Ignition 的做法是:

  1. 给每种可能的 Opcode 生成一段二进制代码,这段代码会实现这个 Opcode 的功能
  2. 在运行时维护一个 dispatch table,维护了 Opcode 到二进制代码地址的映射关系
  3. 在每段代码的结尾,找到下一个 Opcode 对应的代码的地址,然后跳转过去
  4. 调用函数时,先做一系列的准备,找到函数第一个字节码的 Opcode 对应的代码的地址,跳转过去

由于 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

小结一下:

  1. Ignition 给每种可能的 Opcode 类型生成一段代码
  2. 这段代码会进行一些检查(仅 Debug 模式下),然后在汇编里实现这个字节码的功能
  3. 执行完字节码后,进入 Dispatch 逻辑,寻找下一个字节码对应的代码的地址
  4. 特别地,如果下一个字节码是 Short Star (Star0-Star15),因为它比较简单和常见,就直接执行它,执行完再重新寻找再下一个字节码对应的代码的地址
  5. 这些 Opcode 对应的代码会在 v8 编译过程中通过 mksnapshot 命令一次性生成好,运行时直接复用,不用重新生成
  6. V8 的值的最低位标识了它的类型:0 表示 Smi,1 表示指针,因此在存储 Smi 的时候,寄存器里保存的是实际值的两倍,这样最低位就是 0

参考

导出飞书日历为 iCalendar 格式

2025-02-04 08:00:00

导出飞书日历为 iCalendar 格式

背景

之前用了一段时间飞书日历,想要把日历里的事件导出来备份,但是发现飞书自己的导出功能太弱,因此参考 从飞书导出日历到 Fastmail - Xuanwo's Blog 进行了导出的尝试。

导出方法

上面提到的文章,是通过 CalDAV 的方式进行的日历同步。因此我第一步也是配置飞书的 CalDAV 服务:

  1. 打开飞书客户端
  2. 点击设置
  3. 点击日历
  4. 设置 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 文档,这件事情并不复杂:

  1. 按照 vdirsyncer: pip3 install vdirsyncer
  2. 编辑 ~/.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"
  3. 配置好以后,进行同步:vdirsyncer discover && vdirsyncer sync

此时在 ~/.contacts 目录下,已经能看到很多个 ics 文件了,每个 ics 文件对应了日历中的一个事件。实际上,这些文件就已经是 iCalendar 格式了,只不过每个文件只有一个事件。

为了让一个 .ics 文件包括日历的所有事件,写了一个脚本,实际上就是处理每个 ics 文件,去掉每个文件开头结尾的 BEGIN:VCALENDAREND: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 国区的日历和联系人

除了导出飞书的日历,也可以用类似的方法导出 iCloud 国区的日历:把 url 改成 "https://caldav.icloud.com.cn",在 Apple ID 上生成 App 密码,填入上面的 password,再把邮箱写到 username 即可。

更进一步,也可以导出 iCloud 国区的联系人:

  1. 把配置中 fileext = ".ics" 改成 fileext = ".vcf",因为联系人的格式是 vCard,其文件名后缀是 .vcf
  2. type = "caldav" 改成 type = "carddav",把 url = "https://caldav.icloud.com.cn 改成 url = "https://contacts.icloud.com.cn",表示同步联系人而不是日历

如果既要同步日历,又要同步联系人,或者需要同步来自不同来源的日历,建议把 status 和 storage local 放到不同的目录下,避免出现冲突。

Intel Gracemont 微架构评测

2025-01-12 08:00:00

Intel Gracemont 微架构评测

背景

之前 测试了 Intel Alder Lake 的 P 核微架构,这次就来测一下 Alder Lake 的 E 核微架构 Gracemont。

官方信息

Intel 关于 Gracemont 微架构有这些官方的信息:

现有评测

网上已经有较多针对 Gracemont 微架构的评测和分析,建议阅读:

下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。

Benchmark

Intel Gracemont 的性能测试结果见 SPEC

前端

Fetch

官方信息:

  • 2x 32B/cycle

Gracemont 的 Clustered Decode 架构比较特别,目前没有找到方法去证实它的 Fetch 带宽,后续如果找到了更好的方法,再测这个特性。

Decode

官方信息:

  • 2x 3-wide

Gracemont 的 Clustered Decode 架构比较特别,目前没有找到方法去确认它 2x 3-wide 的 Decode 带宽,后续如果找到了更好的方法,再测这个特性。

L1 ICache

官方信息:

  • 64KB

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 4 字节 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:

可以看到 footprint 在 64 KB 之前时可以达到 5 IPC,之后则降到 3.25 IPC,这里的 64 KB 就对应了 L1 ICache 的容量。

L1 ITLB

官方信息:

  • 64 entries, fully associative

构造一系列的 jmp 指令,使得 jmp 指令分布在不同的 page 上,使得 ITLB 成为瓶颈:

可以看到 64 个 Page 出现了明显的拐点,对应的就是 64 的 L1 ITLB 容量。过了拐点后,每次 jmp 的时间延长到了 16 个周期左右,包括了 L2 TLB 到 L1 ITLB 的 refill 时间。

Return Stack

用之前设计的 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

后端

Rename

官方信息:

  • 5-wide

在先前测试 L1 ICache 容量的时候,观察到的最大的 IPC 就是 5,此时测得的瓶颈在于 Rename 宽度,对应 5-wide Rename。

Execution Units

官方信息:

  • 6 alu ports: 0/1/2/3/30/31
    • P0: ALU/SHIFT
    • P1: ALU/SHIFT/MUL/DIV
    • P2: ALU/SHIFT/MUL/DIV
    • P3: ALU/SHIFT
    • P30: JMP
    • P31: JMP
  • 3 simd ports: 20/21/22
    • P20: SALU/SIMUL/FMUL/FADD/FDIV/AES/SHA
    • P21: SALU/FMUL/FADD/AES
    • P22: SALU
  • 2 load ports: 10/11
  • 2 store address ports: 12/13
  • 2 store data ports: 8/9
  • 2 simd store data ports: 28/29
  • ports 10/11/12/13 shares a reservation station
  • ports 8/9/30/31 shares a reservation station
  • ports 28/29 shares a reservation station
  • ports 20/21/22 shares a reservation station

实测各类指令的吞吐:

  • NOP: 5 IPC
  • ALU: 4 IPC

LSU

官方信息:

  • 2x 16B Load/cycle, 2x 16B Store/cycle
  • Load latency 3-4 cycle

Load Store 带宽

针对 Load Store 带宽,实测每个周期可以完成:

  • 2x 128b Load
  • 2x 128b Load + 2x 128b Store
  • 2x 128b Store
  • 1x 256b Load
  • 1x 256b Load + 1x 256b Store
  • 1x 256b Store

最大的读带宽是 32B/cyc,最大的写带宽是 32B/cyc,二者可以同时达到。

Store to Load Forwarding

官方信息:

  • Loads that forward from stores can do so in the same load to use latency as cache hits forcases where the store's address is known, and the store data is available.

经过实际测试,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:

  • 转发成功时,CPI 比较复杂,有的情况是介于 0.5 到 2 之间,有时候又是介于 2 到 4 之间,有时候是 6
  • 重合但不能转发时,CPI 等于 11,特殊情况下还出现了 28.5

不支持多个 Store 对同一个 Load 的转发。跨缓存行时不能转发。

即使 Load 和 Store 不重合,但在一定情况下,也会出现 CPI 等于 11 的情况,例如:

  • 对地址 3 的 16b Store 转发到地址 0 的 8b Load
  • 对地址 1 的 64b Store 转发到地址 9/10/11 的 64b Load
  • 对地址 0 的 8b Store 转发到地址 1/2/3 的 64b Load
  • 对地址 0 的 8b Store 转发到地址 3 的 16b Load

在以上几种情况下,Load 和 Store 访问范围并不重合,但性能和访问范围重合且转发失败时相同(CPI 等于 11),由此猜测 Gracemont 判断是否重合是以对齐的 4B 为粒度,如果 Load 和 Store 访问了相同的对齐的 4B 块,即使不重合,一定情况下也可能会被当成重合的情况来处理,但由于实际上并没有重合,就没法转发,性能就比较差。

小结:Gracemont 的 Store to Load Forwarding:

  • 1 ld + 1 st: 要求 st 完全包含 ld 且地址相同且不能跨缓存行;特别地,64b Store 到 32b Load 转发允许 y-x=4
  • 1 ld + 2+ st: 不支持

Load to Use Latency

测试不同场景下的 Load to Use Latency:

  • mov 0(%rsi), %rsi: 3 cycle,但在跨越 64B 缓存行边界时退化到 11 cycle
  • mov 8(%rsi), %rsi: 3 cycle
  • mov 0(%rsp, %rsi, 8), %rsi: 4 cycle
  • mov 0(%rsi, %rdx, 8), %rsi: 4 cycle
  • Load to ALU Latency: 4 cycle

L1 DCache

官方信息:

  • 32KB
  • dual ported

L1 DTLB

官方信息:

  • 32 entries, fully associative

用类似测 L1 DCache 的方法测试 L1 DTLB 容量,只不过把 pointer chasing 链的指针分布在不同的 page 上,使得 DTLB 成为瓶颈。奇怪的是,虽然官方信息写的是 32-entry 的 L1 DTLB,但是实测它有 48-entry:

这个观察和 Meteor Lake’s E-Cores: Crestmont Makes Incremental Progress 是一致的,怀疑是 Intel 写错了数据。

L2 TLB

官方信息:

  • 4-way 2048 entries for 4K/2M pages
  • fully associative 8 entries for 1GB pages
  • 4 page walkers

使用类似 L1 DTLB 的方式去测试 L2 TLB,在 2048 附近观察到了拐点:

这个结果和官方数据是吻合的。

L2 Cache

官方信息:

  • 2MB/4MB Shared among 4 cores
  • 64 B/cycle shared among 4 cores
  • 17 cycle latency

ReOrder Buffer

官方信息:

  • 256 entries
  • 8 wide retirement

为了测试 ROB 的大小,设计了一个循环,循环开始和结束是长延迟的 long latency load。中间是若干条 NOP 指令,当 NOP 指令比较少时,循环的时候取决于 load 指令的时间;当 NOP 指令数量过多,填满了 ROB 以后,就会导致 ROB 无法保存循环末尾的 load 指令,性能出现下降。测试结果如下:

当 NOP 数量达到 256 时,性能开始急剧下滑,说明 Golden Cove 的 ROB 大小是 256。

Intel Golden Cove 微架构评测

2025-01-10 08:00:00

Intel Golden Cove 微架构评测

背景

前段时间测试了 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 微架构的评测和分析,建议阅读:

下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。

Benchmark

Intel Golden Cove 的性能测试结果见 SPEC

前端

Fetch

官方信息:

  • Legacy decode pipeline fetch bandwidth is increased from 16 to 32 bytes/cycle

Decode

官方信息:

  • The number of decoders is increased from 4 to 6

DSB/uOP Cache

官方信息:

  • The micro-op cache size is increased to hold 4,000 (注:应该是 4096) micro-ops,
  • and its bandwidth is increased to deliver up to 8 micro-ops per cycle.

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 的构造条件是:

  1. 每个 Entry 能记录的 uOP 个数有上限,最多 6 uOP/Entry
  2. Entry 不能跨越 32B 边界,反过来,一个对齐的 32B 区间只能对应最多 3 个 Entry,结合第一条,就是对齐的 32B 块中不能超过 3*6=18 个 uOP(The Decoded ICache can hold only up to 18 micro-ops per each 32 byte aligned memory chunk);如果指令跨了 32B 边界,它被算在后面那个 32B 里面
  3. 指令需要完整地出现在一个 Entry 中:如果一条指令需要的空间太多,在当前 Entry 的剩余空间内放不下,就需要另起一个 Entry
  4. 无条件跳转(或者被预测为要跳转)的指令会结束一个 Entry(each unconditional branch is the last micro-op occupying a Decoded ICache Way
  5. 比较大的立即数也会占用 uOP 空间,减少了实际能存放的 uOP 数量
  6. 比较复杂的需要微码(Microcoded uops)的指令会占用一整个 Entry

下面来分析 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 的情况:

  1. 第 1 个 jmp 放在 64B 内的 0B 偏移处,跳转到 64B 内 16B 偏移处
  2. 第 2 个 jmp 放在 64B 内的 16B 偏移处,跳转到 64B 内 32B 偏移处
  3. 第 3 个 jmp 放在 64B 内的 32B 偏移处,跳转到 64B 内 48B 偏移处
  4. 第 4 个 jmp 放在 64B 内的 48B 偏移处,跳转到下一个 64B 的开头

测试发现这个情况下能达到 512 个 Entry。说明对齐的 64B 内至少可以存 4 个 Entry。

进一步测试,如果每对齐的 64B 有五条 jmp 指令,模拟每 64B 有五个 Entry 的情况:

  1. 第 1 个 jmp 放在 64B 内的 0B 偏移处,跳转到 64B 内 8B 偏移处
  2. 第 2 个 jmp 放在 64B 内的 8B 偏移处,跳转到 64B 内 16B 偏移处
  3. 第 3 个 jmp 放在 64B 内的 16B 偏移处,跳转到 64B 内 24B 偏移处
  4. 第 4 个 jmp 放在 64B 内的 24B 偏移处,跳转到 64B 内 32B 偏移处
  5. 第 5 个 jmp 放在 64B 内的 32B 偏移处,跳转到下一个 64B 的开头

发现最高的 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。

L1 ITLB

官方信息:

  • the iTLBs is doubled to hold 256 entries for 4-KB pages and 32 entries for 2/4 million pages

构造一系列的 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 指令的距离再测试:

  • 如果每 2/4 个 page 放一条 jmp 指令,容量不变还是 256 个 Page
  • 如果改成每 8 个 page 一条 jmp 指令,容量下降到 32 个 Page
  • 每 16 个 page 一条 jmp,容量下降到 16 个 Page
  • 每 32/64/128 个 page 一条 jmp 指令,容量是 8 个 Page

从这个结果来看,L1 ITLB 对于 4K 页应该是 32 Set 8 Way。

L1 ICache

官方信息:

  • 32KB

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 4 字节 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:

可以看到 footprint 在 32 KB 之前时可以达到 6 IPC,之后则降到 4 IPC,这里的 32 KB 就对应了 L1 ICache 的容量。

Return Stack

构造不同深度的调用链,测试每次调用花费的平均时间,得到下面的图:

可以看到调用链深度为 20 时性能突然变差,因此 Return Stack 深度为 20。

Instruction Decode Queue (IDQ) + Loop Stream Detector (LSD)

官方信息:

  • The IDQ can hold 144 uops per logical processor in single thread mode, or 72 uops per thread when SMT is active.

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 左右的小得多的容量,猜测是进入了低功耗模式。

后端

Rename

官方信息:

  • Rename/allocation width grows from 5 to 6 wide

Execution Units

官方信息:

  • The number of execution ports goes from 10 to 12
  • five LEA units as well as five integer ALUs
  • three-cycle fast adders, with two cycles bypass between back-to-back floating-point ADD operations
  • five alu/simd ports: 0/1/5/6/10
    • P0: ALU/LEA/Shift/JMP/FMA/ALU/Shift/fpDIV
    • P1: ALU/LEA/Mul/iDIV/FMA/ALU/Shift/Shuffle/FADD
    • P5: ALU/LEA/MulHi/FMA512/ALU/AMX/Shuffle/FADD
    • P6: ALU/LEA/Shift/JMP
    • P10: ALU/LEA
  • 3 load ports: 2/3/11
  • 2 store address ports: 7/8
  • 2 store data ports: 4/9

LSU

官方信息:

  • Port 11 provides a third load port with a dedicated address-generation unit
  • Load 64Bx2 or 32Bx3 per cycle
  • Store 64Bx2 or 32Bx3 per cycle
  • The L1 load to use latency is 5 cycles

Load Store 带宽

针对 Load Store 带宽,实测每个周期可以完成:

  • 3x 256b Load
  • 2x 256b Load + 2x 256b Store
  • 1x 256b Load + 2x 256b Store
  • 2x 256b Store

因为测试环境是 Client 而非 Server,所以 AVX512 被屏蔽了,无法测试 AVX512 的读写带宽。此时最大的读带宽是 96B/cyc,最大的写带宽是 64B/cyc,二者不能同时达到。

Store to Load Forwarding

官方信息:

  • Partial store forwarding allowing forwarding data from store to load also when only part of the load was covered by the store (in case the load's offset matches the store's offset)

经过实际测试,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 的地址一样:

  • 8b Store -> 8b Load
  • 32b Store -> 8b Load
  • 64b Store -> 8b Load
  • 16b Store -> 16b Load
  • 32b Store -> 32b Load
  • 64b Store -> 32b Load

考虑到 y 必须等于 x,也就是地址要一样,并且没有带来性能损失,猜测 Golden Cove 使用了类似 Memory Renaming 的技术来实现这个效果。如果是连续两个对同一个地址的 Store 对一个 Load 的转发,效果和只有一个 Store 是一样的。

除了上述情况以外,Store Forwarding 成功时的延迟在 5 个周期,失败则要 19 个周期。

小结:Golden Cove 的 Store to Load Forwarding:

  • 1 ld + 1 st: 要求 st 包含 ld,特别地,地址相同时,性能最好
  • 1 ld + 2+ st: 不支持

L1 DCache

官方信息:

  • 48KB

构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间:

可以看到 48KB 出现了明显的拐点,对应的就是 48KB 的 L1 DCache 容量。第二个拐点在 384KB,对应的是 L1 DTLB 的容量。

L1 DTLB

官方信息:

  • 96-entry 6-way 4-KB-page TLB
  • 32-entry 4-way 2-MB/4-MB-page TLB
  • 8-entry 1-GB-page TLB for loads
  • A 16-entry TLB for stores serves all page sizes

用类似测 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 的损失。

L2 TLB

官方信息:

  • 2,048-entry second level TLB (STLB)
  • 4 page table walkers

沿用之前测试 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 的占用。

L2 Cache

官方信息:

  • 1.25MB(Client)/2MB(Server)
  • 64 bytes/cycle
  • 15 cycle latency

构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间:

  • 第一个拐点在 48KB,对应 L1 DCache 的容量,CPI 从 5 提升到 16
  • 第二个拐点在 384KB,对应 L1 DTLB 的容量,CPI 从 16 提升到 23
  • 第三个拐点在 1280KB,对应 L2 Cache 的容量

Prefetcher

官方信息

Intel Golden Cove 的处理器通过 MSR 1A4H 可以配置各个预取器(来源:Software Developers Manual,MSRs Supported by 12th and 13th Generation Intel® Core™ Processor P-core):

  • MSR_1A4H[0]: the L2 hardware prefetcher, which fetches additional lines of code or data into the L2 cache.
  • MSR_1A4H[1]: the L2 adjacent cache line prefetcher, which fetches the cache line that comprises a cache line pair (128 bytes). 这和 AMD 的 Up/Down Prefetcher 应该是一个意思
  • MSR_1A4H[5]: the L2 Adaptive Multipath Probability (AMP) prefetcher. 这个应该属于 Spatial Prefetcher
  • MSR_1A4H[2]: the L1 data cache prefetcher, which fetches the next cache line into L1 data cache. 这个应该属于 Next Line Prefetcher
  • MSR_1A4H[3]: the L1 data cache IP prefetcher, which uses sequential load history (based on instruction pointer of previous loads) to determine whether to prefetch additional lines.

预取延迟

在 Golden Cove 上按 64B 的跳步进行访存,测量每次访存的延迟,得到如下结果:

可以观察到在 48KB 之内是 5 cycle latency,在 L2 Cache 范围内是 5-8 cycle latency。

如果通过 wrmsr -p 0 0x1a4 0x8DCU_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 0x8DCU_IP_PREFETCHER_DISABLE 设为 1,即关闭 L1 data cache IP prefetcher,就会观察到上述 Stride 预取的行为消失,不会预取将要访问的下一个 cache line。

把相同的代码放到 Gracemont 上运行,会看到它的预取器会预取将要访问的未来两个 cache line:

可见不同微架构的预取器的策略是不同的。

ReOrder Buffer

官方信息:

  • 512-entry reorder buffer
  • 8 wide retirement

为了测试 ROB 的大小,设计了一个循环,循环开始和结束是长延迟的 long latency load。中间是若干条 NOP 指令,当 NOP 指令比较少时,循环的时候取决于 load 指令的时间;当 NOP 指令数量过多,填满了 ROB 以后,就会导致 ROB 无法保存循环末尾的 load 指令,性能出现下降。测试结果如下:

当 NOP 数量达到 512 时,性能开始急剧下滑,说明 Golden Cove 的 ROB 大小是 512。

CPU 微架构逆向方法学

2024-12-27 08:00:00

CPU 微架构逆向方法学

背景

最近做了不少微架构的评测,其中涉及到了很多的 CPU 微架构的逆向:

因此总结一下 CPU 微架构逆向方法学。

定义

首先定义一下:什么是 CPU 微架构逆向,我认为 CPU 微架构逆向包括两部分含义:

  1. 在已经知道某 CPU 微架构采用某种设计,只是不知道其设计参数时,通过逆向,得到它的设计参数
  2. 在不确定某 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 设计的几个要素:

  1. 针对什么微架构部件
  2. 针对该部件的什么设计参数
  3. 反映出现瓶颈的指标是什么
  4. 如何构造程序来导致瓶颈出现
  5. 程序在什么情况下会导致瓶颈出现
  6. 程序的参数如何对应到设计参数上

比如上面的 L1 DCache 容量的测试上,这几个要素的回答是:

  1. 针对什么微架构部件:L1 DCache
  2. 针对该部件的什么设计参数:L1 DCache 的容量
  3. 反映出现瓶颈的指标是什么:时间,周期数,缓存缺失次数
  4. 如何构造程序来导致瓶颈出现:在内存中开辟数组,然后不断地扫描访问
  5. 程序在什么情况下会导致瓶颈出现:数组大小超过 L1 DCache 容量
  6. 程序的参数如何对应到设计参数上:数组的大小对应到 L1 DCache 的容量

假如要设计一个针对 ROB(ReOrder Buffer) 容量的测试,思考同样的要素:

  1. 针对什么微架构部件:ROB
  2. 针对该部件的什么设计参数:ROB 能容纳多少条指令
  3. 反映出现瓶颈的指标是什么:时间,周期数
  4. 如何构造程序来导致瓶颈出现:在 ROB 开头和结尾各放一条长延迟指令,中间填充若干条指令
  5. 程序在什么情况下会导致瓶颈出现:如果指令填充得足够多,导致结尾的长延迟指令不能进入 ROB,那么它无法被预测执行
  6. 程序的参数如何对应到设计参数上:把结尾的长延迟指令阻拦在 ROB 之外时,在 ROB 中的指令数

思考明白这些要素,就可以知道怎么设计出一个 Microbenchmark 了。

原理介绍完了,下面介绍一些常用的方法。

指标的获取

上面提到,为了反映出瓶颈,需要有一个指标,它最好能够精确地反映出瓶颈的发生与否,同时也尽量要减少噪声。能用的指标不多,只有两类:

  1. 时间:最通用,所有平台都可以用,在程序前后各记一次时间,取差
  2. 性能计数器:使用起来比较麻烦,有时需要 root 权限,或者硬件相关信息不公开,又或者硬件就没有实现对应的性能计数器。各平台性能计数器可用情况:
    1. Windows:可用,有现成 API
    2. macOS:可用,有逆向出来的私有框架 API
    3. Linux:可用,有现成 API
    4. iOS:在 iOS 外可以通过 Xcode Instruments 访问所有 PMU,但不方便自动化;在 iOS 内只能通过 PROC_PIDTHREADCOUNTS 获得周期数和指令数
    5. Android:需要 root 或通过 adb shell 使用,比较麻烦,API 和 Linux 一样
    6. HarmonyOS NEXT:没找到方案

虽然测时间最简单也最通用,但它会受到频率波动的限制,如果在运行测试的时候,频率剧烈变化(特别是手机平台),引入了大量噪声,就会导致有效信息被淹没在噪声当中。

其中性能计数器是最为精确的,虽然使用起来较为麻烦,但也确实支撑了很多更深入的 CPU 微架构的逆向。希望硬件厂商看到这篇文章,不要为了避免逆向把性能计数器藏起来:因为它对于应用的性能分析真的很有用。具体怎么用性能计数器,可以参考一些现成的 Microbenchmark 框架。

在有异构核的处理器上,为了保证测试的是预期微架构的核心,一般还会配合绑核,绑核在除了 macOS 和 iOS 以外的系统都可以直接指定绑哪个核心,而 macOS 和 iOS 只能通过指定 QoS 来建议调度器调度到 P 核还是 E 核,首先是不能确定是哪个 P 核或哪个 E 核,其次这只是个建议,并非强制。

套路

接下来介绍一些构造瓶颈的一些常见套路:

  1. 测试容量(比如各级 I/D Cache 和 TLB):构造一个程序,去把容量用满,当容量被用满的时候,就可以观察到性能下降
  2. 测试微架构队列或 Buffer 深度(比如 ROB,寄存器堆,调度队列):在队列开头通过指令堵住队列的出队,接着不断地向队列中入队新的指令,当队列满的时候,不再能够入队新的指令,此时再引入一些原来不会被堵住的指令,现在因为队列被堵住了而进不去,导致性能下降
  3. 测试组相连结构(比如 BTB,Cache 等组相连结构):组相连结构下,每个 Index 内的容量是固定的,通过测试容量,可以得到有多少 Index 被覆盖了,如果通过修改 Index 函数的输入(比如 PC),使得某些 Index 无法被访问到,就可以观察到容量上的减少,并且实际容量也反馈出了还有多少 Index 能够被访问到的信息
  4. 构造 pointer chasing:以 8B(对应 64 位指针)、缓存行大小或页大小为粒度,进行随机打乱,然后把它们用指针串联起来,前一个指针指向的内存中保存后一个指针的地址
  5. 构造长延迟指令:在测试指令队列相关的场景下常用,通常可以用 pointer chasing long latency load 或者一段具有串行依赖的浮点除法或开根指令来实现

再介绍一些常见的坑:

  1. 尽量用汇编来构造测例,C/C++ 编译器可能会带来不期望的行为
  2. 链接器有一些行为可能是需要避免的,例如它可能会修改一些指令
  3. 链接器还可能有一些局限性,例如它不支持巨大的对齐
  4. Linux 内核会做优化,例如 Copy-on-Write 和 Transparent Huge Page

现成 Microbenchmark

实际上,现在已经有很多现成的 Microbenchmark,以及一些记录了 Microbenchmark 的文档:

以及一些用 Microbenchmark 做逆向并公开的网站:

如果你想要去逆向某个微架构的某个部件,但不知道怎么做,不妨在上面这些网站上寻找一下,是不是已经有现成的实现了。

如果你对如何编写这些 Microbenchmark 不感兴趣,也可以试试在自己电脑上运行这些程序,或者直接阅读已有的分析。