PyMTL基础入门

Albert Cheung

第01章 简介

我们将在接下来的部分使用 PyMTL3 硬件建模框架进行功能级建模、验证以及模拟器的使用。可以选择 PyMTL3 或 Verilog 进行寄存器传输级建模,但即便使用 Verilog,也需完成本教程,因为课程中部分内容仍需使用 PyMTL3。需要注意的是,PyMTL3 是 PyMTL2 的改进版本,虽然二者在 API 上的差异不大,但 PyMTL3 在具体实现上有显著更新,因此需留意这些变化。

本教程聚焦于 PyMTL3 框架的基本知识,包括开发、测试与评估方法,以及课程中使用的代码规范。涉及的开源工具包括:用于测试驱动开发的 pytest 框架,将 Verilog 转为 C++ 的 Verilator,以及用于查看波形的 GTKWave。PyMTL3 框架本身为开源项目,可在 GitHub 上访问 ,我们可以浏览其源码以深入了解框架的实现。

这里提供一个Fedora的建议安装流程(其他发行版类似)。

首先安装依赖项:

1
sudo dnf install python3 python3-pip python3-venv

推荐使用Conda创建一个虚拟环境,以便更好地管理依赖关系:

1
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh

运行下载的安装脚本,并按照提示安装:

1
bash Miniconda3-latest-Linux-x86_64.sh

完成安装后,初始化 Conda,以便在终端中可以直接使用 conda 命令:

1
2
source ~/miniconda3/bin/activate
conda init

在 Conda 安装完成并配置后,您可以创建一个新的环境,用于安装和运行 PyMTL3。使用以下命令创建名为 pymtl3_env 的环境,同时指定 Python 版本:

1
conda create -n pymtl3_env python=3.8

创建环境后,使用以下命令激活 pymtl3_env 环境:

1
conda activate pymtl3_env

安装PyMTL3:

1
pip install pymtl3

如果您还需要 Verilator(例如进行 RTL 级仿真),可以从 Fedora 的包管理器中安装:

1
sudo dnf install verilator

第02章 PyMTL3 的功能级、周期级和寄存器传输级建模

计算机架构师可以在不同抽象级别上对系统进行建模,包括功能级(Functional-Level, FL)周期级(Cycle-Level, CL)寄存器传输级(Register-Transfer-Level, RTL)。每个建模级别都有其独特的优缺点,因此有效的设计通常需要综合利用这些不同的建模级别。未来将通过示例展示如何从 FL 到 CL 再到 RTL 逐步优化设计,尽管在实验作业中主要集中于 FL 和 RTL 建模。

2.1 FL、CL 和 RTL 建模比较

  • 功能级 (FL):实现硬件功能而不关注时序。FL 模型适用于探索算法、快速仿真目标硬件和创建黄金模型来验证 CL 和 RTL 模型。这种模型构建简单,但与目标硬件的准确性最低。FL可以看作是算法模拟器,用来快速验证算法或目标硬件的功能是否正确。因为 FL 模型不考虑硬件的时序,因此仿真速度快,构建也相对简单,但它不能精确地代表硬件的实际行为。它通常用于编写“黄金模型”,即参考模型,用来验证其他更精确模型的正确性。
  • 周期级 (CL):捕捉硬件目标的近似周期行为,通过在功能行为上增加时序模型来追踪硬件性能,适用于在不同微架构参数下快速探索设计空间。CL 模型在准确性、性能和灵活性之间取得平衡。
  • 寄存器传输级 (RTL):提供周期、资源和位级精确的硬件表示,主要用于特定硬件实现的验证和综合。RTL 模型是最为详细的建模方式,适用于驱动 EDA 工具流以估算面积、能耗和时序,但构建较为复杂。

在本教程中,FL、CL 和 RTL 模型都使用基于端口的接口、并发模块和结构化组合的方式。此外,PyMTL3 支持高级多态接口连接,允许不同级别的接口通过自动插入适配器进行直接连接。这种基于端口和多态的方式支持 PyMTL3 的混合级别建模,即可以将 FL、CL 和 RTL 模型组合成一个统一的系统模型。

2.2 可综合与非可综合 RTL 建模

由于 PyMTL3 嵌入在 Python 中(Python 是一种通用编程语言),可以非常容易地编写无法模拟实际硬件的 PyMTL3 代码,而这对于构建 FL 模型、测试框架、断言和行跟踪等功能是必需的。因此,学生在建模时必须谨慎选择自己编写的是可综合的 RTL 模型还是非可综合代码,时刻明确自己在模拟什么样的硬件以及如何模拟。

学生的设计任务几乎完全使用可综合的 PyMTL3 RTL 模型。可以在 update_ffupdateupdate_once 并发模块之外的 Python 代码(即展开代码)中使用任何 Python 代码,因为展开代码用于生成硬件而不是实际建模硬件。在并发模块中可适量包含非可综合代码用于调试、断言或行跟踪,但应通过注释标记这些非可综合代码,以便自动化工具在综合设计前将其移除。

下面包含了一个表格,列出了哪些 Python 结构在可综合的 PyMTL3 并发模块中允许使用、有限制地允许使用以及显式禁止使用的结构。与 ECE 4750 不同,这些规则仅作为建议。我们可以使用 PyMTL3 可翻译成 Verilog 且 Synopsys Design Compiler 可综合的任何内容。如果发现更高级的语法可以简化设计且可被综合,亦可采用该语法。

1

2

第03章 PyMTL3基础:数据类型和操作符

从本章开始,我们将从基本内容开始,写一些非常基础的代码来介绍PyMTL3的基础数据类型和操作符。我们通过

1
from pymtl3 import *

在Python的开头来导入PyMTL3框架。

3.1 Bits数据类型

在硬件建模框架 PyMTL3 中,Bits 类用于表示固定位宽的值。与许多硬件描述语言 (HDL) 支持四态值(0, 1, X, Z,其中 X 表示未知值,Z 表示高阻抗值)不同,PyMTL3 的 Bits 仅支持二态值(0 和 1)。虽然这种设计可以加速仿真并避免 X 值带来的问题,但也增加了描述一些硬件结构的复杂度。此外,在仅支持二态值的环境下处理复位逻辑可能会有一定挑战,但可以通过已知的技术加以解决。

3.1.1 创建与操作 Bits 对象

以下示例展示了如何在 Python 中实例化和操作 Bits 对象:

  • BitsN 构造函数用于创建固定位宽的 Bits 对象。Bits16(37) 表示创建一个 16 位宽的 Bits 对象,并赋初值为 37。
  • 在 PyMTL3 中,位宽在 1 至 255 位之间的常用 Bits 类型已经定义。也可以通过 mk_bits(N) 动态创建更宽的 Bits 类型。

例如:

1
2
3
4
5
6
7
8
9
>>> a = Bits16(37)  # 创建 16 位宽、初值为 37 的 Bits 对象
>>> type(a)
<class 'pymtl3.datatypes.bits_import.Bits16'>
>>> a
Bits16(0x0025)
>>> str(a)
'0025'
>>> type(a), a
(<class 'int'>, 47)

3.1.2 常量、负数与进制支持

  • 常量创建:可以通过 bN 语法创建常量,如 b16(37)

请注意:这里的BitsN都是2进制的位宽,而非N进制的位宽

1
2
>>> b16(37)
Bits16(0x0025)
  • 进制表示:Bits 支持二进制和十六进制表示,如 Bits8(0b10101100)Bits32(0xabcd0123)。其中:
    • 二进制(Binary) - 0b 前缀
1
2
>>> Bits8( 0b10101100 )
Bits8(0xac)
  • 八进制(Octal) - 0o 前缀
  • 十进制(Decimal) - 无前缀
  • 十六进制(Hexadecimal) - 0x 前缀
1
2
>>> Bits32(0xabcd0123)
Bits32(0xabcd0123)
  • 负值:Bits 对象可存储负数,采用二进制补码表示,例如 Bits8(-1) 将存储为 0xff
  • 动态范围检查:当赋值超出位宽允许的范围时,Bits 构造函数将抛出异常。例如,Bits8(260) 将会报错,因为 260 无法用 8 位表示。可以通过以下方式实现:
1
2
3
4
5
6
>>> Bits260
NameError: name 'Bits260' is not defined
>>> N = 260
>>> BitsN = mk_bits(260)
>>> BitsN(37)
Bits260(0x0000000...0000025)
  • 截断初值:可以通过 trunc_int=True 参数来截断过大的初值值,如 Bits8(0xdeadbeef, trunc_int=True) 将返回 Bits8(0xef)

3.1.3 获取位宽与数值

可以使用 nbits 属性获取 Bits 对象的位宽,uint() 方法获取无符号整数值,int() 方法获取有符号整数值。例如:

1
2
3
4
5
6
7
>>> a = Bits8(128)
>>> a.nbits
8
>>> a.uint()
128
>>> a.int()
-128

3.1.4 位切片与复制

  • 位切片:Bits 对象可以像 Python 列表一样进行位切片以读取或写入特定位段。切片语法 [start:end] 包含 start 位、不包含 end 位。例如,a[28:32] 表示获取 a 的高四位。

示例:

1
2
3
4
5
>>> a = Bits32(0xabcd0123)
>>> a[28:32]
Bits4(0xa)
>>> a[4:24]
Bits20(0xcd012)

在这段代码中,我们对一个 Bits32 对象进行了位切片:

  1. a[28:32]:提取 a 的最高 4 位(第 28 位至第 31 位),返回一个 4 位的 Bits4 对象。

    • 在 0xabcd0123 中,最高 4 位是 0xa(二进制为 1010)。
    • 所以,a[28:32] 返回 Bits4(0xa)。
  2. a[4:24]:提取 a 从第 4 位到第 23 位的位段,共 20 位,返回一个 Bits20 对象。

    • 在 0xabcd0123 中,从第 4 位到第 23 位对应的二进制是 0b11001101000000010010,即 0xcd012。
    • 因此,a[4:24] 返回 Bits20(0xcd012)。

请注意:由于N是2进制的位宽,因此位切片也是在2进制下进行的。例如:

1
2
3
位索引:    31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
二进制值: 1 0 1 0 1 0 1 1 1 1 0 0 1 1 0 1 0 0 0 0 0 0 0 1 0 0 1 0 0 0 1 1
十六进制: a b c d 0 1 2 3
  • 修改位段:通过赋值可以修改特定位段的值,例如,a[28:32] = 0xf 将高四位修改为 f
  • 复制对象:直接赋值并不会复制 Bits 对象,而是创建引用。若想复制 Bits 对象,需要显式创建新对象。例如,b = Bits32(a) 会创建 a 的副本,之后对 ab 的修改不会影响对方。例如:
1
2
3
4
5
6
7
8
9
10
11
>>> a = Bits32( 0xabcd0123 )
>>> b = a
>>> a
Bits32(0xabcd0123)
>>> b
Bits32(0xabcd0123)
>>> a[24:32] = 0x67
>>> a
Bits32(0x67cd0123)
>>> b
Bits32(0x67cd0123)

1
2
3
4
5
6
7
8
9
10
11
>>> a = Bits32( 0xabcd0123 )
>>> b = Bits32( a )
>>> a
Bits32(0xabcd0123)
>>> b
Bits32(0xabcd0123)
>>> a[24:32] = 0x67
>>> a
Bits32(0x67cd0123)
>>> b
Bits32(0xabcd0123)

3.2 Bits操作符(Bits Operators)

3.2.1 支持的操作符

PyMTL3 中的 Bits 对象支持多种操作符,分为以下几类:

  • 逻辑操作符:包括按位与 &、按位或 |、按位异或 ^ 和按位非 ~
  • 算术操作符:支持加法 +、减法 - 和乘法 *
  • 缩减操作符:包括 reduce_and(按位与缩减)、reduce_or(按位或缩减)和 reduce_xor(按位异或缩减),这些操作符会将 Bits 对象缩减为单个位宽。
  • 移位操作符:支持右移 >> 和左移 <<
  • 关系操作符:包括相等 ==、不等 !=、大于 >、大于或等于 >=、小于 < 和小于或等于 <=

其他函数:

  • sext:符号扩展。
  • zext:零扩展。
  • concat:连接多个 Bits 对象。

注意:Python 还支持其他一些操作符(如除法 / 和取模 %),但在 PyMTL3 的 RTL 模型中这些操作符不可翻译,因此应避免使用。

24111001

3.2.2 操作符使用示例

逻辑与缩减操作符:可以对 Bits 对象执行逻辑运算。PyMTL3 支持自动将整数隐式转换为 Bits 对象,但如果两个 Bits 对象的位宽不同,会导致类型不匹配错误。可通过 sextzext 扩展位宽,以匹配操作数的位宽。

1
2
3
4
5
6
7
8
9
10
>>> a = Bits4(0b1010)
>>> b = Bits4(0b1100)
>>> a & b
Bits4(0x8) # 0b1000
>>> a | b
Bits4(0xe) # 0b1110
>>> a ^ b
Bits4(0x6) # 0b110
>>> ~ a
Bits4(0x5) # 0b0101
  • sext(符号扩展,symbol extend):符号扩展用于将带符号的数值扩展到更高的位宽。如果原始值是负数(即最高有效位为1),符号扩展会在扩展的高位填充1;如果是正数,则填充0。这种扩展方法适用于有符号数的操作。
  • zext(零扩展,zero extend):零扩展用于将无符号数扩展到更高的位宽,扩展时在高位补零。适用于无符号数的操作。

例如:

1
2
3
4
5
6
7
8
9
10
11
>>> a = Bits4(0b1010)
>>> a & Bits5(0b1100)

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/afs/ihep.ac.cn/users/z/zhanglikai/.local/lib/python3.9/site-packages/pymtl3/datatypes/PythonBits.py", line 269, in __and__
raise ValueError( f"Operands of '&' (and) operation must have matching bitwidth, "\
ValueError: Operands of '&' (and) operation must have matching bitwidth, but here Bits4 != Bits5.
>>> a = zext(a, 5)
>>> a & Bits5(0b1100)
Bits5(0x08) # 0b1000

移位操作符:支持逻辑左移和右移。移位的位数可以是整数或 Bits 对象,右移时高位用零填充,移位结果的位宽与操作数的位宽一致。

1
2
3
4
5
6
7
>>> a = Bits4(0b1011)
>>> a << 2
Bits4(0xc) # 0b1100

>>> a = Bits4(0b1011) # 4 位的二进制数 1011
>>> a >> 2 # 将 a 右移 2 位
Bits4(0x2) # 0b0010

算术操作符:加法和减法操作的结果位宽为操作数位宽的最大值,支持模运算并采用二进制补码表示负数。

1
2
3
4
5
>>> a = Bits4(3)
>>> a + 2
Bits4(0x5)
>>> a + 15
Bits4(0x2) # 4位宽截断
1
2
3
>>> a = Bits4(-2)
>>> a
Bits4(0xe)

关系操作符:支持比较两个 Bits 对象,结果为单个位宽的 Bits 对象,比较操作默认将操作数视为无符号整数。

1
2
3
4
5
6
>>> a = Bits4(3)
>>> b = Bits4(2)
>>> a > b
Bits1(0x1)
>>> a == b
Bits1(0x0)

其他函数:

  • 连接:concat 可将多个 Bits 对象连接成一个新对象。
1
2
3
4
5
6
>>> a = Bits8(0xab) 
>>> b = Bits12(0xcde)
>>> concat(a, b)
Bits20(0xabcde) # 由于分别是16进制和2进制,所以直接拼接
>>> concat(b, a)
Bits20(0xcdeab)
  • 截断:trunc 可将 Bits 对象截断为较小的位宽(只取低N位)。
1
2
3
>>> a = Bits8(0xff) # 0b11111111
>>> trunc(a, 3)
Bits3(0x7) # 0b111

3.3 BitStruct 数据类型

BitStruct 是 PyMTL3 中用于定义带有命名位字段的结构体类型,这些字段具有固定的位宽。通过 BitStruct,可以将多种数据字段组合成一个紧凑的 Bits 对象,并为这些字段赋予便于访问和操作的名称。

3.3.1 创建 BitStruct

在 PyMTL3 中,使用 @bitstruct 装饰器定义 BitStruct 类,这类似于 Python 3.7 引入的 dataclass。以下是一个定义 Point 的示例,它表示一个二维点,包括 x 和 y 两个四位的字段:

1
2
3
4
@bitstruct
class Point:
x: Bits4
y: Bits4

创建 Point 实例时,可以直接赋值并访问各字段:

1
2
3
pt1 = Point(3, 4)
print(pt1.x) # 输出 Bits4(0x3)
print(pt1.y) # 输出 Bits4(0x4)

3.3.2 转换与打包

  • 打包为 Bits 对象:使用 to_bits() 方法可以将 BitStruct 实例打包为一个 Bits 对象,方便在更大的位结构中使用。此方法从最高有效位开始打包。
1
pt1.to_bits()  # 输出 Bits8(0x34),按高位优先顺序
  • 解包为 BitStruct 实例:可以通过 from_bits 方法将 Bits 对象解包为 BitStruct 实例:
1
Point.from_bits(Bits8(0x34))  # 返回 Point(Bits4(0x3), Bits4(0x4))

3.3.3 参数化的 BitStruct

在某些情况下,字段的位宽可能在运行时才确定。PyMTL3 提供了 mk_bitstruct 函数,支持在运行时定义具有指定位宽的 BitStruct。例如,这是固定位宽的Point结构体:

1
2
3
4
5
6
7
8
>>> @bitstruct
... class Point:
... x: Bits4 # 注意tab
... y: Bits4
... # 直接换行保存退出
>>> pt1 = Point(3,4)
>>> pt1
Point(Bits4(0x3),Bits4(0x4))

这是带参数化位宽的Point结构体。在运行时定义一个新的 PointN 结构体,使得 x 和 y 字段的位宽可以根据需要动态设置。这里使用 mk_bitstruct 函数,生成一个包含两个 nbits 位宽字段的 Point8 结构体:

1
2
3
4
5
6
7
8
>>> nbits = 8
>>> PointN = mk_bitstruct(f"Point{nbits}", {
... 'x': mk_bits(nbits),
... 'y': mk_bits(nbits),
... })
>>> pt2 = PointN(3,4)
>>> pt2
Point8(Bits8(0x03),Bits8(0x04))

第04章 寄存增量器(Registered Incrementer)

在本节中,我们将构建一个基本的 PyMTL3 硬件模型,学习如何模拟、可视化、验证、复用、参数化并封装此模型。我们将以一个8位加1器(Registered Incrementer)为例进行建模。好的设计实践是,在开始编码之前,先绘制硬件模型的图示,例如数据通路图、状态机图或控制信号表,这样可以确保模型准确反映设计意图。

请注意:使用两空格缩进,且仅使用空格避免混淆,而不是tab。

4.1 建模寄存增量器

我们设计一个8位加1器模型。该模型具有一个 8 位输入端口和一个 8 位输出端口。每个时钟上升沿,输入值会被寄存,并加 1 后输出。在下方拆解示例代码:

首先,从 PyMTL3 框架中导入所需内容,并定义 RegIncr 类继承自 Component 基类:

1
2
3
4
from pymtl3 import *

class RegIncr(Component):
def construct(s):
  • Component 是 PyMTL3 模型的基类。
  • 通过 construct 方法定义端口接口、内部信号和并发逻辑块。

声明输入和输出端口:

1
2
s.in_ = InPort(Bits8)
s.out = OutPort(Bits8)
  • s.in_ 是 8 位输入端口(用 in_ 而不是 in,因为 in 是 Python 的保留字)。
  • s.out 是 8 位输出端口。
  • PyMTL3 模型中不需要显式定义时钟 clk 和复位 reset 端口,它们是隐含的输入。

定义一个 8 位的内部信号 reg_out,用于存储寄存器的输出:

1
s.reg_out = Wire(8)

Wire 用于在并发块间传递数据,是一种内部信号。

使用 update_ff 装饰器定义寄存器逻辑,模拟寄存器的行为。update_ff 块在每个时钟上升沿调用一次,用于在寄存器中存储数据:

1
2
3
4
5
6
@update_ff
def block1():
if s.reset:
s.reg_out <<= 0
else:
s.reg_out <<= s.in_
  • 在 PyMTL3 中,update_ff 是一个特殊的装饰器,用来定义依赖时钟上升沿触发的逻辑块(我们称为“同步逻辑块”)。也就是说,update_ff 块会在每个时钟上升沿被调用一次。
  • s.reset 用于判断复位信号。如果复位,reg_out 置为 0;否则,将输入端口的值赋给 reg_out
  • 使用 <<= 操作符来实现非阻塞赋值,使得所有 update_ff 块在同一个时钟周期内看起来是并行执行的,这意味着即使我们在多个 update_ff 块中对信号进行赋值,这些赋值都会在当前时钟周期结束后才真正生效。
[为什么需要非阻塞赋值?]

在硬件电路中,多个寄存器和逻辑单元通常会在同一个时钟信号的控制下更新状态。如果使用阻塞赋值,当一个寄存器的值改变时,它会立即影响到依赖它的其他逻辑单元,从而导致电路在同一个时钟周期内产生不同步的更新,最终产生竞态条件或不稳定的行为。

非阻塞赋值的优点在于,它允许我们在时钟周期内并行更新多个寄存器的值,而不受彼此的影响。这样可以确保电路在每个时钟周期的边界上达到稳定状态,从而避免竞态和不稳定的情况。只需要参考该例:

1
2
3
4
5
6
7
8
9
@update_ff
def block():
A = B + 1 # 立即更新 A 的值
B = A + 2 # 使用 A 的新值来更新 B 的值

@update_ff
def block():
A <<= B + 1 # 非阻塞赋值,延迟到整个块执行完毕后更新
B <<= A + 2 # 非阻塞赋值,延迟到整个块执行完毕后更新

然后,使用 update 装饰器定义递增逻辑,用于将 reg_out 的值加 1 并输出:

1
2
3
@update
def block2():
s.out @= s.reg_out + 1
  • update 表示组合逻辑块,响应信号的变化而立即更新。
  • 使用 @= 操作符进行阻塞赋值,使更新立即生效。
  • 每次 reg_out 值变化时,block2 将被触发,读取 reg_out 的值并加 1 后赋给 s.out

在 PyMTL3 中,复位信号(reset)只能在 update_ff 块中读取(同步复位),不能在 update 块中使用。如果需要在组合逻辑中利用复位信息,应通过复位信号初始化状态位,然后在组合逻辑中读取该状态位。

以下是完整的8位加1器的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pymtl3 import *

class RegIncr(Component):

def construct(s):

# 端口接口
s.in_ = InPort(Bits8)
s.out = OutPort(Bits8)

# 内部信号
s.reg_out = Wire(8)

# 寄存器逻辑
@update_ff
def block1():
if s.reset:
s.reg_out <<= 0
else:
s.reg_out <<= s.in_

# 递增逻辑
@update
def block2():
s.out @= s.reg_out + 1

简单的工作流程如下:

  1. 寄存器更新:每个时钟上升沿,block1 被调用,将 in_ 的值存储到 reg_out
  2. 递增逻辑:reg_out 值发生变化后,block2 立即被调用,读取 reg_out 的值,加 1 后赋给 out
  3. 上述过程在每个时钟周期重复执行。

4.2 模拟模型

在创建了硬件模型后,我们可以通过编写一个模拟脚本来测试其功能。以下内容展示了如何使用一个简单的 Python 脚本来展开模型,创建模拟器,写入输入值并显示输入/输出端口的值。

首先,我们编写脚本 regincr-sim,它从命令行接收输入值,对 RegIncr 模型进行模拟,并输出各时钟周期的输入和输出值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python
#=========================================================================
# regincr-sim <input-values>
#=========================================================================
from pymtl3 import *
from sys import argv
from RegIncr import RegIncr

# 从命令行获取输入值列表
input_values = [int(x, 0) for x in argv[1:]]

# 在输入值列表末尾添加三个零值,以便模拟多几个周期,方便观察输出
input_values.extend([0] * 3)

model = RegIncr()
model.elaborate()

# 应用默认的通用 Pass 组以添加模拟功能
model.apply(DefaultPassGroup())

# 重置模拟器
model.sim_reset()

# 输入值应用与输出值显示
for input_value in input_values:
model.in_ @= input_value
model.sim_eval_combinational()

# 显示当前周期的输入与输出
print(f"cycle = {model.sim_cycle_count()}: in = {model.in_}, out = {model.out}")

# 模拟器前进一个周期
model.sim_tick()

每个输入值经过以下几个步骤:

  1. 写入输入端口:使用 @= 操作符将 input_value 写入模型的 in_ 端口。这里 @= 的使用类似于 update 块中的信号写入,以确保值立即生效。
  2. 组合逻辑求值:调用 model.sim_eval_combinational() 以评估组合逻辑,更新输出值。
  3. 输出显示:调用 print 输出当前周期计数、输入值和输出值。
  4. 前进一个周期:调用 model.sim_tick(),模拟器进入下一个时钟周期。

由于 sim_reset 方法将复位信号激活了两个周期,输出从第 3 周期开始显示。当新输入值写入递增器时,在下一个周期即可观察到对应的递增输出值。

以下是运行结果:

1
2
3
4
5
6
7
8
9
# Line Tracing
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ python regincr-sim.py 0x01 0x13 0x25 0x37
cycle = 3: in = 01, out = 01
cycle = 4: in = 13, out = 02
cycle = 5: in = 25, out = 14
cycle = 6: in = 37, out = 26
cycle = 7: in = 00, out = 38
cycle = 8: in = 00, out = 01
cycle = 9: in = 00, out = 01

4.3 可视化

在设计的早期阶段,通过行跟踪 (line tracing) 可以调试设计的高级行为。然而,当我们需要查看更多信号的细节时,仅依靠行跟踪显得不够直观。PyMTL3 框架可以输出 VCD(Value Change Dump) 格式的波形文件,记录设计中每个信号(包括端口和内部信号)的变化,以便我们在工具中查看波形。

4.3.1 GTKwave

在 regincr-sim 脚本中,可以通过为 DefaultPassGroup 传递 vcdwave 参数来启用 VCD 输出。该参数指定生成的 VCD 文件的名称(无需添加 .vcd 后缀):

1
model.apply(DefaultPassGroup(vcdwave='regincr-sim'))

这行代码将配置 DefaultPassGroup 来生成名为 regincr-sim.vcd 的 VCD 文件。

在终端中执行以下命令以运行模拟脚本,并生成波形文件:

1
2
cd ${TUTROOT}/tut3_pymtl/regincr
./regincr-sim 0x01 0x13 0x25 0x37

这将根据输入数据运行模拟并生成 regincr-sim.vcd 文件。文件生成后,可以使用开源工具 GTKWave 来浏览波形:

1
gtkwave regincr-sim.vcd &

可以通过 GTKWave 的官方文档 了解更多实用功能。

这是输出的结果:

24111102

4.3.2 基于文本的波形

有时没有必要总是使用图形界面工具如 GTKWave 查看 VCD 文件查看,因为它涉及启动图形界面,尤其在远程服务器上可能会耗费较长时间。因此,在一些小规模设计或简单测试中,我们可以采用基于文本的波形查看方式。这种方式可以在终端内直接展示信号的变化,适用于少数周期的快速调试。

和前面相似,在 regincr-sim 脚本中,我们可以通过为 DefaultPassGroup 传递 textwave=True 参数来启用文本波形输出。还需要在模拟循环结束后调用 print_textwave 方法来打印波形。PyMTL3 设计上不会在模拟进行中自动输出文本波形,因此需要手动调用以便在模拟结束后统一输出:

1
model.apply(DefaultPassGroup(textwave=True))

在结束循环后调用:

1
model.print_textwave()

这是输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ python regincr-sim.py 0x01 0x10 0x15 0x17
cycle = 3: in = 01, out = 01

|0 |1 |2 |3

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_____

in_ 00 |01

out 01

reg_out 00

cycle = 4: in = 10, out = 02

|0 |1 |2 |3 |4

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\___________

in_ 00 |01 |10

out 01 |02

reg_out 00 |01

cycle = 5: in = 15, out = 11

|0 |1 |2 |3 |4 |5

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_________________

in_ 00 |01 |10 |15

out 01 |02 |11

reg_out 00 |01 |10

cycle = 6: in = 17, out = 16

|0 |1 |2 |3 |4 |5 |6

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_______________________

in_ 00 |01 |10 |15 |17

out 01 |02 |11 |16

reg_out 00 |01 |10 |15

cycle = 7: in = 00, out = 18

|0 |1 |2 |3 |4 |5 |6 |7

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_____________________________

in_ 00 |01 |10 |15 |17 |00

out 01 |02 |11 |16 |18

reg_out 00 |01 |10 |15 |17

cycle = 8: in = 00, out = 01

|0 |1 |2 |3 |4 |5 |6 |7 |8

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\___________________________________

in_ 00 |01 |10 |15 |17 |00

out 01 |02 |11 |16 |18 |01

reg_out 00 |01 |10 |15 |17 |00

cycle = 9: in = 00, out = 01

|0 |1 |2 |3 |4 |5 |6 |7 |8 |9

clk /‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__

reset ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_________________________________________

in_ 00 |01 |10 |15 |17 |00

out 01 |02 |11 |16 |18 |01

reg_out 00 |01 |10 |15 |17 |00

这是实际在终端中的输出结果示例:

24111301

4.4 使用pytest进行单元测试验证模型

4.4.1 pytest框架简介

在开发硬件模型后,验证其功能正确性是至关重要的环节。仅通过观察行跟踪(line trace)或波形文件来判断设计是否正确是一种“肉眼验证”的方式,容易出错且不具备可重复性。随着设计的复杂度增加,手动检查的方式变得不可行。因此,通过自动化单元测试,可以更严谨、系统化地验证模型的功能,并且在修改设计或进行团队协作时,也能更轻松地复现测试过程。

在本课程中,我们使用 pytest 框架进行单元测试,它提供了强大的功能,包括自动发现测试、参数化测试、丰富的报错信息、标准输出捕获等。pytest 使用非常简便,同时允许我们根据需求编写简单或复杂的测试。

pytest 框架拥有以下特点,非常适合硬件设计的测试:

  • 自动:pytest 会自动寻找以 test_ 开头的函数,认为它们是测试用例。
  • 标准断言语句:使用标准的 assert 语句来检查预期值和实际值,无需额外学习专用的测试库函数。
  • 命令行参数控制:支持参数化命令行控制,如生成 VCD 文件、设置输出捕获等。
  • 报错:当断言失败时,pytest 会提供详细的上下文信息,包括变量值和函数调用栈,帮助快速定位问题。

以下代码展示了如何为 8 位加 1 器编写单元测试,并对模型的行为进行验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pymtl3 import *
from pymtl3.stdlib.test_utils import config_model_with_cmdline_opts
from ..RegIncr import RegIncr

def test_basic(cmdline_opts):
model = RegIncr()
model = config_model_with_cmdline_opts(model, cmdline_opts, duts=[])

model.apply(DefaultPassGroup(linetrace=True))
model.sim_reset()

def t(in_, out):
model.in_ @= in_
model.sim_eval_combinational()

if out != '?':
assert model.out == out

model.sim_tick()

t(0x00, '?')
t(0x13, 0x01)
t(0x27, 0x14)
t(0x00, 0x28)
t(0x00, 0x01)
t(0x00, 0x01)

代码解析:

1
2
model = RegIncr()
model = config_model_with_cmdline_opts(model, cmdline_opts, duts=[])

使用 config_model_with_cmdline_opts 函数配置模型,cmdline_opts 收集命令行参数,如 --dump-vcd,用于控制是否生成 VCD 文件。

1
2
model.apply(DefaultPassGroup(linetrace=True))
model.sim_reset()

DefaultPassGroup 的 linetrace=True 选项启用了行跟踪输出,sim_reset 重置模拟器,使得模型处于初始状态。

然后,定义辅助函数 t

1
2
3
4
5
6
def t(in_, out):
model.in_ @= in_
model.sim_eval_combinational()
if out != '?':
assert model.out == out
model.sim_tick()

辅助函数 t 定义了一个单周期的测试,参数 in_ 表示输入值,out 表示期望输出值:

  • in_ 写入输入端口。
  • 调用 sim_eval_combinational 来评估组合逻辑,更新输出。
  • 如果 out 不等于 ?,表示需要验证输出值,通过 assert 语句进行检查。
  • 使用 sim_tick 模拟器前进一个时钟周期。

测试用例定义:

1
2
3
4
5
6
t(0x00, '?')    # 第一个周期,不关心输出值
t(0x13, 0x01) # 第二个周期,输入值 0x13,期望输出为 0x14
t(0x27, 0x14) # 第三个周期,验证输入值 0x27
t(0x00, 0x28) # 第四个周期,期望输出 0x28
t(0x00, 0x01) # 第五个周期,输出值为 0x01
t(0x00, 0x01) # 第六个周期,输出值保持 0x01

每个周期的输入和期望输出在函数 t 中定义。通过这种逐周期的测试方法,可以精确地验证每个周期的输入输出关系。

这是实际输出结果示例(如果出现报错,可尝试降低pytest版本至7.2.0通过pip install pytest==7.2.0):

1
2
3
4
5
6
7
8
9
10
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ pytest RegIncr_test.py
======================================== test session starts =========================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0
rootdir: /home/albert/CSC/cpt4
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 1 item

RegIncr_test.py . [100%]

========================================= 1 passed in 0.69s ==========================================

4.4.2 运行测试脚本

将测试脚本 RegIncr_test.py 放置在目录中。pytest 会自动发现以 _test.py 结尾的文件并运行其中的测试用例。建议在单独的构建目录中运行测试,以避免生成文件混杂:

1
2
3
mkdir ${TUTROOT}/build
cd ${TUTROOT}/build
pytest ../tut3_pymtl/regincr/test/RegIncr_test.py

执行结果显示每个测试脚本和测试函数的通过情况,每个通过的测试用例会显示一个“.”,失败的测试用例会显示“F”。

4.4.3 使用pytest命令行选项

--capture=no 选项:默认情况下,pytest 会捕获标准输出。若希望在终端中查看行跟踪信息,可以通过 --capture=no 选项禁用输出捕获:

1
pytest ../tut3_pymtl/regincr/test/RegIncr_test.py --capture=no

--tb选项:当测试失败时,可以通过 --tb 参数调整错误输出的详细程度。--tb=short 会输出简短的错误信息,而 --tb=long 会输出完整的调用栈信息:

1
2
pytest ../tut3_pymtl/regincr/test/RegIncr_test.py --tb=short
pytest ../tut3_pymtl/regincr/test/RegIncr_test.py --tb=long

--dump-vcd选项:如果需要生成 VCD 文件以便于在波形查看工具中进一步分析,可以在运行 pytest 时使用 --dump-vcd 选项。生成的 VCD 文件将包含测试的信号波形:

1
2
pytest ../tut3_pymtl/regincr/test/RegIncr_test.py --dump-vcd
gtkwave tut3_pymtl.regincr.test.RegIncr_test__test_basic.vcd &

4.5 使用测试向量验证模型

在硬件设计的单元测试中,通常需要多个定向测试用例来验证设计的不同方面。简单地重复写测试代码不仅冗长,还容易出错。为了简化测试代码,PyMTL3 提供了 run_test_vector_sim 辅助函数,允许通过测试向量(test vectors)的形式进行验证。这种方法将输入和期望输出以表格的方式展示,使得测试代码简洁清晰。

4.5.1 run_test_vector_sim 函数简介

run_test_vector_sim 是 PyMTL3 标准库中的一部分,用于简化基于测试向量的单元测试。这一函数的作用是:

  • 自动展开模型并创建模拟器。
  • 重置模拟器并写入测试向量表中的输入值。
  • 读取模型输出并与测试向量表中的参考输出值进行比较。

测试向量表是一个二维列表,每一行表示一个时钟周期的输入输出关系:

  • 第一行是表头,列出了端口名称。输出端口名以*标识。
  • 每一列对应一个输入或期望输出值。
  • 每一行(从第二行开始)对应一个模拟周期的输入输出。? 表示我们不关心该周期的输出值。

以下示例展示了如何使用 run_test_vector_sim 对 8 位加 1 器模型进行三个测试:小输入值、大输入值以及溢出情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim
from ..RegIncr import RegIncr

# 测试小输入值
def test_small(cmdline_opts):
run_test_vector_sim(RegIncr(), [
('in_ out*'),
[ 0x00, '?' ], # 第一个周期,我们不关心输出
[ 0x03, 0x01 ], # 输入 0x03,期望输出 0x01(上个周期递增 1)
[ 0x06, 0x04 ], # 输入 0x06,期望输出 0x04
[ 0x00, 0x07 ], # 输入 0x00,期望输出 0x07
], cmdline_opts)

# 测试大输入值
def test_large(cmdline_opts):
run_test_vector_sim(RegIncr(), [
('in_ out*'),
[ 0xa0, '?' ], # 输入较大值,第一周期不关心输出
[ 0xb3, 0xa1 ], # 输入 0xb3,期望输出 0xa1
[ 0xc6, 0xb4 ], # 输入 0xc6,期望输出 0xb4
[ 0x00, 0xc7 ], # 输入 0x00,期望输出 0xc7
], cmdline_opts)

# 测试溢出情况
def test_overflow(cmdline_opts):
run_test_vector_sim(RegIncr(), [
('in_ out*'),
[ 0x00, '?' ], # 第一个周期,不关心输出
[ 0xfe, 0x01 ], # 输入 0xfe,期望输出 0x01(0xfe + 1 = 0xff,下一周期回到 0x01)
[ 0xff, 0xff ], # 输入 0xff,递增至溢出,保持 0xff
[ 0x00, 0x00 ], # 输入 0x00,重置输出为 0x00
], cmdline_opts)

代码解析:

  • test_small 函数:测试小输入值情况,输入从 0x00, 0x03, 0x06, 0x00 开始,期望输出为 ?, 0x01, 0x04, 0x07,以验证递增器的基本功能。
  • test_large 函数:测试大输入值情况,输入从 0xa0, 0xb3, 0xc6, 0x00 开始,期望输出为 ?, 0xa1, 0xb4, 0xc7,用于验证递增器对大输入值的处理。
  • test_overflow 函数:测试溢出情况,输入从 0x00, 0xfe, 0xff, 0x00 开始,期望输出为 ?, 0x01, 0xff, 0x00,用于检查递增器的溢出处理逻辑。

4.5.2 运行测试脚本

将测试脚本 RegIncr_extra_test.py 放置在目录中。可以在 build 目录下运行以下命令来执行测试脚本:

1
2
cd ${TUTROOT}/build
pytest ../tut3_pymtl/regincr/test/RegIncr_extra_test.py

测试结果将显示每个测试函数的通过情况,成功的测试用例以 . 表示。此测试脚本可以自动发现并运行所有包含 _test.py 后缀的文件中的测试用例。这是输出结果:

1
2
3
4
5
6
7
8
9
10
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ pytest RegIncr_extra_test.py 
======================================== test session starts =========================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0
rootdir: /home/albert/CSC/cpt4
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 3 items

RegIncr_extra_test.py ... [100%]

========================================= 3 passed in 0.80s ==========================================

4.5.3 使用 pytest 命令行选项

  • -v 选项:用于显示详细输出,列出每个测试用例的执行结果。
1
pytest ../tut3_pymtl/regincr/test -v
  • -k 选项:可以用于选择运行特定名称的测试用例。例如,运行名称包含 small 的测试用例。
1
pytest ../tut3_pymtl/regincr/test -k small
  • -x 选项:在遇到第一个失败的测试用例时停止运行。
1
pytest ../tut3_pymtl/regincr/test -x

4.5.4 三步调试法

当测试一个目录中的所有测试用例时,可以采用三步调试法逐步缩小问题范围:

  1. 运行所有测试:获取当前目录下所有测试用例的总体执行情况,了解哪些测试通过,哪些失败。
1
pytest ../tut3_pymtl/regincr/test/
  1. 查看详细输出:使用 -v 选项运行单个测试脚本,观察失败的具体测试用例。
1
pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py -v
  1. 逐步调试:结合 -x-k--tb=short 等选项,进一步缩小调试范围,定位问题。
1
pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py -v -x --tb=short

4.6 使用随机测试验证模型

在前面的测试中,我们采用了定向逐周期灰盒测试策略,手动设计了特定的输入输出来验证模型的功能。然而,除了手工编写的定向测试外,随机测试也能有效提高验证的覆盖率和测试的严谨性。通过随机生成输入值,能帮助发现手动测试难以察觉的潜在错误。

Python 提供的 random 模块可以方便地生成随机输入值,但在使用随机测试时需要注意模型的延迟,确保每个期望的输出值出现在正确的周期位置上。以下是一个适用于8位加1器模型的随机测试用例示例。

以下代码展示了如何生成随机测试向量表,并利用 run_test_vector_sim 函数来进行随机测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import random
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim
from RegIncr import RegIncr

# 测试随机输入值
def test_random(cmdline_opts):
# 初始化测试向量表,表头定义输入/输出端口
test_vector_table = [('in_', 'out*')]

# 初始化上一个周期的期望输出
last_result = '?'

# 随机生成 20 个周期的输入输出对
for i in range(20):
# 生成一个 8 位随机数作为输入
rand_value = b8(random.randint(0, 0xff))

# 将输入值和上一个周期的期望输出添加到测试向量表
test_vector_table.append([rand_value, last_result])

# 计算当前输入值的期望输出(输入值 +1,确保为 8 位)
last_result = b8(rand_value + 1, trunc_int=True)

# 运行测试向量模拟
run_test_vector_sim(RegIncr(), test_vector_table, cmdline_opts)

代码解析:

  1. 导入 random 模块:使用 Python 的 random 模块生成随机输入值。
  2. 定义测试向量表:
1
test_vector_table = [('in_', 'out*')]

初始化测试向量表,首行为表头,in_ 为输入端口,out* 为输出端口。

  1. 设置期望输出的初始值:
1
last_result = '?'

last_result 初始值为 '?',表示在第一个周期不关心输出值。

  1. 生成随机测试向量:
1
2
3
4
for i in range(20):
rand_value = b8(random.randint(0, 0xff))
test_vector_table.append([rand_value, last_result])
last_result = b8(rand_value + 1, trunc_int=True)
  • 循环生成 20 个测试向量,每次随机生成一个 8 位输入值 rand_value
  • rand_valuelast_result 被添加到测试向量表中。
  • 更新 last_result 为当前输入值加 1 的结果,用于下一个周期的期望输出。trunc_int=True 确保溢出时截断到 8 位。
  1. 运行测试:
1
run_test_vector_sim(RegIncr(), test_vector_table, cmdline_opts)

通过 run_test_vector_sim 函数运行测试向量模拟,以验证模型对随机输入的处理是否正确。

我们还可以运行随机测试并启用行跟踪。在终端中执行以下命令运行随机测试用例,并启用行跟踪(-s 选项显示输出):

1
2
cd ${TUTROOT}/build
pytest ../tut3_pymtl/regincr/test/RegIncr_extra_test.py -k random -s

此命令将运行 test_random 测试函数,并打印出行跟踪信息,有助于观察每个周期的输入输出关系。-k random 参数确保仅运行包含random名称的测试。

注意:在设计随机测试时,应考虑模型的延迟,确保期望输出与正确的周期对应。对于复杂模型,可以增加随机测试的周期数以增强测试覆盖率。

4.7 两级寄存递增器的设计与测试

本节详细介绍了如何通过模块化和层次化设计思想,将简单的单级寄存递增器模型(RegIncr)组合成一个两级寄存递增器(RegIncr2stage)。

下图展示了两级寄存递增器的硬件结构,其中:

  • 输入端口 in 接收 8 位数据。
  • 两个 RegIncr 模块串联,分别完成递增操作。
  • 输出端口 out 传递最终结果。

24112501

以下为两级寄存递增器的 PyMTL3 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pymtl3 import *
from .RegIncr import RegIncr

class RegIncr2stage( Component ):

# 构造函数
def construct( s ):

# 接口定义:输入和输出均为 8 位宽
s.in_ = InPort(8)
s.out = OutPort(8)

# 第一级:实例化第一个单级寄存递增器
s.reg_incr_0 = RegIncr()
connect( s.in_, s.reg_incr_0.in_ )

# 第二级:实例化第二个单级寄存递增器
s.reg_incr_1 = RegIncr()
s.reg_incr_0.out //= s.reg_incr_1.in_ # 使用//= 连接信号
s.reg_incr_1.out //= s.out

def line_trace( s ):
return "{} ({}|{}) {}".format(
s.in_,
s.reg_incr_0.line_trace(), # 第一阶段的线性跟踪
s.reg_incr_1.line_trace(), # 第二阶段的线性跟踪
s.out
)

代码解析

接口定义:

  • s.in_ 是输入端口,接收 8 位宽的数据。
  • s.out 是输出端口,输出最终的处理结果。

模块实例化:

  • 分别实例化了两个单级寄存递增器(RegIncr),分别完成第一阶段和第二阶段的递增操作。

端口连接:

  • 使用 connect 方法和 //= 操作符连接输入、输出和子模块接口。

层次化设计:

  • line_trace 方法结合子模块的 line_trace 输出,提供了两阶段递增器内部状态的完整视图。

测试脚本通过多个测试用例验证了两级寄存递增器的功能,包括:

  1. 小值测试(Small Test):测试小范围数据的递增功能。
  2. 大值测试(Large Test):验证较大范围数据的处理情况。
  3. 溢出测试(Overflow Test):测试数据溢出的正确性。
  4. 随机测试(Random Test):随机生成数据进行验证,覆盖更广的数据范围。

以下为部分测试脚本实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import random
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim
from ..RegIncr2stage import RegIncr2stage

# 小值测试
def test_small( cmdline_opts ):
run_test_vector_sim( RegIncr2stage(), [
('in_ out*'),
[ 0x00,'?' ], # 第一周期:输出未知
[ 0x03,'?' ], # 第二周期:输出未知
[ 0x06, 0x02 ], # 第三周期:输出0x02(0x00+2)
[ 0x00, 0x05 ], # 第四周期:输出0x05(0x03+2)
[ 0x00, 0x08 ], # 第五周期:输出0x08(0x06+2)
], cmdline_opts )

# 随机测试
def test_random( cmdline_opts ):
test_vector_table = [('in_', 'out*')]
last_result_0 = '?'
last_result_1 = '?'
for _ in range(20):
rand_value = b8( random.randint(0, 0xff) )
test_vector_table.append([rand_value, last_result_1])
last_result_1 = last_result_0
last_result_0 = b8(rand_value + 2, trunc_int=True)
run_test_vector_sim( RegIncr2stage(), test_vector_table, cmdline_opts )

逻辑说明:

延迟处理:

  • 由于两级寄存递增器的两周期延迟,前两周期的输出为未知值(?)。
  • 测试用例在第三周期开始断言输出值是否正确。

随机测试:

  • 随机生成 20 组输入数据,通过计算参考输出(+2),验证递增器的功能正确性。
  • 延迟处理同样适用于随机测试,注意保持参考输出与实际行为的一致性。

输出结果为:

1
2
3
4
5
6
7
8
9
10
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ pytest RegIncr2stage_test.py 
======================================== test session starts =========================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0
rootdir: /home/albert/CSC/cpt4
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 2 items

RegIncr2stage_test.py .. [100%]

========================================= 2 passed in 0.73s ==========================================

接下来可以进行运行测试与结果分析。

1
2
3
4
5
6
# 运行所有测试用例
% cd ${TUTROOT}/build
% pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py -v

# 运行特定测试用例
% pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py -k test_small

运行以下命令生成线性跟踪信息:

1
% pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py -k test_small -s

图 20 展示了 test_small 测试的线性跟踪结果,其中:

  • 每一列对应模型中不同模块的状态。
  • 数据从 in 输入,经过 reg_incr_0reg_incr_1 两阶段处理,最终输出到 out
  • 通过观察高亮标注的 0x03,可以清晰地看到数据如何逐周期传递并递增。

24112502

运行的输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt4$ pytest RegIncr2stage_test.py -k test_small -s
======================================================== test session starts =========================================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0
rootdir: /home/albert/CSC/cpt4
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 2 items / 1 deselected / 1 selected

RegIncr2stage_test.py
1r 00 (00 (00) 01|01 (00) 01) 01
2r 00 (00 (00) 01|01 (00) 01) 01
3: 00 (00 (00) 01|01 (00) 01) 01
4: 03 (03 (00) 01|01 (01) 02) 02
5: 06 (06 (03) 04|04 (01) 02) 02
6: 00 (00 (06) 07|07 (04) 05) 05
7: 00 (00 (00) 01|01 (07) 08) 08
8: 00 (00 (00) 01|01 (01) 02) 02
9: 00 (00 (00) 01|01 (01) 02) 02
10: 00 (00 (00) 01|01 (01) 02) 02
.

================================================== 1 passed, 1 deselected in 0.74s ===================================================

使用以下命令生成 VCD 波形文件:

1
2
% pytest ../tut3_pymtl/regincr/test/RegIncr2stage_test.py --dump-vcd
% ls *.vcd

以下是生成的vcd波形文件,分别为small与random:

24112503

24112504

4.8 使用“静态”展开进行组件参数化

在硬件设计中,参数化组件能够显著提高模块的复用性,并支持灵活的设计空间探索。PyMTL3 提供了动态硬件生成工具,结合 Python 的动态特性,允许通过参数化实现组件的“静态”展开。参数化组件可以通过构造函数传递参数来决定组件的接口、行为以及子组件的结构组合。

需要注意的是:

  • PyMTL3 的静态展开(Static Elaboration)发生在模拟器或测试脚本的运行时,通过 Python 的动态代码生成硬件结构。
  • 这种展开过程与传统硬件描述语言(如 Verilog 或 VHDL)的静态展开类似,只不过 PyMTL3 是在“运行时”完成展开。
  • 参数化组件的不同配置需要独立验证,这通常要求更加复杂的测试策略以覆盖所有可能的参数组合。

下面是一个支持位宽(nbits)和递增量(amount)参数化的组合逻辑递增器。

1
2
3
4
5
6
7
8
9
10
class Incrementer( Component ):
def construct( s, nbits=1, amount=1 ): # 参数 nbits 和 amount
# 接口
s.in_ = InPort( nbits )
s.out = OutPort( nbits )

# 参数化递增逻辑
@update
def comb_logic():
s.out @= s.in_ + amount
  • 端口宽度通过 nbits 参数控制,适用于不同数据宽度的输入和输出信号。
  • 递增量由 amount 参数决定,使递增器能够适配不同加法需求。

下面是一个支持寄存器级数参数化的多级寄存递增器(RegIncrNstage)。通过参数 nstages,用户可以灵活指定寄存递增器的级数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pymtl3 import *
from .RegIncr import RegIncr

class RegIncrNstage( Component ):
def construct( s, nstages=2 ): # 参数 nstages 指定寄存递增器的级数
# 接口定义
s.in_ = InPort(8)
s.out = OutPort(8)

# 实例化多级寄存递增器
s.reg_incrs = [ RegIncr() for _ in range(nstages) ]

# 输入连接到第一个寄存递增器
s.in_ //= s.reg_incrs[0].in_

# 级联连接
for i in range(nstages - 1):
s.reg_incrs[i].out //= s.reg_incrs[i+1].in_

# 输出连接到最后一个寄存递增器
s.reg_incrs[-1].out //= s.out

# 线性跟踪
def line_trace( s ):
return f"{s.in_} " \
f"({'|'.join([ str(x.out) for x in s.reg_incrs ])}) " \
f"{s.out}"

参数化组件的不同配置需要动态生成测试用例和测试向量,以覆盖所有参数组合。

对于参数化的寄存递增器,其参考输出与级数(nstages)相关,需要动态生成测试向量表。

1
2
3
4
5
6
7
8
9
10
11
12
def mk_test_vector_table( nstages, inputs ):
# 补充零值以模拟延迟
inputs.extend( [0]*nstages )

test_vector_table = [ ('in_ out*') ]
last_results = collections.deque( ['?']*nstages ) # 初始化延迟队列

for input_ in inputs:
test_vector_table.append( [ input_, last_results.popleft() ] )
last_results.append( b8( input_ + nstages, trunc_int=True ) )

return test_vector_table

解析:

  1. 延迟模拟:
  • 使用 collections.deque 模拟多级寄存递增器的延迟。
  • 输入向量后补零,确保输出参考值正确。
  1. 位截断:
  • 使用 trunc_int=True 确保计算结果符合位宽限制。

通过 pytest.mark.parametrize,可以为参数化组件生成测试用例。

1
2
3
4
5
6
7
8
9
10
11
12
13
test_case_table = mk_test_case_table([
( "nstages inputs "),
[ "2stage_small", 2, [ 0x00, 0x03, 0x06 ] ],
[ "2stage_large", 2, [ 0xa0, 0xb3, 0xc6 ] ],
[ "3stage_small", 3, [ 0x00, 0x03, 0x06 ] ],
[ "3stage_large", 3, [ 0xa0, 0xb3, 0xc6 ] ],
])
@pytest.mark.parametrize( **test_case_table )
def test( test_params, cmdline_opts ):
nstages = test_params.nstages
inputs = test_params.inputs
run_test_vector_sim( RegIncrNstage( nstages ),
mk_test_vector_table( nstages, inputs ), cmdline_opts )
  • 每一行对应一个参数化测试,列表示不同的参数。
  • pytest.mark.parametrize 自动生成并运行对应的测试用例。

使用随机生成的输入向量对多级寄存递增器进行测试。

1
2
3
4
@pytest.mark.parametrize( "n", [ 1, 2, 3, 4, 5, 6 ] )
def test_random( n, cmdline_opts ):
run_test_vector_sim( RegIncrNstage( nstages=n ),
mk_test_vector_table( n, sample(range(0xff),20) ), cmdline_opts )
  • 使用 sample(range(0xff), 20) 生成 20 个随机输入值。

运行所有测试用例:

1
2
% cd ${TUTROOT}/build
% pytest ../tut3_pymtl/regincr/test/RegIncrNstage_test.py -v

运行指定测试用例:

1
% pytest ../tut3_pymtl/regincr/test/RegIncrNstage_test.py -k 3stage -sv

生成波形文件:

1
2
% pytest ../tut3_pymtl/regincr/test/RegIncrNstage_test.py --dump-vcd
% ls *.vcd
  • 测试通过显示 PASSED,若失败会显示 FAILED
  • 参数化测试生成的测试用例以方括号标记参数组合(如 test[2stage_small])。

4.9 打包模型集合

在硬件设计中,尤其是使用 PyMTL3 框架时,将相关模型组织到单独的子目录中可以显著提升项目的模块化程度。通过打包(Packaging),可以使这些子目录成为标准的 Python 包,并通过 import 命令方便地在其他子项目中复用这些模型。本节详细介绍了如何配置打包、使用模型集合,以及如何正确管理包路径。

  1. 什么是打包?
  • 打包是将一个子目录(即子项目)转换为标准 Python 包的过程。一个打包的子项目可以通过 import 命令访问其内部的模型、函数或类。
  • 打包的核心是创建一个 Python 包配置脚本,命名为 __init__.py,并将其放置在子项目的根目录中。
  1. 子项目目录结构
  • 子项目通常由一组相关模型组成,位于一个单独的子目录中。
  • 如果子项目中存在嵌套的子目录,每个子目录都需要包含一个 __init__.py 文件,即使该文件为空。
  1. __init__.py 的作用
  • __init__.py 脚本的主要功能是导入子项目中的模型、函数或类,从而创建包命名空间。

下面展示了 regincr 包的配置脚本 __init__.py 的内容。

1
2
3
4
5
6
#=========================================================================
# regincr
#=========================================================================
from .RegIncr import RegIncr # 导入单级寄存递增器模型
from .RegIncr2stage import RegIncr2stage # 导入两级寄存递增器模型
from .RegIncrNstage import RegIncrNstage # 导入多级寄存递增器模型

解析:

  1. 导入模型:
  • regincr 子项目中的所有模型导入包命名空间。
  • 使用 from . 表示从当前目录(即 regincr 子目录)导入模块。
  1. 灵活扩展:
  • 除了模型,还可以将其他功能,例如辅助函数或工具类,导入包命名空间,供外部使用。

在项目根目录(TUTROOT)中,可以直接导入打包后的 regincr 子项目,并对其中的模型进行使用和仿真。

下面展示了如何从项目根目录导入包,并对模型进行简单仿真。

1
2
% cd ${TUTROOT}  # 切换到项目根目录
% python # 启动 Python 解释器

我们之前建议使用conda在虚拟环境中运行,如果你遵守了,操作流程也是类似的。

在 Python 交互环境中,运行以下命令:

1
2
3
4
5
6
7
8
9
>>> from pymtl3 import *                             # 导入 PyMTL3 框架
>>> from tut3_pymtl.regincr import RegIncr # 从 regincr 包中导入 RegIncr 模型
>>> model = RegIncr() # 实例化模型
>>> model.apply( DefaultPassGroup() ) # 应用默认 Pass 组
>>> model.sim_reset() # 重置仿真
>>> model.in_ @= 0x24 # 设置输入
>>> model.sim_tick() # 执行一个时钟周期
>>> model.out # 检查输出
Bits8(0x25) # 输出正确,递增 1

解析:

  • 使用 from tut3_pymtl.regincr import RegIncr 导入模型。

  • 使用 PyMTL3 的 API 对模型进行仿真,包括初始化、设置输入值、执行时钟周期操作,以及读取输出值。

四、从构建目录中导入包

如果切换到构建目录(build),直接导入 tut3_pymtl.regincr 包会报错。因为 Python 默认的搜索路径不包含 tut3_pymtl 包所在的项目根目录。

下面展示了如何通过设置环境变量 PYTHONPATH 解决这一问题。

1
2
% cd ${TUTROOT}/build                     # 切换到构建目录
% env PYTHONPATH=".." python # 设置 PYTHONPATH 为项目根目录

在 Python 交互环境中,运行以下命令:

1
2
3
4
5
6
7
>>> from tut3_pymtl.regincr import RegIncr           # 导入 RegIncr 模型
>>> model = RegIncr() # 实例化模型
>>> model.elaborate() # 展开模型结构
>>> [ x.get_field_name() for x in model.get_input_value_ports() ] # 获取输入端口名称
['clk', 'in_', 'reset']
>>> [ x.get_field_name() for x in model.get_wires() ] # 获取内部连线名称
['reg_out']
  • 使用 env PYTHONPATH=".." 将项目根目录添加到 Python 包搜索路径。

  • 调用 model.elaborate() 方法展开模型,生成完整的硬件结构。

  • 使用 PyMTL3 提供的接口检查方法,例如 get_input_value_ports()get_wires(),可以获取模型的输入输出端口及内部连线信息。

PyMTL3 框架提供了一系列强大的 API,用于检查已展开(elaborated)模型的结构和组件。这些工具对于调试复杂模型和参数化模型尤为有用。

  1. 获取端口信息:
  • get_input_value_ports():返回模型的输入端口列表。
  • get_output_value_ports():返回模型的输出端口列表。
  1. 获取内部连线:
  • get_wires():返回模型内部的连线。
  1. 获取子组件:
  • get_child_components():返回模型的子组件列表。
  1. 获取并发块:
  • get_update_blocks():返回模型的并发块。

示例:

1
2
>>> model.get_input_value_ports()        # 检查输入端口
>>> model.get_child_components() # 检查子组件

第05章 排序单元(Sort Unit)

5.1 排序单元的 FL 模型

功能级(Functional-Level,FL)模型专注于实现目标硬件的功能行为,而不关注具体的硬件时序。FL 模型通常是硬件设计的第一步,它可以快速实现功能验证,同时为后续的周期级(Cycle-Level,CL)和寄存器传输级(Register-Transfer-Level,RTL)模型设计打下基础。

排序单元(Sort Unit)的功能是对四个输入值进行排序,使输出端口按从小到大的顺序排列。排序单元具有以下接口:

输入端口:

  • in_val:标志输入值是否有效。
  • in_[0:3]:四个待排序的输入值。

输出端口:

  • out_val:标志输出值是否有效。
  • out[0:3]:按升序排列的输出值。

下图使用 “cloud diagram” 对排序单元进行了抽象表示,重点突出其功能逻辑,而忽略了具体的实现细节:

24112001

FL 模型的设计主要关注以下几点:

  1. 功能实现:使用简单的排序函数实现输入值的排序逻辑。
  2. 有效性标志:通过 in_valout_val 标志输入和输出值的有效性。
  3. 行跟踪(Line Trace):提供易于阅读的输入输出状态显示。
  4. 默认信号重置:依赖 PyMTL3 框架的默认信号初始化功能。

以下代码展示了排序单元 FL 模型的实现,核心功能是对输入端口的四个值进行排序,并通过输出端口返回结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from pymtl3 import *

# 排序功能实现
def sort_fl(arr):
"""排序函数,接收一个数组并返回排序后的结果。"""
return sorted(arr)

# 排序单元 FL 模型
class SortUnitFL(Component):

# 构造函数
def construct(s, nbits=8):
# 定义输入端口
s.in_val = InPort() # 输入有效性标志
s.in_ = [InPort(nbits) for _ in range(4)] # 四个输入端口

# 定义输出端口
s.out_val = OutPort() # 输出有效性标志
s.out = [OutPort(nbits) for _ in range(4)] # 四个输出端口

# 核心排序逻辑(update_ff 块)
@update_ff
def block():
# 将输入有效性标志传递到输出
s.out_val <<= s.in_val

# 对输入端口值排序,并赋值到输出端口
for i, v in enumerate(sort_fl(s.in_)):
s.out[i] <<= v

# 行跟踪(显示输入输出状态)
def line_trace(s):
# 输入状态字符串
in_str = '{' + ','.join(map(str, s.in_)) + '}'
if not s.in_val:
in_str = ' ' * len(in_str)

# 输出状态字符串
out_str = '{' + ','.join(map(str, s.out)) + '}'
if not s.out_val:
out_str = ' ' * len(out_str)

# 返回格式化的输入输出状态
return f"{in_str}|{out_str}"

代码解释:

  1. 排序功能实现(sort_fl 函数):
  • 使用 Python 的内置 sorted 函数对数组进行排序。
  • 该函数对 in_ 输入端口的四个值排序后,将结果传递给输出端口 out
  1. 输入输出端口定义:
  • InPortOutPort 用于定义输入和输出接口,支持参数化位宽(默认为 8 位)。
  • 输入端口包括 in_val(输入有效性标志)和 in_[0:3](四个待排序的值)。
  • 输出端口包括 out_val(输出有效性标志)和 out[0:3](排序后的值)。
  1. 核心排序逻辑:
  • 使用 @update_ff 定义逻辑块,确保信号值在时钟上升沿后更新。
  • 通过非阻塞赋值(<<=)将排序后的结果赋值到输出端口。
  • 输入有效性标志 in_val 被直接传递到输出有效性标志 out_val
  1. 行跟踪(Line Trace):
  • 提供 line_trace 方法,用于在模拟过程中格式化显示输入输出状态。
  • 当输入或输出无效时,用空格代替显示,使得行跟踪信息更加直观。
  • 例如:{8,3,5,1}|{1,3,5,8} 表示输入值为 {8,3,5,1},排序后输出 {1,3,5,8}。
  1. 默认信号重置:
  • PyMTL3 框架默认将信号初始化为 0,简化了 FL 模型的设计。
  • 这种简化在 CL 模型中也适用,但在 RTL 模型中需要显式实现状态重置。

接下来可以进行排序单元的测试。排序单元测试包括四个定向测试用例和一个随机测试用例。这些测试验证了 SortUnitFL 的功能正确性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim
from SortUnitFL import SortUnitFL

# 基本测试
def test_basic(cmdline_opts):
run_test_vector_sim(SortUnitFL(), [
# 定义测试向量表:第一行为端口名称,后续行为测试数据
('in_val in_[0] in_[1] in_[2] in_[3] out_val out[0] out[1] out[2] out[3]*'),
# 复位周期:无输入,无输出
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ],
# 第一个输入周期:输入有效,等待排序结果
[ 1, 8, 3, 5, 1, 0, 0, 0, 0, 0 ],
# 输出周期:输出排序结果
[ 0, 0, 0, 0, 0, 1, 1, 3, 5, 8 ],
], cmdline_opts)

# 随机测试
def test_random(cmdline_opts):
import random
# 定义测试向量表
test_vector_table = [
('in_val in_[0] in_[1] in_[2] in_[3] out_val out[0] out[1] out[2] out[3]*')
]
# 添加复位周期
test_vector_table.append([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

# 生成随机输入测试
for _ in range(20):
# 随机生成4个输入值
rand_inputs = [random.randint(0, 255) for _ in range(4)]
sorted_outputs = sorted(rand_inputs) # 对随机输入进行排序
# 输入有效周期
test_vector_table.append([1] + rand_inputs + [0] + [0, 0, 0, 0])
# 输出有效周期
test_vector_table.append([0, 0, 0, 0, 0, 1] + sorted_outputs)

# 运行测试
run_test_vector_sim(SortUnitFL(), test_vector_table, cmdline_opts)
  • 运行所有测试:
1
2
>>> cd ${TUTROOT}/build
>>> pytest ../tut3_pymtl/sort/SortUnitFL_test.py -v
  • 运行基本测试:
1
>>> pytest ../tut3_pymtl/sort/SortUnitFL_test.py -k test_basic -s
  • 运行随机测试:
1
>>> pytest ../tut3_pymtl/sort/SortUnitFL_test.py -k test_random -s

如前面所述,FL模型有如下几个最直接的作用:

  1. 早期验证:FL 模型可以快速实现并验证硬件设计的基本功能,为后续的 CL 和 RTL 模型提供测试基准。
  2. 模块化设计:通过 FL 模型验证功能后,可在更高抽象级别逐步优化和细化设计。
  3. 代码复用性:在 FL 模型中完成的测试用例可以直接复用于 CL 和 RTL 模型,确保验证过程一致。

这是实际的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt5$ pytest SortUnitFL_test.py -v -s
======================================================== test session starts =========================================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0 -- /home/albert/.conda/envs/pymtl3_env/bin/python3.8
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/albert/CSC/cpt5/.hypothesis/examples'))
rootdir: /home/albert/CSC/cpt5
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 2 items

SortUnitFL_test.py::test_basic
1r |
2r |
3: |
4: {08,03,05,01}|
5: |{01,03,05,08}
6: |
7: |
8: |
PASSED
SortUnitFL_test.py::test_random
1r |
2r |
3: |
4: {b2,b2,65,73}|
5: |{65,73,b2,b2}
6: {9c,0b,09,fd}|
7: |{09,0b,9c,fd}
8: {0e,39,e4,c8}|
9: |{0e,39,c8,e4}
10: {79,63,11,b4}|
11: |{11,63,79,b4}
12: {b2,d5,27,8d}|
13: |{27,8d,b2,d5}
14: {89,2e,b6,11}|
15: |{11,2e,89,b6}
16: {fb,06,4b,07}|
17: |{06,07,4b,fb}
18: {ce,79,a1,cb}|
19: |{79,a1,cb,ce}
20: {93,07,d4,b9}|
21: |{07,93,b9,d4}
22: {ca,aa,9f,29}|
23: |{29,9f,aa,ca}
24: {93,98,7c,7c}|
25: |{7c,7c,93,98}
26: {33,16,99,f8}|
27: |{16,33,99,f8}
28: {60,78,26,d7}|
29: |{26,60,78,d7}
30: {d2,c7,75,66}|
31: |{66,75,c7,d2}
32: {bf,08,cc,22}|
33: |{08,22,bf,cc}
34: {15,45,d0,e2}|
35: |{15,45,d0,e2}
36: {98,91,00,70}|
37: |{00,70,91,98}
38: {f4,5d,f7,1e}|
39: |{1e,5d,f4,f7}
40: {da,ef,ab,64}|
41: |{64,ab,da,ef}
42: {78,03,9f,6b}|
43: |{03,6b,78,9f}
44: |
45: |
46: |
PASSED

========================================================= 2 passed in 0.72s ==========================================================

5.2 排序单元的 CL 模型

在 FL(功能级)模型的基础上,我们可以逐步将其细化为 CL(周期级)模型。CL 模型捕获硬件目标的周期近似行为,关注其在每个时钟周期内的性能表现,而不是单纯的功能正确性。

CL 模型在第一周期完成输入值的排序,并使用流水线模拟目标硬件的周期延迟。如 Figure 30 所示:

  • 输入端口 in_ 和 in_val 提供值及其有效性标志。
  • 排序逻辑在第一周期内完成值的排序。
  • 使用 pipe(双端队列)模拟流水线延迟,将排序结果在多个周期内逐步输出。
  • 输出端口 out_ 和 out_val 提供按顺序排列的结果及其有效性。

24112002

以下代码展示了排序单元 CL 模型的具体实现,重点在于使用流水线结构模拟硬件的延迟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from collections import deque
from copy import deepcopy
from pymtl3 import *
from .SortUnitFL import sort_fl

# 排序单元 CL 模型
class SortUnitCL(Component):

# 构造函数
def construct(s, nbits=8, nstages=3):
# 定义输入端口
s.in_val = InPort()
s.in_ = [InPort(nbits) for _ in range(4)]

# 定义输出端口
s.out_val = OutPort()
s.out = [OutPort(nbits) for _ in range(4)]

# 初始化流水线队列
s.pipe = deque([[0, 0, 0, 0, 0]] * (nstages - 1))

# 更新块:核心排序与流水线逻辑
@update_ff
def block():
# 将输入值与有效性标志排序后存入队列
s.pipe.append(deepcopy([s.in_val] + sort_fl(s.in_)))
# 从队列中取出最早的值
data = s.pipe.popleft()
# 更新输出端口
s.out_val <<= data[0]
for i, v in enumerate(data[1:]):
s.out[i] <<= v

# 行跟踪(显示输入输出状态)
def line_trace(s):
# 输入状态字符串
in_str = '{' + ','.join(map(str, s.in_)) + '}'
if not s.in_val:
in_str = ' ' * len(in_str)

# 输出状态字符串
out_str = '{' + ','.join(map(str, s.out)) + '}'
if not s.out_val:
out_str = ' ' * len(out_str)

# 返回格式化的输入输出状态
return f"{in_str}|{out_str}"

代码解释:

  1. 双端队列 pipe 的作用

双端队列(deque)是 Python 标准库 collections 模块中的一种数据结构,用于高效地在序列两端添加或删除元素。

  • 模拟流水线延迟:
    在硬件中,流水线的延迟表现为信号从输入端逐步传播到输出端的过程。在这个 CL 模型中,pipe 作为一个双端队列,用于存储每个时钟周期的输入有效信号 in_val 和排序后的数据列表 sort_fl(s.in_)
  • 元素格式:
    队列的每个元素是一个列表,包含有效性标志 in_val 和排序结果 [sorted_value1, sorted_value2, sorted_value3, sorted_value4]。例如:
1
[1, 2, 3, 4, 5]  # 1 是有效信号,2–5 是排序后的值
  1. 模拟延迟逻辑

在每个时钟周期:

  • 调用 append 方法将新的输入有效信号和排序后的数据加入队列末尾。
  • 调用 popleft 方法从队列头部取出最早的信号值和数据,用于更新输出端口。
  • 初始化长度:
    队列的初始长度为 nstages - 1(流水线的阶段数减一)。例如,当 nstages=3 时,初始队列长度为 2。这种设计在 nstages=1 时(单周期延迟)能够直接运行,不会增加额外的延迟。
  1. 深拷贝 deepcopy

Python 的赋值操作默认是引用传递,这意味着多个变量可能同时指向同一个对象。如果直接将输入信号和排序后的结果添加到队列中,可能会出现以下问题:

  • 信号值在后续周期被修改时,队列中存储的值也会随之改变。
  • 导致信号不稳定或与预期不符。

解决方案:

  • 使用 copy 模块中的 deepcopy 函数对信号和排序结果进行深度拷贝,生成一个独立的副本。
  • 深拷贝确保了队列中的值与输入信号完全独立,避免了因共享内存而引发的副作用。例如:
1
s.pipe.append(deepcopy([s.in_val] + sort_fl(s.in_)))
  1. 更新块逻辑

更新块是通过 @update_ff 装饰器定义的同步逻辑,它在每个时钟上升沿被调用,模拟硬件中寄存器的行为。

排序与存储:

  • 调用 sort_fl(s.in_) 函数对输入信号列表 s.in_ 进行排序。
  • 使用深拷贝将输入有效信号 s.in_val 和排序后的列表组合后追加到队列末尾。

队列的读取:

  • 调用 popleft 从队列头部取出最早的信号值和数据。
  • 将有效性标志 data[0] 赋值给输出端口 s.out_val,并将排序后的数据列表赋值给输出数据端口 s.out[i]

流水线延迟:

  • 队列的长度为 nstages - 1,每次调用 appendpopleft 操作都会导致数据沿着流水线推进一段距离。
  • 如果 nstages=3,则总延迟为 3 个周期(包括更新块本身的 1 个周期延迟)。
  1. 行跟踪(Line Trace)

行跟踪用于在测试和调试过程中直观地展示输入和输出信号的变化。

  • 显示输入状态:
  • 将输入端口 s.in_ 的值通过 mapstr 转换为字符串,并用花括号包裹。
  • in_val 无效时,用空格替代对应的字符串,以清晰显示无效周期。例如:
1
2
3
in_str = '{' + ','.join(map(str, s.in_)) + '}'
if not s.in_val:
in_str = ' ' * len(in_str)

显示输出状态:

  • 将输出端口 s.out 的值转换为字符串,同样在无效周期中用空格替代。
  • 输出字符串与输入字符串通过|分隔,表示输入输出之间的关系。
  • 输出格式:
    行跟踪的结果是一个固定宽度的字符串,便于观察信号的传递。例如:
1
2
{04,02,03,01}|         # 第 4 周期:输入有效,输出无效
|{01,02,03,04} # 第 7 周期:输入无效,输出有效

代码逻辑图解

  1. 初始化队列:
  • 假设 nstages=3,则队列初始状态为:
1
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]  # 两个占位元素
  1. 周期操作:
  • 周期 1:输入 {4, 2, 3, 1},排序后追加队列:
1
[[0, 0, 0, 0, 0], [1, 1, 2, 3, 4]]
  • 周期 2:从队列头部取出一个值更新输出,同时追加新输入。

  • 周期 3:队列状态为:

1
[[1, 1, 2, 3, 4]]

输出 {1, 2, 3, 4}

CL 模型的测试使用参数化测试,包括定向测试随机测试,覆盖了不同输入值和不同流水线深度。

测试示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim
from SortUnitCL import SortUnitCL

# 测试函数:三阶段流水线
def test_3stage_stream(cmdline_opts):
run_test_vector_sim(SortUnitCL(nstages=3), [
('in_val in_[0] in_[1] in_[2] in_[3] out_val out[0] out[1] out[2] out[3]*'),
[ 1, 4, 2, 3, 1, 0, 0, 0, 0, 0 ],
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ],
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ],
[ 0, 0, 0, 0, 0, 1, 1, 2, 3, 4 ],
], cmdline_opts)
  • 运行所有测试:
1
2
>>> cd ${TUTROOT}/build
>>> pytest ../tut3_pymtl/sort/test/SortUnitCL_test.py -v
  • 运行特定测试:
1
>>> pytest ../tut3_pymtl/sort/test/SortUnitCL_test.py -k 3stage_stream -s

Figure 32 显示了测试的行跟踪输出:

  • 第 3 周期:无输入和输出。
  • 第 4 周期:输入值 {4, 2, 3, 1} 被记录。
  • 第 7 周期:输出值 {1, 2, 3, 4} 被排序后输出。

24112003

这是实际输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt5$ pytest SortUnitCL_test.py -v -s
============================================== test session starts ==============================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0 -- /home/albert/.conda/envs/pymtl3_env/bin/python3.8
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/albert/CSC/cpt5/.hypothesis/examples'))
rootdir: /home/albert/CSC/cpt5
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 1 item

SortUnitCL_test.py::test_3stage_stream
1r |
2r |
3: {04,02,03,01}|
4: |
5: |
6: |{01,02,03,04}
7: |
8: |
9: |
PASSED

=============================================== 1 passed in 0.72s ===============================================

此结果验证了 CL 模型正确捕获了排序单元的周期行为。

5.3 排序单元的 RTL 模型

在完成了对功能级(FL)和周期级(CL)模型的探索后,我们将设计进一步深化为RTL模型(寄存器传输级模型)。RTL模型具备以下特性:

  • 周期准确性:严格模拟硬件的周期行为。
  • 资源准确性:具体实现硬件所需的资源,包括寄存器、比较器等。
  • 位级准确性:每一位的操作符合硬件逻辑。

在本节中,我们实现了一个三级流水线的排序单元,采用了Bitonic排序网络进行四个输入值的排序。以下为模型设计的详细说明。

如图(Figure 33)所示,RTL模型使用三级流水线,每一级实现部分排序,逐步将输入数据排序为输出数据。其中,每个流水线阶段包含:

  1. 数据的输入寄存器化。
  2. min/max单元:对两个输入进行比较,较小值输出至上方端口,较大值输出至下方端口。

24112101

排序网络是三级流水线实现的Bitonic网络:

  • 第一阶段(S1):分别比较元素对 (0,1)(2,3)
  • 第二阶段(S2):分别比较元素对 (0,2)(1,3)
  • 第三阶段(S3):比较元素对 (1,2)

最终结果在第七个时钟周期输出,保证了模型的流水线周期级行为。

设计原则:

  • 输入寄存器化(Registered Inputs):每个流水线阶段的输入均通过寄存器进行存储,以避免关键路径延迟。
  • 清晰的阶段划分:每一级流水线的逻辑清晰分离,使用寄存器存储每一级的结果。
  • 复位信号显式处理:RTL模型中所有状态均需显式复位,确保在硬件初始化时的正确性。

以下为排序单元RTL模型的部分代码实现,展示了接口定义、输入寄存器化及第一阶段(S1)的组合逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pymtl3 import *

class SortUnitFlatRTL(Component):

def construct(s, nbits=8):
# 接口定义
s.in_val = InPort() # 输入有效信号
s.in_ = [InPort(nbits) for _ in range(4)] # 输入端口数组
s.out_val = OutPort() # 输出有效信号
s.out = [OutPort(nbits) for _ in range(4)] # 输出端口数组

# 第一阶段输入寄存器(S0 -> S1)
s.val_S1 = Wire() # 有效性信号寄存器
s.elm_S1 = [Wire(nbits) for _ in range(4)] # 数据寄存器

@update_ff
def pipereg_S0S1():
if s.reset:
s.val_S1 <<= 0 # 复位时清零有效性信号
else:
s.val_S1 <<= s.in_val # 正常传递有效性信号

for i in range(4): # 输入数据存入寄存器
s.elm_S1[i] <<= s.in_[i]

# 第一阶段组合逻辑(S1)
s.elm_next_S1 = [Wire(nbits) for _ in range(4)] # 下一阶段数据

@update
def stage_S1():
# 比较输入数据对 (0,1)
if s.elm_S1[0] <= s.elm_S1[1]:
s.elm_next_S1[0] @= s.elm_S1[0]
s.elm_next_S1[1] @= s.elm_S1[1]
else:
s.elm_next_S1[0] @= s.elm_S1[1]
s.elm_next_S1[1] @= s.elm_S1[0]

# 比较输入数据对 (2,3)
if s.elm_S1[2] <= s.elm_S1[3]:
s.elm_next_S1[2] @= s.elm_S1[2]
s.elm_next_S1[3] @= s.elm_S1[3]
else:
s.elm_next_S1[2] @= s.elm_S1[3]
s.elm_next_S1[3] @= s.elm_S1[2]

代码解释:

  1. 接口定义
  • s.in_vals.out_val 是布尔型信号,分别表示输入有效性和输出有效性。
  • s.in_s.out 是四个输入和输出数据端口,位宽可通过参数 nbits 定义。
  1. 输入寄存器化
  • 使用 update_ff 模拟寄存器行为。
  • 每个时钟周期,将输入有效性信号 s.in_val 和输入数据 s.in_ 存储到寄存器中。
  • 在复位信号激活时,将所有寄存器清零。
  1. 第一阶段组合逻辑
  • 通过 update 定义组合逻辑。
  • 比较输入数据对 (0,1)(2,3),将较小值存储到上方端口,较大值存储到下方端口。
  • 输出结果存储在 s.elm_next_S1 中,供下一阶段使用。

测试脚本包含以下内容:

  • 四个定向测试(directed tests)。
  • 一个随机测试(random test)。

这是参考的测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pymtl3 import *
from pymtl3.stdlib.test_utils import run_test_vector_sim

from SortUnitFlatRTL import SortUnitFlatRTL # 引入待测模块

def test_sort_unit_flatrtl(cmdline_opts):
# 定义测试向量
test_vectors = [
('in_val in_[0] in_[1] in_[2] in_[3] out_val out[0] out[1] out[2] out[3]'),
# 初始阶段,输入信号无效,但输入必须为合法值(如全为 0)
[ 0, 0x00, 0x00, 0x00, 0x00, 0, 0x00, 0x00, 0x00, 0x00 ],
# 输入第一组数据,排序结果不可用
[ 1, 0x03, 0x09, 0x04, 0x01, 0, 0x00, 0x00, 0x00, 0x00 ],
# 第一阶段寄存器传递数据
[ 0, 0x00, 0x00, 0x00, 0x00, 1, 0x01, 0x03, 0x04, 0x09 ],
# 输入第二组数据,排序结果不可用
[ 1, 0x10, 0x20, 0x15, 0x05, 0, 0x01, 0x03, 0x04, 0x09 ],
# 第二组排序完成
[ 0, 0x00, 0x00, 0x00, 0x00, 1, 0x05, 0x10, 0x15, 0x20 ],
]

# 运行测试
run_test_vector_sim(SortUnitFlatRTL(), test_vectors, cmdline_opts, print_line_trace=True)

运行测试命令如下:

1
2
3
% cd ${TUTROOT}/build
% pytest ../tut3_pymtl/sort/test/SortUnitFlatRTL_test.py -v
% pytest ../tut3_pymtl/sort/test/SortUnitFlatRTL_test.py -k test_basic -s

输出行跟踪如下(Figure 35):

24112102

这是实际的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(pymtl3_env) (usr) albert@alberts-fedora:~/CSC/cpt5$ pytest SortUnitFlatRTL_test.py -v -s
============================================== test session starts ==============================================
platform linux -- Python 3.8.20, pytest-7.2.0, pluggy-1.5.0 -- /home/albert/.conda/envs/pymtl3_env/bin/python3.8
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/albert/CSC/cpt5/.hypothesis/examples'))
rootdir: /home/albert/CSC/cpt5
plugins: hypothesis-6.113.0, pymtl3-3.1.16
collected 1 item

SortUnitFlatRTL_test.py::test_sort_unit_flatrtl
1r in_val: 0 in: [00,00,00,00] | out_val: 0 out: [00,00,00,00]
2r in_val: 0 in: [00,00,00,00] | out_val: 0 out: [00,00,00,00]
3: in_val: 0 in: [00,00,00,00] | out_val: 0 out: [00,00,00,00]
4: in_val: 1 in: [03,09,04,01] | out_val: 0 out: [00,00,00,00]
5: in_val: 0 in: [00,00,00,00] | out_val: 1 out: [01,03,04,09]
6: in_val: 1 in: [10,20,15,05] | out_val: 0 out: [01,03,04,09]
7: in_val: 0 in: [00,00,00,00] | out_val: 1 out: [05,10,15,20]
8: in_val: 0 in: [00,00,00,00] | out_val: 1 out: [05,10,15,20]
9: in_val: 0 in: [00,00,00,00] | out_val: 1 out: [05,10,15,20]
10: in_val: 0 in: [00,00,00,00] | out_val: 1 out: [05,10,15,20]
PASSED

=============================================== 1 passed in 0.77s ===============================================

5.4 基于结构化 RTL 模型的排序单元

平面RTL模型虽然可以直接实现排序单元,但其设计复杂且代码单一,难以有效利用排序器内部的层次化结构。在这一节中,我们采用结构化RTL模型,通过模块化和层次化的设计方法,将复杂设计分解为多个子模块。这种方法的主要优点包括:

  • 可维护性:通过模块化分离复杂功能,降低了设计和测试的复杂性。
  • 独立测试:每个子模块可以独立开发和测试,从而提高整体模型的可靠性。
  • 代码清晰性:层次化的设计使得代码更易读且逻辑清晰。

它的设计思想如下:

  1. 结构化设计:
  • 使用寄存器存储流水线中每个阶段的中间状态。
  • 将排序逻辑封装到独立的最小-最大单元(MinMaxUnit)中。
  1. 模块化流水线:
  • 每个流水线阶段由寄存器和组合逻辑组成。
  • 数据从一个阶段流入下一个阶段,同时每个阶段执行特定的排序逻辑。
  1. 测试可复用性:
  • 测试脚本可共享之前的测试向量和测试工具,提高测试效率。

以下是排序单元结构化RTL模型的代码(以第一个流水线阶段为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from pymtl3 import *
from pymtl3.stdlib.basic_rtl import Reg, RegRst
from MinMaxUnit import MinMaxUnit # 引入最小-最大单元模块

class SortUnitStructRTL(Component):
def construct(s, nbits=8):
# ---------------------------------------------------------------------
# 接口定义
# ---------------------------------------------------------------------
s.in_val = InPort() # 输入有效信号
s.in_ = [InPort(nbits) for _ in range(4)] # 4个输入端口,每个宽度为nbits
s.out_val = OutPort() # 输出有效信号
s.out = [OutPort(nbits) for _ in range(4)] # 4个输出端口

# ---------------------------------------------------------------------
# Stage S0->S1: 流水线寄存器
# ---------------------------------------------------------------------
# 输入有效信号寄存器化(带复位功能)
s.val_S0S1 = RegRst(Bits1)
s.val_S0S1.in_ //= s.in_val # 将输入有效信号连接到寄存器输入

# 输入数据寄存器化
s.elm_S0S1 = [Reg(mk_bits(nbits)) for _ in range(4)]
for i in range(4):
s.elm_S0S1[i].in_ //= s.in_[i] # 将每个输入端口连接到对应寄存器的输入端口

# ---------------------------------------------------------------------
# Stage S1: 组合逻辑(最小-最大排序单元)
# ---------------------------------------------------------------------
# 第一组比较单元
s.minmax0_S1 = m = MinMaxUnit(nbits) # 实例化一个最小-最大单元
m.in0 //= s.elm_S0S1[0].out # 第1个寄存器的输出连接到最小-最大单元的输入0
m.in1 //= s.elm_S0S1[1].out # 第2个寄存器的输出连接到最小-最大单元的输入1

# 第二组比较单元
s.minmax1_S1 = m = MinMaxUnit(nbits)
m.in0 //= s.elm_S0S1[2].out
m.in1 //= s.elm_S0S1[3].out

代码详解:

  1. 接口定义:
  • 定义了一个输入有效信号端口in_val和一个输出有效信号端口out_val,用于指示输入数据和输出数据的有效性。
  • 定义了4个输入端口in_和4个输出端口out,每个端口的位宽为nbits(默认为8位)。
  1. 流水线寄存器(Stage S0->S1):
  • 使用RegRst寄存器存储输入有效信号(in_val),并支持复位功能。
  • 使用4个普通寄存器(Reg)存储输入数据,每个寄存器分别对应4个输入端口。
  • 通过//=操作符连接寄存器的输入和接口的输入,实现信号的直连。
  1. 组合逻辑(Stage S1):
  • 实例化两个最小-最大比较单元(MinMaxUnit),每个单元比较两个输入数据并输出较小值和较大值。
  • 第一组比较单元(minmax0_S1)比较寄存器elm_S0S1[0]elm_S0S1[1]的输出数据。
  • 第二组比较单元(minmax1_S1)比较寄存器elm_S0S1[2]elm_S0S1[3]的输出数据。
  1. 模块间连接:
  • 使用//=操作符将寄存器输出连接到比较单元输入,避免声明中间信号,简化代码。

测试脚本:

结构化RTL模型的测试脚本包含以下几个关键点:

  1. 测试脚本复用:
  • 使用SortUnitCL_test.py中的mk_test_vector_table生成测试向量,从而复用已有的测试逻辑。
  1. 测试用例:
  • 包含四个定向测试(directed tests)和一个随机测试(random test),覆盖了排序单元的主要功能。
  1. 运行测试:
  • 执行所有测试:
1
2
cd ${TUTROOT}/build
pytest ../tut3_pymtl/sort/test/SortUnitStructRTL_test.py -v
  • 查看基本测试的行跟踪:
1
pytest ../tut3_pymtl/sort/test/SortUnitStructRTL_test.py -k test_basic -s

5.5 使用模拟器评估排序单元

在硬件设计中,评估(Evaluation)是验证(Verification)之后的关键一步。评估的核心目标是量化设计性能,例如执行某个输入数据集所需的时钟周期数、平均延迟、吞吐量等。这一步的重点在于使用模拟器而不是单元测试,模拟器通过完整的循环流程运行设计,生成统计数据,而不再关注具体功能是否正确。

模拟器的功能

  1. 处理命令行参数:通过参数指定实现模型、输入模式等选项。
  2. 生成输入数据集:支持多种输入模式(如随机输入、升序输入、降序输入)。
  3. 实例化并展开设计:根据指定的实现类型创建模型。
  4. 主循环运行设计:驱动输入信号,检测输出有效信号,记录设计行为。
  5. 生成性能统计数据:包括总时钟周期数、平均每次排序所需周期数等。

以下是简化版模拟器脚本的代码和功能详细说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
opts = parse_cmdline()  # 解析命令行参数

# --------------------------
# 1. 生成输入数据集
# --------------------------
ninputs = 100 # 输入数据集大小(可调整)
inputs = [] # 初始化输入数据列表

# 根据命令行参数生成输入数据集
if opts.input == "random": # 随机模式
for i in range(ninputs):
inputs.append([randint(0, 0xff) for _ in range(4)]) # 每组生成4个随机数

# --------------------------
# 2. 实例化并展开设计
# --------------------------
model_impl_dict = {
'cl': SortUnitCL, # 周期近似模型(Cycle-Level Approximate Model)
'rtl-flat': SortUnitFlatRTL, # 平面RTL模型
'rtl-struct': SortUnitStructRTL # 结构化RTL模型
}

model = model_impl_dict[opts.impl]() # 根据命令行参数选择对应模型

# 配置模拟器的选项
unique_name = f"sort-{opts.impl}-{opts.input}" # 唯一标识模拟运行
cmdline_opts = {
'dump_vcd': f"{unique_name}" if opts.dump_vcd else '', # 是否生成波形文件
'test_verilog': 'zeros' if opts.translate else '' # 是否进行Verilog测试
}

# 配置并初始化模型
model = config_model_with_cmdline_opts(model, cmdline_opts, duts=[])
model.apply(DefaultPassGroup(linetrace=opts.trace)) # 添加行跟踪(Line Trace)
model.sim_reset() # 重置模拟器

# --------------------------
# 3. 执行主循环
# --------------------------
counter = 0 # 记录已处理输出的计数器

while counter < ninputs:
# 记录输出有效信号
if model.out_val: # 如果输出有效信号为True
counter += 1 # 增加计数器

# 驱动输入信号
if inputs: # 如果输入队列非空
model.in_val @= 1 # 设置输入有效信号
for i, v in enumerate(inputs.pop()): # 弹出一组输入数据
model.in_[i] @= v
else:
model.in_val @= 0 # 设置输入无效信号
for i in range(4): # 清空输入端口
model.in_[i] @= 0

model.sim_eval_combinational() # 计算组合逻辑
if opts.trace: # 如果启用行跟踪
model.print_line_trace() # 打印行跟踪信息
model.sim_tick() # 模拟器前进一个时钟周期

# --------------------------
# 4. 报告统计数据
# --------------------------
if opts.stats:
print(f"num_cycles = {model.ncycles}") # 总时钟周期数
print(f"num_cycles_per_sort = {model.ncycles / ninputs:.2f}") # 每次排序的平均周期数

代码详解

  1. 生成输入数据集
  • 根据--input命令行参数生成输入数据:
  • 随机模式(random):每组数据包含4个随机数,范围为[0, 255]。
  • 升序模式(sorted-fwd):生成升序排列的输入数据。
  • 降序模式(sorted-rev):生成降序排列的输入数据。
  • 可以通过修改ninputs变量调整数据集大小。
  1. 实例化并展开模型
  • 使用字典model_impl_dict映射命令行参数到模型类,实现灵活切换模型:
  • cl模型:周期近似模型,快速模拟排序行为。
  • rtl-flat模型:平面RTL模型,直接实现硬件行为。
  • rtl-struct模型:结构化RTL模型,通过层次化结构实现更高的代码复用性。

配置选项包括:

  • 是否生成波形文件(--dump-vcd)。
  • 是否进行Verilog测试(--test-verilog)。
  1. 执行主循环
  • 模拟器通过主循环驱动设计,记录输出有效信号并计算性能统计数据。

输入处理逻辑:

  • 如果输入队列非空,则弹出一组数据,驱动输入端口。
  • 如果输入队列为空,则设置输入信号为无效。

输出处理逻辑:

  • 检测out_val信号,统计输出次数。

调用流程:

  • sim_eval_combinational():更新组合逻辑。
  • sim_tick():前进一个时钟周期。
  1. 统计结果输出
  • 模拟器通过--stats选项输出以下统计信息:
  • 总时钟周期数(num_cycles):完成所有数据处理所需的总周期数。
  • 平均每次排序的周期数(num_cycles_per_sort):衡量设计的吞吐量。

命令行参数使用示例

  1. 评估不同实现模型
1
2
3
4
cd ${TUTROOT}/build
../tut3_pymtl/sort/sort-sim --stats --impl cl # 周期近似模型
../tut3_pymtl/sort/sort-sim --stats --impl rtl-flat # 平面RTL模型
../tut3_pymtl/sort/sort-sim --stats --impl rtl-struct # 结构化RTL模型
  1. 使用不同的输入数据集
1
2
3
../tut3_pymtl/sort/sort-sim --stats --impl cl --input random       # 随机输入
../tut3_pymtl/sort/sort-sim --stats --impl cl --input sorted-fwd # 升序输入
../tut3_pymtl/sort/sort-sim --stats --impl cl --input sorted-rev # 降序输入
  1. 生成行跟踪和波形文件
1
../tut3_pymtl/sort/sort-sim --stats --impl rtl-struct --trace --dump-vcd

注意事项

  1. 模拟器与单元测试的区别:
  • 模拟器:关注设计的性能指标,例如吞吐量和延迟。
  • 单元测试:验证设计的功能正确性。
  1. 流水线启动开销:
  • 模拟结果显示,每次排序的平均时钟周期略高于1,这是由于流水线启动阶段的开销。
  1. 设计验证与性能评估的分离:
  • 模拟器假定设计功能正确,所有功能验证工作应在测试阶段完成。

5.6 将排序单元的 RTL 模型翻译为 Verilog

完成从功能级(FL)模型到周期级(CL)模型,再到寄存器传输级(RTL)模型的逐步细化设计后,我们需要将RTL模型翻译为硬件描述语言(HDL),如Verilog或SystemVerilog。

PyMTL3框架提供了强大的翻译功能,支持从RTL模型到Verilog的转换,同时保持模型的层次性和可读性。

翻译流程

  1. 基本翻译实现

翻译流程通过VerilogTranslationPass实现,以下是关键步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 导入相关模块
from pymtl3 import *
from pymtl3.passes.backends.verilog import *
from tut3_pymtl.sort import SortUnitFlatRTL

# 创建RTL模型实例
model = SortUnitFlatRTL()

# 启用Verilog翻译功能
model.set_metadata(VerilogTranslationPass.enable, True)

# 应用Verilog翻译
model.apply(VerilogTranslationPass())

运行上述代码后,PyMTL3将生成翻译后的Verilog代码。

  1. 输出文件

文件命名:SortUnitFlatRTL__nbits_8__pickled.v

  • 文件名包含模型的参数信息(如nbits=8),确保不同参数化实例的模块名唯一。

文件内容:

  • 翻译生成的Verilog代码保留了模块层次。
  • update_ff块翻译为always_ff块。
  • update块翻译为always_comb块。
  • 每个并发块的PyMTL3代码会以注释形式保留在Verilog代码中,方便调试。
  1. 代码特点
  • 层次保留:翻译工具保留了模型的模块层次结构,如子模块实例化。
  • 易读性:通过名称修饰规则(name mangling),生成的Verilog代码具有良好的可读性。

翻译优化:

  • 翻译结果支持缓存机制,避免重复翻译相同模型。
  • 在调试过程中可通过注释和层次结构快速定位问题。

高级翻译功能

PyMTL3提供了VerilogTranslationImportPass,其功能远超简单的Verilog代码生成。它可以
1. 翻译RTL模型为Verilog。
2. 使用Verilator工具将Verilog代码转换为C++。
3. 生成C++包装器,用于调用Verilog的C++仿真。
4. 创建PyMTL3包装器,将C++包装器嵌入PyMTL3模型,形成一个精确的周期级硬件模拟器。

这一流程允许在PyMTL3中无缝调用翻译后的Verilog,同时使用相同的测试脚本验证模型功能。

翻译后可以进行模型测试。以下是一个支持翻译后模型测试的单元测试脚本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from pymtl3 import *
from pymtl3.passes.backends.verilog import *
from pymtl3.stdlib.test_utils import config_model_with_cmdline_opts
from ..SortUnitFlatRTL import SortUnitFlatRTL

def test_verilate(cmdline_opts):
# 配置模型,启用dump_vcd和test_verilog选项
model = SortUnitFlatRTL(8)
model = config_model_with_cmdline_opts(model, cmdline_opts, duts=[])

# 创建并重置模拟器
model.apply(DefaultPassGroup(linetrace=True))
model.sim_reset()

# 辅助函数:设置输入并验证输出
def t(in_val, in_, out_val, out):
model.in_val @= in_val
for i, v in enumerate(in_):
model.in_[i] @= v
model.sim_eval_combinational()
assert model.out_val == out_val
if out_val:
for i, v in enumerate(out):
assert model.out[i] == v
model.sim_tick()

# 测试用例
t(0, [0x00, 0x00, 0x00, 0x00], 0, [0x00, 0x00, 0x00, 0x00])
t(1, [0x03, 0x09, 0x04, 0x01], 0, [0x00, 0x00, 0x00, 0x00])
t(1, [0x10, 0x23, 0x02, 0x41], 0, [0x00, 0x00, 0x00, 0x00])
t(1, [0x02, 0x55, 0x13, 0x07], 0, [0x00, 0x00, 0x00, 0x00])
t(0, [0x00, 0x00, 0x00, 0x00], 1, [0x01, 0x03, 0x04, 0x09])
t(0, [0x00, 0x00, 0x00, 0x00], 1, [0x02, 0x10, 0x23, 0x41])
t(0, [0x00, 0x00, 0x00, 0x00], 1, [0x02, 0x07, 0x13, 0x55])
t(0, [0x00, 0x00, 0x00, 0x00], 0, [0x00, 0x00, 0x00, 0x00])

以下是运行测试的命令:

1
2
3
4
5
# 测试PyMTL3 RTL模型
pytest ../tut3_pymtl/sort/test/SortUnitFlatRTL_v_test.py --dump-vcd

# 测试翻译后的Verilog模型
pytest ../tut3_pymtl/sort/test/SortUnitFlatRTL_v_test.py --dump-vcd --test-verilog

测试过程中会生成多个波形文件(VCD文件),包括:

  • sort-pymtl.vcd:用于测试原始PyMTL3 RTL模型。
  • 测试翻译后的Verilog模型的波形文件(两个VCD文件分别对应PyMTL3包装器和实际Verilog设计)。

最终,我们可以使用模拟器脚本生成排序单元的Verilog代码和波形文件,这些文件可用于FPGA或ASIC工具链。

1
2
3
../tut3_pymtl/sort/sort-sim --impl rtl-flat --input random --translate --dump-vcd
../tut3_pymtl/sort/sort-sim --impl rtl-flat --input sorted-fwd --translate --dump-vcd
../tut3_pymtl/sort/sort-sim --impl rtl-flat --input sorted-rev --translate --dump-vcd
  • Title: PyMTL基础入门
  • Author: Albert Cheung
  • Created at : 2024-11-07 20:20:39
  • Updated at : 2024-11-25 17:42:36
  • Link: https://www.albertc9.github.io/2024/11/07/introduction-to-pymtl-basics/
  • License: This work is licensed under CC BY-NC-SA 4.0.