答疑特辑(二):浮点数五问——无限循环小数、spacing at 1、Python 精度与舍入方式详解

11193 字
56 分钟
答疑特辑(二):浮点数五问——无限循环小数、spacing at 1、Python 精度与舍入方式详解

AI 生成内容声明

开场:这一篇回答哪五个问题#

《浮点数与数值稳定性:从 IEEE 754 到 FP8 的 LLM 精度世界》 发布后,收到了一批追问。这批问题质量很高,每一个都戳在浮点数最容易产生误解的地方,而且彼此之间有隐含的联系:问题 1 和问题 2 其实都在问”表示”这件事的边界,问题 3 问 Python 到底用了什么格式,问题 4、5 问舍入这个动作本身。

本篇(答疑特辑(二))延续上一篇《答疑特辑:秩与 SVD、RoPE 旋转矩阵、投机解码原理与 AI 芯片全景》开启的系列,回答浮点数相关的五个问题:

  1. 二进制里面的无限循环小数算无理数吗?
  2. “严格说这是 spacing at 1”,这是什么意思,这个”严格”有什么特别含义吗?
  3. Python 一般不指定数据格式,那用的是什么精度?FP64 还是什么?
  4. C、C++、FPGA 的 Vitis HLS、CUDA、Triton 等语言与工具的默认舍入方式有什么区别?
  5. 有哪些舍入方法?银行家舍入和 round-to-nearest, ties-to-even 是一样的吗?

另外还有一批关于矩阵秩、TPU 训练、TPU 与 B200/B300 对比、AI 芯片全景的问题,属于另一条主线,将在答疑特辑(三)里逐一回答,本篇末尾有预告。

五个问题都不大,但每个都值得彻底讲清——它们合起来正好补上浮点数知识里最容易被”差不多懂了”带过去的四块:表示、间距、格式选择、舍入。

问题一:二进制里的无限循环小数算无理数吗?#

直接回答:不算#

无限小数和有理数不矛盾。有理数的定义是能写成两个整数之比 pq\frac{p}{q}q0q \neq 0,它和”小数展开长什么样”没有直接关系。0.1=1100.1 = \frac{1}{10},分子分母都是整数,所以 0.10.1 毫无疑问是有理数——哪怕它在二进制里展开成无限长的串。

判断一个小数是否无理数,正确标准是:展开既无限、又不循环。两条缺一不可:

  • 有限(terminating):0.5=0.120.5 = 0.1_2,写完了,当然是有理数;
  • 无限但循环(repeating):0.333=130.333\cdots = \frac{1}{3},无限,但是有理数;
  • 无限且不循环(non-repeating):2=1.011010100000100111102\sqrt{2} = 1.01101010000010011110\cdots_2,这才叫无理数。

所以问题一的准确答案是:“二进制里的无限循环小数”不但是有理数,而且它必然是(某个)有理数。循环是这个数”有理”的证据,不是”无理”的证据。上一篇《浮点数与数值稳定性》里”0.1 在二进制里是无理数”这个小标题用词不准确——0.10.1 的二进制展开是无限循环小数,严格说是”0.1 无法用有限位二进制小数精确表示”,下文专门有一节纠这个措辞。

为什么 1/10 的二进制展开必然循环:长除法#

要理解”循环”从哪来,直接做二进制长除法。十进制里 110\frac{1}{10} 的展开是有限小数 0.10.1,是因为长除法除着除着余数变成 0 了;而二进制里 1÷101 \div 10(即 12÷101021_2 \div 1010_2)的余数永远到不了 0,于是永远除不完。

关键观察在余数上:用 qq 做除数,每一步的余数只能是 0,1,,q10, 1, \dots, q-1qq 个值之一。一旦某个余数重复出现,后面所有步骤都会跟着重复(因为下一步的运算完全由”当前余数 × 进制”决定),展开就进入循环。所以:

  • 余数到达 00 → 展开终止(有限小数);
  • 余数永不重复 → 永远除不完(无理数);
  • 余数重复(鸽笼原理保证至多 qq 步内必重复)→ 展开无限循环。

1÷101 \div 10 在二进制里,余数序列 1,2,4,8,6,2,1, 2, 4, 8, 6, 2, \dots 在第六步就撞上了重复的 2266 的下一个余数),于是循环节从第二位余数开始。展开结果就是

110=0.0001100110011001100112=0.000112\frac{1}{10} = 0.000110011001100110011\cdots_2 = 0.0\overline{0011}_2

循环节是 0011,四位。下面是 Rick Regan 的博客 Why 0.1 Does Not Exist In Floating-Point(2012)里画的完整长除法过程,每一步余数、每一位商都列了出来,可以看到余数 22 如何触发循环:

0.1 的二进制长除法:余数 1, 2, 4, 8, 6, 2… 重复,展开进入 0011 循环(来源:exploringbinary.com,Rick Regan)
0.1 的二进制长除法:余数 1, 2, 4, 8, 6, 2… 重复,展开进入 0011 循环(来源:exploringbinary.com,Rick Regan)

同一篇文章还把展开写到了第 1369 位,可以看到 0011 这个模式忠实重复——任何一个有理数的展开最终都长这样:

0.1 的二进制展开(写到 1369 位):0011 循环节无限重复(来源:exploringbinary.com,Rick Regan)
0.1 的二进制展开(写到 1369 位):0011 循环节无限重复(来源:exploringbinary.com,Rick Regan)

这个证明有个漂亮的副产品:循环节长度上界。余数只有 qq 个可能值,所以任何有理数 pq\frac{p}{q} 在任意进制下的循环节长度最多是 q1q-1(到达余数 00 之前)。110\frac{1}{10} 在二进制里循环节是 4 位,恰好不超过 q1=9q-1 = 9

反过来也成立:循环展开一定是有理数#

方向反过来也对:任何(最终)循环的小数展开都可以写成分数。思路是把循环部分当成无穷等比数列求和。

拿十进制里的经典例子 0.333=0.3330.\overline{333} = 0.333\cdots 来说:

0.333=3×(101+102+103+)=3×1011101=3×19=130.333\cdots = 3 \times \left(10^{-1} + 10^{-2} + 10^{-3} + \cdots\right) = 3 \times \frac{10^{-1}}{1 - 10^{-1}} = 3 \times \frac{1}{9} = \frac{1}{3}

二进制的 0.0001120.0\overline{0011}_2 同理:0.00011001100112=116(1+116+)+0.0001100110011\cdots_2 = \frac{1}{16} \cdot (1 + \frac{1}{16} + \cdots) + \dots 直接算也行,但既然它是长除法的产物,它本来就是 110\frac{1}{10}

把两个方向合起来,就得到一条完整的判别定理(初等数论/小数展开理论的基本结论):

xQ    x 在任意进制下展开有限,或(最终)循环x \in \mathbb{Q} \iff x \text{ 在任意进制下展开有限,或(最终)循环}

“最终循环”意思是前面可以有有限位不循环的前缀(比如 16=0.16\frac{1}{6} = 0.1\overline{6}),后面才进循环。这条定理与进制无关——一个数是有理数还是无理数,是它自身的性质;进制只是把它”展示”出来。进制能改变的是展示的形态(有限还是循环、循环节多长),改变不了本质。

进制决定”是否有限”,不决定”是否有理”#

既然循环不循环不决定有理无理,那进制到底影响什么?影响的是展开会不会终止。判定条件非常干净:

把分数化成最简形式 pq\frac{p}{q},它bb 进制下有限,当且仅当 qq 的所有质因子都在 bb 的质因子集合里。

  • 10=2×510 = 2 \times 5110\frac{1}{10} 的分母质因子是 {2,5}\{2, 5\},都在 1010 的质因子里 → 十进制展开有限(0.10.1);
  • 二进制 b=2b = 2 的质因子只有 {2}\{2\}55 不在里面 → 110\frac{1}{10} 在二进制里不可能有限,只能循环;
  • 13\frac{1}{3} 的分母质因子 {3}\{3\}:十进制、二进制都循环,但三进制 b=3b = 30.130.1_3 就是有限小数。

直觉解释:bb 进制下”一位”代表 1bk\frac{1}{b^k},所以只有分母能整除某个 bkb^k 的数才写得完;分母带 bb 之外质因子的数,除下去那个质因子的倒数永远”除不干净”,只好循环。这也解释了为什么 0.1、0.2、0.3、0.7 这些”干净”的十进制小数在计算机里全部不干净——计算机的”进制”是 2,而十进制小数的分母几乎都带质因子 5

给上一篇的措辞纠个错#

《浮点数与数值稳定性》里的小标题”0.1 在二进制里是无理数”是错的,这里正式更正:0.1=1100.1 = \frac{1}{10} 是有理数,它的二进制展开 0.000110011001120.0001100110011\cdots_2 是无限循环小数。准确的说法应该是:

0.10.1 无法用有限位二进制小数表示(它的二进制展开是无限循环小数),因此任何有限位数的二进制浮点格式都只能存它的近似值。

“无法精确表示”这个结论完全成立,它正是 0.1 + 0.2 != 0.3 的根源;只是”无理数”这个词用错了地方。这种小错误在浮点数科普里很常见,因为大家口头上把”二进制里写不完”叫成”无理”,但数学术语里”无理”只有一个意思:不能写成 pq\frac{p}{q}

浮点数里存的到底是谁#

顺着这个问题再往下一层:既然 0.10.1 存不进去,那 FP64 的 0.1 里到底装了什么?

答案:装的是离 0.10.1 最近的、分母是 2 的幂的分数。FP64 有 53 位有效数字,所以

FP64 里的 0.1=3602879701896397255=0.1000000000000000055511151231257827021181583404541015625\text{FP64 里的 0.1} = \frac{3602879701896397}{2^{55}} = 0.1000000000000000055511151231257827021181583404541015625

注意这个数是有限小数(虽然长得吓人):它的分母是 2552^{55},而 22 的幂能整除 1010 的幂(25510552^{55} \mid 10^{55}),所以它在十进制里也必然写得出头——恰好是上面那串 55 位十进制小数。也就是说,计算机里存的每个浮点数,本质上都是有理数,而且都是”二进制有限小数”;你觉得它”不精确”,不是因为它是无理数,而是因为”你要的那个有理数(1/101/10)恰好不在浮点数的网格上”。浮点格式的能力边界是”分母只能含质因子 2”,55 的出现让 0.10.1 落网。

顺带一提,无理数在计算机里只能用”截断到某位的近似”存在:2\sqrt{2}π\piee 在任何进制下都不循环,任何有限位数都只是近似值——这才是真正”无理”的数。LLM 推理里几乎遇不到它们,但你用的每个 float 都可能是某个”想要的数”的近似,区别只是近似误差 101610^{-16} 还是 10310^{-3}

小结:判别流程#

遇到”这个小数算不算无理数”,按三步走:

  1. 它能不能写成 pq\frac{p}{q}?能 → 有理数,直接判完;
  2. 写不出的 → 看展开:有限或(最终)循环 → 仍然有理(步骤 1 一定能写出来,两条等价);
  3. 展开无限且不循环 → 无理数。

进制在这里面从头到尾只是个”投影仪”,负责决定展开长什么样,不负责决定数是什么。

问题二:“spacing at 1”是什么意思,“严格”严格在哪?#

问题出自上一篇里的这句话:

机器精度 ϵ\epsilon:数值 1 与下一个可表示浮点数之间的间距。ϵFP32=223\epsilon_{FP32} = 2^{-23}。(严格说这是 “spacing at 1”,有的教材把”半间距”也叫 epsilon;本文统一用间距。)

拆成两个问题:一是”spacing at 1”这个说法本身是什么意思,二是为什么前面要加”严格说”。

定义:spacing at 1 = ULP(1)#

浮点数在数轴上不是均匀分布的:越靠近 0 越密,越往两边越稀疏(上一篇讲过)。“间距”(spacing)就是相邻两个可表示浮点数之间的距离,它随数值大小变化。特别地,11 和它下一个邻居之间的距离叫 spacing at 1——“在 1 处的间距”。

FP32 里,1=1.00002×201 = 1.000\cdots0_2 \times 2^0,下一个数是尾数最低位加 1 的 1.00012×201.000\cdots1_2 \times 2^0,距离恰好是

spacing at 1FP32=2231.19×107\text{spacing at 1}_{FP32} = 2^{-23} \approx 1.19 \times 10^{-7}

这个量还有一个更正式的名字:ULP(1)——ULP 是 Unit in the Last Place(末位单位)的缩写,指”尾数最低位那一格代表的数值”。对指数为 ee 的数,ULP 大约是 2e232^{e - 23}(FP32),在 e=0e = 0 时就是 2232^{-23}

Goldberg 的经典论文《What Every Computer Scientist Should Know About Floating-Point Arithmetic》(1991,ACM Computing Surveys)第一张图把这个概念画得最直观——它用 β=2\beta = 2、精度 p=3p = 3(尾数 3 位)的最小例子展示了浮点数在数轴上的分布:[1,2)[1,2) 区间内四个点等距排布,[2,4)[2,4) 区间间距翻倍,[4,8)[4,8) 再翻倍,越往右越稀:

Goldberg 论文 Figure 1:β=2、p=3 时浮点数在数轴上的分布,间距随指数每档翻倍(来源:D. Goldberg, 1991)
Goldberg 论文 Figure 1:β=2、p=3 时浮点数在数轴上的分布,间距随指数每档翻倍(来源:D. Goldberg, 1991)

这张图里 11 和它的下一个邻居之间的距离就是”spacing at 1”(在这个例子里是 22=0.252^{-2} = 0.25)。看这张图还能直接读出上一篇的结论:相对间距恒定——[1,2)[1,2) 里间距 0.250.251125%25\%[4,8)[4,8) 里间距 114425%25\%,比例不随位置变化。这就是”相对精度恒定”的图像:浮点数的相对间距固定为 21p2^{1-p}(本例 p=3p=3 时是 222^{-2}),绝对间距随指数成倍增长。

为什么偏偏是”at 1”:相对精度的基准#

“at 1”不是随便挑的位置。对任何规格化数 xx,间距和 xx 本身成正比(比例因子是 2232^{-23} 上下浮动最多 2 倍,Goldberg 管这个浮动叫 wobble),所以

spacing(x)x223=spacing at 1\frac{\text{spacing}(x)}{|x|} \approx 2^{-23} = \text{spacing at 1}

也就是说,spacing at 1 恰好等于任意位置上的”相对间距”。而相对误差、有效数字这些概念全是相对量,所以”在 1 处的间距”就成了全格式相对精度的标准刻度:知道 2232^{-23},就知道 FP32 任何位置大约有 7 位十进制有效数字。这就是为什么各种语言、教材、硬件手册都拿”1 和下一个数的距离”来定义机器精度——Python 的 sys.float_info.epsilon 甚至直接采用这个定义(问题三会看到它是 2522^{-52},正好是 FP64 的 spacing at 1)。

“严格说”:epsilon 的两个学派#

现在说”严格”到底严格在哪。epsilon(机器精度)这个词在文献里其实有两种用法,数值上差一倍:

  • 学派 A(间距派)ϵ\epsilon = spacing at 1 = 2232^{-23}(FP32)。Goldberg 的论文、Python 的 sys.float_info.epsilon、多数硬件手册用这个。
  • 学派 B(半间距派)ϵ\epsilon = 半间距 = 2242^{-24}(FP32)。理由是:round-to-nearest 下单次运算的相对误差上界恰好是半间距(误差不超过到两个邻居的间距的一半),所以”最大相对误差”这个量更常用,很多数值分析教材干脆把这个上界叫 epsilon。

两个定义都对,只是量的不是同一个东西:一个是”刻度的大小”(spacing),一个是”误差的上界”(half spacing)。上一篇的小注里”有的教材把半间距也叫 epsilon”说的就是学派 B。为了避免混淆,上一篇全文统一用”间距”的叫法,并把误差上界单独说成”半间距”——这就是”严格说”的含义:用词严格化,把两个被混叫成 epsilon 的量分开

把两个量放一起记:

spacing at 1=223,最大相对舍入误差12×223=2245.96×108\text{spacing at 1} = 2^{-23}, \qquad \text{最大相对舍入误差} \approx \frac{1}{2} \times 2^{-23} = 2^{-24} \approx 5.96 \times 10^{-8}

5.96×1085.96 \times 10^{-8} 的倒数取对数 ≈ 7.2,这就是”FP32 约 7 位有效数字”的精确出处。

一个容易被忽略的边角:ULP 定义本身也有两个学派#

既然聊到严格性,顺带把 ULP 的坑也填了。ULP 的”教科书定义”是:xx 与离它最近的可表示浮点数之间的距离。但在 xx 恰好在 2 的幂上(比如 x=1x = 1 本身)时,往左看间距是 2242^{-24}1112241 - 2^{-24}),往右看是 2232^{-23}111+2231 + 2^{-23})——两边不一样,那 ULP(1) 到底算哪个?文献里两个著名定义(Goldberg 的”尾数末位权重”定义与 Muller 的”两邻居间距”定义)在这里差了一倍,学术界专门有论文讨论这件事(Muller 等,《On the definition of ulp(x)》,ACM TOMS 2005)。对工程而言记住一条即可:ULP 在 2 的幂附近有 ±1 倍的不确定性,讨论误差时用”不超过几个 ULP”这种量级说法,别抠死数字

一张表:各格式的 spacing at 1#

格式spacing at 1半间距(RN 下最大相对误差)十进制有效数字(约)
FP642522.2×10162^{-52} \approx 2.2 \times 10^{-16}2531.1×10162^{-53} \approx 1.1 \times 10^{-16}15~16
FP322231.2×1072^{-23} \approx 1.2 \times 10^{-7}2246.0×1082^{-24} \approx 6.0 \times 10^{-8}7
FP162109.8×1042^{-10} \approx 9.8 \times 10^{-4}2114.9×1042^{-11} \approx 4.9 \times 10^{-4}3
BF16277.8×1032^{-7} \approx 7.8 \times 10^{-3}283.9×1032^{-8} \approx 3.9 \times 10^{-3}2
FP8 E4M323=0.1252^{-3} = 0.12524=0.06252^{-4} = 0.06251
FP8 E5M222=0.252^{-2} = 0.2523=0.1252^{-3} = 0.125<1

读表方法:E4M3 的 spacing at 1 是 0.125,意思是”1 附近的两个可表示数之间隔着 0.125”,所以 0.1 和 0.2 这种值在 E4M3 里彼此可能撞到同一个网格点——这就是上一篇说”FP8 只有 1 位有效数字”的含义。

延伸:spacing 在零附近会被”抢救”:次正规数#

看图 1 会有一个疑问:间距按 2k2^k 递减,那接近 0 时间距无限趋近于 0,怎么还有”最小正浮点数”这个概念?答案在上一篇讲过的次正规数(subnormal):规格化数的指数有下限 emine_{min}[0,2emin)[0, 2^{e_{min}}) 这段本来该是空白(floating-point gap),次正规数用”尾数前导位为 0”的方式把这个区间按均匀间距 2emin(p1)2^{e_{min} - (p-1)} 填满,让间距函数在零附近保持平滑递减、不出现断崖。

这个设计的价值,Goldberg 论文的 Figure 2 画得最清楚——“flush to zero(把下溢结果直接清零)“和”gradual underflow(用次正规数平滑过渡)“两条路径的对比:flush 时 aba-baba \approx b 处可能直接从某个小值跌到 0,破坏”减法结果随输入连续变化”的性质;gradual 时则一路平滑:

Goldberg 论文 Figure 2:flush to zero 与 gradual underflow 的对比——前者在零附近留下间距断崖,后者用次正规数平滑过渡(来源:D. Goldberg, 1991)
Goldberg 论文 Figure 2:flush to zero 与 gradual underflow 的对比——前者在零附近留下间距断崖,后者用次正规数平滑过渡(来源:D. Goldberg, 1991)

这张图在问题四还会回来:CUDA 的 -ftz 编译选项干的就是”flush to zero”,默认关、开 fast math 时自动打开;FPGA 的浮点 IP 也常常让你选是否支持次正规数(支持要额外硬件)。“间距”这个概念从 1 一路延伸到 0,处处都牵涉工程取舍。

问题三:Python 默认用什么精度?#

直接回答:float 就是 FP64#

Python 的 float 类型永远是 IEEE 754 双精度 binary64(即 C 语言里的 double),位布局是 1+11+521 + 11 + 52:1 位符号、11 位指数、52 位尾数字段(加隐式前导位共 53 位有效数字)。Python 没有内置的 FP32/FP16,你写 1.00.11e308,全部是 double,不指定也如此——这就是”不指定数据格式时用的什么精度”的答案:FP64。

CPython 的实现里,PyFloatObject 内部就是一个 C double。所以 Python 浮点运算的精度行为与 C 的 double 逐位一致:同样的舍入、同样的 epsilon、同样的溢出到 inf。Python 官方教程的 Floating Point Arithmetic: Issues and Limitations 文档页第一句就是:“Floating-point numbers are represented in computer hardware as base 2 (binary) fractions”,并且明确指出 float 在几乎所有平台上都是 IEEE 754 double。

证据一:sys.float_info#

Python 把宿主机器浮点格式的全部参数暴露在 sys.float_info 里,跑一下就能验证:

>>> import sys
>>> sys.float_info
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308,
min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307,
dig=15, mant_dig=53, epsilon=2.220446049250313e-16,
radix=2, rounds=1)

逐项对照 binary64 的参数表:

  • mant_dig = 53:尾数 53 位(含隐式前导位),对上了 52 位尾数字段;
  • radix = 2:基是 2;
  • epsilon = 2.220446049250313e-16 = 2^{-52}:官方文档定义是”1.0 与大于 1.0 的最小可表示数之差”——这正是问题二讲的 spacing at 1!Python 官方文档采用的是”间距派”定义,与 Goldberg 一致。这是问题二和问题三的接线点:上一篇说 FP64 的 spacing at 1 是 2522^{-52},Python 的文档和实现原样采用了它;
  • rounds = 1:舍入模式是 round-to-nearest(1 表示 IEEE 754 的 RN);
  • dig = 15:十进制下能无损往返(round-trip)的位数——存成十进制再读回来不丢信息的最大位数是 15 位,跟”FP64 约 15~16 位有效数字”对应。

证据二:as_integer_ratio 和 float.hex#

想看到 0.1 这个 float 的真身(而不是显示值),两个 API 最直接:

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> 0.1.hex()
'0x1.999999999999ap-4'
>>> (0.1).as_integer_ratio()[0] / (0.1).as_integer_ratio()[1]
0.1

as_integer_ratio() 返回的正是问题一里的那个精确有理数:3602879701896397×2553602879701896397 \times 2^{-55}float.hex() 给出规格化十六进制形式 0x1.999999999999a×240x1.999999999999a \times 2^{-4},尾数 999999999999a 是 13 个十六进制位(52 个二进制位),能看出它比真值 0.10.1 大一点点——因为 0.10.1 的展开在第 53 位之后是 10011001…,超过了两个候选数 …9999999999999…a 的中点,round-to-nearest 把它向上舍入到了 a(不是 tie,只是”过半进位”)。

为什么打印出来是 0.1:最短往返表示#

既然存的不是 0.1,为什么 print(0.1) 显示 0.1?因为 Python 3.1 起,repr/str 对 float 采用最短往返表示算法:输出”能唯一确定这个 float 的、最短的十进制字符串”。'0.1' 恰好是能唯一定位到这个 double 的最短字符串(比 '0.1000000000000000055511151231257827' 短得多),所以显示 0.1。这不是精度有 0.1 那么高,而是显示层做了”去冗余”处理——打印出的字符串 ≠ 存储的精确值,它只是存储值的”名牌”

这个机制和 dig = 15 配合:dig 的含义是”把任意 double 转成 15 位十进制、再读回来,一定能原样恢复”(faithful round-trip);而”唯一确定一个 double”最坏需要 17 位十进制。最短往返算法输出的字符串长度就落在这两者之间(通常 15~17 位),Python 取其中最短的。

0.1 + 0.2 的完整链路#

把问题一、二、三的知识串起来,0.1 + 0.2 = 0.30000000000000004 的完整解释是:

  1. 字面量 0.10.2 各自被舍入到最近的 double(误差各约半个 spacing at 1,即 2542^{-54} 量级);
  2. 两个近似值相加,真和也舍入到最近的 double(一次新舍入);
  3. 最终 double 恰好比”0.3 的最接近表示”高一个 ULP,于是最短往返显示成了 0.30000000000000004

每一步都”正确”地执行了 IEEE 754,错误纯粹来自”0.1、0.2、0.3 都不在 double 网格上”这个事实——问题一讲的表示问题,在这里兑现成肉眼可见的结果。

与 numpy / PyTorch / C 的精度对比#

“Python 默认 FP64”很容易让人以为 AI 生态里默认也是 FP64,其实恰恰相反,这是新手最容易踩的坑:

环境默认浮点类型说明
Python 内置 floatFP64(binary64)标量运算;numpy.float64 与其互操作
NumPy 数组FP64(float64np.zeros(3).dtype 就是 float64
PyTorch 张量FP32(torch.float32深度学习默认低精度:训练推理都默认 FP32
C/C++double 是 FP64,float 是 FP32类型由你指定,没有”默认值”概念
CUDA 设备代码无类型默认float 即 FP32,double 即 FP64,由代码指定

这个对比有个深层原因:Python 的 float 是给”通用计算”用的,通用计算里精度就是安全垫,能上 double 就上 double;而深度学习是”精度换吞吐”的行业,默认用 FP32 张量 + FP32 累积,配合 16/8 位存储。你在 PyTorch 里写 torch.tensor(0.1),得到的是 FP32 的 0.1(误差约 10810^{-8} 量级);你在 Python 里写 0.1,得到的是 FP64 的 0.1(误差约 101710^{-17} 量级)——同一个字面量,在两个生态里的”身份”不同。如果你的 Python 代码把 PyTorch 标量转成普通 float(float(tensor.item())),精度会从 FP32 提升到 FP64,反方向 torch.tensor(0.1) 则会把 FP64 截断成 FP32——两个方向的转换都隐含一次舍入。

需要精确十进制时怎么办#

Python 的 float 是二进制 FP64,遇到”金额、精确十进制语义”这类需求(以及”0.1+0.2 必须等于 0.3”的严格断言)要用专门的工具:

  • decimal.Decimal:任意精度十进制浮点。默认 28 位有效数字,默认舍入模式 ROUND_HALF_EVEN(问题五的主角),Decimal('0.1') + Decimal('0.2') 精确等于 Decimal('0.3')
  • fractions.Fraction:精确有理数(分子分母都是整数),任何二进制展开问题都不存在;
  • math.fsum():精确求和(用额外累加补偿),上一篇讲过的 Kahan 求和的正规实现,做大量浮点累加时优先用它。

一个容易混淆的点:Decimal 用的是十进制表示,所以它能精确表示 0.1 但不能精确表示 13\frac{1}{3}(十进制里也循环);Fraction 才是对一切有理数精确。选哪个取决于你要精确的对象是”十进制小数”还是”一般有理数”。

round() 的银行家舍入与 2.675 陷阱#

顺带把 Python 的 round() 行为钉死(问题五会展开讲):

>>> round(2.5), round(3.5), round(4.5), round(-2.5)
(2, 4, 4, -2) # ties-to-even:2.5→2,3.5→4,-2.5→-2
>>> round(2.675, 2)
2.67 # 经典陷阱:2.675 存的是 2.6749999...,四舍五入到两位不是 2.68

round(2.675, 2) 是 Python 最著名的坑之一:字面量 2.675 在 double 里实际是 2.674999999999999822364316059974952.67499999999999982236431605997495\cdots,比 2.6752.675 略小,所以保留两位小数时舍入结果是 2.672.67 而不是 2.682.68。这不是 round 的 bug,是表示误差的必然结果——再次呼应问题一:你写的十进制小数,计算机先得把它翻译成二进制近似,翻译这一步就丢了信息。

问题四:C、C++、Vitis HLS、CUDA、Triton 的默认舍入方式#

共同起点:IEEE 754 的 RN#

先给一个全景结论:凡是遵循 IEEE 754 的浮点运算,默认舍入模式都是 round-to-nearest, ties-to-even(RN,最近偶数)。C、C++、Vitis HLS 里的 float/double、CUDA、Triton,算术运算符(+ - * / sqrt)的默认行为全部是 RN,没有例外。工具链之间的差异不在”默认是什么”,而在三件事:

  1. 能不能改:运行时切换舍入模式?还是编译期固定?
  2. 改了对编译器/综合器有没有约束力:优化器会不会无视你的切换继续按 RN 优化?
  3. 非浮点路径的默认:整数转换、定点数、量化——这些路径的默认往往不是 RN,而是截断。

逐家来看。

C/C++:FE_TONEAREST + fesetround,但编译器默认假设 RN#

C99 起 C/C++ 提供浮点环境接口 <fenv.h> / <cfenv>

  • 默认舍入方向宏:FE_TONEAREST(最近偶数)、FE_DOWNWARD(向 -\infty)、FE_UPWARD(向 ++\infty)、FE_TOWARDZERO(向 0);
  • fegetround() 查询当前模式,fesetround() 切换——C 语言里舍入模式是运行时状态,可以随时改
#include <stdio.h>
#include <fenv.h>
#include <math.h>
#pragma STDC FENV_ACCESS ON
int main(void) {
printf("当前舍入模式: %d (FE_TONEAREST = %d)\n", fegetround(), FE_TONEAREST);
fesetround(FE_UPWARD);
printf("rint(2.5) 向上 = %.1f\n", rint(2.5)); // 3.0
fesetround(FE_DOWNWARD);
printf("rint(2.5) 向下 = %.1f\n", rint(2.5)); // 2.0
fesetround(FE_TONEAREST);
printf("rint(2.5) 最近偶数 = %.1f\n", rint(2.5)); // 2.0
return 0;
}

注意两点:

  • rint()/nearbyint() 被标准定义为”按当前舍入模式舍入”,所以上面的代码可靠;但 round() 不是——它是固定的”half away from zero”,与舍入模式无关(问题五细说);
  • 算术运算符 a + b 是否真的跟随你切换的模式,取决于编译器。C 标准规定浮点环境默认是关闭的:编译器有权假设舍入模式恒为 RN,并据此做常量折叠(2.0 * 0.5 直接算成 1.0)、重排、融合等优化。要告诉编译器”别假设 RN”,得写 #pragma STDC FENV_ACCESS ON(GCC/Clang 需要配套编译选项,Clang/GCC 对应 -frounding-math;MSVC 是 #pragma fenv_access(on))。不开这些开关时,fesetround(FE_UPWARD) 对同一编译单元里被常量折叠掉的表达式无效——“改了”和”生效了”是两码事。

还有两条 C 语义需要钉死:

  • float→int 转换永远向零截断(int)2.9 是 2,(int)-2.9 是 -2),无论当前舍入模式是什么;
  • 十进制转换函数(strtodprintf 的浮点格式)在 glibc 里按正确舍入实现,默认模式下即 RN(这也是”打印 0.1 显示 0.1”这类行为在 C 里也成立的原因)。

CUDA:默认 RN,模式编译期固定,用 intrinsic 显式指定#

NVIDIA 官方文档 Floating Point and IEEE 754 Compliance for NVIDIA GPUs 明确写道:

“The IEEE 754 standard defines four rounding modes: round-to-nearest, round towards positive, round towards negative, and round towards zero. CUDA supports all four modes. By default, operations use round-to-nearest.

CUDA 与 CPU 习惯最大的不同在于:舍入模式不是运行时全局状态,而是编译期固定在每条指令里的。PTX 指令自带 .rn / .rz / .ru / .rd 修饰符,编译器生成的代码每一条浮点指令都写着”用什么模式舍入”(默认 .rn)。因此:

  • 普通写法 a + b 生成 .rn 的加指令——默认 RN;
  • 想用其他模式,调用带后缀的 intrinsic,让编译器生成对应修饰符的指令。官方文档给出了完整清单:
运算intrinsic(单精度)运算intrinsic(双精度)
x+yx + y__fadd_[rn/rz/ru/rd](x, y)x+yx + y__dadd_[rn/rz/ru/rd](x, y)
x×yx \times y__fmul_[rn/rz/ru/rd](x, y)x×yx \times y__dmul_[rn/rz/ru/rd](x, y)
fmaf(x,y,z)\text{fmaf}(x,y,z)__fmaf_[rn/rz/ru/rd](x, y, z)fma(x,y,z)\text{fma}(x,y,z)__fma_[rn/rz/ru/rd](x, y, z)
1/x1/x__frcp_[rn/rz/ru/rd](x)1/x1/x__drcp_[rn/rz/ru/rd](x)
x/yx/y__fdiv_[rn/rz/ru/rd](x, y)x/yx/y__ddiv_[rn/rz/ru/rd](x, y)
x\sqrt{x}__fsqrt_[rn/rz/ru/rd](x)x\sqrt{x}__dsqrt_[rn/rz/ru/rd](x)

后缀含义:rn = 最近偶数(默认),rz = 向零,ru = 向 ++\inftyrd = 向 -\infty。这套 intrinsic 还有个隐藏用途:显式舍入的 intrinsic 不会被编译器融合成 FMA——文档明确说想抑制乘加融合就”write the multiplies and additions using intrinsics with explicit rounding mode”。

__device__ float demo(float a, float b, float c) {
float d = a * b + c; // 默认 RN;编译器通常融合成 FMA(单次舍入)
float e = __fadd_rn(a, b); // 显式 RN 加法,且抑制 FMA 融合
float f = __fadd_rz(a, b); // 向零舍入的加法
int i = __float2int_rn(3.5f); // 4:ties-to-even,4 是偶数
int j = (int)3.5f; // 3:C 风格转换,向零截断
return d + e + f + i + j;
}

__float2int_rn 和 C 风格 (int) 的差异是 CUDA 里最常见的舍入坑之一:C 风格转换恒为向零截断,intrinsic 才做舍入__float2int_rn/rz/ru/rd__double2int_rn 等一整套都在 CUDA Math API 里)。

CUDA 的另外两个开关:-ftz 与 -use_fast_math#

CUDA 的”精度行为”除了舍入模式,还有两个编译选项(对应问题二结尾的图 2):

  • --ftz={true|false}:控制单精度次正规数。默认 --ftz=false(IEEE 754 模式:保留次正规数、运算正确舍入);--ftz=true 时次正规数被 flush 成 0——正是 Goldberg 图 2 左边那条路径;
  • -use_fast_math:一键进入”快速模式”。官方文档给的对照表:
模式-ftz-prec-div-prec-sqrt数学函数
IEEE 754 模式(默认)falsetruetrue精确实现
fast 模式(-use_fast_mathtruefalsefalse替换为快速近似 intrinsic(__expf__logf 等)

-use_fast_math 隐含三件事:次正规数清零、除法/开方不再正确舍入(精度下降但变快)、数学库函数换成近似版本。它不改变 RN 舍入模式本身(除法近似是”结果不保证正确舍入”,不是换了个舍入模式)。推理内核里常见的 __expf__fdividef 就是这套东西。注意这些选项只影响单精度,对 double 无效果(文档原文:the flags have no effect on double precision)。

Triton:跟随硬件 + cast 参数 + libdevice#

Triton 是 Python DSL,最终编译成 PTX/LLVM 指令跑在 GPU 上,所以:

  • 默认舍入 = 生成的 PTX 指令的默认修饰符 = RN.rn)。Triton 语言层没有”全局舍入模式”这种东西;
  • 类型降转换可以显式指定舍入tl.castfp_downcast_rounding 参数,官方文档列出两个取值:"rtne"(round to nearest, ties to even,默认)和 "rtz"(round towards zero)。写 FP32→FP16/FP8 的降精度转换时可以用它控制;
  • libdevice 提供显式舍入的逐操作函数:Triton 里通过 triton.language.extra.libdevice 可以调到 CUDA libdevice 的全部 intrinsic,例如 __nv_fadd_rn__nv_fdiv_rn__nv_fmaf_rn(libdevice 用户指南 文档 有完整清单),以及 __nv_fast_fdividef 这类快速近似变体:
import triton.language as tl
from triton.language.extra import libdevice
y = tl.cast(x, tl.float16, fp_downcast_rounding="rtne") # 降精度:最近偶数
z = libdevice.__nv_fdiv_rn(a, b) # 除法:显式 RN
w = libdevice.__nv_fast_fdividef(a, b) # 快速近似除法(不保证正确舍入)

Vitis HLS:float 走 IEEE,定点默认截断#

FPGA 的 HLS(高层次综合)工具链把 C/C++ 综合成 RTL,舍入语义分两套完全不同的体系:

第一套:float/double。Vitis HLS 把它们综合成 IEEE 754 兼容的浮点运算器(Vivado 浮点 IP),算术默认 RN——和 C 一致。浮点 IP 的舍入模式通常还可在 IP 配置里选(RN/RZ/向上的”近”与”远”变体),但默认就是 RN。

第二套:定点类型 ap_fixed / ap_ufixed。这是 HLS 里真正的大头,也是和通用语言差异最大的地方。定点数的”舍入”叫量化模式(quantization mode),是模板参数:

ap_fixed<8, 4, AP_RND_CONV, AP_SAT> x = 2.5; // 收敛舍入(ties-to-even)+ 饱和
ap_fixed<8, 4> y = 2.5; // 默认:AP_TRN 截断 + AP_WRAP 回绕
float f = 2.5f; // float 走 IEEE 754,默认 RN

Xilinx 文档(Vitis HLS UG1399:Arbitrary Precision Types)给出的量化模式全集:

量化模式行为
AP_TRN默认截断:直接丢弃低位(补码负数相当于向 -\infty
AP_TRN_ZERO向零截断(负数也向零)
AP_RND++\infty 舍入
AP_RND_ZERO向零舍入
AP_RND_MIN_INF-\infty 舍入
AP_RND_INF远离零舍入(ties away from zero)
AP_RND_CONV收敛舍入(ties-to-even,即银行家舍入)

配套的溢出模式默认是 AP_WRAP(补码回绕),可选 AP_SAT(饱和)、AP_SAT_ZEROAP_SAT_SYM

为什么定点默认用 AP_TRN 而不是 RN?纯硬件成本原因:截断不需要任何额外逻辑(把低位剪掉即可),RN 需要一个加法器来做”四舍五入”,ties-to-even 还要检测最低保留位(需要加法器 + 一位比较),在 DSP 密集的设计里每个定点寄存器都差这点面积。所以默认值选最省、最可预测的截断,需要精度时显式换 AP_RND_CONV。这跟 IEEE 754 世界里”RN 是默认”形成鲜明对比——浮点把 RN 做成默认是因为它是误差最优的;定点把截断做成默认是因为它是最便宜的。两种默认背后是完全不同的优化目标。

还有一条 HLS 特有的坑:综合器对浮点代码的”优化”自由度比编译器更大(重排、融合、复用运算器都可能改变舍入行为),所以 HLS 项目里浮点代码的数值行为要用仿真严格验证,甚至建议在 C 模型里就固定运算顺序。

一张大表总结#

工具链浮点运算默认舍入能否改改的方式非浮点路径默认
C/C++RN (ties-to-even)运行时切换fesetround() + FENV_ACCESS/-frounding-mathfloat→int 恒截断
CUDARN编译期固定带后缀 intrinsic(__fadd_rz 等)/ PTX 修饰符C 风格转换截断;-ftz/-use_fast_math 另有影响
TritonRN(跟随硬件)逐操作指定tl.cast(fp_downcast_rounding=...)、libdevice __nv_*_rn 系列同 CUDA
Vitis HLS(float)RNIP 配置浮点 IP 舍入模式选项
Vitis HLS(定点)截断 AP_TRN模板参数AP_RND_CONV 等量化模式溢出默认回绕 AP_WRAP

记住一句话就能把这张表串起来:IEEE 754 浮点运算在哪儿都是”默认 RN”;差异只在”如何改、改了是否有效”,以及”整数/定点/转换路径默认截断”。写内核、写 HLS 代码时,凡是出现 float→int、FP32→FP16、浮点转定点的地方,都默认它走的是截断——除非你显式指定了舍入。

问题五:有哪些舍入方法?银行家舍入和 ties-to-even 一样吗?#

直接回答:一样#

银行家舍入(banker’s rounding)就是 round-to-nearest, ties-to-even,两者是完全相同的规则。“ties-to-even”是 IEEE 754 的正式叫法,“银行家舍入”是它在金融界的俗名(据说来自银行计息传统:为了避免”逢五进一”让银行系统性多付利息,中点统一取偶数)。规则一句话:先找最近的可表示数;如果恰好在中点(tie),选尾数为偶数的那个

  • 2.522.5 \to 2(2 是偶数)、3.543.5 \to 4(4 是偶数)、4.544.5 \to 45.565.5 \to 62.52-2.5 \to -2

“round-to-nearest, ties-to-even”这个全名拆开就是两层:round-to-nearest(非中点情况取最近者)+ ties-to-even(中点情况取偶数)。银行家舍入只说后半句,因为前半句默认大家都知道。

舍入方法全景#

把所有常见舍入规则放在一张表里(以 ±2.5\pm 2.5 为例,“中点”情形最能区分它们):

舍入方法2.52.52.5-2.5规则英文名
向下取整2-3-\inftyfloor / toward -\infty
向上取整3-2++\inftyceil / toward ++\infty
截断2-2向 0(无条件丢小数)trunc / toward zero
四舍五入3-3中点远离零round half away from zero
四舍五入(严格向上)3-2中点向 ++\inftyround half up
半数向下2-3中点向 -\inftyround half down
半数向零2-2中点向 0round half toward zero
银行家舍入2-2中点取偶数round half to even / ties-to-even
半数取奇3-3中点取奇数round half to odd
随机舍入2 或 3-2 或 -3按概率随机向上/向下stochastic rounding

三处容易混的:

  1. “四舍五入”和”half away from zero”:中文口语的”四舍五入”通常指中点远离零(2.532.5 \to 32.53-2.5 \to -3),这是最常用的十进制舍入规则,但不是 IEEE 754 浮点的默认规则。IEEE 754 用银行家舍入。做金额计算时别默认”四舍五入”,很多金融系统的”四舍五入”实现其实是 half away from zero,与银行家舍入在 x.5x.5 处结果不同;
  2. “half up”(中点向 ++\infty)和”half away from zero”:负数时不同(2.5-2.5 一个是 2-2 一个是 3-3)。Java 的 Math.round() 就是 half up;
  3. 截断和向下取整:正数相同,负数不同(2.52.5 都是 2;2.5-2.5 截断是 2-2,floor 是 3-3)。上一节说 HLS 的 AP_TRN 和 C 的 (int) 转换——它们都是截断,不是 floor。

IEEE 754 定义的舍入属性:只有 4 种(+1 种可选)#

IEEE 754-2019 标准定义了五种”舍入属性”(rounding attributes),但把”ties”处理方式列全了:

  1. roundTiesToEven(最近偶数)——binary 格式必选、默认
  2. roundTiesToAway(中点远离零)——decimal 格式必选,binary 格式可选;
  3. roundTowardPositive(向 ++\infty);
  4. roundTowardNegative(向 -\infty);
  5. roundTowardZero(向 0)。

注意:IEEE 754 的二进制格式只强制要求 ties-to-even 这一种 ties 处理,其他模式属于推荐实现(x86 的 MXCSR 和 ARM 的 FPCR 都实现了全部四种;CUDA 也实现全部四种)。“ties-to-away”在二进制格式里是可选件——但 RISC-V 的浮点状态寄存器里专门有一个 RMM(round to nearest, ties to Max Magnitude)模式,等价于 ties-away,所以 RISC-V 机器上你能见到”第五种”。FP8 论文里 DeepSeek 这类项目不用它,普通推理代码也几乎碰不到,知道存在即可。

为什么默认是银行家舍入:偏差分析#

选 ties-to-even 而不是”四舍五入”,核心动机是统计偏差。假设有一批数据,小数部分恰好均匀分布在 x.5x.5 附近(比如大批金额、大批测量值):

  • half away from zero:每个中点都进位,误差每次都是 +0.5+0.5 ULP(正偏差),nn 次运算累计约 +0.5n+0.5n ULP 的系统性正偏差;
  • ties-to-even:中点交替进/舍(2.522.5\to2 舍、3.543.5\to4 进、4.544.5\to4 舍、5.565.5\to6 进……),长期看进舍各半,期望误差为 0

用期望语言说:设 XX 均匀落在某个可表示数附近,fl(X)X\text{fl}(X) - X 在 ties-even 下的期望是 0,在 half-away 下的期望是 +0.25+0.25 ULP 左右(只有中点那些值贡献正偏差)。所以 IEEE 754 选 ties-even 不是拍脑袋,而是让舍入误差成为零均值随机量——上一篇讲过,零均值误差可以互相抵消,系统性偏差会累积成真错误。这个理由对浮点运算、金融舍入、量化都成立,所以 Python round()、numpy np.round()Decimal 的默认、CUDA 的 .rn、HLS 的 AP_RND_CONV,全都不约而同地选它。

选偶数而不是奇数还有一个工程理由:偶数结果的最低位是 0,后续某些运算更”干净”(比如结果恰好落在 2 的幂的网格上时,进一步舍入或精确除以 2 都不会引入额外误差)。次要原因,但方向一致。

语言库函数的坑:同一个 round() 名字,不同的规则#

舍入规则的混乱集中在库函数上——名字都叫 round,规则各不相同:

语言/库函数规则例子
C/C++round() / lround()half away from zeroround(2.5)=3round(-2.5)=-3
C/C++rint() / nearbyint()跟随当前舍入模式fesetround(FE_UPWARD)rint(2.5)=3
Pythonround()ties-to-evenround(2.5)=2round(3.5)=4
NumPynp.round()ties-to-evennp.round(2.5)=2.0
JavaMath.round()half up(floor(x+0.5)Math.round(2.5)=3Math.round(-2.5)=-2
JavaMath.rint()ties-to-evenMath.rint(2.5)=2.0
Rustf64::round()half away from zero2.5_f64.round()=3.0

最反直觉的是 C 的 round():它不是银行家舍入,而是 half away from zero,且不受 fesetround 影响。而 Python 的 round() 反而是银行家舍入。跨语言移植数值代码时,round() 是第一个要查手册的函数。区分方法:rint/nearbyint/roundeven(C23)跟随舍入环境;round/lround 是固定规则函数。IEEE 754 的整数转换操作里这六种(ties-even、toward-zero、toward-positive、toward-negative、ties-away、exact)都有对应的 C 绑定,C 的 round() 恰好绑到了 ties-away 那一个。

双重舍入:一个被忽视的细节#

舍入还有一个进阶现象叫双重舍入(double rounding):同一个数先舍入到中间格式、再舍入到目标格式,结果可能与”直接舍入到目标格式”不同。典型场景是 FP64 → FP32 → FP16 或十进制 → FP64 → FP32 的链条:某个数在直接舍入到 FP32 时恰好是”中点”(tie,走 ties-to-even),但在 FP64 里它不是精确的中点(离中点差一点点),先舍入到 FP64 就把它推过了中点,最终结果与直接舍入相差 1 ULP。推理框架里把权重从 FP32 转 FP16 时如果经过中间格式,就可能踩到这个。规避方法就是一次到位:不要链式转换,直接调目标格式的单步转换(CUDA 的 __float2half 就是这样——文档注明它只支持 RN,即单次正确舍入)。

与量化/低精度训练的呼应:stochastic rounding#

最后把舍入和你的方向接上。低精度(FP8/INT8/定点)训练里有一个专门的舍入方法:随机舍入(stochastic rounding)——不固定向上或向下,而是按”离哪个近概率就大”的规则随机决定,期望值等于精确值。它与 ties-to-even 的动机一样(消除偏差),但手段不同:ties-to-even 用”交替”消除偏差,随机舍入用”概率”消除偏差。代价是单次结果方差更大;收益是在位宽极低时(比如 2-bit)保留了梯度的期望信息,不至于系统性丢弃小值。学术上这是”无偏低精度训练”的标准工具,硬件上就是加一个小随机数再截断。HLS 的 AP_RND_CONV、CUDA 的 .rn、Python 的 round() 解决的其实都是同一个问题的不同场景——把无限精度的真值压缩进有限网格时,怎么让误差不偏不倚。这一条主线从问题一的”表示”贯穿到问题五的”舍入”,正是浮点数这门课的骨架。

小结#

五个问题串起来,其实是浮点数四层概念:

  1. 表示(问题一):浮点数只能装”分母是 2 的幂”的有理数;0.1 的二进制展开无限循环但它依然有理,进制只决定展开形态,不决定数性;
  2. 间距(问题二):spacing at 1 = ULP(1) = 2232^{-23}(FP32),是相对精度的基准;“epsilon”有间距派和半间距派两种定义,差一倍,别混用;次正规数把间距延伸到零附近(flush-to-zero 会破坏它);
  3. 格式(问题三):Python float = FP64,sys.float_info.epsilon 恰好就是 FP64 的 spacing at 1;显示层的最短往返表示让”0.1”看起来精确;numpy 默认 FP64、PyTorch 默认 FP32,转换处处隐含舍入;
  4. 舍入(问题四五):IEEE 754 浮点运算全链默认 ties-to-even;差异在”能不能改、改了什么”;整数转换、定点量化默认截断;库函数 round() 的名字相同规则不同,跨语言迁移先查手册。

下一篇预告:答疑特辑(三)将回答另外四个问题——秩为 rr 的矩阵参数计数中 r(n+mr)r(n+m-r) 里的 "r-r" 是什么意思、TPU 怎么做训练(Gemini 与 TPU 训练架构)、TPU 与 B200/B300 的算力对比(为什么”推理快”的 TPU 纸面算力不如 B 系列)、以及各大厂商最先进的 AI 芯片全景(GPU/TPU/NPU 的区别与优劣)。如果你对浮点这篇还有新问题,随时在评论区提出,攒够一批继续出答疑特辑。

参考资料#

  1. What Every Computer Scientist Should Know About Floating-Point Arithmetic(David Goldberg,ACM Computing Surveys 1991:ULP、机器精度、舍入、次正规数的经典文献,本文两张示意图来源)
  2. Why 0.1 Does Not Exist In Floating-Point(Rick Regan,Exploring Binary,2012:0.1 的二进制长除法与 1369 位展开图)
  3. Floating Point Arithmetic: Issues and Limitations(Python 官方教程)(Python 浮点表示与 0.1 问题的官方说明)
  4. sys.float_info(Python 标准库文档)epsilonmant_digrounds 等参数的官方定义)
  5. decimal 模块(Python 标准库文档)(十进制浮点:默认 28 位精度与 ROUND_HALF_EVEN
  6. Floating Point and IEEE 754 Compliance for NVIDIA GPUs(NVIDIA 官方白皮书:四种舍入模式、intrinsic 表格、-ftz/-prec-div/-prec-sqrt 与 fast 模式对照)
  7. CUDA Math API__fadd_rn__float2int_rn 等显式舍入 intrinsic 文档)
  8. CUDA Compiler Driver NVCC-use_fast_math--ftz 等编译选项)
  9. CUDA libdevice Users Guide__nv_fadd_rn 等设备端显式舍入函数清单)
  10. triton.language.cast(Triton 官方文档)fp_downcast_rounding 参数:rtne/rtz
  11. Vitis HLS UG1399:Arbitrary Precision Typesap_fixed 量化模式 AP_TRN/AP_RND_CONV 等与溢出模式)
  12. IEEE 754(Wikipedia)(舍入属性 roundTiesToEven 等五种、binary/decimal 的要求差异)
  13. Rounding(Wikipedia)(银行家舍入、ties-to-even 与各语言实现对照)
  14. fegetround, fesetround(cppreference)(C/C++ 浮点环境与 FENV_ACCESS 语义)
  15. numpy.round(NumPy 文档)(ties-to-even 舍入行为)
  16. 浮点数与数值稳定性:从 IEEE 754 到 FP8 的 LLM 精度世界(本站文章:本篇问题的出处与背景)
  17. 答疑特辑:秩与 SVD、RoPE 旋转矩阵、投机解码原理与 AI 芯片全景(本站文章:答疑特辑(三)问题所依据的前文)

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

答疑特辑(二):浮点数五问——无限循环小数、spacing at 1、Python 精度与舍入方式详解
https://pinghaoyang.com.cn/aigc/posts/numerics-qa-part-1/
作者
平昊阳
发布于
2026-08-23
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
66
分类
16
标签
93
总字数
477,284
运行时长
0
最后活动
0 天前

文章目录