此爲 Lua Programming Gems 一書的第二章:Lua Performance Tips,做者爲 Roberto Ierusalimschy。html
個人翻譯以 網上別人的翻譯 爲基礎,作了比較大的修改,讀起來更通順。算法
關於性能優化的兩條格言:編程
規則 1:不要優化數組
規則 2:仍是不要優化(僅限專家)性能優化
不要在缺少恰當度量(measurements)時試圖去優化軟件。編程老手和菜鳥之間的區別不是說老手更善於洞察程序的性能瓶頸,而是老手知道他們並不善於此。數據結構
作性能優化離不開度量。優化前度量,可知何處須要優化。優化後度量,可知「優化」是否確實改進了代碼。閉包
運行代碼以前,Lua 會把源代碼翻譯(預編譯)成一種內部格式,這種格式由一連串虛擬機的指令構成,與真實 CPU 的機器碼很類似。接下來,這一內部格式交由 C 代碼來解釋,基本上就是一個 while
循環,裏面有一個很大的 switch
,一種指令對應一個 case
。函數
也許你已從他處得知,自 5.0 版起,Lua 使用了一個基於寄存器的虛擬機。這些「寄存器」跟 CPU 中真實的寄存器並沒有關聯,由於這種關聯既無可移植性,也受限於可用的寄存器數量。Lua 使用一個棧(由一個數組加上一些索引實現)來存放它的寄存器。每一個活動的(active)函數都有一份活動記錄(activation record),活動記錄佔用棧的一小塊,存放着這個函數對應的寄存器。所以,每一個函數都有其本身的寄存器。因爲每條指令只有 8 個 bit 用來指定寄存器,每一個函數即可以使用多至 250 個寄存器。性能
Lua 的寄存器如此之多,預編譯時便能將全部的局部變量存到寄存器中。因此,在 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 0 0 ; a
因此,不難證實,要想改進 Lua 程序的性能,最重要的一條原則就是:使用局部變量(use locals)!
除了一些明顯的地方外,另有幾處也可以使用局部變量,能夠助你擠出更多的性能。好比,若是在很長的循環裏調用函數,能夠先將這個函數賦值給一個局部變量。這個代碼:
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 個函數的表,表中的函數分別返回常量 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 秒。
使用閉包,能夠避免動態編譯。下面的代碼建立一樣的 10000 個函數只用了 1/10 的時間(0.14秒):
function fk (k) return function () 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 實現表的方法,不無益處。
Lua 實現表的算法頗爲巧妙。每一個表包含兩部分:數組(array)部分和哈希(hash)部分,數組部分保存的項(entry)以整數爲鍵(key),從 1 到某個特定的 n,(稍後會討論 n 是怎麼計算的。)全部其餘的項(包括整數鍵超出範圍的)則保存在哈希部分。
顧名思義,哈希部分使用哈希算法來保存和查找鍵值。它使用的是開放尋址(open address)的表,意味着全部的項都直接存在哈希數組裏。鍵值的主索引由哈希函數給出;若是發生衝突(兩個鍵值哈希到相同的位置),這些鍵值就串成一個鏈表,鏈表的每一個元素佔用數組的一項。
當 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
作的事情相似,大小增加的倒是表的哈希部分。
對於大型的表,這些初始的開銷將會被整個建立過程平攤:建立 3 個元素的表須要進行 3 次從新哈希,而建立一百萬個元素的表只須要 20 次。可是當你建立幾千個小表時,總開銷就會很顯著。
老版的 Lua 在建立空表時會預分配一些空位(若是沒記錯,是 4),來避免這種建立小表時的初始開銷。不過,這樣又有浪費內存之嫌。好比,以僅有兩個項的表來表示點,每一個點使用的內存就是真正所需內存的兩倍,那麼建立幾百萬個點將會使你付出高昂的代價。這就是如今 Lua 不爲空表預分配空位的緣由。
若是你用的是 C,能夠經過 Lua 的 API 函數 lua_createtable
來避免這些從新哈希。這個函數除了司空見慣的參數 lua_State
外,另接受兩個參數:新表數組部分的初始大小和哈希部分的初始大小。只要這兩個參數給得恰當,就能避免初始時的從新哈希。不過須要注意的是,Lua 只在從新哈希時纔有機會去收縮(shrink)表。因此,若是你指定的初始大小大於實際所需,空間的浪費 Lua 可能永遠都不會爲你糾正。
若是你用的是 Lua,能夠經過構造器(constructors)來避免那些初始的從新哈希。當你寫下 {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 並無聰明到可以檢測出給定的表達式(指那些字面數字)是在描述數組下標,因此它建立了一個哈希部分有 4 個空位的表,既浪費內存也浪費 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
除非特殊狀況須要,我並不推薦這種手法,由於這樣作很慢,並且要知道多少元素纔算「足夠」,也沒有簡單易行的方法。
你可能會想,Lua 爲何不在咱們插入 nil
時收縮表的大小呢?首先,是爲了不對插入元素的檢查;一條檢查 nil
賦值的語句將會拖慢全部的賦值語句。其次,也是更重要的,是爲了容許在遍歷表時對元素賦 nil
值。考慮以下循環:
for k, v in pairs(t) do if some_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 k then break end t[k] = nil end
不過,這個循環在表很大時會很慢。調用函數 next
時,若是沒有傳入前一個鍵值,返回的即是表的「第一個」元素(以某種隨機順序)。(譯:「第一個」之因此加引號,是指就表內部的數組結構而言的第一個元素,「以某種隨機順序」則是從表的角度或用戶使用表的角度來講。)爲此,next
從頭遍歷表的數組空間(譯:包含數組和哈希兩部分),查找一個非 nil
元素。隨着循環逐一將這些第一個元素設爲 nil
,查找第一個非 nil
元素變得愈來愈久。結果是,爲了清除一個有 100000 個元素的表,這個「聰明」的循環用了 20 秒,而使用 pairs
遍歷表的循環只用了 0.04 秒。
和表同樣,瞭解 Lua 實現字符串的細節對高效地使用字符串也會有所幫助。
Lua 實現字符串的方式和大多數其餘的腳本語言有兩點重要的區別。其一,Lua 的字符串都是內化的(internalized);這意味着字符串在 Lua 中都只有一份拷貝。每當一個新字符串出現時,Lua 會先檢查這個字符串是否已經有一份拷貝,若是有,就重用這份拷貝。內化(internalization)使字符串比較及表索引這樣的操做變得很是快,可是字符串的建立會變慢。
其二,Lua 的字符串變量歷來不會包含字符串自己,包含的只是字符串的引用。這種實現加快了某些字符串操做。好比,對 Perl 來講,若是你寫下這樣的語句:$x = $y
,$y
包含一個字符串,這個賦值語句將複製 $y
緩衝區裏的字符串內容到 $x
的緩衝區中。若是字符串很長,這一操做代價將很是高。而對 Lua 來講,這樣的賦值語句只不過複製了一個指向實際字符串的指針。
這種使用引用的實現,使某種特定形式的字符串鏈接變慢了。在 Perl 裏,$s = $s . "x"
和 $s .= "x"
這二者是很不同的。前一個語句,先獲得一份 $s
的拷貝,而後往這份拷貝的末尾加上 "x"
。後一個語句,只是簡單地把 "x"
追加到變量 $s
所持有的內部緩衝區上。因此,第二種鏈接形式跟字符串大小是無關的(假設緩衝區有足夠的空間來存放鏈接的字符串)。若是在循環中執行這兩條語句,那麼它們的區別就是算法複雜度的線性階和平方階的區別了。好比,如下循環讀一個 5MB 的文件,幾乎用了 5 分鐘:
$x = ""; while (<>) { $x = $x . $_; }
若是將 $x = $x . $_
替換成 $x .= $_
,則只要 0.1 秒!
Lua 並無提供這第二種較快的方法,由於 Lua 的變量並無與之關聯的緩衝區。因此,咱們必須使用一個顯式的緩衝區:包含字符串片斷的表就行。如下循環仍是讀 5MB 的文件,費時 0.28 秒。沒 Perl 那麼快,不過也不賴。
local t = {} for line in io.lines() do t[#t + 1] = line end s = table.concat(t,"\n")
當處理 Lua 資源時,咱們應當遵照跟利用地球資源同樣的 3R 原則。
減小(reduce)是最簡單的一種途徑。有幾種方法能夠避免建立對象。例如,若是你的程序使用了大量的表,或許能夠考慮改變它的數據表示。舉個簡單的例子,假如你的程序須要處理折線(polyline)。在 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][4]
易懂得多。
還有一個更經濟的方法,用兩個列表,一個存 x
座標的值,一個存 y
座標的值:
polyline = { x = { 10.3, 10.3, 15.0, ...}, y = { 98.5, 18.3, 98.5, ...} }
以前的 p[i].x
如今就是 p.x[i]
。使用這種方式,一條有一百萬個點的折線只需 24KB 的內存。
循環是尋找下降沒必要要資源建立的好地方。例如,若是在循環中建立了一個常量的(constant)表,即可以把表移到循環以外,或者甚至能夠移到外圍函數以外。比較以下兩段代碼:
function foo (...) for i = 1, n do local t = {1, 2, 3, "hi"} -- 作一些不改變 t 的操做 ... end end
local t = {1, 2, 3, "hi"} -- 一次性地建立 t function foo (...) for i = 1, n do -- 作一些不改變 t 的操做 ... end end
一樣的技巧也能夠用於閉包,只要移動時不致越出閉包所需變量的做用域。例如,考慮如下函數:
function changenumbers (limit, delta) for linein io.lines() do line = string.gsub(line, "%d+", function (num) num = tonumber(num) if num >= limit then return tostring(num + delta) end -- else return nothing, keeping the original number end) io.write(line, "\n") end end
只要將內部(inner)函數移到循環以外,就可避免爲每一行都建立一個新的閉包:
function changenumbers (limit, delta) local function aux (num) num = tonumber(num) if num >= limit then return tostring(num + delta) end end for linein io.lines() do line = string.gsub(line, "%d+", aux) io.write(line, "\n") end end
不過,不能將函數 aux
移到函數 changenumbers
以外,那樣的話,函數 aux
就不能訪問變量 limit
和 delta
了。
不少字符串的處理,均可以經過在現有字符串上使用下標,來避免建立沒必要要的新字符串。例如,函數 string.find
返回的是給定模式出現的位置,而不是一個與之匹配的字符串。返回下標,就避免了在成功匹配時建立一個新的(子)字符串。如有須要,能夠再經過函數 string.sub
來獲取匹配的子字符串。
即便不能避免使用新的對象,也能夠經過 重用(reuse)來避免建立新的對象。對字符串來講,重用是沒有必要的,由於 Lua 已經替咱們這樣作了:全部的字符串都是內化的(internalized),所以只要可能就會重用。對錶來講,重用就顯得卓有成效了。舉一個常見的例子,讓咱們回到在循環內建立表的狀況。不一樣的是,此次的表是可變的(not constant)。不過,每每只需簡單的改變內容,仍是能夠在全部的迭代中重用同一個表的。考慮如下代碼:
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
便記住編譯的結果,方式是用一個表,把描述模式的字符串和相應的內部表示關聯起來。
記憶化方法的一個比較廣泛的問題是,保存以前結果而在空間上的花費可能會甚於重用這些結果的好處。爲了解決這個問題,咱們可使用弱表(weak table),這樣,不用的結果最後就會從表中刪除。
藉助於高階函數(higher-order functions),咱們能夠定義一個通用的記憶化函數:
function memoize (f) local mem = {} -- memoizing table setmetatable(mem, {__mode = "kv"}) -- make it weak return function (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)
新函數的使用方式和老函數同樣,可是若是咱們加載的字符串中有不少重複的字符串,便會得到很大的性能提高。
若是你的程序建立和釋放過多的協程(coroutines),也許能夠經過 回收(recycle)來提升它的性能。目前協程的 API 並無直接提供重用協程的方法,可是咱們能夠設法克服這一限制。考慮如下協程:
co = coroutine.create(function (f) while f do f = coroutine.yield(f()) end end
這個協程接受一個做業(job)(一個待執行的函數),執行這個做業,結束後等待下一個做業。
Lua 中的大多數回收都是由垃圾收集器自動完成的。Lua 使用一個增量(incremental)的垃圾收集器,逐步(in small steps)回收(增量地),跟程序一塊兒交錯執行。每一步回收多少,跟內存分配成正比:Lua 分配了多少內存,垃圾收集器就作多少相應比例的工做。程序消耗內存越快,收集器嘗試回收內存也就越快。
若是咱們在程序中遵照減小和重用的原則,收集器一般沒有太多的事情可作。可是有時候咱們不能避免建立大量的垃圾,這時收集器就可能變得任務繁重了。Lua 的垃圾收集器是爲通常的程序而設的,對大多數應用來講,它的表現都是至關不錯的。可是有時候,某些特殊的應用場景,適當地調整收集器仍是能夠提升性能的。
要控制垃圾收集器,能夠調用 Lua 的函數 collectgarbage
,或者 C 函數 lua_gc
。儘管接口不一樣,這兩個函數的功能基本一致。接下來的討論我會使用 Lua 函數,雖然這種操做每每更適合在 C 裏面作。
函數 collectgarbage
提供了這樣幾種功能:它能夠中止和重啓收集器,強制進行一次完整的收集,強制執行一步收集(collection step),獲得當前內存使用總量,更改兩個影響收集效率(pace)的參數。全部這些操做在缺少內存的程序裏都有其用武之地。
對於某些批處理程序(batch programs),能夠考慮「永遠」地中止收集器。這些批處理程序一般都是先建立一些數據結構,並根據那些結構體產生一些輸出,而後就退出(好比編譯器)。對於那些程序,試圖去收集垃圾也許就比較浪費時間了,由於沒什麼垃圾可回收的,而且程序一旦退出,全部的內存就會獲得釋放。
對於非批處理的程序,永遠中止收集器並不可取。不過,在一些關鍵的時間點,中止收集器對程序可能倒是有益的。若有必要,還能夠由程序來徹底控制垃圾收集器,讓它老是處於中止狀態,只在程序顯式地要求執行一個步驟或者執行一個完整的回收時,收集器纔開始工做。例如,有些事件驅動的平臺會提供一個 idle
函數,這個函數會在沒有事件能夠處理時被調用。這是執行垃圾收集的最佳時刻。(Lua5.1 中,在收集器中止時去強制執行一些收集操做,都會使收集器自動重啓。因此爲了保持它中止的狀態,必須在強制執行一些收集操做以後立刻調用 collectgarbage
("stop")。)
最後一個方法,能夠試着改變收集器的參數。收集器由兩個參數控制其收集的步長(pace)。第一個是 pause
,控制收集器在一輪迴收結束後隔多久纔開始下一輪的回收。第二個參數是 stepmul
,控制收集器每一步要作多少工做。粗略地講,pause
越小,stepmul
越大,收集器工做就越快。
這些參數對一個程序的整體性能的影響是很難預測的。收集器越快,其每秒耗費的 CPU 週期顯然也就越多;可是另外一方面,或許這樣能減小程序的內存使用總量,從而減小換頁(paging)。只有經過仔細的實驗,才能爲這些參數找到最佳的值。