Erlang 一直以慢“著稱”,本文就來看看 Erlang 慢在什么地方,為什么比實現同樣功能的 C 語言程序慢那么多倍。Erlang 作為一種虛擬機解釋的語言,慢是當然的。不過本文從細節上分析為什么 Erlang 這種虛擬機語言會慢。
本文從 shootout benchmark[注1]中選擇了一個 Erlang 和 C 語言單核性能差距最大的例子——reverse complement[注2]。根據 shootout 網站上給出的使用某款 64 位處理器單個核心的 benchmark 數據,Erlang 實現消耗的 CPU 時間為 19.20 秒,而 C 語言實現消耗的時間為 0.71 秒。也就是說,Erlang 實現同樣的功能慢了 27 倍。本文暫不關心 Erlang 的多進程並行化的加速性能,只關心 Erlang 虛擬機單個線程執行機構的性能。
我們先來看一下這個程序要實現的功能是什么。剛好這個例子實現的功能是 shootout benchmark 目前 13 個測試中最好理解的,不需要任何數學背景和復雜的數據結構或算法,只涉及到非常簡單的高中生物學知識。這個程序的功能是計算給定 DNA 序列的反向互補鏈。根據高中生物學,DNA 序列就是鹼基對序列,鹼基對是由兩個互補的鹼基構成的。鹼基的互補關系如下所示:
code meaning complement A A T C C G G G C T/U T A M A or C K R A or G Y W A or T W S C or G S Y C or T R K G or T M V A or C or G B H A or C or T D D A or G or T H B C or G or T V N G or A or T or C N
中間那一列不用管了,也就是說左邊那一列的互補鹼基就是右邊那一列。假設有一個序列為 ACG,那么互補序列就是 TGC,而我們要求的反向互補序列就是 CGT,要求反向的原因和 DNA 的反向轉錄有關。程序的輸入采用 FASTA 格式,這個格式很簡單,例如這個頁面上的示例輸入文件。FASTA 文件分為多個段落,每個段落有一個 ">" 表示的開頭,這一行后面是 DNA 序列的一些信息,具體意義我們不管。接下來的行就是具體的 DNA 序列,每一行顯示 60 個鹼基。在具體的 FASTA 文件中鹼基既可以用大寫表示,也可以使用小寫。要求程序輸出 FASTA 格式的反向互補序列,其中“>”行照抄。比如下面這個輸入文件
>ONE Homo sapiens alu GGCCGGGCGCGGTGGCTCACGCCTGTAATCCCAGCACTTTGGGAGGCCGAGGCGGGCGGA TCACC
得到的輸出文件是
>ONE Homo sapiens alu GGTGATCCGCCCGCCTCGGCCTCCCAAAGTGCTGGGATTACAGGCGTGAGCCACCGCGCC CGGCC
分析程序的需求,可以看出這個程序數據處理方面的工作在於輸入文件的解析、斷行拼接、計算互補序列、計算反向序列以及結果的斷行輸出。shootout 網站上最快的 C 語言程序在這里。這個 C 語言程序采用的方法是首先將整個文件讀到一個緩沖區中,然后解析文件,找“>”行,那么這一行到下一個 “>” 行之間就是一個完整的 DNA 序列,創建新的工作線程,把這個 DNA 序列在整個緩沖區中的位置信息傳遞給工作線程,工作線程負責計算互補的鹼基並且寫入緩沖區。工作線程的主體部分很簡單,維護兩個指針,一頭一尾,每一輪迭代都向中間挪一個位置。每一輪迭代中,計算一頭一尾的互補鹼基,然后交換,當頭尾相遇的時候結束。當然,如果 DNA 序列最后一行不能填滿 60 個字節,工作線程還要先挪動每一行的位置,使得交換之后換行符的位置正確。這個 C 語言程序計算互補鹼基采用了查表法,由於輸入值就是大小寫字母和換行符,所以只需要一個不大的查找表(128字節)。
這個 C 語言程序的效率相當高,只需要一次掃描和一次寫入。在最壞情況下,如果最后一行不滿 60 個字符,則還需要一趟拷貝調整換行符的位置。
然后我們來看一看 shootout 網站上提供的 Erlang 程序。
這個 Erlang 程序的大致工作流程為:主進程(命名為 reader,運行 loop 函數)從 stdin 中一行一行地讀取,湊齊一段完整的 DNA 序列之后,就創建一個新的進程(命名為 collector 進程)處理並打印(至 stdout)這個序列。reader 進程等待 collector 進程完成了打印之后,再繼續從 stdin 中一行一行地讀。collector 進程之所以叫 collector,是因為這個進程將計算反向互補序列的工作分割為幾個大小均等並且帶有編號的“塊”,並創建若干工作進程(對應 revcomp_chunk 函數),讓每個進程處理一個塊,等所有工作進程都干完了之后,collector 進程對所有的塊按照編號進行排序,得到正確順序的結果,然后再將“>”行和結果都輸出到 stdout。
下面簡單地對 shootout 網站上提供的那個 Erlang 程序做了一些注釋,我們可以看到這個程序慢在哪里:
然后我就按照之前說的那個 C 語言程序的思路,另外寫了一個 Erlang 程序,對 binary 的操作做了一些優化,主要使用了 binary comprehension 來緩解上述程序中的熱點,不過運行時間提升並不大,大概快了 10% 吧。為啥還是慢呢?下面來細細分析。先看代碼吧:
1 -module(revcomp_opt). 2 3 -export([main/1]). 4 5 -define(WIDTH, 60). 6 -define(BUFSIZE, 1024*1024). 7 -define(NEWLINECHAR, 10). 8 9 -record(scan_state, 10 {current_state = search_header_begin, % search_header_end,search_is_over 11 current_header = <<>>, 12 current_body = [<<>>]}). 13 14 main([_Args]) -> 15 io:setopts([binary]), 16 InitState = #scan_state{ 17 current_state = search_header_begin, 18 current_header = <<>>, 19 current_body = [] 20 }, 21 % fprof:trace(start, "revcomp_opt_small.trace"), 22 read_and_process_file(<<>>, InitState, []), 23 % fprof:trace(stop), 24 halt(). 25 26 read_and_process_file(Buf, State, JobList) -> 27 case State#scan_state.current_state of 28 search_header_begin -> 29 % 尋找">" 30 case binary:match(Buf, <<">">>) of 31 nomatch -> 32 % 繼續搜索,新添加的內容應該放在 body 中 33 NState = State#scan_state{ 34 current_body=[Buf | State#scan_state.current_body] 35 }, 36 get_new_chunk(NState, JobList); 37 {HeaderStartPos, _Length} -> 38 % 找到了">", 說明要開啟新的行,並且結束之前的 body 39 % 創建新的進程處理 header/body 40 {PreviousBody, BufLeft} = split_binary(Buf, HeaderStartPos), 41 NState = State#scan_state{ 42 current_state = search_header_end, 43 current_header = <<>>, 44 current_body = [] 45 }, 46 case State#scan_state.current_header of 47 <<>> -> 48 % 表示是第一次進來,繼續找 header 的結尾“\n” 49 read_and_process_file(BufLeft, NState, JobList); 50 _ -> 51 % 形成了新的完整 body,創建新的進程處理 52 NewJob = start_revcomp_job( 53 State#scan_state.current_header, 54 [PreviousBody | State#scan_state.current_body], 55 self()), 56 read_and_process_file(BufLeft, 57 NState, 58 [NewJob | JobList]) 59 end 60 end; 61 search_header_end -> 62 % 尋找 ">" 行結尾的 "\n" 63 case binary:match(Buf, <<"\n">>) of 64 nomatch -> 65 % 繼續搜索,如果沒找到,則把整個 Buf 追加到 current_header 中 66 NState = State#scan_state{ 67 current_header = 68 <<(State#scan_state.current_header)/binary, 69 Buf/binary>> 70 }, 71 get_new_chunk(NState, JobList); 72 {HeaderEndPos, _Length} -> 73 % 找到了header行的"\n",說明header已經全了,要開始構建 body 74 {PreviousHeader, BufLeft} = split_binary(Buf, HeaderEndPos), 75 NState = State#scan_state{ 76 current_state = search_header_begin, 77 current_header = 78 <<(State#scan_state.current_header)/binary, 79 PreviousHeader/binary>> 80 }, 81 read_and_process_file(BufLeft, NState, JobList) 82 end; 83 search_is_over -> 84 % 文件已經掃描完了 85 case State#scan_state.current_header of 86 <<>> -> 87 AllJobs = JobList; 88 _ -> 89 NewJob = start_revcomp_job(State#scan_state.current_header, 90 State#scan_state.current_body, 91 self()), 92 AllJobs = [NewJob | JobList] 93 end, 94 % 收集進程的處理結果 95 collect_revcomp_jobs(lists:reverse(AllJobs)) 96 end. 97 98 get_new_chunk(State, JobList) -> 99 case file:read(standard_io, ?BUFSIZE) of 100 eof -> 101 NState = State#scan_state{current_state = search_is_over}, 102 read_and_process_file(<<>>, NState, JobList); 103 {ok, Chunk} -> 104 read_and_process_file(Chunk, State, JobList) 105 end. 106 107 collect_revcomp_jobs([]) -> 108 ok; 109 collect_revcomp_jobs([Job | Rest]) -> 110 receive 111 {Job, HeaderBuf, RevCompBodyPrint} -> 112 erlang:display(Job), 113 file:write(standard_io, [HeaderBuf, ?NEWLINECHAR, RevCompBodyPrint]) 114 end, 115 collect_revcomp_jobs(Rest). 116 117 start_revcomp_job(HeaderBuf, BodyBufList, Master) -> 118 spawn(fun() -> revcomp_job(HeaderBuf, BodyBufList, Master) end). 119 120 revcomp_job(HeaderBuf, BodyBufList, Master) -> 121 RevCompBody = << <<(revcomp_a_chunk(ABuf))/binary>> || 122 ABuf <- BodyBufList >>, 123 RevCompBodyPrint = revcomp_chunk_printable(<<>>, RevCompBody), 124 Master ! {self(), HeaderBuf, RevCompBodyPrint}. 125 126 revcomp_a_chunk(Chunk) -> 127 Complement = << <<(complement(Byte))>> || 128 <<Byte>> <= Chunk, Byte =/= ?NEWLINECHAR >>, 129 % 翻轉 130 ComplementBitSize = bit_size(Complement), 131 <<X:ComplementBitSize/integer-little>> = Complement, 132 ReversedComplement = <<X:ComplementBitSize/integer-big>>, 133 ReversedComplement. 134 %list_to_binary(lists:reverse([complement(C) || C <- binary_to_list(Chunk), C=/= ?NEWLINECHAR])). 135 136 revcomp_chunk_printable(Acc, Rest) when byte_size(Rest) >= ?WIDTH -> 137 <<Line:?WIDTH/binary, Rest0/binary>> = Rest, 138 revcomp_chunk_printable(<<Acc/binary, Line/binary, ?NEWLINECHAR>>, Rest0); 139 revcomp_chunk_printable(Acc, Rest) -> 140 <<Acc/binary, Rest/binary, ?NEWLINECHAR>>. 141 142 complement( $A ) -> $T; 143 % 同前一個 Erlang 程序,以下省略 complement 的其他子句。
這個程序每次從輸入文件中讀取一塊數據,這里設置為 1M 字節。讀文件的循環實際上是一個簡單的狀態機,通過搜索“>”字符和換行符變換狀態。初始狀態搜索“>”,搜索成功要么說明剛開始處理文件,要么說明已經湊齊了完整的 DNA 序列,於是進入找換行符的狀態。因此這個搜索的過程綜合起來看只會對輸入文件進行一次掃描,而且使用了效率較高的 binary:match/2 函數,幾乎相當於線性搜索。此外,由於搜索是針對緩沖塊進行的,而不是針對每一行進行的,因此調用這個函數的頻率也很低,降低了 binary:match 系列函數的啟動開銷(binar:match 函數每一次調用的時候都會對 pattern 做一次編譯解析,這個啟動開銷在調用頻繁的時候不可忽略)。讀文件進程在掃描過程中的狀態數據存放在 scan_state 記錄中。其中 current_body 部分是一個列表。使用列表的目的是為了避免拼接 binary 產生的額外拷貝開銷。
湊齊一段完整的 DNA 序列之后,創建一個工作進程(revcomp_job)處理這個完整的序列。以上讀文件的循環傳遞給工作進程的 DNA 序列中是帶換行符的,因此這個程序把換行符的處理從讀文件的進程挪到了工作進程。工作進程需要對 current_body 列表中每一項表示的 binary 做反向互補操作。這個反向互補操作就是通過 revcomp_a_chunk 函數進行的。這個函數通過 binary comprehension 操作,從 binary 中逐個取出字節,然后調用 complement 函數計算互補鹼基,並填入結果 binary。這個 comprehension 操作會預估好最終 binary 的大小,一次性做好分配,然后直接寫入。針對原始數據只會進行一次掃描。計算完互補鹼基之后,接下來是一個快速的 binary 翻轉操作。這個翻轉是從網上找到的[注3],非常巧妙高效,首先將要翻轉的 binary 以一個巨大的小尾順序大整數讀入,然后再將其以大尾順序的方式寫入一個新的 binary,那么得到的這個 binary 就是原 binary 翻轉后的結果。整個翻轉操作是在 ERTS 內部通過 C 語言實現的,效率非常高。
很明顯,revcomp_a_chunk 函數就是整個程序的熱點所在,通過 profiling 也可以看出這一點。下圖展示的是針對一個較小的輸入文件的 profiling 結果。在虛擬機中,主要耗時的顯然是 process_main 函數了,因為這是虛擬機的引擎函數,整個虛擬機的邏輯都在這里,說明在執行這段程序的時候,有 2.21 秒的 CPU 時間都在運行 Erlang 虛擬機指令。圖的右下角的截圖是 revcomp_a_chunk 函數中 binary comprehension 部分的虛擬機指令。這些指令是虛擬機加載之后實際執行的指令。可以看出,process_main 的 stack trace 中耗時最多的兩個函數分別對應了其中的兩條指令。也就是說,在 process_main 函數耗費的 2.210 秒中,有 0.303 秒中耗費在實現 i_bs_private_append_jId 指令的 erts_bs_private_append 函數,還有 0.280 秒的 CPU 時間用在實現 i_new_bs_put_integer_imm_jIIs 指令的 erts_new_bs_put_integer 函數。根據這篇博文的介紹,這兩條指令中的前一條的作用是維護可寫 binary 數據結構,后一條的作用是填入數據。
從圖中還可以看出,有 1.051 秒的 CPU 時間耗在 process_main() 函數本身的語句,所以我們深入 process_main() 看一下里面耗時的語句有哪些。下圖根據語句的耗時對語句進行了排序。明顯可以看出,那些耗時很多的語句都和 binary comprehension 生成的這個大循環中的指令有關系。
從上面的兩張圖可以看出,這些開銷都是省不掉的了。因為 Erlang 作為一種通用的語言,其提供的數據結構具有一定的通用性,所以很難做到針對某一個任務特別優化。比如說我們這個例子中循環操縱一個緩沖區中每一個字節並寫入另外一個緩沖區的操作,就需要通過上面這些虛擬機指令來實現:讀取一個字節,需要通過 i_bs_get_integer_8_rfd 指令,寫入一個字節需要 i_bs_private_append_jId 和 i_new_bs_put_integer_imm_jIIs 兩條指令來實現,而這些指令實現的是通用的取值和寫值的操作,因此指令本身的實現涉及到很多函數調用、參數檢查和數據結構維護的操作,這必然比純 C 語言的字節拷貝要慢多了。而且虛擬機指令跳轉本身也是有很大開銷的,從上圖中的 Goto 語句和一些 NextPF 語句就可以看出來。
那么碰到這種任務應該怎么辦呢?解決方法就是,我們不要被語言所束縛,該用 C 語言的地方還是用 C 語言吧,發揮各種語言自身的優勢,而不要用一種語言的弱點去和另一種語言的強項作比較,有得必有失。Erlang 也是很體貼地提供了諸如 NIF 機制讓我們可以用 C 語言實現一些只有 C 語言才能高效完成的任務。比如說在這個程序中,用 C 語言來實現 revcomp_a_chunk 函數就是很好的選擇。
本文到這里應該結束了,不過下面我還要啰嗦一些關於 Erlang 有意思的地方。我們剛才提到,虛擬機指令分發本身也是高開銷的操作,因為指令分發意味着跳轉,而 CPU 最擅長的是順序執行,跳轉會破壞 CPU 分支預測的優化,因此很多語言虛擬機的一大優化就是盡量減少指令的分發,也就是盡可能地在一條指令中執行更多的任務。比如說下面的圖還是以 binary comprehension 的那一段為例,左側是 beam 匯編碼,這是編譯器從原始碼直接生成的,右側是虛擬機加載優化之后實際運行的指令:
從圖中可以看出,左側有一些常見的指令組合在右側被優化為一條指令了,可以在一定程度上減少指令分發。
另外,關於 complement 函數,這個函數有 32 個子句,實際上實現了一個映射關系,那么 Erlang 會怎么處理這個函數呢?顯然也是有大大的優化滴。放心,我們聰明的 Erlang 不會笨笨地每一次調用的時候都順序查找這 32 條子句。先看一下編譯器生成的 complement 匯編碼:
可以看出,調用這個函數的時候,主要起作用的是 select_val 指令,這條指令根據輸入值,選擇一個跳轉標簽,比如輸入 114(即“r”的 ASCII碼),跳轉到標簽 44 的位置,可以看到在標簽 44 處返回了整數 89,即“Y”對應的 ASCII 碼。
如果我們查看 process_main() 函數對 select_val 的實現,會發現這條指令的實現采用了二分搜索,因此比線性搜索要快。但是 Erlang 就滿足於此了嗎?再看看虛擬機加載之后生成的實際指令:
怎么樣?厲害吧!加載器把 select_val 給替換掉了,生成了一條更快速的指令 i_jump_on_val_rfII,這條指令的參數是一個跳轉表,通過輸入參數可以直接得到跳轉表中的索引,從而得到跳轉地址。這一下我們再也不用擔心 complement 的效率了,這種函數會被優化為常量時間,實際上已經可以匹配本文開頭提到的 C 語言程序采用的查找表的算法了。
當然,熟悉編譯的同學可能會覺得這些都是編譯領域中常用的優化技術,不過作為編譯領域的外行,我還是覺得 Erlang 在編譯器和虛擬機的配合優化上下了很大的功夫。
[注1] The Computer Language Benchmarks Game http://benchmarksgame.alioth.debian.org/
[注2] http://benchmarksgame.alioth.debian.org/u64/benchmark.php?test=revcomp&lang=all&data=u64
[注3] http://sifumoraga.blogspot.com/2010/12/reversing-binary-object-in-erlang.html