前幾天的浮點數討論再次突顯 float、double 計算結果常存在微小誤差的特性,甚至會出現以下狀況:

float a = 1/3,a 值顯示為 0.3333333,但 a 不等於 0.3333333f,a 也不等於 float.Parse(a.ToString())!

因此,在很需要精準度的場合,像是計算利息、依比例分攤費用、分贓... 等等,差一毛錢都可能引來殺機,最好事先明訂規則,並全程使用 decimal 計算。

這個結論引來一些討論:既然如此,為什麼不乾脆把 float、double 給廢了,永遠只用 decimal 就好?

其實也不行,decimal 的精準並非全無代價,它需要的儲存空間大(16 Bytes),計算速度慢。對於不需要高精準度(例如:座標軸、動畫切換時機),但對速度、儲存空間更敏感的應用(例如:遊戲),decimal 很雷,float 才是王道。

這引發一個有趣的問題:大家都知道 decimal 計算比 float、double 慢,但究竟慢多少?寫個程式試試就知道囉。

我寫了一小段程式,用亂數產生 1000 組介於 0 - 100 小數位數一位的 a、b 值,分別採用 float、double、decimal 型別,對 a, b 進行加、減、乘、除四種計算,交由 BenchmarkDotNet 實測速度:

using BenchmarkDotNet.Attributes;
using System;

namespace FloatBenchmark
{
    public class TestRunner
    {
        const int count = 1000;
        float[] fa = new float[count], fb = new float[count];
        double[] da = new double[count], db = new double[count];
        decimal[] ma = new decimal[count], mb = new decimal[count];
        public TestRunner()
        {
            var rnd = new Random(9527);
            for (int i = 0; i < count; i++)
            {
                var a = rnd.Next(1000) + 1;
                var b = rnd.Next(1000) + 1;
                fa[i] = a / 10f; fb[i] = b / 10f;
                da[i] = a / 10d; db[i] = b / 10d;
                ma[i] = a / 10m; mb[i] = b / 10m;
            }
            for (int i = 0; i < 10; i++)
                Console.WriteLine($"{fa[i],4}, {fb[i],4} | {da[i],4}, {db[i],4} | {ma[i],4}, {mb[i],4}");
        }

        [Benchmark]
        public void CalcAddWithFloat()
        {
            for (var i = 0; i < count; i++) { var a = fa[i] + fb[i]; }
        }
        [Benchmark]
        public void CalcSubWithFloat()
        {
            for (var i = 0; i < count; i++) { var a = fa[i] - fb[i]; }
        }
        [Benchmark]
        public void CalcMultWithFloat()
        {
            for (var i = 0; i < count; i++) { var a = fa[i] * fb[i]; }
        }
        [Benchmark]
        public void CalcDivWithFloat()
        {
            for (var i = 0; i < count; i++) { var a = fa[i] / fb[i]; }
        }

        [Benchmark]
        public void CalcAddWithDouble()
        {
            for (var i = 0; i < count; i++) { var a = da[i] + db[i]; }
        }
        [Benchmark]
        public void CalcSubWithDouble()
        {
            for (var i = 0; i < count; i++) { var a = da[i] - db[i]; }
        }
        [Benchmark]
        public void CalcMultWithDouble()
        {
            for (var i = 0; i < count; i++) { var a = da[i] * db[i]; }
        }
        [Benchmark]
        public void CalcDivWithDouble()
        {
            for (var i = 0; i < count; i++) { var a = da[i] / db[i]; }
        }

        [Benchmark]
        public void CalcAddWithDecimal()
        {
            for (var i = 0; i < count; i++) { var a = ma[i] + mb[i]; }
        }
        [Benchmark]
        public void CalcSubWithDecimal()
        {
            for (var i = 0; i < count; i++) { var a = ma[i] - mb[i]; }
        }
        [Benchmark]
        public void CalcMultWithDecimal()
        {
            for (var i = 0; i < count; i++) { var a = ma[i] * mb[i]; }
        }
        [Benchmark]
        public void CalcDivWithDecimal()
        {
            for (var i = 0; i < count; i++) { var a = ma[i] / mb[i]; }
        }
    }
}

偷看亂數產生的測試資料長這樣:

實測結果如下:

// * Summary *

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i5-7440HQ CPU 2.80GHz (Kaby Lake), 1 CPU, 4 logical and 4 physical cores
  [Host]     : .NET Framework 4.8 (4.8.4300.0), X86 LegacyJIT
  DefaultJob : .NET Framework 4.8 (4.8.4300.0), X86 LegacyJIT


|              Method |         Mean |       Error |      StdDev |
|-------------------- |-------------:|------------:|------------:|
|    CalcAddWithFloat |     617.4 ns |     4.99 ns |     4.67 ns |
|    CalcSubWithFloat |     620.3 ns |     3.51 ns |     3.28 ns |
|   CalcMultWithFloat |     618.6 ns |     3.56 ns |     3.33 ns |
|    CalcDivWithFloat |   1,239.9 ns |     8.23 ns |     7.70 ns |
|   CalcAddWithDouble |     618.1 ns |     6.13 ns |     5.73 ns |
|   CalcSubWithDouble |     620.6 ns |     4.53 ns |     4.24 ns |
|  CalcMultWithDouble |     617.4 ns |     5.63 ns |     5.27 ns |
|   CalcDivWithDouble |   1,249.8 ns |     7.10 ns |     6.65 ns |
|  CalcAddWithDecimal |  15,419.1 ns |   171.69 ns |   160.60 ns |
|  CalcSubWithDecimal |  18,166.3 ns |   148.32 ns |   138.73 ns |
| CalcMultWithDecimal |  14,222.4 ns |    96.13 ns |    85.22 ns |
|  CalcDivWithDecimal | 164,824.1 ns | 1,138.52 ns | 1,064.97 ns |

差異非常明顯,float 與 double 計算速度相同,而靠著浮點運算器硬體加持,二者的計算速度電爆 decimal,加法快 25 倍、減法快 30 倍、乘法快 23 倍、除法則快了 132 倍。double 跟 float 計算速度相同,差別在會 double 多用一倍空間(8 Bytes vs 4 Bytes),如果數值用到的總位數小於 9 位(整數與小數部分合計),float 是最快最省空間的選擇。

題外話。這是我熱愛 Coding 的一個重要原因 - 任何時侯心中有疑問,不需要進實驗室,不用買幾千萬的設備,只要手邊有電腦,隨時可以自己動手實驗釐清。在寫程式這個領域,郭董跟我站在同一條起跑線,等等,不對! 他可以花錢請一百位資工博士幫他研究,我只能自己土砲... XD

Use a benchmark to show why we need float and double as they are not as precise as decimal.


Comments

# by null

我之前幫同事優化程式 修改了些邏輯架構 也把 double 改成 Decimal 結果程式變慢... 很難跟別人解釋

# by Huang

浮點數除了有硬體加速器,可能也是因為基於2^n, 2^-n讓速度不至於像decimal變慢。 有興趣可再參考書籍:計算機概論。

Post a comment