20年前的幾行代碼竟如此牛逼?驚了

最近在知乎上看到了一個話題:世界上有哪些代碼量很少,但很牛逼很經典的算法或項目案例?其中有一個回答是雷神之錘3中的快速逆平方根算法,我本以爲是電影中雷神3中出現的代碼,就特別好奇點進去看了一下,結果真是對應了代碼註釋中的一句話“what the fuck?”。

越不會越好奇,查過之後才知道這是一款遊戲中的部分代碼,1999年發佈,2005年開源,距離現在已經有20年了,據說這部分代碼出現在公共場合時,幾乎震住了所有人,也就是下面這幾行代碼:

float Q_rsqrt( float number )
{
  long i;
  float x2, y;
  const float threehalfs = 1.5f;
  x2 = number * 0.5f;
  y  = number;
  i  = * ( long * ) &y;  // evil floating point bit level hacking
  i  = 0x5f3759df - ( i >> 1 ); // what the fuck?
  y  = * ( float * ) &i;
  y  = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration
  // y  = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
  return y;
}

可能很多程序員老手都知道這段牛B的代碼,但對於很多人應該是空白的。求一個數的平方根我們通常會用到庫中的內置方法,也就是sqrt,比如C語言中求一個數的平方根倒數,利用float(1.0/sqrt(x))即可。

可能那個時候沒有sqrt方法,作者被“被逼無奈”想到了這種方法,但是最牛逼的是這種方法要比sqrt方法快的多,據說在有的CPU上可以快4倍,也有說快20%的,但我的電腦上編譯是要快接近30%,而快的很大原因之一是因爲代碼中的一串神祕的數字0x5f3759df。下面結合一些相關知識和推導來介紹作者幾個騷操作,最終的目的都是爲了得到這個神祕數字。

首先我們要先明確一個基礎知識,即單精度浮點數在32位中是如何儲存的。32bit可以共分爲三個部分S、E、M,其中S只有1位,0表示正數、1表示負數;E表示小數,共有8位;M表示尾數,共有23位,如下圖:

以數字4.25爲例,整數5的二進制表示爲0100;與二進制表示整數同理,小數也是如此,比如202^0右側就是212^{-1},那麼4.25轉化爲二進制形式就是0100.01。將二進制形式轉用科學計數法的形式可以表示爲1.0001×22=(1+0.0001)×221.0001\times2^2=(1+0.0001)\times2^2

這裏先用s、e、m表示,因爲S、E、M另有含義

由於4.25是正數,那麼s位就存儲爲0,然後可以將指數處的2+127以二進制形式存儲至e中,加上127的理由是將指數的範圍由(-127,128)移碼至(0,255),這樣就可以用指數位置就不用考慮符號問題。最後將小數點後的0001存儲至m中,因爲後面的數足以確定小數點前爲1,所以沒有必要再存儲1,綜上就是一個單精度浮點數在32位內存中的存儲方式,如下圖:

所以對於一個要開放的浮點數x,有以下表示形式,先暫稱爲存儲表達式(這個表達式下文需要用到):

x=(1)s+(1+m)×2ex = (-1)^s+(1+m)\times 2^e

雖然e和m表示的實際意義是浮點數,但是畢竟二進制是都可以轉換成十進制的整數嘛,那麼如果從整數的角度看,整數E和浮點數e、整數M和浮點數m有沒有某種關係呢?答案是肯定有的,但是理由呢我不知道=.=

E=127+eE = 127+e

M=1019mM = 10^{19}m

加127的原因上文已經介紹了,就是爲了調整指數的符號範圍;101910^{19}是m中1後所需補零的個數,因爲當從浮點數角度看時,1左邊的0是有起到佔位作用的,而如果從整數角度看時,1右邊的0纔有實際意義,所以在原基礎的m上乘以1後零個數的次冪就可以轉化至整M。

我們再回到最初的問題,對於一個浮點數x,求它的逆平方根:

y=1x=x12y = \frac{1}{\sqrt{x} \quad} = x^ {- \frac{1}{2}}

如果對等式兩邊同時取對數:

log2y=12log2xlog_2 y = - \frac{1}{2} log_2x

如果結合上面的存儲表達式,又可以得到一個新的等式:

對於上式中的log2(1+mx)log_2(1+m_x)可以繪製出一條平滑的曲線,我們可以用直線y=mxy=m_x對比(如下圖),可以看到兩條線在(0,1)之間非常相近,那麼如果再將這條直線向上平移一點點,可以假設這個平移的距離爲b,那麼兩條線就有一種近似相等的關係:log2(1+mx)mx+blog_2(1+m_x)\approx m_x+b

需要注意的是這種關係成立的前提是0mx10\leq m_x\leq 1,經代入得到下面式子:

如果對log2ylog_2 y做相同處理,那麼就有下面式子:

my+ey+b12(mx+ex+b)m_y+e_y+b \approx -\frac{1}{2}(m_x+e_x+b)

上面我們不是利用整數型的M和E分別表示m和e嘛,那麼自然可以用整數型套用上式中的浮點型,對於不同數字,整數型和浮點型之間的關係也不同,所以這裏統一用常量B和L表示,如下:

E=B+eE = B+e

M=LmM = Lm

用整數型替換浮點型可以得到新式子:

可以看到整理後的式子左右有一個相同的部分M+LEM+LE,我們已經知道了這三個字母代表的意思,但合在一起表達的又是一個新概念,合併兩個整數是一個很簡單的問題,比如合併33和55,33×100+55=335533\times100+55=3355,那麼LE+MLE+M不就是這個道理嘛,只不過前者是十進制後者是二進制,所以LE+MLE+M的作用就是將M和E存儲的數字捆綁在一起,用來表示儲存的數字,有人想前面不是還有一位S嗎?S的作用是表示存儲數字的正負,根號下的數字一定爲正,所以S這位一定爲0,沒有實際意義。有沒有感覺真的秒!

既然這個組合用來表示一個數字,那麼就可以用另一個變量I來表示:

Iy32L(Bb)12IxI_y \approx \frac{3}{2}L(B-b) - \frac{1}{2}I_x

其實代碼中下面這條語句就是套用的這個公式。

代碼中利用了一個右移運算表示公式中的12\frac{1}{2},而32L(Bb)\frac{3}{2}L(B-b)就是求出代碼中這串神祕數字的基礎,至於怎麼求得的,只有作者知道。後面又有一位大佬對這串數字進行推導,經過精密的演算求得了一串新的數字0x5f375a86,它略優於原常數,大佬只管算,我們膜拜就好。

因爲上文中含I的公式是從整型的角度計算的,所以需要強制類型轉換將整形轉回浮點型,緊接着最後一行代碼是利用牛頓迭代法提高結果的精確度,沒有什麼驚奇之處,下面回顧一下上文過程中作者的幾個騷操作:

  • 用整數型表示浮點型
  • log2(1+mx)mx+blog_2(1+m_x)\approx m_x+b約等關係的替換
  • LE+MLE+M捆綁二進制

也許作者利用的想法是已經存在了很久的理論,但是能把這些理論相組合並靈活運用創造出這種新興高效的算法,真的不得不感嘆一句NiuB,但需要注意的是這個算法依賴於浮點數的儲存和字節順序,所以在Mac上行不通。

上面代碼可以再精簡一些,但是原理一致,只是將一些變量簡化:

float InSqrt(float x)
{
	float xhalf = 0.5f * x;
	int i = *(int*)&x;
	i = 0x5f3759df - (i>>i);
	x = *(float*)&i;
	x = x*(1.5f-xhalf*x*x);
	return x; 
}

可能你看到最後還是那句"what the fuck",畢竟太多的公式推導都並沒有相應的理論依據,只是靠着作者這些腦洞大開的想法,難道就是“我不要你覺得,我只要我覺得?”,關鍵還是要了解一下流程中幾處很牛逼的想法,平時編程中不失爲一種辦法嘛,也可以當成一次知識拓展。

參考視頻及博客:
https://www.bilibili.com/video/BV1D4411Y7TP?from=search&seid=424001316764974197
https://blog.csdn.net/noahzuo/article/details/51555161

關注公衆號【奶糖貓】第一時間獲取更多精彩好文

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