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

开场:这一篇回答哪五个问题#
《浮点数与数值稳定性:从 IEEE 754 到 FP8 的 LLM 精度世界》 发布后,收到了一批追问。这批问题质量很高,每一个都戳在浮点数最容易产生误解的地方,而且彼此之间有隐含的联系:问题 1 和问题 2 其实都在问”表示”这件事的边界,问题 3 问 Python 到底用了什么格式,问题 4、5 问舍入这个动作本身。
本篇(答疑特辑(二))延续上一篇《答疑特辑:秩与 SVD、RoPE 旋转矩阵、投机解码原理与 AI 芯片全景》开启的系列,回答浮点数相关的五个问题:
- 二进制里面的无限循环小数算无理数吗?
- “严格说这是 spacing at 1”,这是什么意思,这个”严格”有什么特别含义吗?
- Python 一般不指定数据格式,那用的是什么精度?FP64 还是什么?
- C、C++、FPGA 的 Vitis HLS、CUDA、Triton 等语言与工具的默认舍入方式有什么区别?
- 有哪些舍入方法?银行家舍入和 round-to-nearest, ties-to-even 是一样的吗?
另外还有一批关于矩阵秩、TPU 训练、TPU 与 B200/B300 对比、AI 芯片全景的问题,属于另一条主线,将在答疑特辑(三)里逐一回答,本篇末尾有预告。
五个问题都不大,但每个都值得彻底讲清——它们合起来正好补上浮点数知识里最容易被”差不多懂了”带过去的四块:表示、间距、格式选择、舍入。
问题一:二进制里的无限循环小数算无理数吗?#
直接回答:不算#
无限小数和有理数不矛盾。有理数的定义是能写成两个整数之比 qp(q=0),它和”小数展开长什么样”没有直接关系。0.1=101,分子分母都是整数,所以 0.1 毫无疑问是有理数——哪怕它在二进制里展开成无限长的串。
判断一个小数是否无理数,正确标准是:展开既无限、又不循环。两条缺一不可:
- 有限(terminating):0.5=0.12,写完了,当然是有理数;
- 无限但循环(repeating):0.333⋯=31,无限,但是有理数;
- 无限且不循环(non-repeating):2=1.01101010000010011110⋯2,这才叫无理数。
所以问题一的准确答案是:“二进制里的无限循环小数”不但是有理数,而且它必然是(某个)有理数。循环是这个数”有理”的证据,不是”无理”的证据。上一篇《浮点数与数值稳定性》里”0.1 在二进制里是无理数”这个小标题用词不准确——0.1 的二进制展开是无限循环小数,严格说是”0.1 无法用有限位二进制小数精确表示”,下文专门有一节纠这个措辞。
为什么 1/10 的二进制展开必然循环:长除法#
要理解”循环”从哪来,直接做二进制长除法。十进制里 101 的展开是有限小数 0.1,是因为长除法除着除着余数变成 0 了;而二进制里 1÷10(即 12÷10102)的余数永远到不了 0,于是永远除不完。
关键观察在余数上:用 q 做除数,每一步的余数只能是 0,1,…,q−1 这 q 个值之一。一旦某个余数重复出现,后面所有步骤都会跟着重复(因为下一步的运算完全由”当前余数 × 进制”决定),展开就进入循环。所以:
- 余数到达 0 → 展开终止(有限小数);
- 余数永不重复 → 永远除不完(无理数);
- 余数重复(鸽笼原理保证至多 q 步内必重复)→ 展开无限循环。
1÷10 在二进制里,余数序列 1,2,4,8,6,2,… 在第六步就撞上了重复的 2(6 的下一个余数),于是循环节从第二位余数开始。展开结果就是
101=0.000110011001100110011⋯2=0.000112循环节是 0011,四位。下面是 Rick Regan 的博客 Why 0.1 Does Not Exist In Floating-Point(2012)里画的完整长除法过程,每一步余数、每一位商都列了出来,可以看到余数 2 如何触发循环:

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

这个证明有个漂亮的副产品:循环节长度上界。余数只有 q 个可能值,所以任何有理数 qp 在任意进制下的循环节长度最多是 q−1(到达余数 0 之前)。101 在二进制里循环节是 4 位,恰好不超过 q−1=9。
反过来也成立:循环展开一定是有理数#
方向反过来也对:任何(最终)循环的小数展开都可以写成分数。思路是把循环部分当成无穷等比数列求和。
拿十进制里的经典例子 0.333=0.333⋯ 来说:
0.333⋯=3×(10−1+10−2+10−3+⋯)=3×1−10−110−1=3×91=31二进制的 0.000112 同理:0.0001100110011⋯2=161⋅(1+161+⋯)+… 直接算也行,但既然它是长除法的产物,它本来就是 101。
把两个方向合起来,就得到一条完整的判别定理(初等数论/小数展开理论的基本结论):
x∈Q⟺x 在任意进制下展开有限,或(最终)循环“最终循环”意思是前面可以有有限位不循环的前缀(比如 61=0.16),后面才进循环。这条定理与进制无关——一个数是有理数还是无理数,是它自身的性质;进制只是把它”展示”出来。进制能改变的是展示的形态(有限还是循环、循环节多长),改变不了本质。
进制决定”是否有限”,不决定”是否有理”#
既然循环不循环不决定有理无理,那进制到底影响什么?影响的是展开会不会终止。判定条件非常干净:
把分数化成最简形式 qp,它在 b 进制下有限,当且仅当 q 的所有质因子都在 b 的质因子集合里。
- 10=2×5。101 的分母质因子是 {2,5},都在 10 的质因子里 → 十进制展开有限(0.1);
- 二进制 b=2 的质因子只有 {2},5 不在里面 → 101 在二进制里不可能有限,只能循环;
- 31 的分母质因子 {3}:十进制、二进制都循环,但三进制 b=3 下 0.13 就是有限小数。
直觉解释:b 进制下”一位”代表 bk1,所以只有分母能整除某个 bk 的数才写得完;分母带 b 之外质因子的数,除下去那个质因子的倒数永远”除不干净”,只好循环。这也解释了为什么 0.1、0.2、0.3、0.7 这些”干净”的十进制小数在计算机里全部不干净——计算机的”进制”是 2,而十进制小数的分母几乎都带质因子 5。
给上一篇的措辞纠个错#
《浮点数与数值稳定性》里的小标题”0.1 在二进制里是无理数”是错的,这里正式更正:0.1=101 是有理数,它的二进制展开 0.0001100110011⋯2 是无限循环小数。准确的说法应该是:
0.1 无法用有限位二进制小数表示(它的二进制展开是无限循环小数),因此任何有限位数的二进制浮点格式都只能存它的近似值。
“无法精确表示”这个结论完全成立,它正是 0.1 + 0.2 != 0.3 的根源;只是”无理数”这个词用错了地方。这种小错误在浮点数科普里很常见,因为大家口头上把”二进制里写不完”叫成”无理”,但数学术语里”无理”只有一个意思:不能写成 qp。
浮点数里存的到底是谁#
顺着这个问题再往下一层:既然 0.1 存不进去,那 FP64 的 0.1 里到底装了什么?
答案:装的是离 0.1 最近的、分母是 2 的幂的分数。FP64 有 53 位有效数字,所以
FP64 里的 0.1=2553602879701896397=0.1000000000000000055511151231257827021181583404541015625注意这个数是有限小数(虽然长得吓人):它的分母是 255,而 2 的幂能整除 10 的幂(255∣1055),所以它在十进制里也必然写得出头——恰好是上面那串 55 位十进制小数。也就是说,计算机里存的每个浮点数,本质上都是有理数,而且都是”二进制有限小数”;你觉得它”不精确”,不是因为它是无理数,而是因为”你要的那个有理数(1/10)恰好不在浮点数的网格上”。浮点格式的能力边界是”分母只能含质因子 2”,5 的出现让 0.1 落网。
顺带一提,无理数在计算机里只能用”截断到某位的近似”存在:2、π、e 在任何进制下都不循环,任何有限位数都只是近似值——这才是真正”无理”的数。LLM 推理里几乎遇不到它们,但你用的每个 float 都可能是某个”想要的数”的近似,区别只是近似误差 10−16 还是 10−3。
小结:判别流程#
遇到”这个小数算不算无理数”,按三步走:
- 它能不能写成 qp?能 → 有理数,直接判完;
- 写不出的 → 看展开:有限或(最终)循环 → 仍然有理(步骤 1 一定能写出来,两条等价);
- 展开无限且不循环 → 无理数。
进制在这里面从头到尾只是个”投影仪”,负责决定展开长什么样,不负责决定数是什么。
问题二:“spacing at 1”是什么意思,“严格”严格在哪?#
问题出自上一篇里的这句话:
机器精度 ϵ:数值 1 与下一个可表示浮点数之间的间距。ϵFP32=2−23。(严格说这是 “spacing at 1”,有的教材把”半间距”也叫 epsilon;本文统一用间距。)
拆成两个问题:一是”spacing at 1”这个说法本身是什么意思,二是为什么前面要加”严格说”。
定义:spacing at 1 = ULP(1)#
浮点数在数轴上不是均匀分布的:越靠近 0 越密,越往两边越稀疏(上一篇讲过)。“间距”(spacing)就是相邻两个可表示浮点数之间的距离,它随数值大小变化。特别地,1 和它下一个邻居之间的距离叫 spacing at 1——“在 1 处的间距”。
FP32 里,1=1.000⋯02×20,下一个数是尾数最低位加 1 的 1.000⋯12×20,距离恰好是
spacing at 1FP32=2−23≈1.19×10−7这个量还有一个更正式的名字:ULP(1)——ULP 是 Unit in the Last Place(末位单位)的缩写,指”尾数最低位那一格代表的数值”。对指数为 e 的数,ULP 大约是 2e−23(FP32),在 e=0 时就是 2−23。
Goldberg 的经典论文《What Every Computer Scientist Should Know About Floating-Point Arithmetic》(1991,ACM Computing Surveys)第一张图把这个概念画得最直观——它用 β=2、精度 p=3(尾数 3 位)的最小例子展示了浮点数在数轴上的分布:[1,2) 区间内四个点等距排布,[2,4) 区间间距翻倍,[4,8) 再翻倍,越往右越稀:

这张图里 1 和它的下一个邻居之间的距离就是”spacing at 1”(在这个例子里是 2−2=0.25)。看这张图还能直接读出上一篇的结论:相对间距恒定——[1,2) 里间距 0.25 是 1 的 25%,[4,8) 里间距 1 是 4 的 25%,比例不随位置变化。这就是”相对精度恒定”的图像:浮点数的相对间距固定为 21−p(本例 p=3 时是 2−2),绝对间距随指数成倍增长。
为什么偏偏是”at 1”:相对精度的基准#
“at 1”不是随便挑的位置。对任何规格化数 x,间距和 x 本身成正比(比例因子是 2−23 上下浮动最多 2 倍,Goldberg 管这个浮动叫 wobble),所以
∣x∣spacing(x)≈2−23=spacing at 1也就是说,spacing at 1 恰好等于任意位置上的”相对间距”。而相对误差、有效数字这些概念全是相对量,所以”在 1 处的间距”就成了全格式相对精度的标准刻度:知道 2−23,就知道 FP32 任何位置大约有 7 位十进制有效数字。这就是为什么各种语言、教材、硬件手册都拿”1 和下一个数的距离”来定义机器精度——Python 的 sys.float_info.epsilon 甚至直接采用这个定义(问题三会看到它是 2−52,正好是 FP64 的 spacing at 1)。
“严格说”:epsilon 的两个学派#
现在说”严格”到底严格在哪。epsilon(机器精度)这个词在文献里其实有两种用法,数值上差一倍:
- 学派 A(间距派):ϵ = spacing at 1 = 2−23(FP32)。Goldberg 的论文、Python 的
sys.float_info.epsilon、多数硬件手册用这个。 - 学派 B(半间距派):ϵ = 半间距 = 2−24(FP32)。理由是:round-to-nearest 下单次运算的相对误差上界恰好是半间距(误差不超过到两个邻居的间距的一半),所以”最大相对误差”这个量更常用,很多数值分析教材干脆把这个上界叫 epsilon。
两个定义都对,只是量的不是同一个东西:一个是”刻度的大小”(spacing),一个是”误差的上界”(half spacing)。上一篇的小注里”有的教材把半间距也叫 epsilon”说的就是学派 B。为了避免混淆,上一篇全文统一用”间距”的叫法,并把误差上界单独说成”半间距”——这就是”严格说”的含义:用词严格化,把两个被混叫成 epsilon 的量分开。
把两个量放一起记:
spacing at 1=2−23,最大相对舍入误差≈21×2−23=2−24≈5.96×10−85.96×10−8 的倒数取对数 ≈ 7.2,这就是”FP32 约 7 位有效数字”的精确出处。
一个容易被忽略的边角:ULP 定义本身也有两个学派#
既然聊到严格性,顺带把 ULP 的坑也填了。ULP 的”教科书定义”是:x 与离它最近的可表示浮点数之间的距离。但在 x 恰好在 2 的幂上(比如 x=1 本身)时,往左看间距是 2−24(1 和 1−2−24),往右看是 2−23(1 和 1+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 下最大相对误差) | 十进制有效数字(约) |
|---|---|---|---|
| FP64 | 2−52≈2.2×10−16 | 2−53≈1.1×10−16 | 15~16 |
| FP32 | 2−23≈1.2×10−7 | 2−24≈6.0×10−8 | 7 |
| FP16 | 2−10≈9.8×10−4 | 2−11≈4.9×10−4 | 3 |
| BF16 | 2−7≈7.8×10−3 | 2−8≈3.9×10−3 | 2 |
| FP8 E4M3 | 2−3=0.125 | 2−4=0.0625 | 1 |
| FP8 E5M2 | 2−2=0.25 | 2−3=0.125 | <1 |
读表方法:E4M3 的 spacing at 1 是 0.125,意思是”1 附近的两个可表示数之间隔着 0.125”,所以 0.1 和 0.2 这种值在 E4M3 里彼此可能撞到同一个网格点——这就是上一篇说”FP8 只有 1 位有效数字”的含义。
延伸:spacing 在零附近会被”抢救”:次正规数#
看图 1 会有一个疑问:间距按 2k 递减,那接近 0 时间距无限趋近于 0,怎么还有”最小正浮点数”这个概念?答案在上一篇讲过的次正规数(subnormal):规格化数的指数有下限 emin,[0,2emin) 这段本来该是空白(floating-point gap),次正规数用”尾数前导位为 0”的方式把这个区间按均匀间距 2emin−(p−1) 填满,让间距函数在零附近保持平滑递减、不出现断崖。
这个设计的价值,Goldberg 论文的 Figure 2 画得最清楚——“flush to zero(把下溢结果直接清零)“和”gradual underflow(用次正规数平滑过渡)“两条路径的对比:flush 时 a−b 在 a≈b 处可能直接从某个小值跌到 0,破坏”减法结果随输入连续变化”的性质;gradual 时则一路平滑:

这张图在问题四还会回来:CUDA 的 -ftz 编译选项干的就是”flush to zero”,默认关、开 fast math 时自动打开;FPGA 的浮点 IP 也常常让你选是否支持次正规数(支持要额外硬件)。“间距”这个概念从 1 一路延伸到 0,处处都牵涉工程取舍。
问题三:Python 默认用什么精度?#
直接回答:float 就是 FP64#
Python 的 float 类型永远是 IEEE 754 双精度 binary64(即 C 语言里的 double),位布局是 1+11+52:1 位符号、11 位指数、52 位尾数字段(加隐式前导位共 53 位有效数字)。Python 没有内置的 FP32/FP16,你写 1.0、0.1、1e308,全部是 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 里,跑一下就能验证:
1>>> import sys2>>> sys.float_info3sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308,4 min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307,5 dig=15, mant_dig=53, epsilon=2.220446049250313e-16,6 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 是 2−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 最直接:
1>>> (0.1).as_integer_ratio()2(3602879701896397, 36028797018963968)3>>> 0.1.hex()4'0x1.999999999999ap-4'5>>> (0.1).as_integer_ratio()[0] / (0.1).as_integer_ratio()[1]60.1as_integer_ratio() 返回的正是问题一里的那个精确有理数:3602879701896397×2−55。float.hex() 给出规格化十六进制形式 0x1.999999999999a×2−4,尾数 999999999999a 是 13 个十六进制位(52 个二进制位),能看出它比真值 0.1 大一点点——因为 0.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 的完整解释是:
- 字面量
0.1和0.2各自被舍入到最近的 double(误差各约半个 spacing at 1,即 2−54 量级); - 两个近似值相加,真和也舍入到最近的 double(一次新舍入);
- 最终 double 恰好比”0.3 的最接近表示”高一个 ULP,于是最短往返显示成了
0.30000000000000004。
每一步都”正确”地执行了 IEEE 754,错误纯粹来自”0.1、0.2、0.3 都不在 double 网格上”这个事实——问题一讲的表示问题,在这里兑现成肉眼可见的结果。
与 numpy / PyTorch / C 的精度对比#
“Python 默认 FP64”很容易让人以为 AI 生态里默认也是 FP64,其实恰恰相反,这是新手最容易踩的坑:
| 环境 | 默认浮点类型 | 说明 |
|---|---|---|
Python 内置 float | FP64(binary64) | 标量运算;numpy.float64 与其互操作 |
| NumPy 数组 | FP64(float64) | np.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(误差约 10−8 量级);你在 Python 里写 0.1,得到的是 FP64 的 0.1(误差约 10−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 但不能精确表示 31(十进制里也循环);Fraction 才是对一切有理数精确。选哪个取决于你要精确的对象是”十进制小数”还是”一般有理数”。
round() 的银行家舍入与 2.675 陷阱#
顺带把 Python 的 round() 行为钉死(问题五会展开讲):
1>>> round(2.5), round(3.5), round(4.5), round(-2.5)2(2, 4, 4, -2) # ties-to-even:2.5→2,3.5→4,-2.5→-23>>> round(2.675, 2)42.67 # 经典陷阱:2.675 存的是 2.6749999...,四舍五入到两位不是 2.68round(2.675, 2) 是 Python 最著名的坑之一:字面量 2.675 在 double 里实际是 2.67499999999999982236431605997495⋯,比 2.675 略小,所以保留两位小数时舍入结果是 2.67 而不是 2.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,没有例外。工具链之间的差异不在”默认是什么”,而在三件事:
- 能不能改:运行时切换舍入模式?还是编译期固定?
- 改了对编译器/综合器有没有约束力:优化器会不会无视你的切换继续按 RN 优化?
- 非浮点路径的默认:整数转换、定点数、量化——这些路径的默认往往不是 RN,而是截断。
逐家来看。
C/C++:FE_TONEAREST + fesetround,但编译器默认假设 RN#
C99 起 C/C++ 提供浮点环境接口 <fenv.h> / <cfenv>:
- 默认舍入方向宏:
FE_TONEAREST(最近偶数)、FE_DOWNWARD(向 −∞)、FE_UPWARD(向 +∞)、FE_TOWARDZERO(向 0); fegetround()查询当前模式,fesetround()切换——C 语言里舍入模式是运行时状态,可以随时改。
1#include <stdio.h>2#include <fenv.h>3#include <math.h>4
5#pragma STDC FENV_ACCESS ON6
7int main(void) {8 printf("当前舍入模式: %d (FE_TONEAREST = %d)\n", fegetround(), FE_TONEAREST);9
10 fesetround(FE_UPWARD);11 printf("rint(2.5) 向上 = %.1f\n", rint(2.5)); // 3.012 fesetround(FE_DOWNWARD);13 printf("rint(2.5) 向下 = %.1f\n", rint(2.5)); // 2.014 fesetround(FE_TONEAREST);15 printf("rint(2.5) 最近偶数 = %.1f\n", rint(2.5)); // 2.016 return 0;17}注意两点:
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),无论当前舍入模式是什么; - 十进制转换函数(
strtod、printf的浮点格式)在 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+y | __fadd_[rn/rz/ru/rd](x, y) | x+y | __dadd_[rn/rz/ru/rd](x, y) |
| x×y | __fmul_[rn/rz/ru/rd](x, y) | x×y | __dmul_[rn/rz/ru/rd](x, y) |
| fmaf(x,y,z) | __fmaf_[rn/rz/ru/rd](x, y, z) | fma(x,y,z) | __fma_[rn/rz/ru/rd](x, y, z) |
| 1/x | __frcp_[rn/rz/ru/rd](x) | 1/x | __drcp_[rn/rz/ru/rd](x) |
| x/y | __fdiv_[rn/rz/ru/rd](x, y) | x/y | __ddiv_[rn/rz/ru/rd](x, y) |
| x | __fsqrt_[rn/rz/ru/rd](x) | x | __dsqrt_[rn/rz/ru/rd](x) |
后缀含义:rn = 最近偶数(默认),rz = 向零,ru = 向 +∞,rd = 向 −∞。这套 intrinsic 还有个隐藏用途:显式舍入的 intrinsic 不会被编译器融合成 FMA——文档明确说想抑制乘加融合就”write the multiplies and additions using intrinsics with explicit rounding mode”。
1__device__ float demo(float a, float b, float c) {2 float d = a * b + c; // 默认 RN;编译器通常融合成 FMA(单次舍入)3 float e = __fadd_rn(a, b); // 显式 RN 加法,且抑制 FMA 融合4 float f = __fadd_rz(a, b); // 向零舍入的加法5 int i = __float2int_rn(3.5f); // 4:ties-to-even,4 是偶数6 int j = (int)3.5f; // 3:C 风格转换,向零截断7 return d + e + f + i + j;8}__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 模式(默认) | false | true | true | 精确实现 |
fast 模式(-use_fast_math) | true | false | false | 替换为快速近似 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.cast有fp_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这类快速近似变体:
1import triton.language as tl2from triton.language.extra import libdevice3
4y = tl.cast(x, tl.float16, fp_downcast_rounding="rtne") # 降精度:最近偶数5z = libdevice.__nv_fdiv_rn(a, b) # 除法:显式 RN6w = 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),是模板参数:
1ap_fixed<8, 4, AP_RND_CONV, AP_SAT> x = 2.5; // 收敛舍入(ties-to-even)+ 饱和2ap_fixed<8, 4> y = 2.5; // 默认:AP_TRN 截断 + AP_WRAP 回绕3float f = 2.5f; // float 走 IEEE 754,默认 RNXilinx 文档(Vitis HLS UG1399:Arbitrary Precision Types)给出的量化模式全集:
| 量化模式 | 行为 |
|---|---|
AP_TRN(默认) | 截断:直接丢弃低位(补码负数相当于向 −∞) |
AP_TRN_ZERO | 向零截断(负数也向零) |
AP_RND | 向 +∞ 舍入 |
AP_RND_ZERO | 向零舍入 |
AP_RND_MIN_INF | 向 −∞ 舍入 |
AP_RND_INF | 远离零舍入(ties away from zero) |
AP_RND_CONV | 收敛舍入(ties-to-even,即银行家舍入) |
配套的溢出模式默认是 AP_WRAP(补码回绕),可选 AP_SAT(饱和)、AP_SAT_ZERO、AP_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-math | float→int 恒截断 |
| CUDA | RN | 编译期固定 | 带后缀 intrinsic(__fadd_rz 等)/ PTX 修饰符 | C 风格转换截断;-ftz/-use_fast_math 另有影响 |
| Triton | RN(跟随硬件) | 逐操作指定 | tl.cast(fp_downcast_rounding=...)、libdevice __nv_*_rn 系列 | 同 CUDA |
| Vitis HLS(float) | RN | IP 配置 | 浮点 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.5→2(2 是偶数)、3.5→4(4 是偶数)、4.5→4、5.5→6、−2.5→−2。
“round-to-nearest, ties-to-even”这个全名拆开就是两层:round-to-nearest(非中点情况取最近者)+ ties-to-even(中点情况取偶数)。银行家舍入只说后半句,因为前半句默认大家都知道。
舍入方法全景#
把所有常见舍入规则放在一张表里(以 ±2.5 为例,“中点”情形最能区分它们):
| 舍入方法 | 2.5 | −2.5 | 规则 | 英文名 |
|---|---|---|---|---|
| 向下取整 | 2 | -3 | 向 −∞ | floor / toward −∞ |
| 向上取整 | 3 | -2 | 向 +∞ | ceil / toward +∞ |
| 截断 | 2 | -2 | 向 0(无条件丢小数) | trunc / toward zero |
| 四舍五入 | 3 | -3 | 中点远离零 | round half away from zero |
| 四舍五入(严格向上) | 3 | -2 | 中点向 +∞ | round half up |
| 半数向下 | 2 | -3 | 中点向 −∞ | round half down |
| 半数向零 | 2 | -2 | 中点向 0 | round half toward zero |
| 银行家舍入 | 2 | -2 | 中点取偶数 | round half to even / ties-to-even |
| 半数取奇 | 3 | -3 | 中点取奇数 | round half to odd |
| 随机舍入 | 2 或 3 | -2 或 -3 | 按概率随机向上/向下 | stochastic rounding |
三处容易混的:
- “四舍五入”和”half away from zero”:中文口语的”四舍五入”通常指中点远离零(2.5→3,−2.5→−3),这是最常用的十进制舍入规则,但不是 IEEE 754 浮点的默认规则。IEEE 754 用银行家舍入。做金额计算时别默认”四舍五入”,很多金融系统的”四舍五入”实现其实是 half away from zero,与银行家舍入在 x.5 处结果不同;
- “half up”(中点向 +∞)和”half away from zero”:负数时不同(−2.5 一个是 −2 一个是 −3)。Java 的
Math.round()就是 half up; - 截断和向下取整:正数相同,负数不同(2.5 都是 2;−2.5 截断是 −2,floor 是 −3)。上一节说 HLS 的
AP_TRN和 C 的(int)转换——它们都是截断,不是 floor。
IEEE 754 定义的舍入属性:只有 4 种(+1 种可选)#
IEEE 754-2019 标准定义了五种”舍入属性”(rounding attributes),但把”ties”处理方式列全了:
roundTiesToEven(最近偶数)——binary 格式必选、默认;roundTiesToAway(中点远离零)——decimal 格式必选,binary 格式可选;roundTowardPositive(向 +∞);roundTowardNegative(向 −∞);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.5 附近(比如大批金额、大批测量值):
- half away from zero:每个中点都进位,误差每次都是 +0.5 ULP(正偏差),n 次运算累计约 +0.5n ULP 的系统性正偏差;
- ties-to-even:中点交替进/舍(2.5→2 舍、3.5→4 进、4.5→4 舍、5.5→6 进……),长期看进舍各半,期望误差为 0。
用期望语言说:设 X 均匀落在某个可表示数附近,fl(X)−X 在 ties-even 下的期望是 0,在 half-away 下的期望是 +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 zero | round(2.5)=3,round(-2.5)=-3 |
| C/C++ | rint() / nearbyint() | 跟随当前舍入模式 | fesetround(FE_UPWARD) 后 rint(2.5)=3 |
| Python | round() | ties-to-even | round(2.5)=2,round(3.5)=4 |
| NumPy | np.round() | ties-to-even | np.round(2.5)=2.0 |
| Java | Math.round() | half up(floor(x+0.5)) | Math.round(2.5)=3,Math.round(-2.5)=-2 |
| Java | Math.rint() | ties-to-even | Math.rint(2.5)=2.0 |
| Rust | f64::round() | half away from zero | 2.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() 解决的其实都是同一个问题的不同场景——把无限精度的真值压缩进有限网格时,怎么让误差不偏不倚。这一条主线从问题一的”表示”贯穿到问题五的”舍入”,正是浮点数这门课的骨架。
小结#
五个问题串起来,其实是浮点数四层概念:
- 表示(问题一):浮点数只能装”分母是 2 的幂”的有理数;0.1 的二进制展开无限循环但它依然有理,进制只决定展开形态,不决定数性;
- 间距(问题二):spacing at 1 = ULP(1) = 2−23(FP32),是相对精度的基准;“epsilon”有间距派和半间距派两种定义,差一倍,别混用;次正规数把间距延伸到零附近(flush-to-zero 会破坏它);
- 格式(问题三):Python float = FP64,
sys.float_info.epsilon恰好就是 FP64 的 spacing at 1;显示层的最短往返表示让”0.1”看起来精确;numpy 默认 FP64、PyTorch 默认 FP32,转换处处隐含舍入; - 舍入(问题四五):IEEE 754 浮点运算全链默认 ties-to-even;差异在”能不能改、改了什么”;整数转换、定点量化默认截断;库函数
round()的名字相同规则不同,跨语言迁移先查手册。
下一篇预告:答疑特辑(三)将回答另外四个问题——秩为 r 的矩阵参数计数中 r(n+m−r) 里的 "−r" 是什么意思、TPU 怎么做训练(Gemini 与 TPU 训练架构)、TPU 与 B200/B300 的算力对比(为什么”推理快”的 TPU 纸面算力不如 B 系列)、以及各大厂商最先进的 AI 芯片全景(GPU/TPU/NPU 的区别与优劣)。如果你对浮点这篇还有新问题,随时在评论区提出,攒够一批继续出答疑特辑。
参考资料#
- What Every Computer Scientist Should Know About Floating-Point Arithmetic(David Goldberg,ACM Computing Surveys 1991:ULP、机器精度、舍入、次正规数的经典文献,本文两张示意图来源)
- Why 0.1 Does Not Exist In Floating-Point(Rick Regan,Exploring Binary,2012:0.1 的二进制长除法与 1369 位展开图)
- Floating Point Arithmetic: Issues and Limitations(Python 官方教程)(Python 浮点表示与 0.1 问题的官方说明)
- sys.float_info(Python 标准库文档)(
epsilon、mant_dig、rounds等参数的官方定义) - decimal 模块(Python 标准库文档)(十进制浮点:默认 28 位精度与
ROUND_HALF_EVEN) - Floating Point and IEEE 754 Compliance for NVIDIA GPUs(NVIDIA 官方白皮书:四种舍入模式、intrinsic 表格、
-ftz/-prec-div/-prec-sqrt与 fast 模式对照) - CUDA Math API(
__fadd_rn、__float2int_rn等显式舍入 intrinsic 文档) - CUDA Compiler Driver NVCC(
-use_fast_math、--ftz等编译选项) - CUDA libdevice Users Guide(
__nv_fadd_rn等设备端显式舍入函数清单) - triton.language.cast(Triton 官方文档)(
fp_downcast_rounding参数:rtne/rtz) - Vitis HLS UG1399:Arbitrary Precision Types(
ap_fixed量化模式AP_TRN/AP_RND_CONV等与溢出模式) - IEEE 754(Wikipedia)(舍入属性 roundTiesToEven 等五种、binary/decimal 的要求差异)
- Rounding(Wikipedia)(银行家舍入、ties-to-even 与各语言实现对照)
- fegetround, fesetround(cppreference)(C/C++ 浮点环境与
FENV_ACCESS语义) - numpy.round(NumPy 文档)(ties-to-even 舍入行为)
- 浮点数与数值稳定性:从 IEEE 754 到 FP8 的 LLM 精度世界(本站文章:本篇问题的出处与背景)
- 答疑特辑:秩与 SVD、RoPE 旋转矩阵、投机解码原理与 AI 芯片全景(本站文章:答疑特辑(三)问题所依据的前文)
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
部分内容可能已过时
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



