LloOpcode 到 Proto
本页中的每个偏移、值和地址都从
libtpu-0.0.40-cp314wheel 中的libtpu.so按字节精确读取(BuildID md589edbbe81c5b328a958fe628a9f2207d)。其他版本会有所不同。
摘要
LLO 有两个必须区分开的 opcode 索引空间。内存中的 LloOpcode 枚举是编译器操作的、从零开始的紧凑 461 个值集合(0x000..0x1CC)。LloOpcodeProto 枚举是序列化到 TpuProgram/LloModuleProto 内部 LloInstructionProto 消息中的线格式形式:它是从 1 开始的,取值最高到 499,并且有 38 个保留/移除的空洞。一对纯转换函数连接两者:序列化时使用 LloOpcodeToProto,解码时使用 ProtoToLloOpcode。它们不是恒等映射,不是常量偏移,也不是单调的。
这种不对称性源自 protobuf 常见原因:线格式兼容性。新的 opcode 会按其家族的自然位置插入到内存中的 LloOpcode(这样家族保持连续,范围测试仍然有效),但会追加到 LloOpcodeProto 枚举末尾并且永不重新编号(这样旧的序列化程序仍可解码)。经过多代硅片后,这会产生一个打乱且带空洞的映射。若重实现者假设 proto_value == llo_value 或 proto_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::LloInstructionProto(GetClassData @ 0x1d1a0d40) |
| 值空间 | LloOpcode ∈ 0..460(紧凑);LloOpcodeProto ∈ 1..499(38 个空洞) |
| 置信度 | CONFIRMED(字节锚定),除非行内另有说明 |
正向映射 — LloOpcodeToProto
序列化会用一次表读取把内存中的 opcode 转成它的线格式值。反编译出的函数体只有一条语句:对 _GLOBAL_OFFSET_TABLE_ 做一次 lea,加上表基址,然后一次带索引的 mov 和 ret(26 B 代码,用 int3 填充到 0x14420040 的下一个函数):
// 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 498 和 499 是最新的线格式槽位,却映射到*较低*的内存 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
}
}有三种不同结果,重实现者必须全部复现:
- 活跃值 → 对应的紧凑
LloOpcode。开头部分(proto 1..51)是干净的proto − 1;每经过一个空洞,输出索引继续前进而不跳过,因此偏移会增大。 - 保留/移除值 →
__builtin_trap()(UD2)。这些是曾经分配给 opcode 后被删除、或被显式保留的线格式值。它们是 proto 枚举范围内的有效整数,但绝不应该出现在格式良好的程序中;命中它们是硬中止,不是可恢复错误。 - 越界值 → 在源代码第 1953 行用消息
"Invalid LloOpcodeProto: <value>"调用absl::log_internal::LogMessageFatal。这会捕获完全位于枚举之外的 proto 值(负数、零或高于声明最大值)。
38 个保留 proto 值
这些线格式值会被反向 switch 路由到 __builtin_trap()。它们正好是正向表输出中缺失的值,这交叉验证了两个转换器一致:
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:
// 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` 线格式。