像其他任何編程語言一樣,在Lua中,我們也要遵守以下兩條優化程序的規則:
規則1:不要優化。
規則2:仍然不要優化(專家除外)
當用Lua編程時,這兩條規則顯得尤為重要。Lua以性能著稱,而且在腳本語言中也因此而值得贊美。
然而,我們都知道性能是編程的一個關鍵因素。具有復雜指數時間的問題被稱作疑難問題並不是偶然發生。太遲的結果是無用的結果。因此,每個優秀的程序員應該總是在花費資源去優化一段代碼的代價和這段代碼在運行代碼時節約資源的收益相平衡。一個優秀的程序員關於優化的第一個問題總是會問:“程序需要優化嗎?”如果答案是肯定的(僅當此時),第二個問題應該是:“哪地方?”
為了回答這兩個問題我們需要些手段。我們不應該在沒有合適的測量時嘗試優化軟件。大牛和菜鳥之前的不同不是有經驗的程序員更好的指出程序的一個地方可能耗時:不同之處是大牛知道他們並不擅長那項任務。
最近幾年,Noemi Rodriguez和我用Lua開發了一個CORBA ORB(Object Request Broker)原型,后來進化成OiL(Orb in Lua)。作為第一個原型,以執行簡明為目標。為了避免引用額外的C語言庫,這個原型用一些計算操作分離每個字節(轉化成256的基數)。不支持浮點數。因為CORBA把字符串作為字符序列處理,我們的ORB第一次把Lua的字符串轉化成字符序列(是Lua中的table),然后像其他序列那樣處理結果。
當我們完成第一個原型,我們和用C++實現的專業的ORB的性能相比較。我們預期我們的ORB會稍微慢點,因為它是用Lua實現的,但是,慢的太讓我們失望了。開始時,我們只是歸咎於Lua。最后,我們猜想原因可能是每個數字序列化所需要的那些操作。因此,我們決定在分析器下下運行程序。我們用了一個非常簡單的分析器,像《Programming in Lua》第23章描述的那樣。分析器的結果震驚到我們。和我們的直覺不同,數字序列化對性能的影響不大,因為沒有太多的數字序列化。然而,字符串序列化占用總時間的很大一部分。實際上每個CORBA消息都有幾個字符串,即使我們不明確地操作字符串:對象引用,方法名字和其他的某些整數值都被編碼成字符串。並且每個字符串序列化需要昂貴的代價去操作,因為這需要創建新表,用每個單獨的字符填充,然后序列化這些結果的順序,這涉及到一個接一個序列化每個字符。一旦我們重新實現字符串序列化作為特殊的事件(替換使用一般的序列代碼),我們就能得到可觀的速度提升。僅僅用額外的幾行代碼,你的執行效率就能比得上C++的執行(當然,我們的執行仍然慢,但不是一個數量級)。
因此,當優化程序性能時,我們應總是去測量。測量前,知道優化哪里。測量后,知道所謂的“優化”是否真正的提高了我們的代碼。
一旦你決定確實必須優化你的Lua代碼,本文可能幫助你如何去優化,主要通過展示在Lua中哪樣會慢和哪樣會快。在這里我不會討論優化的一般技術,比如更好的算法。當然,你應該懂得並且會用這些技術,但是,你能從其他的地方學習到那些一般的優化技術。在這篇文章里我僅講解Lua特有的技術。整篇文章,我將會時不時的測量小程序的時間和空間。除非另有說明,我所有的測量是在Pentium IV 2.9 GHz和主存1GB,運行在Ubuntu 7.10, Lua 5.1.1。我會頻繁地給出實際的測量結果(例如,7秒),但是會依賴於不同測量方法。當我說一個程序比另一的“快X%”的意思是運行時間少“X%”。(程序快100%意味着運行不花時間。)當我說一個程序比另一個“慢X%”的意思是另一個快X%。(程序慢50%的意思是運行花費兩倍時間。)
運行任何代碼前,Lua會把源碼轉化(預編譯)成內部格式。這種格式是虛擬機指令的序列,類似於真正CPU的機器碼。這種內部格式然后被必須內部有一個每個指令是一種情況大的switch的while循環的C語言解釋。
可能在某些地方你已經讀過從5.0版本Lua使用基於寄存器的虛擬機。這個虛擬機的“寄存器”和真正CPU的寄存器不相符,因為這種相符是不能移植並且十份限制可用寄存器的數量。取而代之的是,Lua使用堆(一個數組加上些索引來實現)容納寄存器。每個活動函數有一個活動記錄,那是個函數在其中存儲其寄存器的堆片段。因此,每個函數有他自己的寄存器(這類似於在windows某些CPU創建的寄存器)。每個函數可能使用超過250個寄存器,因此每個指令僅有8位引用寄存器。
提供了大量的寄存器,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程序的一個重要規則:使用局部變量!
如果你需要進一步提高你程序的性能,除了明顯的那些,這里還有你能使用局部變量的地方。例如,如果你在長循環中調用函數,你可以用局部變量引用這個函數。舉個例子,代碼
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)。除非你必須運行動態的代碼,像通過終端輸入的代碼,你很少需要編譯動態代碼。
作為例子,考慮下面的代碼,創建一個返回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秒)創建同樣的100000個函數。
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
通常,你不需要為使用表而了解Lua是如何執行表的任何事。實際上,Lua竭盡全力確保實現細節不暴露給用戶。然而,這些細節通過表操作的性能展示出來。因此,要優化使用表的程序(這幾乎是任何Lua程序),還是知道Lua是如何執行表的會比較好。
在Lua中表的執行涉及一些聰明的算法。Lua中的表有兩部分:數組和哈希。對某些特殊的n,數組存儲從1到n的整數鍵的條目。(稍后我們將會講解這個n是如何計算的。)所有其他的條目(包括范圍外的整數鍵)轉到哈希部分。
顧名思義,哈希部分使用哈希計算存儲和尋找他們的鍵。使用被稱作開發地址的表,意思是所有的條目被儲存在它自己的哈希數組中。哈希函數給出鍵的主要索引;如果存在沖突(即如何兩個鍵被哈希到同一個位置),這些鍵被連接到每個元素占用一個數組條目的列表中。
當Lua在表中插入一個新鍵,並且哈希數組已滿的時候,Lua會重新哈希。重新哈希第一步是決定新數組部分和新哈希部分的大小。因此,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
也做類似的 操作,除了表的哈希部分增長外。
對於很大的表,初始化的開銷會分攤到整個過程的創建:雖然有三個元素的表如要三次重新哈希,但有一百萬個元素的表只需要20次。但是當你創建上千個小的表時,總的消耗會很大。
舊版本的Lua創建空表時會預分配幾個位置(4個,如果我沒記錯的話),以避免這種初始化小表時的開銷。然而,這種方法會浪費內存。舉個例子,如果你創建一百萬個坐標點(表現為只有兩個元素的表)而每個使用實際需要的兩倍內存,你因此會付出高昂的代價。這也是現在Lua創建空表不會預分配的原因。
如果你用C語言編程,你可以通過Lua的API中lua_createtable函數避免那些重新哈希。他在無處不在的lua_State后接受兩個參數:新表數組部分的初始大小和哈希部分的初始大小。(雖然重新哈希的運算法則總會將數組的大小設置為2的冪次方,數組的大小可以是任意值。然而,哈希的大小必須是2的冪次方,因此,第二個參數總是取整為不比原值小的較小的2的冪次方)通過給出新表合適的大小,這很容易避免那些初始的再哈希。當心,無論如何,Lua只能在再哈希時候才能收縮表。因此,如果你初始大小比需要的大,Lua可能永遠不會糾正你浪費的空間。
當用Lua編程時,你可以用構造器避免那些初始再哈希。當你寫下{true, true, true}時,Lua會預先知道表的數組部分將會需要上三個空位,因此Lua用這個大小創建表。同樣地,如果你寫下{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時間。
僅有當表重新哈希時,表的數組和哈希部分的大小才會重新計算,只有在表完全滿且Lua需要插入新的元素時候發生。如果你遍歷表清除所有的字段(即設置他們為空),結果是表不會收縮。然而,如果你插入一些新的元素,最后表不得不重新調整大小。通常這不是個問題:如果你一直清除元素和插入新的(在很多程序中都是有代表性的),表的大小保持不變。然而,你應該不期望通過清除大的表的字段來恢復內存:最好是釋放表本身。
一個強制重新哈希的鬼把戲是插入足夠多是空值到表中。看接下來的例子:
a = {} lim = 10000000 for i = 1, lim do a[i] = i end -- create a huge table print(collectgarbage("count")) --> 196626 for i = 1, lim do a[i] = nil end -- erase all its elements print(collectgarbage("count")) --> 196626 for i = lim + 1, 2*lim do a[i] = nil end -- create many nil elements print(collectgarbage("count")) --> 17
我不推薦這種鬼把戲,除非在特殊情況下:這會很慢並且沒有容易的方法指導“足夠”是指多少元素。
你可能會好奇為什么當插入空值時Lua不會收縮表。首先,要避免測試插入表的是什么;檢測賦空值會導致所有的賦值變慢。其次,更重要的是,當遍歷表時允許賦空值。思考接下來的這個循環:
for k, v in pairs(t) do if some_property(v) then t[k] = nil -- erase that element end end
如果賦空值后Lua對表重新哈希,這回破壞本次遍歷。
如果你想清空表中所有的元素,一個簡單的遍歷是實現他的正確方法:
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函數開始遍歷表的數組,查找不為空的元素。當循環設置第一個元素為空時,next函數花更長的時間查找第一個非空元素。結果是,“聰明”的循環花費20秒清除有100,000個元素的表;使用pairs遍歷循環花費0.04秒。
和表一樣,為了更高效的使用字符串,最好知道Lua是如何處理字符串的。
不同於大多數的腳本語言,Lua實現字符串的方式表現在兩個重要的方面。第一,Lua中所有的字符串都是內化的。意思是Lua對任一字符串只保留一份拷貝。無論何時出現新字符串,Lua會檢測這個字符串是否已經存在備份,如果是,重用拷貝。內化使像字符串的比較和表索引操作非常快,但是字符串的創建會慢。
第二,Lua中的變量從不持有字符串,僅是引用他們。這種實現方式加快了幾個字符串的操作。舉個例子,在Perl語言中,當你寫下類似於$x = $y,$y含有一個字符串,賦值會從$y緩沖中字符串內容復制到$x的緩沖。如果字符串很長的話,這就會變成昂貴的操作。在Lua中,這種賦值只需復制指向字符串的指針。
然而,這種帶有引用實現減慢了字符串連接的這種特定形式。在Perl中,$s = $s . "x"和$s . = "x"操作使完全不一樣的。在第一個中,你得到的一個$s的拷貝,並在它的末尾加上“x”。在第二個中,“x”簡單地附加到由$s變量保存的內部緩沖上。因此,第二種形式和字符串的大小不相關(假設緩沖區有多余文本的空間)。如果你在循環內部用這些命令,他們的區別是線性和二次方算法的區別。舉個例子,下面的循環讀一個5M的文件花費了約5分鍾。
$x = ""; while (<>) { $x = $x . $_; }
如果我們把 $x = $x . $_ 變成 $x .= $_, 這次時間下降到0.1秒!
Lua不支持第二個,更快的那個,這是因為它的變量沒有緩沖和它們相關聯。因此,我們必須用顯示的緩沖:字符串表做這項工作。下面的循環0.28秒讀取同樣的5M文件。雖然不如Perl快,但也很不錯了。
local t = {} for line in io.lines() do t[#t + 1] = line end s = table.concat(t, "\n")
當處理Lua資源時,我們應該同樣用推動地球資源的3R倡議。
簡化是這三個選項中最簡單的。有幾種方法可以避免對新對象的需要。舉個例子,如果你的程序使用了很多的表,可以考慮數據表現的改動。舉個簡單的例子,考慮程序操作折線。在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 = { x = { 10.3, 10.3, 15.0, ...}, y = { 98.5, 18.3, 98.5, ...} }
原來的p[i].x 變成現在的 p.x[i]。通過使用這種做法,一百萬個點的折線僅僅用了24KB的內存。
查找減少生成垃圾的好地方是在循環中。舉個例子,如果在循環中不斷的創建表,你可以從循環中把它移出來,甚至在外面封裝創建函數。比較:
function foo (...) for i = 1, n do local t = {1, 2, 3, "hi"} -- do something without changing ’t’ ... end end local t = {1, 2, 3, "hi"} -- create ’t’ once and for all function foo (...) for i = 1, n do -- do something without changing ’t’ ... end end
閉包可以用同樣的技巧,只要你不把它們移出它們所需要的變量的作用域。舉個例子,考慮接下來的函數:
function changenumbers (limit, delta) for line in 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
我們通過把內部的函數移到循環的外面來避免為每行創建一個新的閉包:
function changenumbers (limit, delta) local function aux (num) num = tonumber(num) if num >= limit then return tostring(num + delta) end end for line in io.lines() do line = string.gsub(line, "%d+", aux) io.write(line, "\n") end end
然而,我們不能把aux已到changenumbers函數外面,因為那樣aux不能訪問到limit和delta。
對於很多種字符串處理,我們可以通過操作現存字符串的索引來減少對新字符串的需要。舉個例子,string,find函數返回他找到模式的位置,代替了匹配。通過返回索引,對於每次成功匹配可以避免創建一個新(子)的字符串。當必要時,程序員可以通過調用string.sub得到匹配的子字符串。(標准庫有一個比較子字符串的功能是個好主意,以便我們不必從字符串提取出那個值(因而創建了一個新字符串))
當我們不可避免使用新對象時,通過重用我們任然可以避免創建那些新對象。對於字符串的重用是沒有必要的,因為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中新的模式匹配包,對memoizing的使用很有意思。LPeg把每個模式編譯成內在的形式,一個用於解析機器執行匹配的“程序”。這種編譯與匹配自身相比代價非常昂貴。因此,LPeg記下它的編譯結果並復用。一個簡單的表將描述模式的字符串與相應的內部表示相關聯。
memoizing的通常問題是儲存以前結果花費的空間可能超過復用這些結果的收益。Lua為了解決這個問題,我們可以用弱表來保存結果,以便沒有用過的結果最后能從表里移除。
Lua中,用高階函數我們可以定義個通用的memoization函數:
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返回相同結果的函數,並且記錄它們。舉個例子,我們可以重新定義帶memoizing版本的loadstring:
loadstring = memoize(loadstring)
我們完全像之前的那個那樣使用新函數,但是如果我們加載的字符串中有很多重復的,我們能獲得可觀的收益。
如果你的程序創建和釋放太多的協程,回收再生可能是個提高性能的選擇。當前的協程API不提供直接支持復用協程,但是我們可以突破這個限制。考慮下面的協程
co = coroutine.create(function (f) while f do 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周期;然而,它能減少程序使用的總的內存,從而減少分頁。只有仔細的嘗試才能給你這些參數的最佳值。
正如我們介紹中討論的那樣,優化是有技巧的。這里有幾點需要注意,首先程序是否需要優化。如果它有實際的性能問題,那么我們必須定位到哪個地方以及如何優化。
這里我們討論的技術既不是唯一也不是最重要的一個。我們關注的是Lua特有的技術,因為有更多的針對通用技術的資料。
在我們結束前,我想提兩個在提升Lua程序性能邊緣的選項。因為這兩個都涉及到Lua代碼之外的變化。第一個是使用LUaJIT,Mike Pall開發的Lua即使編譯器。他已經做了出色的工作,並且LuaJIT可能是目前動態語言最快的JIT。缺點是,他只能運行在x86架構上,而且,你需要非標准的Lua解釋器(LuaJIT)來運行程序。優點是在一點也不改變代碼的情況下能快5倍的速度運行你的程序。
第二個選擇是把部分代碼放到C中。畢竟,Lua的特點之一是與C代碼結合的能力。這種情況下,最重要的一點是為C代碼選擇正確的粒度級別。一方面,如果你只把非常簡單的函數移到C中,Lua和C通信的開銷可能超過那些函數對性能提升的收益。另一方面,如果你把太大的函數移到C中,又會失去靈活性。
最后,謹記,這兩個選項有點不兼容。程序中更多的C代碼,LuaJIT能優化代碼就會更少。
簡化,復用,再生