Saya baru-baru ini kembali ke analisis kesalahan titik apung untuk memperbaiki beberapa detail dalam edisi berikutnya buku
Rendering Berbasis Fisik . Angka floating-point adalah bidang perhitungan yang menarik, penuh kejutan (baik dan buruk), serta trik rumit untuk menghilangkan kejutan yang tidak menyenangkan.
Dalam prosesnya, saya menemukan
posting ini
di StackOverflow , dari mana saya belajar tentang algoritma yang elegan untuk perhitungan yang tepat
a kalibβc kalid .
Tetapi sebelum melanjutkan dengan algoritma, Anda perlu memahami apa yang begitu licik dalam berekspresi
a kalibβc kalid ? Ambil
a=33962.035 ,
b=β30438,8 ,
c=41563,4 dan
d=β24871.969 . (Ini adalah nilai nyata yang saya dapatkan selama peluncuran
pbrt .) Untuk nilai float 32-bit, kita dapatkan:
a kalib=β1.03376365 kali109 dan
c kalid=β1.03376352 kali109 . Kami melakukan pengurangan, dan kami mendapatkannya
β128 . Tetapi jika Anda melakukan perhitungan dengan presisi ganda, dan pada akhirnya mengubahnya menjadi mengambang, Anda dapatkan
β75,1656 . Apa yang terjadi
Masalahnya adalah bahwa nilai setiap pekerjaan dapat jauh melampaui garis bawah
β1 kali109 , di mana jarak antara nilai floating point yang diwakili sangat besar - 64. Artinya, saat pembulatan
a kalib dan
c kalid secara individual, ke float representable terdekat, mereka berubah menjadi angka yang merupakan kelipatan dari 64. Pada gilirannya, perbedaan mereka akan menjadi kelipatan dari 64, dan tidak akan ada harapan bahwa itu akan menjadi
β75,1656 lebih dekat dari
β64 . Dalam kasus kami, hasilnya bahkan lebih jauh karena bagaimana keduanya bekerja
β1 kali109 . Kita akan secara langsung menghadapi pengurangan bencana tua yang baik
1 .
Ini adalah solusi yang lebih baik daripada
2 :
inline float DifferenceOfProducts(float a, float b, float c, float d) { float cd = c * d; float err = std::fma(-c, d, cd); float dop = std::fma(a, b, -cd); return dop + err; }
DifferenceOfProducts()
menghitung
a kalibβc kalid dengan cara yang menghindari kontraksi bencana. Teknik ini pertama kali dijelaskan oleh William Kahan yang legendaris dalam artikel
On Cost of Floating-PointComputasi Tanpa Aritmatika Ekstra Tepat . Perlu dicatat bahwa karya Kahan menarik untuk dibaca secara keseluruhan, mereka memiliki banyak komentar tentang keadaan dunia saat ini poin mengambang, serta pertimbangan matematika dan teknis. Inilah salah satu temuannya:
Kita yang telah berjuang dengan perubahan aritmatika floating point dan "optimisasi" kompiler yang buruk dapat dengan bangga bangga dengan kemenangan dalam pertempuran ini. Tetapi jika kita meneruskan kelanjutan pertempuran ini untuk generasi masa depan, ini akan bertentangan dengan esensi seluruh peradaban. Pengalaman kami mengatakan bahwa bahasa pemrograman dan sistem pengembangan adalah sumber dari terlalu banyak kekacauan yang harus kita tangani. Terlalu banyak kesalahan dapat ditiadakan, serta beberapa "optimasi" menarik yang aman untuk bilangan bulat, tetapi kadang-kadang terbukti berakibat fatal untuk angka floating point.
Setelah membayar upeti untuk kecerdasannya, mari kita kembali ke
DifferenceOfProducts()
: dasar dari penguasaan fungsi ini adalah penggunaan penggandaan multiply-add, instruksi FMA
3 . Dari sudut pandang matematika,
FMA(a,b,c)
adalah
a kalib+c Karena itu, pada awalnya tampaknya operasi ini hanya berguna sebagai optimasi mikro: satu instruksi, bukan dua. Namun fma
Ini memiliki properti khusus - itu hanya membulatkan sekali.
Seperti biasa
a kalib+c pertama kali dihitung
a kalib , dan kemudian nilai ini, yang dalam kasus umum tidak dapat direpresentasikan dalam format floating point, dibulatkan ke float terdekat. Kemudian untuk nilai bulat ini ditambahkan
c , dan hasil ini kembali dibulatkan ke pelampung terdekat. FMA diimplementasikan sedemikian rupa sehingga pembulatan dilakukan hanya pada akhirnya - nilai menengah
a kalib mempertahankan akurasi yang cukup, jadi setelah menambahkannya
c hasil akhir akan paling mendekati nilai sebenarnya
a kalib+c nilai float.
Setelah berurusan dengan FMA, kami akan kembali ke
DifferenceOfProducts()
. Sekali lagi, saya akan menunjukkan dua baris pertama:
float cd = c * d; float err = std::fma(-c, d, cd);
Yang pertama menghitung nilai bulat
c kalid dan yang kedua ... kurangi
c kalid dari pekerjaan mereka? Jika Anda tidak tahu cara kerja FMA, maka Anda mungkin berpikir bahwa
err
akan selalu menjadi nol. Tetapi ketika bekerja dengan FMA, baris kedua sebenarnya mengekstraksi nilai kesalahan pembulatan dalam nilai yang dihitung
c kalid dan menyimpannya untuk
err
. Setelah itu, hasilnya sangat sederhana:
float dop = std::fma(a, b, -cd); return dop + err;
FMA kedua menghitung perbedaan antara karya-karya menggunakan FMA, melakukan pembulatan hanya di bagian paling akhir. Akibatnya, tahan terhadap pengurangan bencana, tetapi perlu bekerja dengan nilai bulat.
c kalid .
return
"memperbaiki" masalah ini, menambahkan kesalahan yang disorot di baris kedua. Dalam sebuah artikel oleh Jeannenrod et al.
ditunjukkan bahwa hasilnya benar hingga 1,5 ulps (ukuran presisi unit), yang sangat bagus: FMA dan operasi floating point sederhana akurat hingga 0,5 ulps, sehingga algoritmenya hampir sempurna.
Kami menggunakan palu baru
Ketika Anda mulai mencari cara untuk menggunakan
DifferenceOfProducts()
, ternyata sangat berguna. Menghitung pembeda persamaan kuadrat? Call
DifferenceOfProducts(b, b, 4 * a, c)
4 . Menghitung determinan matriks 2x2? Algoritma akan menyelesaikan masalah ini. Dalam versi
pbrt berikutnya
, saya menemukan sekitar 80 kegunaan untuk itu. Dari mereka semua, fungsi produk vektor adalah yang paling dicintai. Itu selalu menjadi sumber masalah, karena itu Anda harus mengangkat tangan dan menggunakan ganda dalam implementasi untuk menghindari pengurangan bencana:
inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) { double v1x = v1.x, v1y = v1.y, v1z = v1.z; double v2x = v2.x, v2y = v2.y, v2z = v2.z; return Vector3f(v1y * v2z - v1z * v2y, v1z * v2x - v1x * v2z, v1x * v2y - v1y * v2x); }
Dan sekarang kita dapat terus bekerja dengan float dan menggunakan
DifferenceOfProducts()
.
inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) { return Vector3f(DifferenceOfProducts(v1.y, v2.z, v1.z, v2.y), DifferenceOfProducts(v1.z, v2.x, v1.x, v2.z), DifferenceOfProducts(v1.x, v2.y, v1.y, v2.x)); }
Contoh licik dari awal posting sebenarnya adalah bagian dari karya vektor. Pada tahap tertentu, kode
pbrt perlu menghitung produk vektor dari vektor
(33962.035,41563.4,7706.415) dan
(β24871.969,β30438.8,β5643.727) . Ketika dihitung menggunakan float, kita akan mendapatkan vektor
(1552,β1248,β128) . (Aturan umum: jika dalam perhitungan titik apung di mana angka besar terlibat, Anda tidak mendapatkan nilai integer yang begitu besar, maka ini adalah tanda yang hampir pasti bahwa pengurangan bencana telah terjadi.)
Dengan presisi ganda, produk vektor adalah
(1556.0276,β1257.5151,β75.1656) . Kita melihatnya dengan pelampung
x terlihat normal
y sudah sangat buruk juga
z menjadi bencana yang telah menjadi motivasi untuk menemukan solusi. Dan hasil apa yang akan kita dapatkan dengan
DifferenceOfProducts()
dan nilai float?
(1556.0276,β1257.5153,β75.1656) . Nilai-nilai
x dan
z sesuai dengan presisi ganda, dan
y sedikit diimbangi - karenanya ulp ekstra berasal dari.
Bagaimana dengan kecepatan?
DifferenceOfProducts()
melakukan dua FMA, serta multiplikasi dan penambahan. Algoritma naif dapat diimplementasikan dengan satu FMA dan satu multiplikasi, yang, tampaknya, akan memakan waktu setengah. Namun dalam praktiknya, ternyata setelah mendapatkan nilai dari register, itu tidak masalah: dalam tolok ukur sintetis yang dipegang di laptop saya,
DifferenceOfProducts()
hanya 1,09 kali lebih mahal daripada algoritma naif. Operasi presisi ganda lebih lambat 2,98 kali.
Segera setelah Anda mengetahui tentang kemungkinan pengurangan bencana, semua jenis ekspresi yang tampak tidak bersalah dalam kode mulai tampak mencurigakan.
DifferenceOfProducts()
tampaknya menjadi obat yang baik untuk sebagian besar dari mereka. Mudah digunakan, dan kami tidak punya alasan khusus untuk tidak menggunakannya.
Catatan
- Pengurangan katastropik tidak menjadi masalah ketika mengurangi jumlah dengan tanda yang berbeda atau ketika menambahkan nilai dengan tanda yang sama. Namun, ini bisa menjadi masalah saat menambahkan nilai dengan tanda yang berbeda. Artinya, jumlahnya harus dipertimbangkan dengan kecurigaan yang sama dengan perbedaan.
- Sebagai latihan untuk pembaca, saya sarankan untuk menulis fungsi
SumOfProducts()
, yang memberikan perlindungan terhadap kontraksi bencana. Jika Anda ingin menyulitkan tugas, maka jelaskan mengapa di DifferenceOfProducts()
, dop + err == dop
, karena tanda-tanda a*b
dan c*d
berbeda. - Instruksi FMA telah tersedia di GPU selama lebih dari satu dekade, dan pada sebagian besar CPU setidaknya selama lima tahun. Mungkin perlu menambahkan flag compiler ke CPU untuk secara langsung membuatnya ketika menggunakan
std::fma()
; dalam gcc dan dentang, -march=native
karya -march=native
. - Dalam format floating point IEEE, perkalian dengan kekuatan dua dilakukan persis, sehingga
4 * a
tidak menyebabkan kesalahan pembulatan, kecuali terjadi overflow.