汇编与可逆导出#
本文说明 .tpuasm 源码如何变成机器字,以及程序映像如何导出为可重新汇编的源码。所述机制由各目标共用,资源模型以 v4 TC 为例;BCS 的差异见 BCS 目标。
设计动机#
整体设计由以下三个事实决定。
一、同一 bundle 的多个槽共用一部分字段。 TC 的标量槽与向量槽共用 6 个 16 位立即数 lane imm0..imm5(标量指令只能使用其中的 imm0..imm3)、3 个标量读取字段 vs0..vs2,以及若干 TC VREG 读写通路字段。libtpu 的 encoder 按固定的槽顺序,把各槽 protobuf 中存在的字段写进同一个 bundle 缓冲区,后写入的值覆盖先写入的值,显式写入的零也会覆盖。例如 s0 和 s1 的两条 simm 都选择 imm0,分别写入 7 和 21,结果两条指令都读到 21。所以不能先逐槽编码、再把结果拼起来。
二、可见的文本不能决定全部机器位。 以下几种情况中,两份程序映像字节不同,却有相同的规范汇编文本:没有指令读取的立即数 lane 中仍保存着值;两种 selector 产生同一个值,例如 ScalarY 的“常量零”和“零扩展 imm0”在 imm0=0 时结果相同;两条读通路读到编号相同的寄存器;没有被选中的目的字段中仍编码着寄存器号。tests/reproduce_tpu_v4_tc.py 收录了九对这样的程序映像。
三、ISA protobuf 中有些字段不对应机器位。 槽级的 latency、resource_usage 和 bit_width 由 decoder 生成,encoder 不读取它们。删除或改写这些字段后,程序映像不变。
这三点分别对应以下设计:以整个 bundle 为单位求解共享字段;exact 导出用命名的 .encoding 约束保存可见指令以外的编码信息,约束使用硬件名称,不使用 protobuf 字段号;普通源码不必写约束,由确定的规则选出规范编码。第三类辅助字段不进入语法。
汇编器不做以下事情:选择发射槽、重排指令、插入流水线等待、把一条指令展开为多条。{ … } 的边界和 slot: 都由作者决定。
位模型#
assembly_model.py 定义了与目标无关的四个类型:
Field(number, start, width):start和width是该字段在 bundle 机器字中的位置。机器字是一个 Python 整数,v4 TC 为 408 位(51 字节),BCS 为 256 位,v6e TC 为 512 位(64 字节)。number是该字段在 libtpu ISA protobuf 中的字段号,只在与原生后端交换数据时使用。Form:某个槽的一个 oneof 分支,包括槽名、分支字段号branch、操作名和字段表。fixed(predicate)绑定 opcode、谓词和其他固定位。空槽的编码是谓词 NEVER(31);EMPTY_WORD在所有槽的谓词位置都填入 NEVER。Bits(mask, value, immediates, scalars):部分赋值,以及本次赋值实际读取的共享资源集合(立即数 lane 与vs各用一个位集表示)。两个Bits只有在重叠的已赋值位上取值相同时才能合并。Signature:形式、助记符、操作数表达式、固定字段和具名参数。
冲突检测只看位,不看名字。某个形式的 imm0 字段和全局的 imm0 覆盖相同的位,DMA 的若干字段互为别名,这些别名关系都通过 Bits.merge 自动保持一致,不需要额外的别名表。
Form.bind(..., consumed=True) 表示某个操作数确实读取了这个共享字段。TC 按位置识别立即数 lane 和 vs 字段,并把它们记入 immediates / scalars。.encoding 约束写入的值不算读取,因此不计入求解代价。
签名与操作数#
一个 (slot, mnemonic) 可以对应多个签名,一个形式也可以有多个签名。例如,ScalarMove 形式有 smov(寄存器源)和 simm.s32(立即数源)两个签名;DMA 形式借助固定字段 trace 派生出 .trace 变体;vst.8x128 同时对应 VmemStore 和 VmemStoreNoOffset 两个形式。反汇编时,按顺序选取第一个固定字段匹配、且所有操作数都能解码的签名。
操作数表达式定义在 assembly_expressions.py,是带标签的元组:literal、register、number、choice、memory、table、pattern,以及 v4 专用的 dma_address 和 trace。数值表达式还可以限制每个来源字段的有效位宽:v6e 的 20 位立即数字段在双槽拼接时只读取低 16 位,忽略的位由 .encoding 保留;v6e TEC 的配置字由若干字段按位拼成一个数。memory 的地址修饰(sm=、ss=)由签名逐项给出修饰名、字段和操作数,省略修饰时字段取 0。table 把一段固定文本对应到若干字段的取值,用于由单元号决定的目的寄存器组,例如 v6e 的 (mrf1, gmr1, msra1)。pattern 是嵌有若干操作数的固定文本,例如 v6e TEC 的 [hbm4b:{0}+{1}],汇编时忽略空白。choice(field, alternatives) 用一个 selector 字段在几种来源之间选择,所以同一段文本可以产生多个候选。例如 ScalarY 位置上的 7 可以来自内置常量 selector,也可以来自某个立即数 lane 的零扩展。
assembly_operands.py 中的 encode_operand 把文本转换为候选 Bits 列表,decode_operand 从机器字读回文本。二者必须互逆:decode_operand 输出的文本重新编码时,候选中必须有一个与它读取的字段取值相同。新增表达式类型时,两个方向都要实现。
数值由若干立即数字段拼接而成。字段中超出操作数位宽或有效位宽的高位不影响数值,编码时保持未约束,由 exact 导出的 .encoding 保留。v6e 的 ScalarY、VectorY 和 VectorShuffle 双立即数 selector 只读取两个 20 位字段各自的低 16 位,不存在重叠。vtrace 的立即数表达式目前仍按 formatter 的 20 位按位或模型登记,尚未通过设备 trace 记录核对,因此求解器仍需枚举其重叠位的分配,不能直接删掉该分支。
数值带有类型(s32、u32、hex、f32)和位宽。十进制数检查数值范围;非负十六进制数还可以表示该位宽下的原始位型。浮点十进制数以精确有理数计算,一次舍入到 binary32(ties-to-even)。打印时使用 Python 的最短往返表示,NaN 和 −0.0 打印为 f32bits(...)。这样打印出的文本重新解析后必然得到相同的位型,与 libtpu formatter 的显示精度无关。
一条指令的候选集是 fixed(predicate)、签名的固定字段和各操作数候选的笛卡尔积合并结果,去掉冲突和重复的组合。
指令包求解#
TC 的求解在 tc_solver.py 中完成,v4 与 v6e 共用。代际之间的差异由 TcIsa 给出:机器字字节数、EMPTY_WORD、始终由 encoder 写出的位(v6e 的共享立即数与标量操作数)、全局约束、签名表、形式的可写位、形式不能同时占用的槽,以及是否优先使用编号小的共享槽。
先把全局约束(
imm*、vs*、port.*)合并为一个Bits。再把槽级约束(<slot>.<name>)合并到该槽的每个候选上,丢弃冲突的候选。某个槽的候选全部被丢弃时,报错并列出涉及的约束。各槽的候选集按大小升序排列后做深度优先搜索,用分支定界剪枝。比较键依次为:实际读取的立即数 lane 数、实际读取的
vs数、(仅 v6e)所用立即数与标量槽的编号集合、机器字的 little-endian 字节串。v6e 加入编号集合,是为了让规范编码像编译器一样先用imm0、vs0;否则字节串比较会随 selector 编码的位置随意挑选共享槽。计算下界时,任何候选都不写的位固定为EMPTY_WORD中的值。最终机器字在所选形式可写的位上取求解值,没有赋值的位取 0;其余位取
EMPTY_WORD。全局约束只能落在所选形式可写的位上。encoder 只写入存在的槽的字段,不属于任何存在的形式的位无法编码。
比较键与源码中槽的书写顺序、字典遍历顺序和 protobuf 字段顺序都无关,所以结果是确定的。先最小化实际读取的共享资源,一是让规范编码为后续编辑留出空间,二是优先使用不占资源的内置常量。例如 simm.s32 s0, 7 选择常量 selector,不占用 imm0。
与 libtpu 交换字段#
tpu_v4_tc_codec.py 把求解结果写成 libtpu 的 ISA program protobuf:Program 的 field 1 是重复的 bundle;bundle 的 field 槽序号+1 是槽消息;槽消息的 field 1 是谓词,field branch 是操作消息;操作消息的各字段取 Field.number,值从求解出的机器字中读取。
每个形式的所有字段都显式写出,零值也写。这些值全部读自同一个机器字,所以重叠的字段一定携带相同的位,encoder 按什么顺序写入不再影响结果。解码方向相反:从各字段值重建机器字,同时检查重叠字段的取值是否一致。
TC 的机器字不能直接与程序映像字节比较,因为块内编排由 libtpu 处理。isa_data 中登记的位位置如果有误,是在下面这一步被发现的:编码后重新解码,要求机器字和已占用槽集合都与求解结果一致。位置登记错误时,求解器没有发现真实的字段重叠,encoder 的覆盖会使重新解码的机器字与求解结果不同,汇编随即报错。
命名约束#
tpu_v4_tc_constraints.py 定义约束的名称和取值:
全局名称包括
imm0..imm5、vs0..vs2、port.<槽>.<x|y|src|dst>和port.aux.dst。形式中某个字段的位范围如果与全局字段完全相同,就以全局名称公开。槽级名称为
<slot>.<字段>,部分字段使用更易读的别名(如sy→y、read_port→read、base_address→base)。向量结果槽的目的通路写作<vrN>.write。此外每个形式都公开<slot>.opcode和额外的固定字段。selector 字段取符号值,如
lo(imm0)、const(0)、port.va0.x、vs1、s5;其他字段取整数。selector_values是编码值与符号名之间唯一的对照表。导出时遇到表中没有的编码值会报错(“no named encoding”),不会退回为打印裸数字。
约束与操作数候选一样以 Bits 的形式参与搜索。约束不能绕过操作数检查:与可见操作数冲突的约束会导致汇编失败。
导出#
两个目标共用 assembly_export.py 中的 export_program。
exact 模式逐个 bundle 处理:
不带约束求解。结果等于原机器字时,这个 bundle 不需要约束。
否则,从解码得到的形式列出全部命名约束,并确认它们足以重建原机器字。不足时报错“named fields do not cover the original encoding”,说明约束登记表缺少某个字段。这里必须使用解码得到的形式,而不是规范求解选出的形式。原因是同一助记符可能对应多个形式(如
VmemStore与VmemStoreNoOffset),只有<slot>.opcode等约束才能把求解固定到原来的形式上。按名称顺序逐项尝试删除约束,删除后仍能重建原机器字才真正删除,反复进行直到没有约束可删。得到的约束集没有冗余,也就是说其中任何一项都不能单独删除,但不一定是全局最少的约束集。值为零的约束也可能被保留,只要它限制了资源分配。
最后渲染整份清单并调用 assemble_listing,要求结果与原程序映像逐字节相同。这一步使 exact 导出的正确性只依赖公开的汇编器,而不依赖导出过程的中间结果。
canonical 模式不输出约束,重汇编成功即可返回。BCS 还额外核对重汇编后的可见操作数保持不变。canonical 模式不承诺字节相同。
两种模式共同的规则如下:
PC 0 标为
entry,程序内的直接分支目标标为L_xxxx,目标超出程序范围时保留数值。带 NEVER 谓词、却占用了槽的指令在清单中不可见,因此拒绝导出。
来源注释由
render_program插入#注释中,不参与编码。
验证层次#
检查 |
位置 |
证明的内容 |
|---|---|---|
解码 bundle 数、原生往返、逐槽 formatter |
|
decoder 接受了完整映像,解码对象没有丢失机器信息;逐槽 formatter 核对槽名映射(仅 TC)。 |
编码后重新解码的机器字与槽集合等于求解结果 |
|
后端没有改变求解结果,显式约束和所选槽依然成立,位位置登记与 encoder 一致。 |
encoder 输出的字节等于求解出的机器字,重新解码后槽集合不变 |
|
v6e 的机器字就是映像字节,所以直接逐字节比较;槽集合的比较能发现被 encoder 静默丢弃的槽。 |
|
|
导出的源码不依赖其他信息即可恢复原程序映像。 |
这些检查不证明程序的调度正确,也不证明设备执行效果。不能命名的字段、未登记的形式和无法往返的输入都会报错。
维护 ISA 表#
本节说明 v4 TC 的表。v6e 的表由工具从 libtpu 生成,维护方法见 TPU v6e TC 目标。
v4 TC 的表位于 tpu_v4_tc_isa_data.py:
OPCODES:各槽 opcode 的位置;谓词紧接在 opcode 之上,占 5 位。FIELD_LAYOUTS:多个形式共用的字段组,每项为 (名称, protobuf 字段号, 起始位, 位宽)。INSTRUCTION_FORMS:槽、分支号、ISA 名、opcode、字段组编号、助记符、固定操作数骨架和额外固定字段。
字段名、字段号和 oneof 结构来自 libtpu 内嵌的 ISA descriptor;位位置来自 encoder 实际写入的位(通过反汇编 encoder,或只改变一个字段后比较编码差异得到)。两个已支持的 libtpu 版本在这些方面完全一致。tpu_v4_tc_isa.py 按槽族的规则从形式生成签名,操作数顺序的约定(例如部分 ALU 指令 Y 源在前)也写在这里。
未登记的形式#
descriptor 中除 Noop 外有 582 个槽内形式,目前登记了 573 个。以下 9 个未登记:s1 的 ReadDone、WriteDone;vst 的 VmemStoreIndexedNoOffset;misc 的 DelayUntilNotDone、ClearResultFifo、AtomicRemoteWriteSetDone、AtomicRemoteWriteSetDoneInverted、AtomicRemoteAddSetDone、AtomicRemoteAddSetDoneInverted。这几个形式用默认操作数做编码探针时失败,还没有找到合法的非零操作数,但这并不证明它们无法编码。遇到这些形式时,解码报 “unsupported instruction form”。
新增形式的步骤#
在
INSTRUCTION_FORMS中加入形式,必要时新增字段组。确认
tpu_v4_tc_isa.py生成了预期的助记符与操作数;新的 selector 字段需要在tpu_v4_tc_constraints.selector_values中补充符号值。用该形式的若干操作数变体检查:汇编→解码往返,以及 exact 导出能否逐字节恢复(包括只有
.encoding才能区分的变体)。运行 tests/reproduce_tpu_v4_tc.py,用
tools/generate_isa_reference.py重新生成指令索引并检查。