FPGA 定点数缩位:截断、舍入与饱和,避免溢出回绕
乘加结果接到较窄的输出端口时,截掉几位看起来很直接。真正需要先确定的是:丢弃小数位时怎样取近似值,整数范围不足时怎样处理溢出。这两件事分别影响量化误差和大幅度信号行为,不能用一句"做个截断"代替。本篇先建立整数参考模型,用穷举把负数、中点和进位边界检查清楚,再用于后续 RTL 对照。
1. 把格式写完整
只写 Qm.n 容易产生歧义:不同资料对 m 是否包含符号位的约定可能不同。这里统一用三项定义:总位宽 W、小数位数 F、有符号补码。存储整数为 R,真实值为 R×2^(-F),范围是 -2^(W-1-F) 到 2(W-1-F)-2(-F)。步进为 2^(-F)。补码负端比正端多一个量化台阶。精度与范围说明
例如 W=8、F=4 表示 -8 到 7.9375。改为 W=4、F=2 后,范围变成 -2 到 1.75,步进由 1/16 增至 1/4。这里既少了小数精度,也缩小了可表示范围;仅仅右移不能解决第二个问题。
下面限定 F_in≥F_out,令 d=F_in-F_out,目标存储整数的理想值为 R/2^d。输入 R 必须是已解码的有符号整数,不是尚未解释符号的十六进制码字。
2. 负数的截断不等于向零
令 D=2^d,做欧几里得除法 R=qD+r,其中 q 向负无穷取整,0≤r<D。Python 的 divmod 对正除数正好符合此约定。
以 R=-7、d=2 为例,理想输出整数是 -1.75。直接丢弃补码低位得到 -2,即 floor;向零取整得到 -1。两者在正数上可能一致,在负数上却差一个输出 LSB。MathWorks 也明确区分补码低位截断与向零取整。舍入模式说明
因此 floor 直接取 q;向零模式仅在 R<0 且 r≠0 时取 q+1。别把"truncation"这个词当作跨语言、跨库完全一致的接口约定,应写明数学方向。
3. 最近偶数舍入怎样处理中点
最近舍入还需要规定正好半格时选哪边。本例采用 ties-to-even:距离相等时选偶数输出整数。例如 -2.5→-2、-1.5→-2、-0.5→0,正数 0.5→0、1.5→2。
基于 q、r,只需判断:2r>D 时加一;2r=D 且 q 为奇数时也加一,其余保留 q。这对负数同样成立,不需要给负数另加一个"负半格偏置"。当 d=0 时余数为零,算法自然保持输入,不应构造 2^(d-1) 这样的特殊常量。
偶数舍入避免把所有中点固定推向同一方向,但不能保证任意信号序列的平均误差都是零。量化误差还依赖数据分布;若后续发生饱和,误差也不再受半个 LSB 限制。
4. 饱和必须赶在窄化之前
输出 W 位有符号整数的边界为 L=-2(W-1)、H=2(W-1)-1。先在足够宽的域中得到舍入结果 Q,再计算 min(H,max(L,Q)),最后编码为 W 位。
取 W=4、d=2、R=30,理想目标整数是 7.5,偶数舍入产生 8。虽然 floor 结果 7 能放进去,舍入进位却越过正端。饱和输出应为 7;若先只保留四位,8 会被解释成 -8,此后再检查范围已经无法发现原来的正溢出。
负端也要独立覆盖:R=-36 对应 -9,饱和为 -8,回绕则为 7。饱和是夹到端点,回绕是模运算;两种策略都可以被明确选择,但不能混用。厂商工具同样将舍入模式与溢出动作分开配置。转换与溢出设置
整数模型利用 Python 的任意精度整数保留进位。移植到硬件时,中间位宽必须容纳舍入前后的值;不能让加法器先溢出,再期待比较器补救。
5. 怎样选规则并定位误差
不发生饱和时,若误差定义为"输出真实值减输入真实值",floor 的误差位于负一个输出 LSB 到零之间,且不包含负一个 LSB;向零取整的误差方向随输入符号改变;最近偶数舍入的绝对误差不超过半个输出 LSB。调试时可以先按这些界限检查正常幅度样本,再单独观察越界样本,避免把削顶误认为普通量化噪声。
不要在乘法输出、加法输出和最终接口上随意重复缩位。多次量化可能与宽域运算后的一次量化不同:例如目标整数值 1.375,先舍入到半整数网格得到 1.5,再舍入到整数得到 2;直接舍入到整数则得到 1。两步都用最近偶数,结果仍然不同。哪些节点需要保留保护位,应由数据范围与误差预算决定。
如果系统规定模运算,例如某些相位累加路径,回绕可以是期望行为;如果输出代表受限幅度,饱和更容易保持端点方向。最终选择应服从算法接口,不宜仅因某种写法少几行代码就更改数值规则。软件模型保留两种溢出动作,正是为了让这项选择能被单独测试。
6. 可直接运行的整数模型
保存以下代码为 demo.py,用 Python 3 执行。模型没有浮点除法,也不借助 Python 的 round,避免参考值自身引入另一套舍入规则。oracle 使用符号与绝对值独立计算,再与商余数算法对照。
python
"""Integer-only fixed-point narrowing model; Python 3, no dependencies."""
def rounded(raw, drop, mode):
if drop < 0:
raise ValueError("drop must be nonnegative")
den = 1 << drop
q, r = divmod(raw, den) # floor quotient; 0 <= r < den
if mode == "floor":
return q
if mode == "zero":
return q + int(raw < 0 and r != 0)
if mode == "even":
return q + int(2*r > den or (2*r == den and q % 2 != 0))
raise ValueError("unknown rounding mode")
def narrow(raw, drop, width, mode="even", overflow="sat"):
if width < 2:
raise ValueError("width must be at least 2")
q = rounded(raw, drop, mode)
lo, hi = -(1 << (width-1)), (1 << (width-1))-1
if overflow == "sat":
return min(hi, max(lo, q)) # clamp BEFORE packing into width bits
if overflow == "wrap":
return (q-lo) % (1 << width) + lo
raise ValueError("unknown overflow mode")
def oracle_round(raw, drop, mode):
# Independent sign/magnitude formulation, never floating point.
den = 1 << drop
a, rem = divmod(abs(raw), den)
if mode == "floor":
return -a-int(rem != 0) if raw < 0 else a
if mode == "zero":
return -a if raw < 0 else a
if rem*2 > den or (rem*2 == den and a % 2):
a += 1
return -a if raw < 0 else a
def self_test():
checks = 0
for win in range(3, 9):
for wout in range(2, win):
lo, hi = -(1 << (wout-1)), (1 << (wout-1))-1
for fin in range(win):
for fout in range(fin+1):
drop = fin-fout
for raw in range(-(1 << (win-1)), 1 << (win-1)):
for mode in ("floor", "zero", "even"):
q = oracle_round(raw, drop, mode)
assert rounded(raw, drop, mode) == q
sat = lo if q < lo else hi if q > hi else q
# Obtain wrapping independently from low-bit pattern.
bits = q & ((1 << wout)-1)
wrap = bits-(1 << wout) if bits & (1 << (wout-1)) else bits
assert narrow(raw, drop, wout, mode, "sat") == sat
assert narrow(raw, drop, wout, mode, "wrap") == wrap
assert raw >> drop == rounded(raw, drop, "floor")
checks += 1
assert [rounded(x, 1, "even") for x in (-5, -3, -1, 1, 3, 5)] == [-2, -2, 0, 0, 2, 2]
assert narrow(31, 2, 4) == 7 and narrow(31, 2, 4, overflow="wrap") == -8
assert narrow(-36, 2, 4) == -8 and narrow(-36, 2, 4, overflow="wrap") == 7
print(f"PASS: {checks} exhaustive rounding cases; saturation and wrap both checked")
print("PASS: positive/negative ties, input extrema, zero-drop, rounding carry")
print("raw floor zero even sat4 wrap4 (drop=2)")
for raw in (-36, -32, -7, -6, -2, 2, 6, 30, 31):
row = [raw] + [rounded(raw, 2, m) for m in ("floor", "zero", "even")]
row += [narrow(raw, 2, 4, "even", o) for o in ("sat", "wrap")]
print(*row)
if __name__ == "__main__":
self_test()
7. 实际测试结果与使用边界
本次运行穷举输入位宽 3~8 位、输出位宽 2~W_in-1 位,F_in 从 0 到 W_in-1,F_out 从 0 到 F_in;每组遍历全部输入码值及三种舍入模式。每个计数项均检查舍入结果、饱和结果和回绕结果,并覆盖 d=0、正负端点及中点。不同格式可能对应相同 d,计数保留这些格式组合。
运行输出如下,表中全部是存储整数,sat4、wrap4 使用偶数舍入;若 F_out=2,还需除以 4 才是真实值。
text
PASS: 241200 exhaustive rounding cases; saturation and wrap both checked
PASS: positive/negative ties, input extrema, zero-drop, rounding carry
raw floor zero even sat4 wrap4 (drop=2)
-36 -9 -9 -9 -8 7
-32 -8 -8 -8 -8 -8
-7 -2 -1 -2 -2 -2
-6 -2 -1 -2 -2 -2
-2 -1 0 0 0 0
2 0 0 0 0 0
6 1 1 2 2 2
30 7 7 8 7 -8
31 7 7 8 7 -8
这 241200 组是软件模型的穷举检查,不是 RTL 仿真或板级验证,也不说明资源、频率或吞吐率。落到 RTL 后,应将同一批向量送入设计,按实际流水延迟比对输出,并额外检查复位和有效信号。验收数值接口时,把 W、F、补码符号、舍入规则、溢出动作一起写入规格,才能让算法模型和硬件实现讨论同一个结果。