編寫高效Lua代碼的方法
翻譯自《Lua Programming Gems》Chapter 2:Lua Performance Tips:Basic fact By Roberto Ierusalimschy
基本知識
Lua在運行代碼之前,會先把源碼翻譯(預編譯)成一種內部編碼,這種編碼由一連串的虛擬機能夠識別指令構成,與CPU的機器碼很相似。接下來由C代碼中的一個while循環負責解釋這些內部編碼,這個while循環中有一個很大的switch,一種指令就有對應的一個case。
可能你已經從其他地方得知,自5.0版本開始,Lua就使用一個基於寄存器的虛擬機。但是這些“寄存器”跟CPU中的寄存器沒有任何關聯,因為這種關聯會使Lua失去可移植性,並且會使Lua受限於可用的寄存器數量。Lua使用一個棧(由一個數組加上一些索引實現)來存放它的寄存器。每一個運行中的函數都有各自的一份活動記錄,這些活動記錄保存在棧中,內部存放着每個函數對應的寄存器。所以每個函數都有一組各自的寄存器。每條指令中只有8個bit用來標志寄存器,所以每個函數最多能夠使用250個寄存器。
由於Lua有如此大量的寄存器,所以在預編譯時能夠將所有的局部變量(local)存放到寄存器中。所以,在Lua中,訪問局部變量是很快的。舉個例子,如果a和b是局部變量,語句a= a + b只生成一條指令:ADD 0 0 1 (假設a和b分別在寄存器0和1中)。對比一下如果a和b是全局變量,生成上述加法運算的中間代碼會像這樣:
GETGLOBAL 0 0 ; a
GETGLOBAL 1 1 ; b
ADD 0 0 1
SETGLOBAL 00 ; a
所以,很明顯我們可以得出Lua編程里面其中一條最重要的改進性能的規則:使用局部變量(uselocals)!
如果你需要盡可能的提升程序的性能,你可以使用局部變量,比如,如果你在一個很長的循環里調用一個函數,你可以先將函數賦值給一個局部變量。比如以下代碼
for i = 1, 1000000 do local x= math.sin(i) end
會比以下代碼慢30%:local sin = math.sin for i = 1, 1000000 do local x= sin(i) end
訪問外層局部變量(也就是外一層函數的局部變量)並沒有訪問局部變量快,但是還是比訪問全局變量快。看看以下代碼片段:function foo(x) for i =1, 1000000 do x =x + math.sin(i) end return x end print(foo(10))
我們通過在foo函數外面聲明一次sin來優化它:
local sin = math.sin function foo(x) for i =1, 1000000 do x =x + sin(i) end return x end print(foo(10))
第二段代碼比第一段快30%。比起其他編譯器,Lua的編譯器是比較高效的,盡管如此,編譯還是一項比較繁重的任務。所以,無論何時都要盡量避免在程序中編譯代碼(比如,調用loadstring函數)。除非你需要真正地動態地執行你的代碼,比如代碼是由用戶輸入的,否則你很少需要編譯動態的代碼。
考慮以下例子,下面的代碼創建一個存放了10000函數的table,這些存放在table中的函數分別返回常量1到10000:
local lim = 10000 local a = {} for i = 1, lim do a[i] =loadstring(string.format("return %d", i)) end print(a[10]()) --> 10
這份代碼運行了1.4秒。我們通過使用閉包來避免動態編譯。下面的代碼在1/10的時間里(0.14)創建了同樣的10000個函數:
function fk (k) returnfunction () return k end end local lim = 100000 local a = {} for i = 1, lim do a[i] = fk(i) end print(a[10]()) --> 10
表相關
通常情況下,你在使用表(table)的時候並不需要任何有關Lua如何實現表的細節。事實上,Lua竭盡全力地避免實現細節暴露給用戶。但是這些細節還是在table操作的性能中暴露出來了。所以,為了高效地使用表,知道一點Lua如何實現table是有好處的。
Lua使用了一些巧妙的算法來實現table。每個表包含兩部分:數組(array)部分和哈希(hash)部分,數組部分保存整數鍵值(key)為1到n范圍內的值(entry),n是一些獨特的數值。(我們后面會討論在某一刻n是怎么被計算出來的。)其他的值(包括整數下標超出1到n范圍的)保存在哈希部分。
顧名思義,哈希部分使用哈希算法來保存和查找里面的鍵值。它使用的是開發地址列表,所有的值都存在哈希數組里。哈希函數算出一個鍵值的主索引;如果發生碰撞(兩個鍵值的哈希值是相同的),這些有相同主索引的鍵值將會被連成一個鏈表,每個元素在數組中占一個位置。
當Lua需要在表中插入一個新的鍵值而此時哈希數組沒有空位置時,Lua將會做一次重新哈希(rehash)。重新哈希的第一步是決定新的數組部分和哈希部分的大小。所以Lua會遍歷表中的所有值,對這些值進行計數和分類,然后選擇一個小於數組部分大小的2的最大指數值,使數組的一半以上會被使用。哈希部分的大小就是大於剩下的值(不能放在數組部分的值)的數量的最小2的指數值。
當Lua創建一個空表的時候,其中的兩部分的大小都是0,並且此時並沒有賦予他們內存空間。下面我們來看看下面代碼會發生什么事情:
local a = {} for i = 1, 3 do a[i] =true end
代碼一開始創建一個空表。第一次循環的時候,賦值語句a[1]=true觸發了一次重新哈希計算;Lua將表中的數組部分大小設為1,哈希部分還是空的。第二次循環的時候,賦值語句a[2]=true又觸發了一次重新哈希計算,現在,表中的數組部分大小為2。最后,第三次循環又觸發了一次重新哈希計算,數組部分的大小增大到4。代碼:
a = {} a.x = 1; a.y = 2; a.z = 3
做的事情是類似的,不過大小增長的是table中的哈希部分。對於大型的表,這些初始化開銷將會被整個創建過程平攤:一個包含三個元素的表需要進行3次重新哈希計算,而一個包含了一百萬個元素的表只需要20次。但是當你創建幾千個小的表時,總開銷就會很顯著。
老版本的Lua在創建空表的時候會為其預分配一些空位(如果沒記錯,是4),來避免創建較小的表時的開銷。但是這樣可能會出現浪費內存的情況。比如,如果你創建幾百萬個點(在表里面只存放了兩個數字),那么每個點使用的內存將會是其真正需要的內存的兩倍,你將會付出高昂的代價。這就是為什么現在的Lua沒有為空表預分配空位。
如果你是用C語言編程,你可以通過調用Lua的API函數lua_createtable來避免這些重新哈希計算。它在隨處可見的lua_State中獲取兩個參數:新表數組部分的初始大小和哈希部分的初始大小。通過提供一個適當的初始大小給新表,可以很容易地避免這些初始化時的重新哈希計算。需要注意的是,Lua只有在進行重新哈希計算的時候,才會縮小表的大小。所以,如果你提供的初始大小比實際使用的大的話,Lua不會糾正你對空間的浪費。
當你在Lua下面編程的時候,你可以通過構造來避免那些初始化的重新哈希計算。當你寫下{true,true, true}的時候,Lua就會事先知道新的表的數組部分將會用到3個空位,並創建一個相應大小的表。類似的,當你寫下{x = 1, y = 2, z =3}的時候,Lua將創建一個哈希部分包含4個空位的表。作為例子,下面的循環將會運行2.0秒:
for i = 1, 1000000 do local a= {} a[1] =1; a[2] = 2; a[3] = 3 end
如果以一個適當的初始大小來創建一個表的話,運行時間將會降低到0.7秒:for i = 1, 1000000 do local a ={true, true, true} a[1] = 1;a[2] = 2; a[3] = 3 end
但是,當你寫下類似{[1] = true, [2] = true, [3] =true}的時候,Lua並沒有聰明到檢測到以上的表達式(這里指字面數字123)是在描述數組下標,所以它創建了一個哈希部分有四個空位的表,這浪費了內存和CPU時間。
表的兩個組成部分的大小只在表進行重新哈希計算的時候計算出來,而重新哈希計算只會在表已經被放滿時需要插入一個新元素的時候發生。因此,當你遍歷一個表並把其中的元素都刪除的時候(就是把表里的值設為nil),表並不會縮小。當你插入一些新元素時,表才會重新改變其大小。通常這並不是一個問題:當你持續地刪除和插入元素時(很多程序的典型情況),表的大小將保持穩定。你不應該通過在一個巨大的表中刪除一些數據來節省空間:刪除這個巨大的表會更好。
有一種比較骯臟的手段來強迫表進行重新哈希計算,就是通過在表中插入足夠的nil元素。看看以下例子:
a = {} lim = 10000000 for i = 1, lim do a[i] = i end -- 創建一個巨大的表 print(collectgarbage("count")) -->196626 for i = 1, lim do a[i] = nil end -- 刪除其所有的元素 print(collectgarbage("count")) -->196626 for i = lim + 1, 2*lim do a[i] = nil end --插入大量nil元素 print(collectgarbage("count")) --> 17
除了個別特殊情況之外,我不推薦這種手法,因為這樣很慢,並且沒有比較簡單的方法來獲知“足夠”到底是多少。
你可能會好奇為什么我們插入nil元素時Lua沒有縮小表的大小。首先,是為了避免需要測試我們正插入什么東西到表中;測試插入的元素是否為nil會降低所有賦值語句的速度。第二個原因,更重要的是,為了允許遍歷表時,對表元素賦nil值。考慮一下循環:
for k, v in pairs(t) do ifsome_property(v) then t[k]= nil -- 刪除這個元素 end end
如果Lua在表元素被賦nil值之后進行重新哈希,將會摧毀這個遍歷。如果你想刪除表中的所有元素,下面一個簡單的循環式其中一種正確的方法:
for k in pairs(t) do t[k] = nil end
另外一個“智能”的選擇是下面的這個循環:while true do local k =next(t) if not kthen break end t[k] = nil end
然而,這個循環再表很大的時候會很慢。當調用函數next時,如果沒有傳入前一個鍵值作為參數,函數next會返回表中的“第一個”元素(以一種隨機的排序方法)。為了做到這個,next函數會從表的數組部分的開頭開始遍歷,查找非nil元素。隨着循環將一個個的第一個元素設為nil,查找第一個非nil元素變得越來越久。最后,這個“智能”循環用了20秒時間來清除一個有100000元素的表;使用pairs的遍歷表的循環則耗費了0.04秒。字符串
和表一樣,了解Lua如何實現字符串(string)對高效地使用字符串是有好處的。
Lua實現字符串的方式有兩個地方跟其它腳本語言截然不同。首先,Lua中的所有字符串都被內部化;這意味着Lua中所有的字符串只有一份拷貝。任何時候,當一個新的字符串出現時,Lua會先檢查這個字符串是否已經有一份拷貝,如果有,就重用這份拷貝。內部化使字符串比較和表索引等操作變得非常快,但是字符串的創建會變慢。
第二,Lua中的字符串變量不包含字符串實體,而是一個字符串的引用。這種實現加快了若干字符串操作。比如說,在Perl里,如果你寫下類似這樣的語句:$x= $y,$y包含一個字符串,這個賦值語句將復制$y緩沖區字符串的內容到$x的緩沖區中。如果這個字符串很長,這個操作將是非常昂貴的。在Lua里,執行這條賦值語句只是復制了一個指向實際字符串的指針。
這種使用引用來實現字符串的方案,降低了某種方式的字符串連接的速度。在Perl里,操作$s= $s . "x"和$s .= "x"是很不同的。前一個語句,你得到的是一份$s的拷貝,這份拷貝后面加入了"x"。后一個語句,"x"只是被簡單地放在了$s的緩沖區之后。所以第二種連接格式跟字符串的大小是無關的(假設緩沖區有足夠的空間來存放連接的字符)。如果你將這兩條語句放在一個循環中,那么它們的區別相當於一個線性復雜度的算法和一個平方復雜度的算法。比如,一下循環用了五分鍾時間來讀取一個5MB的文件:
$x = "";
while (<>) {
$x = $x . $_;
}
如果我們將$x = $x . $_替換成$x .= $_,以上片段只耗費0.1秒的時間!
Lua並沒有提供第二種,也就是比較快的方法,因為Lua的字符串變量並不擁有緩沖區,所以我們必須顯式地使用一個緩沖區:包含了字符串碎片的表來完成這項工作。下面的代碼耗費了0.28秒來讀那個5MB的文件。不比Perl快,不過很不錯了。
local t = {}
for line in io.lines() do
t[#t + 1] = line
end
s = table.concat(t,"\n")
減少,重用,回收(Reduce, Reuse, Recycle)
當處理Lua資源時,我們應當遵守跟利用地球資源一樣的3R's原則。
減少是最簡單的一種途徑。有幾種方法可以避免創建對象。例如,如果你的程序使用了大量的表,你可以考慮改變它的數據表示方式。舉個簡單的例子,假如你的程序需要處理多邊形。在Lua里表示一個多邊形最自然的方式就是表示成一個點的列表,像這樣:
polyline = { { x = 10.3, y = 98.5 }, { x = 10.3, y = 18.3 }, { x = 15.0, y = 98.5 }, ... }
polyline = { { 10.3, 98.5 }, { 10.3, 18.3 }, { 15.0, 98.5 }, ... }
一個有一百萬個點的多邊形,這種改變會將內存使用從95KB降到65KB。當然,你犧牲了程序的可讀性作為代價:p[i].x要比p[i][1]讓人容易看得懂。另一個更經濟的方法是用一個列表來存儲x軸的值,另一個列表存儲y軸的值:
polyline 之前的p[i].x就是現在的p.x[i]。使用這種方式,一個有一百萬個點的多邊形使用的內存只有24KB。
循環體內是找到降低不必要資源創建的地方。例如,如果在一個循環中創建了一個不會改變內容的表,你可以把表放在循環體之外,或者甚至放在執行這段代碼的函數之外。比較:
function foo (...) for i =1, n do local t = {1, 2, 3, "hi"} -- 做一些不改變t的操作。 ... end end
local t = {1, 2, 3, "hi"} -- 一勞永逸 function foo (...) for i =1, n do -- 做一些不改變t的操作。 ... end end
同樣的技巧還可以用到閉包中,只要不要將其移至閉包需要的變量的作用域之外。例如,考慮一下函數:
我們可以通過將內嵌的函數放在循環體之外來避免讀取每行數據都要創建一個閉包:
然而,我們不能將函數aux搬到函數changenumbers之外,因為在那里函數aux不能訪問變量limit和delta。
對於大多數字符串操作,我們都可以通過下標在已存在的字符串上工作,從而減少字符串的創建。例如,函數string.find返回字符串上匹配正則表達式的下標,而不是返回一個匹配的字符串。返回下標,就避免了在成功匹配時創建一個新的(子)字符串。開發者需要時,可以通過函數string.sub來獲取匹配的子字符串。
當我們不能避免使用新的對象的時候,我們還是可以通過重用來避免創建這些對象。考慮字符串的重用是沒有必要的,因為Lua已經替我們做了這些工作:Lua總是將用到的字符串內部化,不會放過任何重用的機會。重用表則是比較有效。舉一個常見的例子,讓我們回到在一個循環體內部創建表的情況。不同的是這次的表的內容不是固定不變的。不過,我們往往還是可以簡單地更改表的內容從而能夠在所有迭代中重用這個表。考慮一下代碼:
local t = {} for i = 1970, 2000 do t[i] =os.time({year = i, month = 6, day = 14}) end
一下是與之等價的代碼,但是重用了表:
local t = {} local aux = {year = nil, month = 6, day = 14} for i = 1970, 2000 do aux.year =i t[i] =os.time(aux) end
另一種比較有用的實現重用的方法是記憶化(memoizing)。原理很簡單:對於一個給定的某些輸入值,保存其計算結果,當同樣的值輸入時,程序只需重用之前保存的結果。
LPeg,Lua中一個新的模式匹配的包,有趣地使用了記憶化方法。LPeg把每個模式都編譯成一種內部的格式,負責匹配的分析器把這些格式的編碼看成一個個“程序”。這個編譯動作相對於匹配動作來說是比較耗費資源的。所以,LPeg把編譯的結果記憶化,從而達到重用的目的。它通過在一個表里面把模式字符串以及其對應的內部格式關聯起來來實現。
記憶化方法的一個比較普遍的問題是,保存之前計算結果所耗費的空間可能會掩蓋重用這些結果的好處。為了在Lua中解決這個問題,我們可以使用一個弱表來保存計算結果,這樣,不用的結果就會從表中被刪除。
在Lua里,有了更高等的函數(譯注,Lua的函數是一等類型,即Lua處理函數和變量的方式是一樣的),我們可以定義一個通用的記憶化函數:
function memoize (f) local mem= {} -- memoizing table setmetatable(mem, {__mode = "kv"}) -- make it weak returnfunction (x) -- new version of ’f’, with memoizing local r = mem[x] if r== nil then -- no previous result? r = f(x) -- calls original function mem[x] = r -- store result for reuse end return r end end
對於一個給定的函數f,memoize(f)返回一個新的函數,這個函數會返回跟f一樣的結果,但是會吧結果記錄下來。例如,我們可以重新定義loadstring函數的一個記憶化版本:
loadstring = memoize(loadstring)
我們像使用老 函數一樣使用這個新函數,但是如果我們加載很多重復的字符串的時候,我們將會從性能上獲得很大的收益。如果你的程序創建和釋放過多的協程的時候,回收是一個提高程序性能的又一選擇。目前協程的API並沒有直接提供重用一個協程的方法,但是我們可以規避這個限制。考慮以下協程:
co = coroutine.create(function (f) while fdo f =coroutine.yield(f()) end end
這個協程接受一個作業(一個將要被執行的函數),運行這個作業,結束后等待下一個作業。Lua中的大多數回收都是由垃圾收集器自動完成的。Lua使用一個增量垃圾收集器。這意味着收集器每次都執行一小步動作(增量地),跟程序一起交錯執行。每一步的工作量是跟程序的內存申請量成正比的:Lua申請了多少內存,垃圾收集器就做相當比例的工作。程序消耗內存越快,收集器就越快地嘗試回收內存。
如果我們在程序中遵守減少和重用的准則,通常收集器沒有太多的事情做。但是有時候我們不能避免創建大量的垃圾,這時收集器的工作就變得繁重了。Lua的垃圾收集器是用來調整程序平衡的,所以再大多數程序中,它的表現都是很合理的。但是有些特殊情況,我們還是可以通過更好地調整收集器提高程序的性能的。
在Lua里,我們可以通過調用函數collectgarbage來控制垃圾收集器,在C里則是調用lua_gc。盡管接口不同,以上兩個函數基本上提供了相同的功能。接下來的討論我會使用Lua的接口,但是這種操作往往在C里面做會更好。
函數collectgarbage提供了幾種功能:它可以停止和重啟收集器,強制進行一次完成的收集,強制執行一步收集,得到Lua使用的總內存量,更改兩個影響到收集步伐的參數。所有這些操作在需要大量內存的程序里都有其用武之地。
一些批處理程序,它們創建了若干結構體,根據那些結構體產生一些輸出,然后退出(比如編譯器)。“永遠”停止收集器將是一個好選擇。對於那些程序,垃圾收集是比較浪費時間的,因為可回收的垃圾很少,並且程序一旦退出,所有的內存就會被釋放了。
對於一些非批處理的程序,永遠關閉收集器就不是個好選擇了。盡管如此,在程序的一些關鍵時間點關閉收集器還是有好處的。必要的時候,還可以由程序來完全控制垃圾收集器:收集器總是處於關閉狀態,只有程序顯式地要求執行一個步驟或者執行一個完整的回收時,收集器才開始工作。例如,有些事件驅動的平台會提供一個idle函數,這個函數會在沒有事件可以處理時被調用。這是執行垃圾收集的最好時刻。(在Lua5.1中,每次在收集器關閉時強制執行一些收集工作都會使收集器自動啟動。所以為了保持收集器做完馬上關閉,你必須在強制執行一些收集操作之后馬上調用collectgarbage("stop")。)
最后一個方法,你可以嘗試改變收集器的參數。收集器由兩個參數控制其收集步伐。第一個是“pause”(暫停),控制收集器在一輪回收結束后隔多久才開始下一輪的回收。第二個參數是“stepmul”(即 step multiplier,步伐遞增),控制收集器每一步要做多少工作。粗略地講,暫停間隔越小,步伐遞增越大,收集器工作就越快。
這些參數對一個程序的總體性能的影響是很難預測的。很明顯地,一個越快的收集器每秒耗費的CPU周期就越多;但是另一方面,這將會減少程序的總內存占用量,從而減少頁面切換的幾率。只有認真的實驗能夠讓你給這些參數設定一個最好的值。
============ End
