冷知識:達夫裝置(Duff's Device)效率真的很高嗎?

技術讓夢想更偉大發表於2020-07-06

ID:技術讓夢想更偉大

作者:李肖遙

wechat連結:https://mp.weixin.qq.com/s/b1jQDH22hk9lhdC9nDqI6w

 

相信大家寫業務邏輯的時候,都是面向if、elseforwhileswitch程式設計。但是你見過switch巢狀do..while嗎?

先上程式碼

void send( int * to, int * from, int count)
{
    int n = (count + 7 ) / 8 ;
    switch (count % 8 ) {
    case 0 :    do { * to ++ = * from ++ ;
    case 7 :          * to ++ = * from ++ ;
    case 6 :          * to ++ = * from ++ ;
    case 5 :          * to ++ = * from ++ ;
    case 4 :          * to ++ = * from ++ ;
    case 3 :          * to ++ = * from ++ ;
    case 2 :          * to ++ = * from ++ ;
    case 1 :          * to ++ = * from ++ ;
           } while ( -- n >    0 );
    }  
}

咋的一看,這啥玩意啊,switch/while 這組合能編譯通過嗎?您可別懷疑,還真能。這個就是達夫裝置(Duff's Device)

什麼是達夫裝置

百度百科說法如下:

在電腦科學領域,達夫裝置(英文:Duff's device)是序列復制(serial copy)的一種優化實現,通過組合語言程式設計時一常用方法,實現展開迴圈,進而提高執行效率。這一方法據信為當時供職於盧卡斯影業的湯姆·達夫於1983年11月發明,並可能是迄今為止利用C語言switch語句特性所作的最巧妙的實現。

達夫裝置是一個加速迴圈語句的C編碼技巧。其基本思想是--減少迴圈測試的執行次數。

簡單講下背景

時間要回到1983年,那是一個雨過天晴的夏天,在盧卡斯影業上班的程式設計師Tom Duff,他是想為了加速一個實時動畫程式,實現從一個陣列複製資料到一個暫存器這樣一個功能,真臉如下。

一般情況下,若要將陣列元素複製進儲存器對映輸出暫存器,較為直接的做法如下所示

do {
  /* count > 0 assumed (假定count的初始值大於0) */    
  *to = *from++;            
  /* Note that the 'to' pointer is NOT incremented 
  (注意此處的指標變數to指向並未改變) */
} while(--count > 0);

但是達夫洞察到,若在這一過程中將一條switch和一個迴圈相結合,則可展開迴圈,應用的是C語言裡面case 標籤的Fall through特性,實際就是沒有break繼續執行。實現如上程式碼所示。

其實第一版是這樣寫的:

void send(to, from, count)
register short *to, *from;
register int count;
{
    /* count > 0 assumed */
    do {        
        *to++ = *from++;
    } while (--count > 0);
}

這段程式碼等價於:

void send(register short* to, register short* from, register int count)
{
    /* count > 0 assumed */
    do {                          
        *to++ = *from++;
    } while (--count > 0);
}

但是在這種使用場景下,不易於移植和應用,然後他就更新了第二版,程式碼如下:

void send2(short* to, short* from, int count)
{
    int n = count / 8;
    do {
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
        *to++ = *from++;
    } while (--n > 0);
}

這種寫法減少了比較次數,在彙編層面單純講到下面程式碼的時候

do... while(--count > 0) 

總共有6條指令。大家可以用godbolt.org/ 測一下。如下(彙編測試參考網上資源,大家可以自行測試)

subl  $1,-4(%rbp)
cmp1  $0,-4(%rgp)
setg  %al,
testb %al,%al
je    ,L8
jmp   ,L7

如果原始count是256,就這一部分指令減少(256-256/8)*6=(256-32)*6=1344。對應6條指令:

movl   -36(%rbp),%eax
leal   7(%rax),%edx
testl  %eax,%eax
cmovs  %edx,%eax
sarl   $3,%eax
movl   %eax,-4(%rbp)

但是這個版本在通用效能還不夠,count一定要是8的倍數,所以經過了這兩個版本的發展,最終才有了上述那個最終版本的誕生。雖然效能上沒有什麼優化,但是最終版的達夫裝置,count不侷限於一定是8的倍數了!

實現機制、程式碼解析

實現機制

在達夫解決這個問題的時候,當時的C語言對switch語句的規範是比較鬆的,在switch控制語句內,條件標號(case)可以出現在任意子語句之前,充作其字首。

此外若未加入break語句,則在switch語句在根據條件判定,跳轉到對應的標號,並在開始執行後,控制流會一直執行到switch巢狀語句的末尾。

利用這種特性,這段程式碼可以從連續地址中將count個資料複製到儲存器中,對映輸出暫存器中。

另一方面,C語言本身也對跳轉到迴圈內部提供了支援,因而此處的switch/case語句便可跳轉到迴圈內部。

程式碼解析

首先說下這段程式碼,編譯沒問題,我們寫個程式碼如下:

#include < iostream > 
using namespace std;
int  main()
{
    int  n  = 0 ;
    switch  (n)  { 
    case 0 :  do   {cout  <<   " 0 "   <<  endl;
    case 1 :         cout  <<   " 1 "   <<  endl;
    case 2 :         cout  <<   " 2 "   <<  endl;
    case 3 :         cout  <<   " 3 "   <<  endl; 
      }   while ( -- n  > 0 ); 
   } 
} 

根據n的不同輸入,實驗結果如下

n的值程式輸出
0 0 1 2 3
1 1 2 3
2 2 3 0 1 2 3
3 3 0 1 2 3 0 1 2 3

這段程式碼的主體還是do-while迴圈,但這個迴圈的入口點並不一定是在do那裡,而是由這個switch(n),把迴圈的入口定在了幾個case標號那裡。

即程式的執行流程是: 

程式執行到了switch的時候,就會根據n的值,直接跳轉到 case n那裡,再當它執行到while那裡時,就會判斷迴圈條件。若為真,則while迴圈開始,程式跳轉到do那裡開始執行迴圈;為假,則退出迴圈,即程式中止。(這個swicth語句就再也沒有用了)

我們再看以下程式碼,這裡 count 個位元組從 from 指向的陣列複製到 to 指向的記憶體地址,是個記憶體對映的輸出暫存器。它把 swtich 語句和複製 8 個位元組的迴圈交織在一起, 從而解決了剩餘位元組的處理問題 (當 count % 8 != 0)。

void send( int * to, int * from, int count)
{
    int n = (count + 7 ) / 8 ;
    switch (count % 8 ) {
    case 0 :    do { * to ++ = * from ++ ;
    case 7 :          * to ++ = * from ++ ;
    case 6 :          * to ++ = * from ++ ;
    case 5 :          * to ++ = * from ++ ;
    case 4 :          * to ++ = * from ++ ;
    case 3 :          * to ++ = * from ++ ;
    case 2 :          * to ++ = * from ++ ;
    case 1 :          * to ++ = * from ++ ;
           } while ( -- n >    0 );
    }  
}

switch內的表示式計算被8除的餘數。執行開始於while迴圈內的哪個位置由這個餘數決定,直到最終迴圈退出(沒有break)。Duff's Device這樣就簡單漂亮地解決了邊界條件的問題。

效能表現

我們一般使用用for迴圈或者while迴圈的時候,如果執行迴圈內容本身用不了多少時間,本質上時間主要是消耗在了每次迴圈的比較語句上邊。

而事實上,比較語句是有很大優化空間的,我們假設你要迴圈10000次,結果你從第一次開始就不斷的比較是否達到上界值,這是不是很徒勞呢?

我們寫一個達夫裝置的函式用來測試執行時間(參考網上資源,這個測試不難,不同測試會有不同效果,大家可以自行測試一下):

int duff_device(int a)
{
    resigter x = 0;
    int n = (a) / 10;
    switch(a%10){
        case 0do{ x++;
        case 9:x++; 
        case 8:x++;   
        case 7:x++;  
        case 6:x++;   
        case 5:x++;   
        case 4:x++;   
        case 3:x++;   
        case 2:x++;   
        case 1:x++;   
        }while(--n>0)
    }
    return x;
}

測試主函式如下

#include <Windows.h>
#define count 999999999
long int overtime = count;
int main()
{
    printf("over %d",duff_device(overtime));
    return 0;
}

執行時間如下

現在我們看一下傳統的迴圈的執行時間,其測試程式碼如下:

int classical(int a)
{
    register x=0;
    do{
        x ++;
    }while(--a>0);
    return x;
}

測試主函式如下

#include <Windows.h>
#define count 999999999
long int overtime = count;
int main()
{
    printf("over %d",classical(overtime));
    return 0;
}

執行時間如下

結果顯示達夫裝置確實縮短了不少時間,這裡x的定義是要用register關鍵字,這樣cpu就會把x儘可能存入cpu內部的暫存器,新的cpu應該會有很通用暫存器使用。

值得一提的是,針對序列復制的需求,標準C語言庫提供了memcpy函式,而其效率不會比斯特勞斯魯普版的達夫裝置低,並可能包含了針對特定架構的優化,從而進一步大幅提升執行效率。

從不同角度看達夫裝置

從語言的角度來看

我個人覺得這種寫法不是很值得我們借鑑。畢竟這不是符合我們“正常”邏輯的程式碼,至少C/C++標準不會保證這樣的程式碼一定不會出錯。

另外, 這種程式碼冷知識,估計有很多人根本都沒見過,如果自己寫的程式碼別人看不懂,估計會被罵的。

從演算法的角度來看

我覺得達夫裝置是個很高效、很值得我們去學習的東西。把一次消耗相對比較高的操作“分攤“到了多次消耗相對比較低的操作上面,就像vector中實現可變長度的陣列的思想那樣,節省了大量的機器資源,也大大提高了程式的效率。這是值得我們去學習的。

總結

達夫裝置能實現的優化效果日趨在減弱,時代在變化,語言在發展,硬體裝置在變化,編譯器效能優化,除非特殊的需求下,一般還是沒必要做像這種層次的效能考量的。不過,這種奇妙的 switch-case 寫法經常研究一下還是很有樂趣的,你們覺得呢……

 

關注微信公眾號『技術讓夢想更偉大』,後臺回覆“m”檢視更多內容,回覆“加群”加入技術交流群。

長按前往圖中包含的公眾號關注

相關文章