大数跨境

openTPU 模拟器:概念、状态、执行与数值

openTPU 模拟器:概念、状态、执行与数值 ai算法芯片与系统
2026-10-27
4
导读:opentpu模拟器分冻结配置、切片状态、机器调度三层,逐指令执行,主循环管控制流、分发与循环栈。数值三路:浮点、整数乘加、位级转换各依其定义。不模拟时序,结果与硬件逐位一致,快百千倍,可作硬件金标准

 

目录

• 架构总览
• 概念
    1. 模拟器在软件栈里的位置
    2. 与 RTL 的关系
    3. Config:冻结的配置
    4. Slice:切片状态
    5. Machine:多切片调度
    6. 数值来源:三分法
• 状态与内存
    7. 状态容器总览
    8. DRAM
    9. TMEM
    10. ACT RAM
    11. IMEM、循环栈与寄存器
    12. 循环栈
• 执行
    13. 执行主循环
    14. 指令分发
    15. MM 的整数数据流
    16. QACT 与 QST
    17. VOP 与 hazard
    18. DSTEP 与 STREAM
    19. 机器级调度
• 数值
    20. fp32 语义与位精确
    21. 复合函数
    22. 量化
    23. 归约
• 速度与一致性
    24. 为什么比 RTL 快
    25. 与 RTL 的一致性
• 总结


架构总览

isasim.py 是 openTPU 的位精确 ISA 模拟器。同一个 Program、同一个 DRAM 镜像,它逐指令执行,产生的 TMEM/DRAM 必须与 RTL 逐位一致。它不做时序,只保证结果;因此比 RTL 快 100–1000 倍,同时充当 RTL 的金标准。

主逻辑是一条固定的链:

Program + DRAM 镜像 → 逐指令执行 → 维护 Slice 状态 → 数值按来源分三条路径 → TMEM/DRAM 与 RTL 逐位一致。

  • • Program 是编译器输出的指令列表,是所有执行路径的输入。
  • • Config 是冻结的硬件参数,编译器和模拟器共享,保证两边行为一致。
  • • Machine 是顶层调度器,持有多个 Slice 按轮转执行。
  • • Slice 是一个切片的全部状态容器:DRAM / TMEM / ACT / 寄存器 / PC / 循环栈。
  • • 执行主循环 逐指令推进:step 处理控制流,execute 大分发,advance 处理循环栈。
  • • 数值按来源分三条路径:浮点运算走 fp32.py,整数乘加在 numpy int64/float64 中完成,位级转换与比较走 isa.py。
  • • 最终产出 TMEM/DRAM,与 RTL 逐位一致。

系统切成三层容器:

层
容器
职责
配置层 Config
(frozen dataclass)
冻结的硬件参数,编译器和模拟器共享
状态层 Slice 一个切片的全部状态:DRAM/TMEM/ACT/寄存器/PC/循环栈
调度层 Machine 多切片轮转、BAR/GATHER/WAITW/HALT CHAIN

一致性靠三层保证:整数域靠累加器足够宽,浮点域靠不重排,舍入靠 IEEE 754 RNE。


概念

1. 模拟器在软件栈里的位置

  • • kernel 被编译成 Program。
  • • Program 同时喂给 ISA 模拟器和 RTL 仿真。
  • • 两者在浮点运算上都调用 fp32.py 作为语义来源。
  • • 最后对比两者的 TMEM/DRAM,必须逐位一致。

模拟器有三类角色:

角色
作用
功能验证 快速验证算法正确性
金标准 作为 RTL 的参考
交叉验证 与 RTL 对比逐位一致

编译器关心*"指令怎么组织",模拟器关心"指令怎么执行"*。

2. 与 RTL 的关系

  • • 同一个 Program 喂给两条路径。
  • • ISA 模拟器执行后得到 TMEM/DRAM。
  • • RTL 仿真执行后得到 TMEM/DRAM。
  • • 逐位对比两者的状态,必须一致。
特性
ISA 模拟器
RTL 仿真
抽象层次 逐指令 逐周期
时间 无时序 有时序
速度 快 100–1000 倍 慢
用途 功能验证 时序验证

一致性的数学基础:两者实现相同的数值语义——浮点运算上 ISA 模拟器调用 fp32.py,RTL 用 otpu_fp.sv,tb_fp.sv 用向量测试验证两者一致;整数乘加上 ISA 模拟器用 int64 精确累加,RTL 用 48 位累加器,两者都不舍入。

3. Config:冻结的配置

Config 是 @dataclass(frozen=True),字段包括 S(切片数)、D(MXU 深度)、MCOLS(MXU 列数)、ACT_BLOCKS、TMEM_WORDS、DRAM_BYTES、IMEM_WORDS、LANES(VPU 通道数)、PAIR、DSTEP、STREAM、ACT_ROWS、SEQ_WC。

冻结的原因:编译器追踪时读 Config,模拟器执行时也读 Config,两者必须一致。如果可变,编译器按 TMEM_WORDS = 64K 分配,模拟器按 32K 执行,就会越界。

三个变体:

变体
用途
design_config 仿真用,2 切片
board_config 板级,从 target 读
Config 默认 1 切片

4. Slice:切片状态

  • • dram 是字节数组,通过 view 转成 32 位字或 fp32。
  • • tmem 是 32 位字数组,通过 view 转成 fp32。
  • • act 是 int8 数组,二维(行 × 块×D)。
  • • ascale 和 adoff 是 ACT RAM 的 scale 记录。
  • • R 是 16 个 32 位寄存器。
  • • pc 是程序计数器。

存储用 numpy 的原因:向量化批量操作快、view 转类型、切片批量读写、广播逐元素运算快。

5. Machine:多切片调度

  • • 每个切片尽可能多地执行。
  • • 所有切片 halted 就结束。
  • • 所有切片 waiting 就处理集合操作。
  • • 有切片 polling 就调用 host hook。

多切片的一致性靠:

机制
作用
BAR 所有切片到达后继续
GATHER 跨切片广播
WAITW 等待主机写入

Machine.run 的轮转时序

  • • 参与者:Machine 是 调度器,Slice 是 执行单元,host hook 处理 WAITW 的主机写入。
  • • 主循环:每个切片 尽可能多地 执行 step。
  • • 退出条件:所有切片 halted 结束;所有 live 切片 waiting 处理集合操作;存在 polling 且无进展 调用 host hook。
  • • 异常路径:host hook 之后仍无进展,抛 SimError。

6. 数值来源:三分法

  • • fp32.py:浮点运算。VOP 的 ADD/SUB/MUL/MAX/MIN/EXP2/RECIP/RSQRT/ABS/COPY/FILL/EXP2SUB/LOG2/ARGMAX、RSUM/RMAX/RSSQ/RDOT、OUTER;STREAM 的标量运算与归约;DSTEP;QACT/QST 的 quantize_mx;MM 的 epilogue、ACC、ASCALE、RMAX。这些是 IEEE-754 binary32 加 FTZ、RNE、canonical NaN,必须逐位对齐 RTL 的 FP32 单元。
  • • numpy int64/float64:整数乘加。MM 的 int8 乘加用 np.einsum(float64 中间,因为 int8 乘加可能超过 int32,float64 在   以内精确),astype(np.int64) 得精确整数;块缩放 B = m << sh 是纯整数移位。结果精确,不需要舍入;与 RTL 48 位累加器逐位一致,靠的是*"累加器足够宽"*。
  • • isa.py:位级转换与条件比较。RLD 的 f2i 把 fp32 位转 int,WAITW 的 waitw_holds 比较两个 32 位字,NVFP4 的 E4M3 解码直接从字节取指数与尾数。这些是 ISA 的定义,与浮点舍入无关。

准确的说法是:浮点语义由 fp32.py 定义;整数乘加的精确性由 int64 保证;位级转换与条件比较由 isa.py 定义。三者合起来才构成模拟器的数值基础。

fp32.py 内部按四层组织:

  • • 基本运算是复合函数的构建块。
  • • 复合函数用基本运算的固定序列。
  • • 量化用基本运算的 FTZ 和 RNE。
  • • 归约用基本运算的从左到右累加。

关键函数:f32(x) 转 float32、bits(x) 取 uint32 位、from_bits(b) 从位转 float32、ftz(x) flush-to-zero、_canon(x) NaN 规范化、_run(fn, *args) 包装。


状态与内存

7. 状态容器总览

  • • Machine 持有 Config 和多个 Slice。
  • • 每个 Slice 有独立的 DRAM、TMEM、ACT、寄存器。
  • • Config 是共享的,不可变。

模拟器里没有"内存模型"这一层抽象,而是把每种存储直接映射到 numpy 数组 或 Python 容器 上:

存储
模拟容器
dtype / 结构
容量字段
DRAM Slice.dram np.uint8
 一维数组
DRAM_BYTES
TMEM Slice.tmem np.uint32
 一维数组
TMEM_WORDS
ACT RAM act
 / ascale / adoff
int8
 / float32 / uint8 三数组
act_rows × ACT_BLOCKS × D
IMEM Slice.prog list[Instr] IMEM_WORDS // 8
 条指令
循环栈 Slice.stack list[list[int]]
至多 4 层
寄存器 Slice.R list[int]
16 个
  • • DRAM 和 TMEM 是 numpy 数组 + view,逐位与 RTL 对齐。
  • • ACT RAM 是 三个独立 numpy 数组,分别存量化值、行 scale、子块 offset。
  • • IMEM 不是字节镜像,而是 指令对象列表;因为指令是编译产物,不是运行时可变的存储。
  • • 循环栈与寄存器 是 Python 容器,属于执行状态,不是"存储器"。

8. DRAM


   
   
   
   
    
   
   
   
   self.dram = np.zeros(cfg.DRAM_BYTES, dtype=np.uint8)

@property

def
 m32(self) -> np.ndarray:
    return
 self.dram.view(np.uint32)
  • • 一维字节数组,大小由 Config.DRAM_BYTES 决定。
  • • dtype 是 uint8:DRAM 在 RTL 里是字节寻址的。
  • • m32 是 DRAM 的 32 位字视图,零拷贝。
  • • 读写 fp32 用 m32[idx].view(np.float32):uint32 再 view 成 float32,仍然零拷贝。
  • • 字节序是 numpy 默认的小端,与 RTL 的 DRAM 字节序一致。

DRAM 有 四种访问模式:

模式
典型指令
实现
批量切片
LD / ST
m32[wi] = tmem[...]
,一次搬 n 个字
零拷贝视图
MM 的 weight rows
np.lib.stride_tricks.as_strided(...)
直接字节索引
MM 的 scale rows
self.dram[ba]
,逐字节读
fp32 视图
DSTEP / STREAM
m32[wi].view(np.float32)

批量切片(LD):


   
   
   
   
    
   
   
   
   if op == I.LD:
    d = (self.reg(ins.ra) + w[0]) & 0xFFFFFFFF
    t = (self.reg(ins.rb) + w[1]) & 0xFFFFFFFF
    n = w[2]
    wi = self._widx(d + 4 * np.arange(n))
    self
.tmem[self._tidx(t + np.arange(n))] = self.m32[wi]
  • • wi 是 字索引数组,由 _widx 算出并做检查。
  • • 右侧 m32[wi] 一次读出 n 个字,左侧 tmem[...] 一次写入 n 个字。
  • • 整个搬运用 numpy 的 fancy indexing 完成,不逐字循环。

零拷贝视图(MM 的 weight rows):


   
   
   
   
    
   
   
   
   wv = np.lib.stride_tricks.as_strided(self.dram[sa:], (N, KB, bpb),
                                     (rs, bpb, 1))               # [N, KB, bpb], no copy
  • • as_strided 把 dram[sa:] 按 (N, KB, bpb) 解释,strides 是 (rs, bpb, 1)。
  • • 不拷贝:wv 与 dram 共享同一块内存,MM 的 einsum 直接读。
  • • rs 是 行 stride(N 行之间隔多少字节),bpb 是 每块的字节数(int8 是 D,4-bit 是 D/2)。
  • • 这一层与 RTL 的 streamed rows 语义一致:RTL 按 (N, KB, bpb) 流式读,模拟器按同样的 strides 读。

直接字节索引(MM 的 scale rows):


   
   
   
   
    
   
   
   
   ba = rows[:, None] + 4 + kk[None, :]
dw = np.repeat(self.dram[ba].astype(np.int64)[:, :, None], G, axis=2)
  • • self.dram[ba] 按字节索引读,因为 scale rows 里存的是 int8 dw 和 4-bit multiplier。
  • • 这一层 不做 fp32 转换,纯整数。

fp32 视图(DSTEP):


   
   
   
   
    
   
   
   
   wi = self._widx(d + 4 * np.arange(rows * cols))
S = self.m32[wi].view(np.float32).reshape(rows, cols)
...
self
.m32[wi] = F.f32(S).reshape(-1).view(np.uint32)
  • • 读:m32[wi].view(np.float32),把 uint32 视图再 view 成 fp32。
  • • 写:F.f32(S).reshape(-1).view(np.uint32),先把值规整到 fp32,再 view 回 uint32 写回。

地址检查:


   
   
   
   
    
   
   
   
   def _widx(self, byte_addr):
    a = np.asarray(byte_addr, dtype=np.int64)
    if
 np.any(a % 4) or np.any(a < 0) or np.any(a + 4 > self.cfg.DRAM_BYTES):
        raise
 SimError(f"slice {self.sid}: bad DRAM word address")
    return
 a // 4
  • • 对齐:addr % 4 == 0,因为 DRAM 是 字寻址 的。
  • • 范围:0 <= addr + 4 <= DRAM_BYTES,防止越界。
  • • 返回值:字索引数组,供 m32[wi] 使用。

各指令的 DRAM 访问方式:

指令
访问 DRAM 的方式
LD
批量切片 m32[wi],搬 n 个字到 TMEM
ST
批量切片 m32[wi] = tmem[...],搬 n 个字到 DRAM
MM
零拷贝视图 as_strided(weight rows)、字节索引 dram[ba](scale rows)
QST
字节赋值 dram[baddr] = q.view(np.uint8),写 int8 与 scale 记录
DSTEP
fp32 视图 m32[wi].view(np.float32),读写状态矩阵
STREAM
批量切片 m32[didx[lo:hi]](fp32)或 tput(TMEM 目标)
WAITW
单字 m32[_widx(addr)],做 waitw_holds 比较
HALT CHAIN
批量切片 m32[_widx(addr + 4*np.arange(8n))],读 8n 个字解码成指令

与 RTL 的对应:mem/otpu_dram.sv 与 mem/otpu_native_dram.sv 是字节寻址的 DRAM;ISA 模拟器用 uint8 数组、小端字节序、字对齐检查,与 RTL 的 DRAM 端口一致。

9. TMEM


   
   
   
   
    
   
   
   
   self.tmem = np.zeros(cfg.TMEM_WORDS, dtype=np.uint32)

def
 tget(self, idx):
    return
 self.tmem[self._tidx(np.asarray(idx, dtype=np.int64))].view(np.float32)

def
 tput(self, idx, vals):
    self
.tmem[self._tidx(np.asarray(idx, dtype=np.int64))] = F.f32(vals).view(np.uint32)
  • • 一维 32 位字数组,大小由 Config.TMEM_WORDS 决定。
  • • tget 读出 32 位字后 view 成 fp32。
  • • tput 把值规整到 fp32 后 view 回 uint32 写入。
  • • 两者都走 _tidx,做范围检查。

   
   
   
   
    
   
   
   
   def _tidx(self, idx):
    if
 np.any(idx < 0) or np.any(idx >= self.cfg.TMEM_WORDS):
        raise
 SimError(f"slice {self.sid}: TMEM index out of range")
    return
 idx
  • • 范围:0 <= idx < TMEM_WORDS。
  • • 无对齐要求:TMEM 是字寻址,任何字索引都合法。
指令
访问 TMEM 的方式
LD
批量切片 tmem[_tidx(...)] = m32[wi],写 n 个字
ST
批量切片 m32[wi] = tmem[_tidx(...)],读 n 个字
RLD
单字 self.tmem[_tidx(addr)],转 int
VOP tget(aidx)
 读二维索引数组,tput(didx, ...) 写
MM tget(idx)
 / tput(idx, y) 读写输出 tile
QACT self.tget(idx)
 读源行
QST self.tget(idx)
 读源行
DSTEP tget(qk + ...)
 / tput(o + ...)
STREAM tget(vec + ...)
 / tput(didx[lo:hi], ...)
GATHER s.tmem[s._tidx(idx)]
 读写

与 RTL 的对应:mem/otpu_tmem.sv 是 32 位字寻址的 TMEM;ISA 模拟器用 uint32 数组、字寻址、tget/tput 的 fp32 视图,与 RTL 的 FP32 单元一致。

10. ACT RAM


   
   
   
   
    
   
   
   
   self.act = np.zeros((cfg.act_rows, cfg.ACT_BLOCKS * cfg.D), dtype=np.int8)
self
.ascale = np.zeros((cfg.act_rows, cfg.ACT_BLOCKS), dtype=np.float32)
self
.adoff = np.zeros((cfg.act_rows, cfg.ACT_BLOCKS, Q.NSUB), dtype=np.uint8)
数组
类型
形状
含义
act int8 (act_rows, ACT_BLOCKS × D) 量化后的 int8 值
ascale float32 (act_rows, ACT_BLOCKS) 每个 (row, block) 的行 scale
adoff uint8 (act_rows, ACT_BLOCKS, NSUB) 每个 (row, block, subblock) 的 offset
  • • act 的列索引是 block * D + element:每块 D 字节连续排列。
  • • ascale 的列索引是 block:每个 (row, block) 一个 fp32 scale。
  • • adoff 的第三维是 NSUB:每个 (row, block) 有 4 个子块 offset。

QACT 写入:


   
   
   
   
    
   
   
   
   q, s, d = self._quant_rows(...)
for
 r0 in ((0, rows) if dup else (0,)):
    self
.act[r0:r0 + rows, ab * cfg.D:(ab + KB) * cfg.D] = q
    self
.ascale[r0:r0 + rows, ab:ab + KB] = s[:, None]
    self
.adoff[r0:r0 + rows, ab:ab + KB] = d.reshape(rows, KB, Q.NSUB)
  • • q, s, d 由 fp32.quantize_mx 产生。
  • • 三份数组同时切片赋值:act 写值,ascale 写行 scale,adoff 写子块 offset。
  • • dup 标志:为真时同样写两次(行 r 与行 r + rows),对应 PAIR 的列复用。

MM 读取:


   
   
   
   
    
   
   
   
   act = self.act[:R, ab * D:(ab + KB) * D].reshape(R, KB, D)
act = act[rsel, kk[None, :]].astype(np.float64)                 # [M, KB, D]
da = self.adoff[:R, ab:ab + KB][rsel, kk[None, :]].astype(np.int64)   # [M, KB, 4]
sa_rows = self.ascale[:R, ab:ab + KB]
  • • act 按 (R, KB, D) reshape,按 (M, KB) 索引取行。
  • • adoff 按 (M, KB, NSUB) 索引取子块 offset。
  • • ascale 按 (R, KB) 索引取行 scale。
  • • rsel 处理 PAIR:偶数块从行 j 读,奇数块从行 j + M 读。

与 RTL 的对应:mem/otpu_actram.sv 也是 三份存储:数据、行 scale、子块 offset;ISA 模拟器用三个独立数组,布局与 RTL 一致,保证 MM 的读取路径与 RTL 逐位一致。

11. IMEM、循环栈与寄存器

IMEM 不是字节数组,而是 指令对象列表:


   
   
   
   
    
   
   
   
   self.prog = program  # list[I.Instr]
  • • 每条指令 8 个 32 位字,但模拟器直接存 Instr 对象,因为指令是编译产物,不是运行时可变的存储。
  • • 长度检查:len(p) > cfg.IMEM_WORDS // 8 抛 SimError。
  • • HALT CHAIN 加载:从 DRAM 读 8n 个字(m32[wi] 批量切片),解码成 Instr 对象,替换 prog。

   
   
   
   
    
   
   
   
   def _chain(self, s):
    addr, n = s.chain
    words = s.m32[s._widx(addr + 4 * np.arange(8 * n))]
    s.prog = [I.Instr.decode(words[8 * k:8 * k + 8]) for k in range(n)]
    s.R, s.pc, s.stack, s.halted, s.waiting = _regs(self.args), 0, [], False, None
    s.chain, s.polling = None, None
    self
.chains += 1
  • • 从 DRAM 读 8n 个字(m32[wi] 批量切片)。
  • • 解码成 Instr 对象,替换 prog。
  • • 重置 pc / R / stack。
  • • 与 RTL 的 IMEM 加载路径一致:IMEM 是从 DRAM 加载的。

寄存器是 list[int],16 个 32 位整数,R0 恒为 0:


   
   
   
   
    
   
   
   
   self.R = [0] * 16

def
 reg(self, r):
    return
 0 if r == 0 else self.R[r]

循环栈是 list[list[int]],每个元素 [起始 PC, 结束 PC, 剩余迭代次数],至多 4 层:


   
   
   
   
    
   
   
   
   self.stack = []  # list[list[int]]
  • • 至多 4 层:LOOP 时检查 len(self.stack) >= 4,超出就抛 SimError。
  • • 与 RTL 的寄存器是时序逻辑、循环栈是计数器对应,但模拟器用 Python 容器,因为它们是 执行状态,不是"存储器"。

12. 循环栈

  • • LOOP 指令压栈,记录起始、结束、计数。
  • • 执行循环体。
  • • advance 检查是否到循环体末尾。
  • • 计数 > 1 就跳回循环头。
  • • 计数 = 1 就弹栈,继续。

LOOP 时检查:body >= 1、嵌套循环的 end 不重复、栈深度 < 4。


执行

13. 执行主循环

Slice.step() 处理六种控制流:

操作码
处理
HALT 停机
BAR / GATHER 进入 waiting
WAITW poll 或 polling
LOOP 压栈或跳过
其他 execute + advance

   
   
   
   
    
   
   
   
   def step(self):
    ins = self.prog[self.pc]
    op = ins.op
    self
.icount += 1
    if
 op == I.HALT:
        self
.halted = True
        if
 ins.flags & I.F_CHAIN:
            self
.chain = (self.reg(ins.ra), self.reg(ins.rb))
        return

    if
 op in (I.BAR, I.GATHER):
        self
.waiting = ins
        return

    if
 op == I.WAITW:
        if
 self.poll(ins):
            self
.advance()
        else
:
            self
.polling = ins
        return

    if
 op == I.LOOP:
        ...
    self
.execute(ins)
    self
.advance()
  • • icount 每指令加 1。
  • • HALT 停机,F_CHAIN 时记录链地址。
  • • BAR / GATHER 进入 waiting。
  • • WAITW poll 成立就前进,否则 polling。
  • • LOOP 处理循环栈。
  • • 其他指令走 execute,然后 advance。

advance() 的逻辑:


   
   
   
   
    
   
   
   
   def advance(self):
    pc = self.pc
    if
 self.stack and self.stack[-1][1] == pc:
        top = self.stack[-1]
        if
 top[2] > 1:
            top[2] -= 1
            self
.pc = top[0]
            return

        self
.stack.pop()
    self
.pc = pc + 1
  • • 如果当前 PC 是某个循环的末尾,且循环还没结束,就跳回循环头。
  • • 否则弹出循环栈,PC 加 1。

LOOP 的处理:


   
   
   
   
    
   
   
   
   if op == I.LOOP:
    count = (self.reg(ins.ra) + ins.w[1]) & 0xFFFFFFFF
    body = ins.w[0]
    if
 body < 1:
        raise
 SimError("LOOP with empty body")
    end = self.pc + body
    if
 any(e[1] == end for e in self.stack):
        raise
 SimError("nested loop bodies end on the same instruction")
    if
 count == 0:
        self
.pc += 1 + body
        return

    if
 len(self.stack) >= 4:
        raise
 SimError("loop stack overflow")
    self
.stack.append([self.pc + 1, end, count])
    self
.pc += 1
    return
  • • count 是 R[ra] + w1,ra 为 0 时就是 w1。
  • • body 是循环体长度。
  • • end = pc + body 是循环体最后一条指令的 PC。
  • • count == 0直接跳过循环体。
  • • 否则压栈,pc += 1进入循环体。

控制流是纯 Python 整数运算。

14. 指令分发

execute() 是一个大分发:

操作码
处理函数
数值来源
NOP 直接返回 —
LI / ADDI 寄存器整数运算 Python int
RLD 读 TMEM、isa.f2i、可乘 isa.py + Python int
LD / ST numpy 切片搬运 numpy 赋值
DSTEP _dstep fp32.py
STREAM _stream fp32.py
MM _mm 整数乘加 + fp32.py epilogue
QACT _qact fp32.py 量化 + numpy 写入
QST _qst fp32.py 量化 + numpy 写入
VOP _vop fp32.py

寄存器操作示例:


   
   
   
   
    
   
   
   
   if op == I.LI:
    if
 ins.rd:
        self
.R[ins.rd] = w[0]
    return

if
 op == I.ADDI:
    if
 ins.rd:
        self
.R[ins.rd] = (self.reg(ins.ra) + w[0]) & 0xFFFFFFFF
    return

if
 op == I.RLD:
    v = int(self.tmem[self._tidx((self.reg(ins.ra) + w[0]) & 0xFFFFFFFF)])
    v = v if ins.flags & I.F_RAW else I.f2i(v)
    if
 ins.flags & I.F_MUL:
        v = (v * ((self.reg(ins.rb) + w[1]) & 0xFFFFFFFF)) & 0xFFFFFFFF
    if
 ins.rd:
        self
.R[ins.rd] = v
    return
  • • LI 写立即数。
  • • ADDI 加立即数。
  • • RLD 从 TMEM 读,可选 F_RAW / F_MUL / F_BOUND。
  • • I.f2i 是 isa.py 里的纯 Python 实现。

LD/ST 用 numpy 切片一次搬运:


   
   
   
   
    
   
   
   
   if op == I.LD:
    d = (self.reg(ins.ra) + w[0]) & 0xFFFFFFFF
    t = (self.reg(ins.rb) + w[1]) & 0xFFFFFFFF
    n = w[2]
    wi = self._widx(d + 4 * np.arange(n))
    self
.tmem[self._tidx(t + np.arange(n))] = self.m32[wi]
    return
  • • d 是 DRAM 字节地址。
  • • t 是 TMEM 字地址。
  • • n 是字数。
  • • wi 是 DRAM 的字索引数组。
  • • 一次切片完成整个搬运。

15. MM 的整数数据流

MM 的内部分阶段时序

  • • 前段(读 scale、读 weight、展开、einsum、块缩放)全部整数,结果精确。
  • • epilogue(i2f、乘 Sw、乘 Sa、乘 2^-W)进入 fp32.py。
  • • ACC/ASCALE 若开启,先读旧的 acc,与 epilogue 的结果相加。
  • • RMAX 若开启,最后用 F.chain_max 写行最大值。
  • • MM 指令先读三个地址。
  • • 读 scale rows:_mx_scales 直接读 DRAM,NVFP4 的 E4M3 解码是位运算。
  • • 读 weight rows:int8 用 view(np.int8),4-bit 用 quant.py 的 unpack4 与 DEC 查表。
  • • 按 group 算整数和:np.einsum,float64 中间。
  • • sh = W - dw - da:纯整数。
  • • B = m << sh:纯整数移位。
  • • acc += B * c:int64 累加。
  • • epiloguei2f * Sw * Sa * 2^-W:走 fp32.py。
  • • 写 TMEM。
  • • 如果 RMAX,F.chain_max 走 fp32.py。

整数乘加公式(  行、  列、  块、每块   个 group、每组   个元素):

块缩放因子:

其中  。累加:

分块 einsum 实现:


   
   
   
   
    
   
   
   
   acc = np.zeros((M, N), np.int64)
step = max(1, self.MM_CHUNK // max(1, M * KB * G))
for
 n0 in range(0, N, step):
    n1 = min(N, n0 + step)
    c = np.einsum("jkgi,nkgi->jnkg", act.reshape(M, KB, G, D // G),
                  wel[n0:n1].reshape(n1 - n0, KB, G, D // G).astype(np.float64),
                  optimize=True).astype(np.int64)
    sh = W - dw[None, n0:n1] - da[:, None]
    B = np.where(sh >= 0, mw[None, n0:n1] << np.maximum(sh, 0), 0)
    acc[:, n0:n1] = (B * c).sum(axis=(2, 3))
  • • MM_CHUNK = 1 << 22 限制临时数组大小。
  • • einsum 用 float64 作为中间类型,因为 int8 乘加可能超过 int32,float64 在   以内精确。
  • • astype(np.int64) 转回精确整数。
  • • B = m << sh 是纯整数移位。
  • • sh < 0 的组直接丢掉。
  • • 这一段是整数乘加。

最后 epilogue 才进入 fp32.py:


   
   
   
   
    
   
   
   
   y = F.mul(F.mul(F.i2f(acc), sw[None, :]),
          F.mul(self.ascale[:M, ab][:, None], np.float32(2.0 ** -W)))
if
 ins.flags & I.F_ASCALE:
    alpha = self.tget(ssa + np.arange(M))[:, None]
    y = F.add(F.mul(self.tget(idx), alpha), y)
elif
 accf:
    y = F.add(self.tget(idx), y)
  • • F.i2f、F.mul、F.add 是 fp32.py。
  • • self.tget 是 numpy view。
  • • 累加器宽度:RTL 48 位( ),ISA int64( ),实际数据不会溢出。

16. QACT 与 QST

QACT 把 TMEM 行量化到 ACT RAM:


   
   
   
   
    
   
   
   
   def _qact(self, ins):
    src = (self.reg(ins.ra) + w[0]) & 0xFFFFFFFF
    rows, ab, KB = w[1] & 0xFF, (w[1] >> 8) & 0xFF, w[1] >> 16
    q, s, d = self._quant_rows(src, rows, KB, srs, ...)
    for
 r0 in ((0, rows) if dup else (0,)):
        self
.act[r0:r0 + rows, ...] = q
        self
.ascale[r0:r0 + rows, ...] = s[:, None]
        self
.adoff[r0:r0 + rows, ...] = d.reshape(rows, KB, Q.NSUB)
  • • 从 TMEM 读源。
  • • 量化成 int8 + scale + offset,走 fp32.py 的 quantize_mx。
  • • 写入 ACT RAM 的三个数组,numpy 赋值。

QST 把 TMEM 行量化到 DRAM:


   
   
   
   
    
   
   
   
   def _qst(self, ins):
    q, s, d = self._quant_rows(src, rows, KB, srs, cfg.D, row_mode)
    ne = KB * cfg.D // 2 if half else KB * cfg.D
    baddr = dst + np.arange(rows)[:, None] * drs + np.arange(ne)[None, :] * es
    self
.dram[baddr] = q[:, :ne].view(np.uint8)
    self
.dram[sa] = sbytes
  • • 量化成 int8 + scale + offset,走 fp32.py。
  • • 数据写到 DRAM。
  • • scale 记录写到 DRAM。

量化在硬件上是两遍扫描(第一遍求 amax,第二遍按 scale 量化),模拟器上一次算完。

17. VOP 与 hazard

_vop 按函数号分发,全部走 fp32.py:


   
   
   
   
    
   
   
   
   ops = {I.V_ADD: lambda: F.add(A, B), I.V_SUB: lambda: F.sub(A, B),
       I.V_RSUB: lambda: F.sub(B, A), I.V_MUL: lambda: F.mul(A, B),
       I.V_MAX: lambda: F.fmax(A, B), I.V_MIN: lambda: F.fmin(A, B),
       I.V_COPY: lambda: F.ftz(A), I.V_EXP2: lambda: F.exp2(A),
       I.V_RECIP: lambda: F.recip(A), I.V_RSQRT: lambda: F.rsqrt(A),
       I.V_ABS: lambda: F.fabs(A), I.V_FILL: lambda: F.ftz(B),
       I.V_EXP2SUB: lambda: F.exp2(F.sub(A, B)), I.V_LOG2: lambda: F.log2(A)}
  • • 逐元素运算用 F.add / F.sub / F.mul / F.fmax / F.fmin。
  • • COPY / FILL 用 F.ftz。
  • • EXP2 / RECIP / RSQRT / LOG2 用复合函数。
  • • EXP2SUB 用 F.exp2(F.sub(A, B))。

REDUCE 用 F.interleaved_sum、F.rdot、F.chain_max;ARGMAX 用 F._key、F.chain_max、F.i2f;OUTER 用 F.outer。

_check_hazard 检查 read-after-write hazard:


   
   
   
   
    
   
   
   
   def _check_hazard(self, writes, reads, reduce_cols=0, wpos=None):
    n = len(writes)
    pos = np.arange(n) if wpos is None else np.asarray(wpos, np.int64)
    if
 reduce_cols:
        pos = (pos // reduce_cols + 1) * reduce_cols - 1
    writes = np.asarray(writes, np.int64)
    order = np.argsort(writes, kind="stable")
    uw, first = np.unique(writes[order], return_index=True)
    first_pos = pos[order][first]
    for
 rd in reads:
        rd = np.asarray(rd, np.int64)
        loc = np.minimum(np.searchsorted(uw, rd), len(uw) - 1)
        hit = uw[loc] == rd
        late = hit & (first_pos[loc] < np.arange(len(rd)))
        if
 late.any():
            v = int(rd[np.argmax(late)])
            raise
 SimError(f"VOP read-after-write hazard at TMEM {v}")
  • • 对每个写地址,记录最早写位置。
  • • 对每个读地址,二分查找。
  • • 写位置比读位置晚就是 hazard。

hazard 存在的条件: 。目的是保证软件不会写出 RTL 无法正确执行的程序——RTL 的 VPU 按元素顺序处理,依赖乱序行为会出错,模拟器提前发现。hazard 检查是纯整数运算。

18. DSTEP 与 STREAM

DSTEP 是 Gated DeltaNet 的硬件实现,全程走 fp32.py:


   
   
   
   
    
   
   
   
   def _dstep(self, ins):
    q = self.tget(qk + np.arange(cols))[None, :]
    k = self.tget(qk + cols + np.arange(cols))[None, :]
    vv = self.tget(v + np.arange(rows))
    e = self.tget(np.array([g]))
    beta = self.tget(np.array([g + gs]))
    S = self.m32[wi].view(np.float32).reshape(rows, cols)
    kv = F.rdot(S, k)
    dd = F.mul(F.sub(vv, F.mul(kv, e)), beta)
    S = F.outer(S, e[None, :], dd[:, None], k)
    oo = F.rdot(S, q)
    self
.m32[wi] = F.f32(S).reshape(-1).view(np.uint32)
    self
.tput(o + np.arange(rows), oo)
  • • 读 q、k、v、e、beta。
  • • 计算 kv = rdot(S, k)。
  • • 计算 d = (v - kv*e)*beta。
  • • 计算 S = outer(S, e, d, k)。
  • • 计算 o = rdot(S, q)。
  • • 写回 S 和 o。
  • • 输入先读:q、k、v、e、beta、S 都在写之前读出。
  • • 行级语义:每一行 S[r] 的 kv、d、S[r] 更新、o[r] 是 行内顺序,行与行之间互不依赖。

STREAM 是更通用的流式引擎:从 TMEM 读 descriptor(isa.StreamDesc.decode),标量运算全部走 fp32.py:


   
   
   
   
    
   
   
   
   regs[dst_] = {
    I.SC_ADD: F.add, I.SC_SUB: F.sub, I.SC_MUL: F.mul, I.SC_MAX: F.fmax,
    I.SC_MIN: F.fmin, I.SC_MOV: lambda p, q: F.ftz(p),
    I.SC_RSQRT: lambda p, q: F.rsqrt(p), I.SC_RECIP: lambda p, q: F.recip(p),
    I.SC_EXP2: lambda p, q: F.exp2(p), I.SC_LOG2: lambda p, q: F.log2(p),
    I.SC_EXP2SUB: lambda p, q: F.exp2(F.sub(p, q))}[op](x, y)
  • • ADD / SUB / MUL / MAX / MIN 用逐元素函数。
  • • MOV 用 F.ftz。
  • • RSQRT / RECIP / EXP2 / LOG2 / EXP2SUB 用复合函数。
  • • U_FMMA、U_MUL、U_ADD 也是 F.outer、F.mul、F.add;REDUCE 是 F.rdot、F.chain_max。

19. 机器级调度

  • • Running 是主循环。
  • • Halted 表示所有切片停机。
  • • Collective 表示所有 live 切片都 waiting。
  • • Polling 表示某切片 WAITW 未成立。
  • • HostHook 是无进展时调用 host。
  • • Timeout 是重试后仍无进展。

WAITW 与 host hook 的轮询时序

  • • 一旦所有能跑的切片都 polling 而没有任何进展,就调用 host。
  • • host 完成外部动作后重试 poll。
  • • 仍未成立就 WAITW 超时。

GATHER 的跨切片同步:


   
   
   
   
    
   
   
   
   def _gather(self):
    sl = self.slices
    key = []
    for
 s in sl:
        w = s.waiting.w
        key.append(((s.reg(s.waiting.rb) + w[1]) & 0xFFFFFFFF, w[2], w[4], w[5]))
    if
 any(k != key[0] for k in key):
        raise
 SimError("GATHER: slices disagree")
    vals = [s.tmem[s._tidx(...)].copy() for s in sl]
    for
 s in sl:
        for
 k, v in enumerate(vals):
            ...
            s.tmem[s._tidx(idx)] = v
  • • 所有切片都进入 waiting 后才处理。
  • • 先检查参数一致,防止切片走叉。
  • • 从每个切片读数据,再广播写回到每个切片。
  • • 处理完毕后统一 advance。

数值

20. fp32 语义与位精确

fp32.py 只覆盖浮点运算。基本运算用 numpy float32,加 FTZ 和 NaN 规范化:


   
   
   
   
    
   
   
   
   def _run(fn, *args):
    with
 np.errstate(over="ignore", under="ignore", invalid="ignore"):
        return
 _canon(ftz(fn(*[ftz(a) for a in args])))

def
 add(a, b):
    return
 _run(lambda x, y: (x + y).astype(np.float32), a, b)
  • • _run 三步:ftz 输入 → 算 → ftz + canon 输出。
  • • add 调用 _run,对操作数先做 FTZ。
  • • _canon 把 NaN 统一成 0x7FC00000。

FTZ:


   
   
   
   
    
   
   
   
   def ftz(x):
    x = f32(x)
    if
 not ((x.view(np.uint32) & np.uint32(0x7F800000)) == 0).any():
        return
 x
    den = (np.abs(x) < MIN_NORMAL) & (x != 0)
    if
 den.any():
        x = np.where(den, np.copysign(F32(0), x), x).astype(np.float32)
    return
 x
  • • 先检查是否有 denormal。
  • • 如果有,替换成带符号零。
  • • 快速路径:没有 denormal 直接返回。

NaN 规范化:


   
   
   
   
    
   
   
   
   def _canon(x):
    x = f32(x)
    nan = np.isnan(x)
    if
 nan.any():
        x = np.where(nan, from_bits(np.uint32(0x7FC00000)), x).astype(np.float32)
    return
 x
  • • 检查是否有 NaN。
  • • 如果有,替换成 0x7FC00000。
  • • 避免 numpy 平台差异。

比较用 total order:

得到总序 。

不在这部分的是:整数乘加(_mm 的 einsum 与块缩放)、位级转换(isa.f2i、isa.waitw_holds)、NVFP4 的 E4M3 解码、4-bit 的 unpack 与查表、LD/ST 的搬运。

21. 复合函数

函数
固定序列
exp2 范围缩减 → 7 阶 Horner → 指数调整
log2 范围缩减 → 8 阶 Horner → 加指数
recip magic 初值 → 2 次 Newton → 修正步
rsqrt magic 初值 → 2 次 Newton → 修正步

recip 实现:


   
   
   
   
    
   
   
   
   def recip(x):
    x = ftz(x)
    xb = bits(x)
    ab = xb & np.uint32(0x7FFFFFFF)
    zero = ab == 0
    big = ab >= np.uint32(0x7E800000)
    a = from_bits(np.uint32(127 << 23) | (ab & np.uint32(0x7FFFFF)))
    y = from_bits(np.uint32(RECIP_MAGIC) - bits(a)).copy()
    for
 _ in range(2):
        y = mul(y, sub(F32(2), mul(a, y)))
    y = add(mul(y, sub(F32(1), mul(a, y))), y)
    ...
  • • 处理 zero 和 big 边界。
  • • magic number 生成初值。
  • • 两次 Newton 迭代。
  • • 一次修正步。
  • • 调整指数。

exp2 实现:


   
   
   
   
    
   
   
   
   def exp2(x):
    x = _canon(ftz(x))
    lo, hi = gt(F32(-126), x), ~gt(F32(128), x)
    xf = np.where(~lo & ~hi, x, F32(0)).astype(np.float32)
    i = np.floor(xf.astype(np.float64)).astype(np.int64)
    f = sub(xf, i2f(i))
    p = np.broadcast_to(EXP2_COEFFS[7], f.shape).astype(np.float32)
    for
 c in reversed(EXP2_COEFFS[:7]):
        p = add(mul(p, f), c)
    pb = bits(p).astype(np.int64) + (i << 23)
    ...
  • • 处理边界:  返回 0,  返回  。
  • • 范围缩减: 。
  • • 7 阶 Horner: 。
  • • 调整指数。

精度:exp2 1.61 ulp、log2 2.3 ulp、recip 1.16 ulp、rsqrt 1.03 ulp。

22. 量化


   
   
   
   
    
   
   
   
   def quantize_mx(x, g: int, row_mode: bool = False):
    x = ftz(x)
    rows, n = x.shape
    ax = bits(fabs(x))
    amax = from_bits(np.max(ax, axis=1))
    zero = amax == 0
    s = np.where(zero, F32(0), mul(amax, INV127)).astype(np.float32)
    inv = np.where(zero, F32(0), mul(F32(127), recip(amax))).astype(np.float32)
    if
 row_mode:
        d = np.zeros((rows, n // g), np.uint8)
    else
:
        a = from_bits(np.max(ax.reshape(rows, n // g, g), axis=2))
        d = mx_offset(a, amax[:, None])
    invg = mul(inv[:, None], np.ldexp(F32(1), d.astype(np.int32)).astype(np.float32))
    q = q8(mul(x.reshape(rows, n // g, g), invg[:, :, None])).reshape(rows, n)
    return
 q, s, d
  • • amax 是行最大绝对值,按位比较。
  • • s = amax / 127 是行 scale。
  • • inv = 127 * recip(amax) 是逆 scale。
  • • 每 group 的 offset d。
  • • q = q8(x * (inv * 2^d))。

量化公式:

quantize_mx 内部的浮点运算全走 fp32.py;返回值 q, s, d 写回 DRAM/ACT 时是 numpy 赋值。

23. 归约


   
   
   
   
    
   
   
   
   def interleaved_sum(a, P: int, pad: float = 0.0) -> np.ndarray:
    a = ftz(a)
    R, C = a.shape
    n = -(-C // P) * P
    if
 n != C:
        a = np.concatenate([a, np.full((R, n - C), pad, np.float32)], axis=1)
    part = chain_sum(a.reshape(R, n // P, P).transpose(0, 2, 1).reshape(R * P, n // P))
    x = part.reshape(R, P)
    while
 x.shape[1] > 1:
        n = x.shape[1] // 2
        x = add(x[:, :n], x[:, n:])
    return
 x[:, 0]
  • • 补齐列数到 P 的倍数。
  • • 重排成 [R, n/P, P]。
  • • 每个 partial sum 用 chain_sum。
  • • P 个 partial sum 用 pairwise 折叠。

chain_sum 用快路径 + 慢路径:先 np.add.accumulate,一旦某个 partial sum 是 denormal 就逐元素重做。

归约的  ,与 RTL 的 otpu_vtree 一致。


速度与一致性

24. 为什么比 RTL 快

  • • RTL 逐周期,每个周期更新所有信号。
  • • ISA 模拟器逐指令,一条指令一次算完。
  • • MM 指令在 RTL 里是几千周期,在 ISA 模拟器里是几百微秒。

MM 在 RTL 里:systolic 阵列跑   个周期,加上 DRAM 延迟、FIFO 等待、epilogue。在 ISA 模拟器里:用 einsum 一次算完,几百微秒。典型倍率 100–1000 倍。

其他操作的对比:

操作
RTL
ISA 模拟器
LD 逐 beat numpy 切片
ST 逐 beat numpy 切片
VOP 每周期 LANES numpy 向量化
QACT 两遍扫描 numpy 向量化
QST 逐字节 numpy 向量化

25. 与 RTL 的一致性

  • • ISA 模拟器调用 fp32.py。
  • • RTL 用 otpu_fp.sv。
  • • tb_fp.sv 用向量测试验证两者一致。
  • • 三者一致才有浮点域的 bit 一致。

一致性的三层来源:

域
保证
浮点域 fp32.py 与 otpu_fp.sv 语义一致:RNE 舍入、FTZ、canonical NaN;复合函数固定序列、不重排
整数域 ISA 用 int64 精确累加,RTL 用 48 位累加器,实际数据不溢出(量化约束)
位级域 RLD 的 f2i、WAITW 的 waitw_holds 与 RTL 的 otpu_seq 一致,是 ISA 的定义

浮点域的边界处理:denormal、NaN、inf 在 fp32.py 中显式处理;整数乘加的块缩放 sh < 0 直接丢组,与 RTL 一致;位级转换 f2i 的饱和值 0x80000000 与 RTL 一致。


总结

openTPU 模拟器的设计可以归纳成六句话:

  1. 1. 三层容器:Config 是冻结配置,Slice 是状态容器,Machine 是多切片调度。
  2. 2. 状态用 numpy 数组和 Python 容器:
    • • DRAM 是 uint8 数组 + m32 视图,四种访问模式(批量切片、零拷贝 as_strided、字节索引、fp32 视图)。
    • • TMEM 是 uint32 数组 + tget / tput,字寻址,fp32 视图读写。
    • • ACT RAM 是三个独立数组:act(int8)、ascale(float32)、adoff(uint8),QACT 同时写三份,MM 同时读三份。
    • • IMEM / 循环栈 / 寄存器 是 Python 容器:IMEM 是 Instr 对象列表,循环栈是 list[list[int]],寄存器是 list[int]。
  3. 3. 执行逐指令:step 处理六种控制流,execute 大分发,advance 处理循环栈。
  4. 4. 数值按来源分三条路径:
    • • 浮点运算走 fp32.py:VOP、STREAM、DSTEP、量化、归约、MM 的 epilogue 与 RMAX/ASCALE/ACC。舍入方式与 RTL 的 FP32 单元逐位一致。
    • • 整数乘加在 numpy int64/float64 中完成:MM 的 int8 einsum 与块缩放。结果精确,不需要舍入;与 RTL 48 位累加器逐位一致,靠*"累加器足够宽"*。
    • • 位级转换与条件比较在 isa.py 中:RLD 的 f2i、WAITW 的 waitw_holds、NVFP4 的 E4M3 解码。这些是 ISA 的定义,与浮点舍入无关。
  5. 5. 一致性三层保证:浮点域靠 fp32.py 的语义,整数域靠累加器足够宽,位级域靠 isa.py 的定义。
  6. 6. 搬运不做数值运算:LD/ST、TMEM/DRAM 读写、ACT/ascale/adoff 写入、寄存器与 PC 更新,都是 numpy 赋值或纯 Python 整数。

设计哲学:逐指令、逐位、可检查。不做时序,但保证结果与 RTL 逐位一致——这就是它能作为 RTL 金标准、同时比 RTL 快 100–1000 倍的原因。

 


【声明】内容源于网络
0
0
ai算法芯片与系统
长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
内容 240
粉丝 0
ai算法芯片与系统 长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
总阅读7.0k
粉丝0
内容240