← 返回信息流
AI 资讯Hacker News·3 天前

Unity 引擎浮点数精度问题引发争议

原标题:Unity vs. Floating Point

速览

Unity 引擎近期因浮点数精度问题引发开发者社区的广泛关注与争议。该问题涉及图形渲染及物理模拟等核心功能,可能影响游戏及应用的稳定性。开发者正积极探讨解决方案并反馈给官方。

AI 深度解读

Unity 浮点数性能陷阱:System.MathF 与 UnityEngine.Mathf 的深度博弈

背景

近期,开发者 @VehiclePhysics 在 Twitter 上的一条推文引发了广泛关注。该推文建议:在大多数数学函数(如 SqrtSinCosLogPow 等)中,应优先使用 System.MathF 而非 UnityEngine.Mathf

其核心论点在于:Unity 的 Mathf 内部实现会将 float 强制转换为 double,调用双精度版本的数学函数,然后再转换回 float;而 System.MathF 则直接调用单精度原生实现。这意味着后者计算量更少,且结果相同,性能更优。

这一建议大体正确,但实际情况比表面看起来要复杂得多。Unity 引擎内部的浮点数处理机制涉及历史遗留问题、运行时差异以及编译器优化等多个层面,深入探究这些细节对于优化 C# 脚本性能至关重要。

核心内容

Unity 中隐藏的“双精度”陷阱

上述建议适用于所有涉及三角函数(Sin, Cos, Tan 等)、指数函数(Sqrt, Pow, Log 等)、舍入函数(Ceil, Floor 等)以及比较函数(Min, Max, Clamp 等)的 UnityEngine.Mathf 方法。唯一例外的是 Mathf.Abs

造成这一现象的历史原因是:早期的 C#/.NET 并没有为这些数学运算提供单精度方法。单精度的 System.MathF 直到 .NET Core 2.0(2017年)才引入。尽管距今已有近十年,Unity 出于向后兼容性的考虑(或仅仅是疏忽),并未将 Mathf 内部实现改为单精度。

更令人意外的是,Unity 官方推荐的 Unity.Mathematics 包(作为 DOTS 体系的一部分,旨在模拟 HLSL 行为)在处理单精度浮点函数时,竟然也路由到了双精度的 C# 实现中。例如,math.sqrt(float x) 依然调用的是双精度版本。

此外,Unity 使用的 Mono C# 运行时存在一个更底层的性能问题:Mono 在内部将所有 32 位 float 计算都当作 64 位 double 处理,尽管数据在内存中仍以 32 位存储。这意味着代码中充斥着大量的 floatdouble 的转换。早在 2018 年,Miguel de Icaza 就在博客中解释了这一缺陷,并计划让 Mono 真正使用浮点数进行浮点运算。虽然官方 Mono 发布版后来修复了这一问题,但 Unity 为了兼容性,至今未启用该功能,导致 Mono 后端依然保持这种低效的双精度计算模式。

值得注意的是,这种“万物皆双精度”的行为仅适用于 Mono 运行时。Unity 目前使用的另外两种 C# 实现——IL2CPP 和 Burst——并不具备这一特性。然而,Unity 并未统一其 Mono 版本的实现,这显得颇为奇怪,因为 iOS、主机和 Web 等主要部署平台根本不使用 Mono。

平方根测试:性能差异实测

为了直观展示差异,作者编写了一个简单的循环,对 1000 万个平方根进行累加:

const int N = 10000000;
public static float UnityMathf(float v)
{
    for (int i = 0; i < N; ++i)
    {
        v += UnityEngine.Mathf.Sqrt(v); 
    }
    return v;
}

在 Unity 编辑器(版本 6000.0.76,其他版本耗时相近)的 Windows / Ryzen 5950X 机器上测试:

  • Debug 模式UnityEngine.Mathf 耗时 282ms,System.MathF 耗时 186ms。
  • Release 模式UnityEngine.Mathf 耗时 242ms,System.MathF 耗时 149ms。

结果显示,System.MathF 确实显著更快。但需注意,Unity 编辑器的性能极大依赖于脚本调试是否开启(即 Debug vs Release 状态切换器)。

多后端与多实现的全面对比

为了获得更完整的图景,测试扩展到了使用 Unity.Mathematics 包的变体,并对比了 Mono、IL2CPP 脚本后端以及 Burst 编译器的表现。同时,还测试了非 Unity 环境下的 C# (.NET) 和 C++ 实现。

测试总结:

  • 最佳性能:在该机器上,该循环的理论极限约为 35ms。这一性能由 C++ 和 .NET 原生实现达成。在 Unity 内部,使用 Burst + Unity.MathematicsIL2CPP(配合 Mathf.SqrtSystem.MathF.Sqrt)可以达到这一水平。
  • IL2CPP 的特殊优化:在 IL2CPP 后端,似乎存在特殊的代码路径,能够识别出“这应该是单精度平方根”,并生成相应的底层 C++ 代码。因此,在 IL2CPP 下,Mathf.SqrtSystem.MathF.Sqrt 性能相当。
  • Burst 的限制:Burst 编译器目前不支持 System.MathF 函数(尝试使用会导致编译错误)。如果不需要使用 Burst,System.MathF 通常更快。但这也会增加代码迁移到 Burst 的难度。
  • Unity.Mathematics 的表现:通常情况下,Unity.Mathematics 略优于经典的 Mathf,但在 IL2CPP 下,对于平方根运算,Unity.Mathematics 似乎没有触发单精度优化,且存在其他开销。
  • 行为反转:与 IL2CPP 相反,Burst 不会对 Mathf.Sqrt 进行单精度优化,但会对 Mathematics.math.sqrt 进行单精度优化。
  • 精度泄露的证据:所有 Unity 实现打印出的循环结果为 24212990000000.0。有趣的是,这个数值在单精度浮点数中并不存在(最接近的单精度值是 24212989280256.024212991377408.0)。这进一步证明了在某些地方,底层计算确实使用了双精度。而非 Unity 实现(C# .NET, C++)打印的结果为 24212987183104.0

汇编层面的深度解析:Mono 的浮点转换

Mono 运行时下的 UnityEngine.Mathf.Sqrt 为例,其 C# 源码实现类似于: public static float Sqrt(float f) => (float)Math.Sqrt((double)f);

它确实调用了双精度的 System.Math.Sqrt。但如果查看 Mono JIT 生成的机器码,会发现更多的 floatdouble 转换。

使用 Sebastian Schöner 的 Asm Explorer 工具分析生成的汇编代码,循环体包含大量冗余指令:

  1. 将内存中的 float (v) 加载到寄存器。
  2. 使用 cvtss2sd 指令将 float 转换为 double
  3. 为了传递给 sqrt 函数,再次进行转换和存储。
  4. 使用 x87 FPU 栈 (fld, fsqrt) 执行双精度平方根。
  5. 将结果存回内存,并多次在 floatdouble 之间转换 (cvtsd2ss, cvtss2sd) 以进行累加。

这种频繁的精度转换不仅消耗指令周期,还增加了寄存器压力和内存带宽的消耗,是 Mono 后端性能低下的根本原因。

关键要点

  • 优先使用 System.MathF:在大多数数学运算中,System.MathFUnityEngine.Mathf 更快,因为它避免了不必要的 floatdouble 的转换。
  • Unity.Mathematics 并非万能
查看原文 →aras-p.info