Mengapa mengubah 0.1f ke 0 memperlambat kinerja sebesar 10x?

1527

Mengapa ini sedikit kode,

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0.1f; // <--
        y[i] = y[i] - 0.1f; // <--
    }
}

jalankan lebih dari 10 kali lebih cepat dari bit berikut (identik kecuali di mana dicatat)?

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0; // <--
        y[i] = y[i] - 0; // <--
    }
}

saat kompilasi dengan Visual Studio 2010 SP1. Tingkat optimasi adalah -02dengan sse2aktif. Saya belum menguji dengan kompiler lain.

Dragarro
sumber
10
Bagaimana Anda mengukur perbedaannya? Dan opsi apa yang Anda gunakan saat dikompilasi?
James Kanze
158
Mengapa kompilator tidak hanya menjatuhkan +/- 0 dalam kasus ini?!?
Michael Dorgan
127
@ Zyx2000 Kompiler tidak berada di dekat yang sebodoh itu. Pembongkaran contoh sepele di LINQPad menunjukkan bahwa itu meludah keluar kode yang sama apakah Anda menggunakan 0, 0f, 0d, atau bahkan (int)0dalam konteks di mana doublediperlukan.
milimoose
14
apa tingkat optimasi?
Otto Allmendinger

Jawaban:

1616

Selamat datang di dunia floating-point yang didenormalkan ! Mereka dapat mendatangkan malapetaka pada kinerja !!!

Angka yang tidak normal (atau subnormal) adalah jenis peretasan untuk mendapatkan beberapa nilai tambahan yang mendekati nol dari representasi floating point. Operasi pada floating-point terdenormalkan bisa puluhan hingga ratusan kali lebih lambat daripada pada floating-point normal. Ini karena banyak prosesor tidak dapat menanganinya secara langsung dan harus menjebak dan menyelesaikannya menggunakan mikrokode.

Jika Anda mencetak angka-angka setelah 10.000 iterasi, Anda akan melihat bahwa mereka telah konvergen ke nilai yang berbeda tergantung pada apakah 0atau 0.1digunakan.

Berikut kode tes yang dikompilasi di x64:

int main() {

    double start = omp_get_wtime();

    const float x[16]={1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.0,2.1,2.2,2.3,2.4,2.5,2.6};
    const float z[16]={1.123,1.234,1.345,156.467,1.578,1.689,1.790,1.812,1.923,2.034,2.145,2.256,2.367,2.478,2.589,2.690};
    float y[16];
    for(int i=0;i<16;i++)
    {
        y[i]=x[i];
    }
    for(int j=0;j<9000000;j++)
    {
        for(int i=0;i<16;i++)
        {
            y[i]*=x[i];
            y[i]/=z[i];
#ifdef FLOATING
            y[i]=y[i]+0.1f;
            y[i]=y[i]-0.1f;
#else
            y[i]=y[i]+0;
            y[i]=y[i]-0;
#endif

            if (j > 10000)
                cout << y[i] << "  ";
        }
        if (j > 10000)
            cout << endl;
    }

    double end = omp_get_wtime();
    cout << end - start << endl;

    system("pause");
    return 0;
}

Keluaran:

#define FLOATING
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007

//#define FLOATING
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.46842e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.45208e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044

Perhatikan bagaimana pada putaran kedua jumlahnya sangat mendekati nol.

Angka yang didenormalkan umumnya jarang dan karenanya kebanyakan prosesor tidak mencoba menanganinya secara efisien.


Untuk menunjukkan bahwa ini semua ada hubungannya dengan angka-angka yang didenormalkan, jika kita membesar-besarkan denormals ke nol dengan menambahkan ini ke awal kode:

_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

Kemudian versi dengan 0tidak lagi 10x lebih lambat dan sebenarnya menjadi lebih cepat. (Ini mengharuskan kode dikompilasi dengan SSE diaktifkan.)

Ini berarti bahwa alih-alih menggunakan nilai presisi hampir nol yang aneh ini, kami hanya membulatkannya ke nol.

Pengaturan waktu: Core i7 920 @ 3.5 GHz:

//  Don't flush denormals to zero.
0.1f: 0.564067
0   : 26.7669

//  Flush denormals to zero.
0.1f: 0.587117
0   : 0.341406

Pada akhirnya, ini benar-benar tidak ada hubungannya dengan apakah itu bilangan bulat atau floating-point. The 0atau 0.1fdiubah / disimpan menjadi luar daftar dari kedua loop. Sehingga tidak berpengaruh pada kinerja.

Mistikal
sumber
100
Saya masih merasa agak aneh bahwa "+ 0" tidak sepenuhnya dioptimalkan oleh kompiler secara default. Apakah ini akan terjadi jika ia meletakkan "+ 0,0f"?
s73v3r
51
@ s73v3r Itu pertanyaan yang sangat bagus. Sekarang saya melihat perakitan, bahkan tidak + 0.0fdioptimalkan. Jika saya harus menebak, bisa jadi itu + 0.0fakan memiliki efek samping jika y[i]kebetulan menjadi sinyal NaNatau sesuatu ... Tapi saya bisa salah.
Mysticial
14
Doubles masih akan mengalami masalah yang sama dalam banyak kasus, hanya pada besaran numerik yang berbeda. Flush-to-zero tidak masalah untuk aplikasi audio (dan yang lainnya di mana Anda dapat kehilangan 1e-38 di sana-sini), tapi saya yakin tidak berlaku untuk x87. Tanpa FTZ, perbaikan yang biasa untuk aplikasi audio adalah menyuntikkan amplitudo sangat rendah (tidak terdengar) DC atau atau sinyal gelombang persegi ke nomor jitter menjauh dari denormalitas.
Russell Borogove
16
@ Isaac karena ketika y [i] secara signifikan lebih kecil dari 0,1 menambahkannya mengakibatkan hilangnya presisi karena angka paling signifikan dalam angka menjadi lebih tinggi.
Dan Is Fiddling By Firelight
167
@ s73v3r: + 0.f tidak dapat dioptimalkan karena floating-point memiliki 0 negatif, dan hasil menambahkan + 0.f ke -.0f adalah + 0.f. Jadi menambahkan 0.f bukan operasi identitas dan tidak dapat dioptimalkan.
Eric Postpischil
415

Menggunakan gccdan menerapkan diff pada perakitan yang dihasilkan hanya menghasilkan perbedaan ini:

73c68,69
<   movss   LCPI1_0(%rip), %xmm1
---
>   movabsq $0, %rcx
>   cvtsi2ssq   %rcx, %xmm1
81d76
<   subss   %xmm1, %xmm0

Yang cvtsi2ssq10 kali lebih lambat memang.

Rupanya, floatversi menggunakan register XMM yang dimuat dari memori, sementara intversi mengkonversi intnilai nyata 0 untuk floatmenggunakan cvtsi2ssqinstruksi, mengambil banyak waktu. Melewati -O3ke gcc tidak membantu. (gcc versi 4.2.1.)

(Menggunakan doublebukannya floattidak masalah, kecuali bahwa itu mengubah cvtsi2ssqa cvtsi2sdq.)

Memperbarui

Beberapa tes tambahan menunjukkan bahwa itu belum tentu cvtsi2ssqinstruksi. Setelah dihilangkan (menggunakan a int ai=0;float a=ai;dan menggunakan abukan 0), perbedaan kecepatan tetap. Jadi @Mysticial benar, float denormalized membuat perbedaan. Ini dapat dilihat dengan menguji nilai antara 0dan 0.1f. Titik balik dalam kode di atas kira-kira di 0.00000000000000000000000000000001, ketika loop tiba-tiba memakan waktu 10 kali lebih lama.

Perbarui << 1

Visualisasi kecil dari fenomena menarik ini:

  • Kolom 1: pelampung, dibagi 2 untuk setiap iterasi
  • Kolom 2: representasi biner dari float ini
  • Kolom 3: waktu yang diambil untuk menjumlahkan float ini 1e7 kali

Anda dapat dengan jelas melihat eksponen (9 bit terakhir) berubah ke nilai terendahnya, ketika denormalization masuk. Pada titik itu, penambahan sederhana menjadi 20 kali lebih lambat.

0.000000000000000000000000000000000100000004670110: 10111100001101110010000011100000 45 ms
0.000000000000000000000000000000000050000002335055: 10111100001101110010000101100000 43 ms
0.000000000000000000000000000000000025000001167528: 10111100001101110010000001100000 43 ms
0.000000000000000000000000000000000012500000583764: 10111100001101110010000110100000 42 ms
0.000000000000000000000000000000000006250000291882: 10111100001101110010000010100000 48 ms
0.000000000000000000000000000000000003125000145941: 10111100001101110010000100100000 43 ms
0.000000000000000000000000000000000001562500072970: 10111100001101110010000000100000 42 ms
0.000000000000000000000000000000000000781250036485: 10111100001101110010000111000000 42 ms
0.000000000000000000000000000000000000390625018243: 10111100001101110010000011000000 42 ms
0.000000000000000000000000000000000000195312509121: 10111100001101110010000101000000 43 ms
0.000000000000000000000000000000000000097656254561: 10111100001101110010000001000000 42 ms
0.000000000000000000000000000000000000048828127280: 10111100001101110010000110000000 44 ms
0.000000000000000000000000000000000000024414063640: 10111100001101110010000010000000 42 ms
0.000000000000000000000000000000000000012207031820: 10111100001101110010000100000000 42 ms
0.000000000000000000000000000000000000006103515209: 01111000011011100100001000000000 789 ms
0.000000000000000000000000000000000000003051757605: 11110000110111001000010000000000 788 ms
0.000000000000000000000000000000000000001525879503: 00010001101110010000100000000000 788 ms
0.000000000000000000000000000000000000000762939751: 00100011011100100001000000000000 795 ms
0.000000000000000000000000000000000000000381469876: 01000110111001000010000000000000 896 ms
0.000000000000000000000000000000000000000190734938: 10001101110010000100000000000000 813 ms
0.000000000000000000000000000000000000000095366768: 00011011100100001000000000000000 798 ms
0.000000000000000000000000000000000000000047683384: 00110111001000010000000000000000 791 ms
0.000000000000000000000000000000000000000023841692: 01101110010000100000000000000000 802 ms
0.000000000000000000000000000000000000000011920846: 11011100100001000000000000000000 809 ms
0.000000000000000000000000000000000000000005961124: 01111001000010000000000000000000 795 ms
0.000000000000000000000000000000000000000002980562: 11110010000100000000000000000000 835 ms
0.000000000000000000000000000000000000000001490982: 00010100001000000000000000000000 864 ms
0.000000000000000000000000000000000000000000745491: 00101000010000000000000000000000 915 ms
0.000000000000000000000000000000000000000000372745: 01010000100000000000000000000000 918 ms
0.000000000000000000000000000000000000000000186373: 10100001000000000000000000000000 881 ms
0.000000000000000000000000000000000000000000092486: 01000010000000000000000000000000 857 ms
0.000000000000000000000000000000000000000000046243: 10000100000000000000000000000000 861 ms
0.000000000000000000000000000000000000000000022421: 00001000000000000000000000000000 855 ms
0.000000000000000000000000000000000000000000011210: 00010000000000000000000000000000 887 ms
0.000000000000000000000000000000000000000000005605: 00100000000000000000000000000000 799 ms
0.000000000000000000000000000000000000000000002803: 01000000000000000000000000000000 828 ms
0.000000000000000000000000000000000000000000001401: 10000000000000000000000000000000 815 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 44 ms

Diskusi yang setara tentang ARM dapat ditemukan di pertanyaan Stack Overflow. Titik mengambang yang dinormalisasi di Objective-C? .

mvds
sumber
27
-OIni tidak memperbaikinya, tetapi -ffast-mathtidak. (Saya menggunakannya sepanjang waktu, IMO kasus sudut di mana hal itu menyebabkan masalah presisi seharusnya tidak muncul dalam program yang dirancang dengan baik.)
leftaroundabout
Tidak ada konversi pada tingkat optimisasi positif dengan gcc-4.6.
Jed
@leftaroundabout: kompilasi yang dapat dieksekusi (bukan perpustakaan) dengan -ffast-mathtautan beberapa kode startup tambahan yang menetapkan FTZ (flush ke nol) dan DAZ (tidak normal adalah nol) di MXCSR, sehingga CPU tidak perlu lagi mengambil mikrokode bantu yang lambat untuk penyangkalan.
Peter Cordes
34

Ini karena penggunaan floating-point yang didenormalkan. Bagaimana cara menyingkirkannya dan penalti kinerja? Setelah menjelajahi Internet untuk mencari cara membunuh nomor yang tidak normal, tampaknya belum ada cara "terbaik" untuk melakukan ini. Saya telah menemukan tiga metode ini yang paling berhasil di lingkungan yang berbeda:

  • Mungkin tidak berfungsi di beberapa lingkungan GCC:

    // Requires #include <fenv.h>
    fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
  • Mungkin tidak berfungsi di beberapa lingkungan Visual Studio: 1

    // Requires #include <xmmintrin.h>
    _mm_setcsr( _mm_getcsr() | (1<<15) | (1<<6) );
    // Does both FTZ and DAZ bits. You can also use just hex value 0x8040 to do both.
    // You might also want to use the underflow mask (1<<11)
  • Tampaknya berfungsi di GCC dan Visual Studio:

    // Requires #include <xmmintrin.h>
    // Requires #include <pmmintrin.h>
    _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);
    _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);
  • Kompiler Intel memiliki opsi untuk menonaktifkan denormals secara default pada CPU Intel modern. Lebih detail di sini

  • Saklar kompiler. -ffast-math, -msseatau -mfpmath=sseakan menonaktifkan denormals dan membuat beberapa hal lain lebih cepat, tetapi sayangnya juga melakukan banyak perkiraan lain yang dapat merusak kode Anda. Uji dengan cermat! Setara dengan matematika cepat untuk kompiler Visual Studio adalah /fp:fasttetapi saya belum dapat mengkonfirmasi apakah ini juga menonaktifkan denormals. 1

ara
sumber
1
Ini terdengar seperti jawaban yang layak untuk pertanyaan yang berbeda tetapi terkait (Bagaimana saya bisa mencegah perhitungan numerik dari menghasilkan hasil yang tidak normal?) Namun, itu tidak menjawab pertanyaan ini.
Ben Voigt
Windows X64 melewati pengaturan tiba-tiba underflow ketika meluncurkan .exe, sedangkan Windows 32-bit dan linux tidak. Di linux, gcc -fast-math harus mengatur underflow mendadak (tapi saya pikir tidak pada Windows). Kompiler Intel seharusnya diinisialisasi dalam main () sehingga perbedaan OS ini tidak melewati, tapi saya sudah digigit, dan perlu mengaturnya secara eksplisit dalam program. CPU Intel yang dimulai dengan Sandy Bridge seharusnya menangani subnormal yang muncul dalam add / kurangi (tetapi tidak dibagi / dikalikan) secara efisien, sehingga ada kasus untuk menggunakan underflow bertahap.
tim18
1
Microsoft / fp: fast (bukan default) tidak melakukan hal agresif apa pun yang ada dalam gcc -fast-math atau ICL (default) / fp: fast. Ini lebih seperti ICL / fp: source. Jadi Anda harus mengatur / fp: (dan, dalam beberapa kasus, mode underflow) secara eksplisit jika Anda ingin membandingkan kompiler ini.
tim18
18

Di gcc, Anda dapat mengaktifkan FTZ dan DAZ dengan ini:

#include <xmmintrin.h>

#define FTZ 1
#define DAZ 1   

void enableFtzDaz()
{
    int mxcsr = _mm_getcsr ();

    if (FTZ) {
            mxcsr |= (1<<15) | (1<<11);
    }

    if (DAZ) {
            mxcsr |= (1<<6);
    }

    _mm_setcsr (mxcsr);
}

juga gunakan sakelar gcc: -msse -mfpmath = sse

(kredit yang sesuai untuk Carl Hetherington [1])

[1] http://carlh.net/plugins/denormals.php

Garcia Jerman
sumber
Juga lihat fesetround()dari fenv.h(ditetapkan untuk C99) untuk yang lain, cara yang lebih portabel pembulatan ( linux.die.net/man/3/fesetround ) (tapi ini akan mempengaruhi semua operasi FP, bukan hanya subnormals )
Jerman Garcia
Apakah Anda yakin perlu 1 << 15 dan 1 << 11 untuk FTZ? Saya hanya melihat 1 << 15 dikutip di tempat lain ...
fig
@ gambar: 1 << 11 adalah untuk Masker Underflow. Info lebih lanjut di sini: softpixel.com/~cwright/programming/simd/sse.php
German Garcia
@GermanGarcia ini tidak menjawab pertanyaan OP; pertanyaannya adalah "Mengapa sedikit kode ini, berjalan 10 kali lebih cepat daripada ..." - Anda harus berusaha menjawabnya sebelum memberikan solusi ini atau memberikannya dalam komentar.
9

Komentar Dan Neely harus diperluas menjadi jawaban:

Bukan konstanta nol 0.0fyang didenormalisasi atau menyebabkan pelambatan, melainkan nilai-nilai yang mendekati nol setiap iterasi dari loop. Ketika mereka semakin dekat dan semakin dekat ke nol, mereka membutuhkan lebih banyak ketelitian untuk mewakili dan mereka menjadi terdenormalisasi. Inilah y[i]nilainya. (Mereka mendekati nol karena x[i]/z[i]kurang dari 1,0 untuk semua i.)

Perbedaan penting antara versi kode yang lambat dan cepat adalah pernyataannya y[i] = y[i] + 0.1f;. Segera setelah garis ini dieksekusi setiap iterasi loop, presisi ekstra dalam float hilang, dan denormalization diperlukan untuk menyatakan bahwa presisi tidak lagi diperlukan. Setelah itu, operasi floating point y[i]tetap cepat karena tidak didenormalisasi.

Mengapa presisi ekstra hilang saat Anda menambahkan 0.1f? Karena angka floating point hanya memiliki begitu banyak digit signifikan. Katakanlah Anda memiliki cukup penyimpanan untuk tiga digit signifikan, lalu 0.00001 = 1e-5, dan 0.00001 + 0.1 = 0.1, setidaknya untuk format float contoh ini, karena tidak memiliki ruang untuk menyimpan bit yang paling tidak signifikan 0.10001.

Singkatnya, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f;bukan no-op yang mungkin Anda pikirkan.

Mistik juga mengatakan ini : isi masalah mengapung, bukan hanya kode perakitan.

sepeda motor
sumber