Skip to content

LloOpcode 到 Proto

本页中的每个偏移、值和地址都从 libtpu-0.0.40-cp314 wheel 中的 libtpu.so 按字节精确读取(BuildID md5 89edbbe81c5b328a958fe628a9f2207d)。其他版本会有所不同。

摘要

LLO 有两个必须区分开的 opcode 索引空间。内存中的 LloOpcode 枚举是编译器操作的、从零开始的紧凑 461 个值集合(0x000..0x1CC)。LloOpcodeProto 枚举是序列化到 TpuProgram/LloModuleProto 内部 LloInstructionProto 消息中的线格式形式:它是从 1 开始的,取值最高到 499,并且有 38 个保留/移除的空洞。一对纯转换函数连接两者:序列化时使用 LloOpcodeToProto,解码时使用 ProtoToLloOpcode。它们不是恒等映射,不是常量偏移,也不是单调的。

这种不对称性源自 protobuf 常见原因:线格式兼容性。新的 opcode 会按其家族的自然位置插入到内存中的 LloOpcode(这样家族保持连续,范围测试仍然有效),但会追加LloOpcodeProto 枚举末尾并且永不重新编号(这样旧的序列化程序仍可解码)。经过多代硅片后,这会产生一个打乱且带空洞的映射。若重实现者假设 proto_value == llo_valueproto_value == llo_value + 1,则会错误解码每个包含近期新增 opcode 的程序。

本页按字节精确记录这两个转换器:正向是一个扁平查找表;反向是一个 499 分支的 switch,它会在保留值上陷入,并在越界时 fatal。随后展示转换器的调用位置(LloSerializer::LloInstructionToProto 在 proto 字段偏移 +52 的写入,以及 xprof 成本估算的读取),并列出重实现者需要的结构事实。

正向LloOpcodeToProto(LloOpcode) @ 0x14420020(26 B 函数体)— 表查找
正向表int32[] @ VMA 0x344cb4c(GOT 相对;461 个活跃条目)
反向ProtoToLloOpcode(LloOpcodeProto) @ 0x14420040 — 499 分支 switch
反向越界absl::LogMessageFatal "Invalid LloOpcodeProto: "(第 1953 行)
反向保留分支38 个 proto 值 → __debugbreak()(UD2)
序列化器LloSerializer::LloInstructionToProto @ 0x1441c000(opcode → proto 字段 +52)
Proto 消息xla::jellyfish::LloInstructionProtoGetClassData @ 0x1d1a0d40
值空间LloOpcode0..460(紧凑);LloOpcodeProto1..499(38 个空洞)
置信度CONFIRMED(字节锚定),除非行内另有说明

正向映射 — LloOpcodeToProto

序列化会用一次表读取把内存中的 opcode 转成它的线格式值。反编译出的函数体只有一条语句:对 _GLOBAL_OFFSET_TABLE_ 做一次 lea,加上表基址,然后一次带索引的 movret(26 B 代码,用 int3 填充到 0x14420040 的下一个函数):

c
// xla::jellyfish::LloOpcodeToProto @ 0x14420020 (decompiled, exact)
uint32_t LloOpcodeToProto(LloOpcode op) {
    return forward_table[op];        // *((uint32_t*)&_GLOBAL_OFFSET_TABLE_ + op - 130144141)
}
```text

反编译器以 GOT 相对形式呈现表地址:`(uint32_t*)&_GLOBAL_OFFSET_TABLE_ + op - 130144141`。在 `_GLOBAL_OFFSET_TABLE_` 位于 VMA `0x224c2980` 时,它解析为基址 `0x224c2980 + 4*(-130144141) = 0x344cb4c`,也就是说正向表是在 VMA `0x344cb4c` 处的扁平 `int32` 数组,直接由 `LloOpcode` 值索引。这里**没有边界检查**,调用方被信任会传入有效的 `0`..`460` 值;实际也总是如此,因为该值来自编译器自己的 opcode 字段。

读取全部 461 个条目可以确认其结构:每个条目映射到不同的 proto 值,产生的值集合是 `{1, 2, …, 499} \ {38 gaps}`(正好 461 个不同值),并且映射在尾部**不是单调的**。早期连续段是干净的 `+1` 位移(内存 0 → proto 1,内存 1 → proto 2,……),但后期新增的 opcode 打破了它:

| 内存中 `LloOpcode` | 名称 | → Proto 值 |
|---|---|---:|
| `0x000` (0) `kEvent` | 基础 | 1 |
| `0x001` (1) `kVectorReadIar` | 基础 | 2 |
| `0x086` (134) `kScalarAddressCalculation` | 基础 | 158 |
| `0x14E` (334) `kVectorEupResult` | 基础 | 371 |
| `0x151` (337) `kVectorCmemResult` | 基础 | 374 |
| `0x17B` (379) `kScalarMove` | 基础 | 417 |
| `0x084` (132) `kVectorTraceArg` | **后期** | 498 |
| `0x197` (407) `kVectorMaskPackCompressedEven` | **后期** | 499 |
| `0x1CC` (460) `kBarnaCoreVectorStore` | 基础 | 497 |

> **怪异点 — proto 498499 是最新的线格式槽位,却映射到*较低*的内存 opcode。** `kVectorTraceArg`(内存中 `0x084`)和 `kVectorMaskPackCompressedEven`(内存中 `0x197`)被插入到 `LloOpcode` 枚举中部,但追加到了 `LloOpcodeProto` 末尾。因此最高的两个线格式值会解码为位于内存枚举深处的 opcode。该映射是带空洞的排列,不是位移;移植实际表,不要移植公式。

---

## 反向映射 — `ProtoToLloOpcode`

解码是逆映射,但实现为显式 `switch` 而不是第二张表,因为它还必须拒绝保留/移除的线格式值:

```c
// xla::jellyfish::ProtoToLloOpcode @ 0x14420040 (decompiled, structure)
LloOpcode ProtoToLloOpcode(int proto_value) {
    switch (proto_value) {
        case 1:   return (LloOpcode)0;        // dense head: proto N -> llo N-1
        case 2:   return (LloOpcode)1;
        // ...
        case 51:  return (LloOpcode)50;
        case 52: case 53: case 90: /* ...38 values... */ case 376:
            __builtin_trap();                  // UD2 — reserved/removed wire value
        case 54:  return (LloOpcode)51;        // numbering resumes, shifted past the gap
        // ...
        case 498: return (LloOpcode)132;       // late slots map back to low in-memory opcodes
        case 499: return (LloOpcode)407;
        default:                               // proto value never assigned at all
            LogMessageFatal("Invalid LloOpcodeProto: ", proto_value);   // line 1953
    }
}

有三种不同结果,重实现者必须全部复现:

  1. 活跃值 → 对应的紧凑 LloOpcode。开头部分(proto 1..51)是干净的 proto − 1;每经过一个空洞,输出索引继续前进而不跳过,因此偏移会增大。
  2. 保留/移除值__builtin_trap()(UD2)。这些是曾经分配给 opcode 后被删除、或被显式保留的线格式值。它们是 proto 枚举范围内的有效整数,但绝不应该出现在格式良好的程序中;命中它们是硬中止,不是可恢复错误。
  3. 越界值 → 在源代码第 1953 行用消息 "Invalid LloOpcodeProto: <value>" 调用 absl::log_internal::LogMessageFatal。这会捕获完全位于枚举之外的 proto 值(负数、零或高于声明最大值)。

38 个保留 proto 值

这些线格式值会被反向 switch 路由到 __builtin_trap()。它们正好是正向表输出中缺失的值,这交叉验证了两个转换器一致:

text
52  53  90  91  92  93  94  95  96  97  98  99 100 101 102 103 104
109 110 111 132 134 140 141 163 176 177 179 183 184
301 312 313 314 315 316 317 376
```text

这些聚类很有信息量:`90`..`104` 块和 `109`..`111` 块对应一代被重做的 conversion/EUP opcode,而 `312`..`317` 对应一个被移除的 transcendental 区段。若重实现把这些值映射到*任何东西*,哪怕是 no-op opcode,也会默默接受真实解码器会拒绝的畸形程序。

> **陷阱 — 对保留值执行 `__builtin_trap()` 是特性,不是缺失 case。** 反编译器显示 38 个 `case` 标签落入同一个 `__debugbreak()`。很容易把它读成“未处理”并添加 fallthrough;这样做会移除完整性检查。proto 枚举正是为了让过时或损坏的程序在解码时被捕获、而不是被错误执行,才保留这些槽位。保留该陷入。

---

## 转换器的调用位置

### 序列化:`LloInstructionToProto`

opcode 字段在 `LloSerializer::LloInstructionToProto`(@ `0x1441c000`)开头附近写入;该函数从内存中的 `LloInstruction` 构建一个 `LloInstructionProto`:

```c
// LloSerializer::LloInstructionToProto @ 0x1441c000 (decompiled, opcode path)
LloInstructionProto LloInstructionToProto(const LloInstruction *inst) {
    LloInstructionProto proto;                       // proto2::Arena-allocated
    proto.field_at(+0) = inst->result_index;         // line ~108
    proto.set_has_bit(0x04);                          // field-presence bit for the index
    proto.field_at(+52) = LloOpcodeToProto(inst->opcode);   // <- forward convert, line 110
    proto.set_has_bit(0x08);                          // field-presence bit for the opcode
    // ... operands (LloInstructionProto_LloOperand), predication, FIFO ids ...
    return proto;
}

opcode 占据 LloInstructionProto 布局(_impl_ 块)中字节偏移 +52 处的 4 字节字段,并被设置为 proto 值,而不是内存值。匹配的字段存在性(has)位是存在性字中的 0x08。函数其余部分会把操作数降低成 LloInstructionProto_LloOperand 子消息(GetClassData @ 0x1d1a0800)、predication 以及运行时结果 FIFO id;但 opcode 是唯一一个经过值转换的字段。

解码:读取字段 +52,做反向转换

反向转换器从成本估算路径到达,该路径直接消费一个序列化LloInstructionProto

c
// xprof::AddInstructionCost(LloInstructionProto, LloModuleProto, ...) @ 0xf23c340
LloOpcode op = (uint16_t)ProtoToLloOpcode(*(uint32_t*)(proto + 52));   // line 67
// ...later, compares the wire opcode against a known constant by re-encoding:
if (wire_opcode == LloOpcodeToProto(219 /* kScalarConstantU32 */)) ...  // line 148
```text

这是规范解码模式:读取 proto 偏移 +52 处的 4 字节 opcode 字段,经过 `ProtoToLloOpcode` 恢复内存中的 `LloOpcode`,然后分发。第二次调用(`LloOpcodeToProto(219)`)是对偶技巧:热路径不会解码每条指令,而是把一个已知的内存 opcode 重新编码为它的线格式值,并与原始 proto 字段比较,从而避免逐指令 `switch`。

> **注意 — proto opcode 字段只是普通 `int32`,只在转换时校验。** proto 消息中没有东西保护 opcode 字段;完整性检查完全位于 `ProtoToLloOpcode` 中(38 个陷入 + fatal default)。若解码器读取字段 +52 后不经过 `ProtoToLloOpcode` 就把它用作数组索引,就会跳过保留值陷入和越界 fatal,并用一个未经校验、且编号方式不同的值索引它的 `LloOpcode` 键控表(`opcode_info`、成本网格)。

---

## 结构摘要

重实现者需要的事实汇总如下:

| 属性 | `LloOpcode`(内存中) | `LloOpcodeProto`(线格式) |
|---|---|---|
| 基数 |0 开始 |1 开始 |
| 紧凑? | 是(`0`..`460`,无空洞) | 否(`1`..`499` 中有 38 个保留空洞) |
| 活跃值数量 | 461 | 461 |
| 最大值 | 460(`0x1CC`) | 499 |
| 编号策略 | 按家族插入(范围测试保持有效) | 仅追加(线格式稳定) |
| 边界检查 | `LloOpcodeName` `>= 0x1CD` → `ud1`;元数据表 `< 0x1CE` | 反向转换器:陷入(保留)/ fatal(越界) |
| 存储 | C++ scoped enum | `LloInstructionProto` +52 处的 proto `int32` 字段 |

> **怪异点 — 两个转换器在各自有效域上都是*全函数*,否则会中止。** `LloOpcodeToProto` 没有越界分支,因为它的输入总是来自编译器自己的 opcode 字段;`ProtoToLloOpcode` 则完整覆盖 `1`..`499`,带显式的保留值陷入和 fatal default。这里没有“未知 opcode”返回路径:任一侧出现无效值都会终止进程。若重实现返回错误码,就会把失败模式从边界处崩溃改变为下游静默损坏。

---

## 交叉引用

- [LloOpcode 枚举](llo-opcode-enum.md) — 这些转换器映射往返的内存中 461 值枚举,按家族分组;它的追加与插入编号方式解释了为什么 proto 498/499 会解码到较低的内存 opcode。
- [LLO Opcode 表(附录)](../appendix/llo-opcode-table.md) — 正向表索引的完整 461 行 value→name→slot 转储。
- [ISA 概览](overview.md) — 把 `LloOpcodeProto` 放入两级 LLO-IR/VLIW-bundle 编码拆分中;注意那里“462”的数字统计的是线格式枚举声明的符号(包含 value-0 哨兵),而这里是 461 个活跃可映射值。
- [TpuProgram 序列化](../compiler/tpu-program-serialization.md) — `LloInstructionProto` opcode 字段所在的外围 `LloModuleProto` / `TpuProgram` 线格式。