由GobiesVM的IO.readlines實作論Go讀檔及MRI效能

昨晚寫完 GobiesVM 裡的IO.readlines函式後,很開心地用了有著100k個單詞的單行文字檔作測試。咦?怎麼一個字都沒有?

第一版的IO.readlines實作,就如同你在stackoverflow上會找到的一樣,是用bufio.Scanner來讀文字檔

input, err := os.Open(filename)
scanner := bufio.NewScanner(input)

for scanner.Scan() {
  line := scanner.Text()
}

上面這段code看似沒有問題,在一般的文字檔(例如程式碼本身)測試也都正常,為什麼在遇到大檔的時候就出包了呢?
當我們去看 bufio.Scanner的實作 時,會發現 Scanner 在運作時,會將文字分為一個個的token,並交由SplitFunc決定如何區分token(預設是斷行),由於是buffered IO,所以會有每個token所能用的buffer的最大size(MaxScanTokenSize = 64 * 1024),以上面的檔案為例,因為100k個單字全部都集中於一行內,所以很輕易地就超過了這個限制。因此當我將每個詞分至多行後就可以正常運行。

有鑑於這樣的解決之道只是治標不治本,所以讓我們來看看第二個版本吧:

input, _ := os.Open(filename)
defer input.Close()

reader := bufio.NewReader(input)

line, err := reader.ReadString('\n')
for ; err == nil; line, err = reader.ReadString('\n') {
  // Do something
}

同樣在bufio之下有實作io.Reader的buffered Reader物件可使用,裡頭也提供了bufio.Reader.ReadLine()可用,不過在文件內提到一般建議使用bufio.Reader.ReadString('\n'),而 bufio的實作 內也是後者單純些。

螢幕快照 2014-07-18 下午23.33.59.png

註:這裡雖然有印出內容,但以下的測試皆改為僅只有印出回傳陣列的大小,所以速度會有所不同。

這次測試出來的結果,相當地令人滿意,至少就如同上圖,GobiesVM的IO.readlines所用的時間都比MRI來得少。

然而事情沒有憨人想得那麼簡單,在不安穩的一覺醒來,我再度用10M行的文字檔作測試時,GobiesVM就原形畢露了。此時的MRI平均只要4秒多就能夠讀取完畢,而GobiesVM則要11秒左右。

當然最便利的藉口就是:GobiesVM沒有作最佳化!所以我就跑去編了個把最佳化(這些選項位於vm_opts.h裡,如果你想知道的話)全部關掉的MRI出來了…… XD
把最佳化全部拿掉之後的效果十分顯著,完成時間都在30秒以上。

……好吧,這樣的方法基本上只是自嗨,還是得找出一個比較實在點的方式才行。
可能的overhead在哪裡呢?一來當然就是即使Go效能出眾,但在多數時候拖著一個Runtime的它還是比輕盈的C還要來得慢些,二來buffered IO本來就是一個chunk一個chunk地讀進來時作處理,甚至會丟掉在所設定的delimiter後的內容,這樣難免會有些overhead。

所以綜合以上兩點,最偷吃步的方法就是:一口氣將檔案讀進來後再處理斷行:

content, _ := ioutil.ReadFile(filename)
str := string(content[:])

lines := strings.SplitAfter(str, "\n")
for _, line := range lines {
  // Do something
}

以下是簡易的效能測試

GobiesVM MRI 2.0.0
7.930 4.495
8.004 4.058
7.102 4.649
7.075 5.129
7.521 4.192
7.696 3.851
7.202 4.490
7.327 3.732
7.606 4.082
7.536 4.041
Mean
7.499 4.272

雖然還是有2~3秒的差距,但其實這也差不多是多數benchmark中Go與C本身的速度差異,所以個人是覺得這樣的結果尚可接受,況且再繼續追下去所花的時間與得到的效益相比可能沒有那麼高。

喔,有些人可能會好奇將檔案全部讀入是否會讓記憶體的使用量飆高,根據不負責的觀察,在使用第二個方法和第三個方法的memory peak並沒有明顯的差異(吃記憶體同樣都吃很兇的意味,而MRI本身也差不多)。