一個簡單例子說明為什麼C語言在2013年仍很重要

jobbole發表於2013-09-02

  本文作者在開發Dynym專案,這是一個動態語言的通用執行時。在開發時,作者以其他語言的執行速度作為基礎比較語言的執行速度,因此發現了一些小祕密。迭代計算斐波那契數列是測試各種語言執行速度的常見方法。作者以不同的語言進行測試,最終發現C語言要比Python編寫的計算斐波那契數列快278.5倍。在底層開發,以及專注效能的應用程式中,選擇是顯而易見的。而為什麼會有如此大的執行效能差距呢。作者進一步研究了程式的反彙編程式碼,發現差別出在記憶體的訪問次數,以及預測的CPU指令的正確性方面。

  原作者注:在本文最開始,我並沒說明進行模2^64的計算——我當然明白那些不是“正確的”斐波那契數列,其實我不是想分析大數,我只是想探尋編譯器產生的程式碼和計算機體系結構而已。

  最近,我一直在開發Dynvm——一個通用的動態語言執行時。就像其他任何好的語言執行時專案一樣,開發是由基準測試程式驅動的。因此,我一直在用基準測試程式測試各種由不同語言編寫的演算法,以此對其典型的執行速度有一個感覺上的認識。一個經典的測試就是迭代計算斐波那契數列。為簡單起見,我以2^64為模,用兩種語言編寫實現了該演算法。

  用Python語言實現如下:

def fib(n):
    SZ = 2**64
    i = 0
    a, b = 1, 0
    while i < n:
        t = b
        b = (b+a) % SZ
        a = t
        i = i + 1
    return b

  用C語言實現如下:

#include <stdio.h>
#include <stdlib.h>
typedef unsigned long ulong;

int main(int argc, char *argv[])
{
    ulong n = atoi(argv[1]);
    ulong a = 1;
    ulong b = 0;
    ulong t;

    for(ulong i = 0; i < n; i++) {
        t = b;
        b = a+b;
        a = t;
    }

    printf("%lu\n", b);
    return 0;
}

  用其他語言實現的程式碼示例,我已放在github上。

  Dynvm包含一個基準測試程式框架,該框架可以允許在不同語言之間對比執行速度。在一臺Intel i7-3840QM(調頻到1.2 GHz)機器上,當 n=1,000,000 時,對比結果如下:

=======================================================
  語言                               時間 (秒)
=======================================================
  Java                               0.133
  C Language                         0.006
  CPython                            0.534
  Javascript V8                      0.284

  很明顯,C語言是這裡的老大,但是java的結果有點誤導性,因為大部分的時間是由JIT編譯器啟動(~120ms)佔用的。當n=100,000,000時,結果變得更明朗:

=======================================================
  語言                              時間(秒)
=======================================================
  Java                               0.300
  C Language                         0.172
  CPython                            47.909
  Javascript V8                      24.179

  在這裡,我們探究下為什麼C語言在2013年仍然很重要,以及為什麼程式設計世界不會完全“跳槽”到Python或者V8/Node。有時你需要原始效能,但是動態語言仍在這方面艱難掙扎著,即使對以上很簡單的例子而言。我個人相信這種情況會克服掉,通過幾個專案我們能在這方面看到很大的希望:JVM、V8、PyPy、LuaJIT等等,但在2013年我們還沒有到達“目的地”。

  然而,我們無法迴避這樣的問題:為什麼差距如此之大?在C語言和Python之間有278.5倍的效能差距!最不可思議的地方是,從語法角度講,以上例子中的C語言和Python內部迴圈基本上一模一樣。

  為了找到問題的答案,我搬出了反彙編器,發現了以下現象:

0000000000400480 <main>:
247   400480:       48 83 ec 08             sub    $0x8,%rsp
248   400484:       48 8b 7e 08             mov    0x8(%rsi),%rdi
249   400488:       ba 0a 00 00 00          mov    $0xa,%edx
250   40048d:       31 f6                   xor    %esi,%esi
251   40048f:       e8 cc ff ff ff          callq  400460 <strtol@plt>
252   400494:       48 98                   cltq
253   400496:       31 d2                   xor    %edx,%edx
254   400498:       48 85 c0                test   %rax,%rax
255   40049b:       74 26                   je     4004c3 <main+0x43>
256   40049d:       31 c9                   xor    %ecx,%ecx
257   40049f:       31 f6                   xor    %esi,%esi
258   4004a1:       bf 01 00 00 00          mov    $0x1,%edi
259   4004a6:       eb 0e                   jmp    4004b6 <main+0x36>
260   4004a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
261   4004af:       00
262   4004b0:       48 89 f7                mov    %rsi,%rdi
263   4004b3:       48 89 d6                mov    %rdx,%rsi
264   4004b6:       48 83 c1 01             add    $0x1,%rcx
265   4004ba:       48 8d 14 3e             lea    (%rsi,%rdi,1),%rdx
266   4004be:       48 39 c8                cmp    %rcx,%rax
267   4004c1:       77 ed                   ja     4004b0 <main+0x30>
268   4004c3:       be ac 06 40 00          mov    $0x4006ac,%esi
269   4004c8:       bf 01 00 00 00          mov    $0x1,%edi
270   4004cd:       31 c0                   xor    %eax,%eax
271   4004cf:       e8 9c ff ff ff          callq  400470 <__printf_chk@plt>
272   4004d4:       31 c0                   xor    %eax,%eax
273   4004d6:       48 83 c4 08             add    $0x8,%rsp
274   4004da:       c3                      retq
275   4004db:       90                      nop

  (譯註:

  • 1、不同的編譯器版本及不同的優化選項(-Ox)會產生不同的彙編程式碼。
  • 2、反彙編方法:gcc -g -O2 test.c -o test;objdumb -d test>test.txt  讀者可以自己嘗試變換優化選項對比反彙編結果)

  最主要的部分是計算下一個斐波那契數值的內部迴圈:

262   4004b0:       48 89 f7                mov    %rsi,%rdi
263   4004b3:       48 89 d6                mov    %rdx,%rsi
264   4004b6:       48 83 c1 01             add    $0x1,%rcx
265   4004ba:       48 8d 14 3e             lea    (%rsi,%rdi,1),%rdx
266   4004be:       48 39 c8                cmp    %rcx,%rax
267   4004c1:       77 ed                   ja     4004b0 <main+0x30>

  變數在暫存器中的分配情況如下:

a:  %rsi
b:  %rdx
t:  %rdi
i:  %rcx
n:  %rax

  262和263行實現了變數交換,264行增加迴圈計數值,雖然看起來比較奇怪,265行實現了b=a+t。然後做一個簡單地比較,最後一個跳轉指令跳到迴圈開始出繼續執行。

  手動反編譯以上程式碼,程式碼看起來是這樣的:

loop:   t = a
        a = b
        i = i+1
        b = a+t
        if(n > i) goto loop

  整個內部迴圈僅用六條X86-64彙編指令就實現了(很可能內部微指令個數也差不多。譯者注:Intel X86-64處理器會把指令進一步翻譯成微指令,所以CPU執行的實際指令數要比彙編指令多)。CPython解釋模組對每一條高層的指令位元組碼至少需要六條甚至更多的指令來解釋,相比而言,C語言完勝。除此之外,還有一些其他更微妙的地方。

  拉低現代處理器執行速度的一個主要原因是對於主存的訪問。這個方面的影響十分可怕,在微處理器設計時,無數個工程時(engineering hours)都花費在找到有效地技術來“掩藏”訪存延時。通用的策略包括:快取、推測預取、load-store依賴性預測、亂序執行等等。這些方法確實在使機器更快方面起了很大作用,但是不可能完全不產生訪存操作。

 在上面的彙編程式碼中,從沒訪問過記憶體,實際上變數完全儲存在CPU暫存器中。現在考慮CPython:所有東西都是堆上的物件,而且所有方法都是動態的。動態特性太普遍了,以至於我們沒有辦法知道,a+b執行integer_add(a, b)、string_concat(a, b)、還是使用者自己定義的函式。這也就意味著很多時間花在了在執行時找出到底呼叫了哪個函式。動態JIT執行時會嘗試在執行時獲取這個資訊,並動態產生x86程式碼,但是這並不總是非常直接的(我期待V8執行時會表現的更好,但奇怪的是它的速度只是Python的0.5倍)。因為CPython是一個純粹的翻譯器,在每個迴圈迭代時,很多時間花在瞭解決動態特性上,這就需要很多不必要的訪存操作。

  除了以上記憶體在搞鬼,還有其他因素。現代超標量亂序處理器核一次性可以取好幾條指令到處理器中,並且“在最方便時”執行這些指令,也就是說:鑑於結果仍然是正確的,指令執行順序可以任意。這些處理器也可以在同一個時鐘週期並行執行多條指令,只要這些指令是不相關的。Intel Sandy Bridge CPU可以同時將168條指令重排序,並可以在一個週期中發射(即開始執行指令)至多6條指令,同時結束(即指令完成執行)至多4條指令!粗略地以上面斐波那契舉例,Intel這個處理器可以大約把28(譯者注:28*6=168)個內部迴圈重排序,並且幾乎可以在每一個時鐘週期完成一個迴圈!這聽起來很霸氣,但是像其他事一樣,細節總是非常複雜的。

  我們假定8條指令是不相關的,這樣處理器就可以取得足夠的指令來利用指令重排序帶來的好處。對於包含分支指令的指令流進行重排序是非常複雜的,也就是對if-else和迴圈(譯者注:if-else需要判斷後跳轉,所以必然包含分支指令)產生的彙編程式碼。典型的方法就是對於分支指令進行預測。CPU會動態的利用以前分支執行結果來猜測將來要執行的分支指令的執行結果,並且取得那些它“認為”將要執行的指令。然而,這個推測有可能是不正確的,如果確實不正確,CPU就會進入復位模式(譯者注:這裡的復位不是指處理器reset,而是CPU流水線的復位),即丟棄已經取得的指令並且重新開始取指。這種復位操作有可能對效能產生很大影響。因此,對於分支指令的正確預測是另一個需要花費很多工程時的領域。

  現在,不是所有分支指令都是一樣的,有些可以很完美的預測,但是另一些幾乎不可能進行預測。前面例子中的迴圈中的分支指令——就像反彙編程式碼中267行——是最容易預測的其中一種,這個分支指令會連續向後跳轉100,000,000次。

  以下是一個非常難預測的分支指令例項:

void main(void)
{
    for(int i = 0; i < 1000000; i++) {
         int n = random();
         if(n >= 0) {
            printf("positive!\n");
        } else {
            printf("negative!\n");
        }
    }
}

  如果random()是真正隨機的(事實上在C語言中遠非如此),那麼對於if-else的預測還不如隨便猜來的準確。幸運的是,大部分的分支指令沒有這麼頑皮,但是也有很少一部分和上面例子中的迴圈分支指令一樣變態。

  回到我們的例子上:C程式碼實現的斐波那契數列,只產生一個非常容易預測的分支指令。相反地,CPython程式碼就非常糟糕。首先,所有純粹的翻譯器有一個“分配”迴圈,就像下面的例子:

void exec_instruction(instruction_t *inst)
{
    switch(inst->opcode) {
        case ADD:    // do add
        case LOAD:   // do load
        case STORE:  // do store
        case GOTO:   // do goto
    }
}

  編譯器無論如何處理以上程式碼,至少有一個間接跳轉指令是必須的,而這種間接跳轉指令是比較難預測的。

  接下來回憶一下,動態語言必須在執行時確定如“ADD指令的意思是什麼”這樣基本的問題,這當然會產生——你猜對了——更加變態的分支指令。

  以上所有因素加起來,最後導致一個278.5倍的效能差距!現在,這當然是一個很簡單的例子,但是其他的只會比這更變態。這個簡單例子足以凸顯低階靜態語言(例如C語言)在現代軟體中的重要地位。我當然不是2013年裡C語言最大的粉絲,但是C語言仍然主導著低階控制領域及對效能要求高的應用程式領域。

  原文連結: Anthony J Bonkoski   翻譯: 伯樂線上 - qianlong

  譯文連結: http://blog.jobbole.com/47096/

相關文章