double VS float

0. 寫在最前面

本文持續更新地址:https://haoqchen.site/2019/12/28/double-vs-float/

首先說明,如果只是一兩次的浮點運算,無腦使用double即可。下面主要針對需要大量浮點運算的情況做分析,比較float和double的優缺點。如無特殊說明,我的環境如下:

  • 系統:Ubuntu1604(64bit)
  • 編譯器:g++ 5.4.0
  • CPU:i7-4771

文中所有的時間計算函數使用我的另一篇博文:Linux時間相關函數總結中講到的clock_gettimeCLOCK_THREAD_CPUTIME_ID

爲了避免計算時間的隨機性,每個計算運行3次。

另外吐槽個事情,最近博客閱讀量到10萬了,然後想申請CSDN的博客專家,然後CSDN給出的回覆是“都是基礎,沒有深度”:

在這裏插入圖片描述
嗯,基礎不重要。

如果覺得寫得還不錯,可以找我其他文章來看看哦~~~可以的話幫我github點個讚唄。
你的Star是作者堅持下去的最大動力哦~~~

1. 數據類型的基本情況

從《C++ Primer Plus(第6版)》瞭解到,C++定義了3種浮點類型:float、double、long double,並且要求:

  • float至少32位(一般爲32位)
  • double至少48位,且不少於float(一般爲64位)
  • long double至少和double一樣多

關於浮點數在內存中如何存儲,這裏有一篇講得非常詳細的博客:浮點數在計算機內存中是如何存儲的?,這裏不再細述。

很明顯,float相對於double的優點有:

  1. 佔用內存少。這個做過單片機的同學應該深有體會,能用float的堅決不用double,用double一不小心程序就滿了。現代電腦如果不是大數據,問題都不大。
  2. 位數少,硬件讀取快。要從硬件讀取大量數據,或者要將大量參數保存到本地硬盤的時候需要考慮。
  3. 精度低,運算收斂快。要算個cossinlog,float只需要運算7次即可收斂,而double需要迭代8~18次。

相對的,double的優點有:

  1. 精度高。《C++ Primer Plus(第6版)》中舉了一個例子,10.0/3.0*1.0e6,float只有3333333.25,而double能精確到3333333.333333。。。對於精度要求非常高的運算,float的誤差是不能容忍的。

下面是我從普通加減乘除對比硬盤讀寫對比複雜函數三個方面做的一個實驗對比,如果有興趣也可以在你自己的電腦上做個對比。

2. 普通加減乘除對比

測試代碼:

#include <iostream>
#include <string>
#include <sstream>
#include <vector>
#include <time.h>

using std::cout;
using std::endl;

auto time2String = [](struct timespec t_start, struct timespec t_end) -> const std::string {
    std::string result;
    std::stringstream ss;
    long double temp = 0.0;
    temp += (t_end.tv_sec - t_start.tv_sec);
    temp += static_cast<long double>((t_end.tv_nsec - t_start.tv_nsec) / 1000u) / static_cast<long double>(1000000.0);
    ss.precision(6);
    ss.setf(std::ios::fixed);
    ss << temp  ;
    ss >> result;
    return result;
};

int main(int argc, char **argv)
{  
    if (argc != 2){
        std::cerr << "Please run as: cpp_test 1000" << std::endl \
                  << "with 1000 means loop count" << std::endl;
        return -1;
    }

    std::size_t loop_count = stoul(std::string(argv[1]));
    volatile std::size_t i = 0; // 要求編譯器每次都直接讀取原始內存地址,防止編譯器對循環做優化

    std::vector<float> vfloat(loop_count, 5.5f);
    std::vector<double> vdouble(loop_count, 5.5);

    struct timespec time_start;
    if (0 != clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_start)){
        std::cerr << "get time wrong" << std::endl;
    }
// float 循環做加減乘除
    for (; i < loop_count; ++i){
        vfloat[i] += 2.3f;
        vfloat[i] -= 3.4f;
        vfloat[i] *= 4.5f;
        vfloat[i] /= 5.6f;
    }

    struct timespec time_1;
    if (0 == clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_1)){
        std::cout << "float  +-*/ time: " << time2String(time_start, time_1) << "s" << std::endl;
    }
// double 循環做加減乘除
    i = 0; 
    for (; i < loop_count; ++i){
        vdouble[i] += 2.3;
        vdouble[i] -= 3.4;
        vdouble[i] *= 4.5;
        vdouble[i] /= 5.6;
    }
    struct timespec time_2;
    if (0 == clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_2)){
        std::cout << "double +-*/ time: " << time2String(time_1, time_2) << "s" << std::endl;
    }
}

使用以下指令進行編譯,其中-O0讓編譯器不做任何優化:

g++ cpp_test.cpp -std=c++11 -o cpp_test -O0

測試結果:

1百~1萬 10萬到1億
] 在這裏插入圖片描述

從1百到1億,每個數量級都運算了3次以避免偶然因素。可以發現,在1百次到1萬次之間,double類型會比float耗時要多得多,而到了10萬次以上,兩者的時間就相差不大了。

實驗到這裏,表示非常的疑惑,數量級非常小的時候,差距反而擴大了?仔細檢查後發現,竟然是std::cout惹的禍,將float後面的輸出註釋掉,再運行,double的時間就跟float相差不大了。

總結: 對於普通的加減乘除而言,如果數據量少於10萬,兩者的運算效率幾乎沒有差距,而數據量大於10萬以後,float要略微優於double7~8個百分點。

注意:

  1. 這裏千萬記得賦值給float時要在數字後面加上f,不要寫成下面這樣。因爲對於系統而言,默認的浮點型是double,如果你不帶後面的f,只寫數字,那麼這個數字就是double類型的,要賦值給float需要經過一次類型轉換。我將上面的代碼中的f去掉後進行了一次測試,結果顯示,float的耗時甚至會反超double6~7個百分點。在優化等級爲O2的情況下耗時增加更加明顯,float的耗時甚至超過了double20個百分點。

    for (; i < loop_count; ++i){
        vfloat[i] += 2.3;
        vfloat[i] -= 3.4;
        vfloat[i] *= 4.5;
        vfloat[i] /= 5.6;
    }
    
  2. 對於普通的加減乘除,兩者的運算時間不會有太大的區別。因爲目前CPU中的加法器位數遠不止64位,運算一個32位和運算一個64位的數據對他們來說沒有太大區別。但對於一些優化指令集就不一樣了,比如SSE(僅支持單精度的浮點運算)、SSE2、SSE3等。這些指令集有專門對整形和浮點型做運算優化,尤其是一些向量運算和矩陣運算。比如SSE2使用了128位的運算單元,可以同時運算4個32位的浮點數或者2個64位的浮點數。根據g++官方文檔的說法,g++是默認開啓SSE×指令集的,但上面其實並沒有並行運算,所以效果並不明顯。具體可對比本文第4章。

2. 硬盤讀寫對比

測試代碼:

int main(int argc, char **argv)
{  
    if (argc != 2){
        std::cerr << "Please run as: cpp_test 1000" << std::endl \
                  << "with 1000 means loop count" << std::endl;
        return -1;
    }

    std::size_t loop_count = stoul(std::string(argv[1]));
    volatile std::size_t i = 0; // 要求編譯器每次都直接讀取原始內存地址,防止編譯器對循環做優化

    float data_float = 0.1018f;
    double data_double = 0.1018;

    struct timespec time_1;
    struct timespec time_2;
    struct timespec time_3;
    struct timespec time_4;

    struct timespec time_start;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_start);

// float 循環寫入硬盤
    i = 0;
    std::ofstream float_out;
    float_out.open("./float_out.txt", std::ios::out | std::ios::trunc); // 如果存在則先刪除
    float_out.setf(std::ios::fixed);
    float_out.precision(7);
    for (; i < loop_count; ++i){
        float_out << data_float;
    }
    float_out.close();
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_1);

// float 循環讀取硬盤
    i = 0;
    std::ifstream float_in;
    float_in.open("./float_out.txt", std::ios::in);
    float_in.setf(std::ios::fixed);
    float_in.precision(7);
    for (; i < loop_count; ++i){
        float_in >> data_float;
        // std::cout << data_float << ",";
    }
    // std::cout << std::endl;
    float_in.close();
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_2);

// double 循環寫入硬盤
    i = 0;
    std::ofstream double_out;
    double_out.open("./double_out.txt", std::ios::out | std::ios::trunc); // 如果存在則先刪除
    double_out.setf(std::ios::fixed);
    double_out.precision(14); 
    for (; i < loop_count; ++i){
        double_out << data_double;
    }
    double_out.close();
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_3);

// double 循環讀取硬盤
    i = 0;
    std::ifstream double_in;
    double_in.open("./double_out.txt", std::ios::in);
    double_in.setf(std::ios::fixed);
    double_in.precision(14);
    for (; i < loop_count; ++i){
        double_in >> data_double;
        // std::cout << data_double << ",";
    }
    // std::cout << std::endl;
    double_in.close();
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_4);
    
    std::cout << "float  write time: " << time2String(time_start, time_1) << "s" << std::endl;
    std::cout << "double write time: " << time2String(time_2, time_3) << "s" << std::endl;
    std::cout << "float  read  time: " << time2String(time_1, time_2) << "s" << std::endl;
    std::cout << "double read  time: " << time2String(time_3, time_4) << "s" << std::endl;
}

測試結果:

測試的是否發現,先讀寫float還是讀寫double對時間的影響非常大,尤其是次數比較少時,第一個讀寫的類型消耗的時間會增加很多。爲了公平起見,分別將float和double都放到了前面進行測試。如下圖所示,其中左邊的是double放在前面的測試時間,右邊的是float放在前面的測試時間。

10~1萬 10萬到1億
在這裏插入圖片描述 在這裏插入圖片描述

總結:

  1. 首先對於10~1萬的數據,初步猜測哪個放前面,哪個消耗的時間就比較多的原因是,程序獲取硬盤的系統資源需要一定的時間,這個時間幾乎都消耗在第一次獲取的時間上,後面該程序會一直持有,時間不再消耗在獲取上。
  2. 對於10~1萬的數據,綜合兩個順序的時間來看,double仍然要比float慢一點。
  3. 寫入的時間大學比讀取的時間慢40%。
  4. 對於10萬~1億的數據,無論哪種順序,double都要比float慢,寫入的速度float:double最後差不多穩定在32:39,讀取速度差不多穩定在23:29。
  5. 當寫入量達到1億的時候,生成的文件大小對比:
    在這裏插入圖片描述

3. 複雜函數

測試代碼:

int main(int argc, char **argv)
{  
    if (argc != 2){
        std::cerr << "Please run as: cpp_test 1000" << std::endl \
                  << "with 1000 means loop count" << std::endl;
        return -1;
    }

    std::size_t loop_count = stoul(std::string(argv[1]));
    volatile std::size_t i = 0; // 要求編譯器每次都直接讀取原始內存地址,防止編譯器對循環做優化

    std::vector<float> vfloat(loop_count, 0.0f);
    std::vector<double> vdouble(loop_count, 0.0);

    for (i = 0; i < loop_count; ++i){
        vfloat[i] = (float)loop_count + 0.1234567f;
        vdouble[i] = (double)loop_count + 0.1234567;
    }

    struct timespec time_1;
    struct timespec time_2;

    struct timespec time_start;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_start);

// float 循環做cos
    i = 0;
    for (; i < loop_count; ++i){
        vfloat[i] = cosf(vfloat[i]);
        vfloat[i] = sinf(vfloat[i]);
    }
    
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_1);
// double 循環做cos
    i = 0; 
    for (; i < loop_count; ++i){
        vdouble[i] = cos(vdouble[i]);
        vdouble[i] = sin(vdouble[i]);
    }
    
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_2);

    std::cout << "float  cos and sin time: " << time2String(time_start, time_1) << "s" << std::endl;
    std::cout << "double cos and sin time: " << time2String(time_1, time_2) << "s" << std::endl;
}

測試結果:

10~1萬 10萬到1億
在這裏插入圖片描述 在這裏插入圖片描述

總結:

  1. double比float快,而且快不止一點點
  2. 在10萬和1000萬這兩個點,有點迷,搞不懂,我重複測了幾次,結果也是相差不多,很奇怪,暫時沒有解釋。換了一個固定的cos值,在1000萬時時間比變成0.26:0.38,但10萬測試多次仍有很大隨機性。
  3. 在查找cos的頭文件發現,math.h中的函數都是用了SIMD來實現的,SIMD是Single Instruction,Multiple Data的縮寫——意爲單指令多數據。具體細節還沒時間研究,可以肯定的是,這裏面利用了SSE等指令集進行了並行優化。
  4. 另外也測試了loglogf的區別,兩者的時間差會更加明顯,最終穩定的時間比約1.6:2.5

4. Eigen矩陣運算

測試代碼:

int main(int argc, char **argv)
{
    if (argc != 2){
        std::cerr << "Please run as: cpp_test 1000" << std::endl \
                  << "with 1000 means loop count" << std::endl;
        return -1;
    }

    std::size_t loop_count = stoul(std::string(argv[1]));
    volatile std::size_t i = 0; // 要求編譯器每次都直接讀取原始內存地址,防止編譯器對循環做優化

    MatrixXf float_m1 = MatrixXf::Random(loop_count, loop_count);
    MatrixXf float_m2 = MatrixXf::Random(loop_count, loop_count);
    MatrixXd double_m1 = MatrixXd::Random(loop_count, loop_count);
    MatrixXd double_m2 = MatrixXd::Random(loop_count, loop_count);

    struct timespec time_1;
    struct timespec time_2;

    struct timespec time_start;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_start);

// float 循環做加減乘除
    MatrixXf float_p = float_m1 * float_m2;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_1);
// double eigen矩陣相乘
    MatrixXd double_p = double_m1 * double_m2;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time_2);

    std::cout << "float  Eigen * time O0: " << time2String(time_start, time_1) << "s" << std::endl;
    std::cout << "double Eigen * time O0: " << time2String(time_1, time_2) << "s" << std::endl;
}

使用以下命令進行編譯:

g++ cpp_test.cpp -std=c++11 -o cpp_test -I /usr/include/eigen3 -O0

測試結果:

乘法 加法
在這裏插入圖片描述 在這裏插入圖片描述

總結:

Eigen內部是利用SSE指令集進行優化的,當數據量非常大的時候,double所用的時間就接近於float的兩倍了,這是非常明顯的提升。

參考


喜歡我的文章的話Star一下唄Star

版權聲明:本文爲白夜行的狼原創文章,未經允許不得以任何形式轉載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章