和在全部其餘編程語言中同樣,在Lua中,咱們依然應當遵循下述兩條有關程序優化的箴言:html
原則1:不要作優化。程序員
原則2:暫時不要作優化(對專家而言)。算法
這兩條原則對於Lua編程來講尤爲有意義,Lua正是因其性能而在腳本語言中鶴立雞羣。編程
固然,咱們都知道性能是編程中要考量的一個重要因素,指數級時間複雜度的算法會被認爲是棘手的問題,絕非偶然。若是計算結果來得太遲,它就是無用的結果。所以,每個優秀的程序員都應該時刻平衡在優化代碼時所花費的資源和執行代碼時所節省的資源。小程序
優秀的程序員對於代碼優化要提出的第一個問題是:「這個程序須要被優化嗎?」若是(僅當此時)答案是確定的,第二個問題則是:「在哪裏優化?」數組
要回答這樣兩個問題,咱們須要制定一些標準。在進行有效的性能評定以前,不該該作任何優化工做。有經驗的程序員和初學者以前的區別並不是在於前者善於指出一個程序的主要性能開銷所在,而是前者知道本身不善於作這件事情。緩存
幾年前,Noemi Rodriguez和我開發了一個用於Lua的CORBA ORB[2]原型,以後演變爲OiL。做爲第一個原型,咱們的實現的目標是簡潔。爲防止對額外的C函數庫的依賴,這個原型在序列化整數時使用少許四則運算來分離各個字節(轉換爲以256爲底),且不支持浮點值。因爲CORBA視字符串爲字符序列,咱們的ORB最初也將Lua字符串轉換爲一個字符序列(也就是一個Lua表),而且將其和其餘序列等同視之。性能優化
當咱們完成這個原型以後,咱們把它的性能和一個使用C++實現的專業ORB進行對比。因爲咱們的ORB是使用Lua實現的,預期上咱們能夠容忍它的速度要慢一些,可是對比結果顯示它慢得太多了,讓咱們很是失望。一開始,咱們把責任歸結於Lua自己;後來咱們懷疑問題出在那些須要序列化整數的操做上。咱們使用了一個很是簡單的性能分析器(Profiler),與在《Lua程序設計》[3]第23章裏描述的那個沒什麼太大差異。出乎咱們意料的是,整數序列化並無明顯拖慢程序的速度,由於並無太多整數須要序列化;反而是序列化字符串須要對低性能負很大責任。實際上,每一條CORBA消息都包含若干個字符串,即便咱們沒有顯式地操做字符串亦是如此。並且序列化每一條字符串都是一個性能開銷巨大的工做,由於它須要建立一個新表,並使用單獨的字符填充;而後序列化整個序列,其中須要依次序列化每一個字符。一旦咱們將字符串序列化做爲一種特殊狀況(而不是經過通用的序列化流程)從新實現,整個程序的性能就獲得了顯著的提高。咱們只是添加了幾行代碼,程序的性能已經和C++實現的那個版本有得一拼了[4]。數據結構
所以,咱們老是應該在優化性能以前進行性能測試。經過測試,才能瞭解到要優化什麼;在優化後再次測試,來確認咱們的優化工做確實帶來了性能的提高。閉包
一旦你決定必須優化你的Lua代碼,本文將可能有所幫助。本文描述了一些優化方式,主要是展現在Lua中怎麼作會更慢,怎麼作又會更快。在這裏,我將不會討論一些通用的優化技巧,例如優化算法等等——固然,你應該掌握和使用這些技巧,有不少其餘地方能夠了解這方面的內容。本文主要討論一些專門針對Lua的優化技巧,與此同時,我還會持續地測試小程序的時間和空間性能。若是沒有特別註明的話,全部的測試都在一臺Pentium IV 2.9GHz、1GB內存、運行Ubuntu 7.十、Lua 5.1.1的機器上進行。我常常會給出實際的測量結果(例如7秒),可是這隻在和其餘測量數據進行對比時有意義。而當我說一個程序比另外一個快X%時,意味着前者比後者少消耗X%的時間(也就是說,比另外一個程序快100%的程序的運行不須要時間);當我說一個程序比另外一個慢X%時,則是說後者比前者快X%(意即,比另外一個程序慢50%的程序消耗的時間是前者的兩倍)。
基本事實
在運行任何代碼以前,Lua都會把源代碼翻譯(預編譯)成一種內部的格式。這種格式是一個虛擬機指令序列,與真實的CPU所執行的機器碼相似。以後,這個內部格式將會被由一個包含巨大的switch結構的while循環組成的C代碼解釋執行,switch中的每一個case對應一條指令。
可能你已經在別處瞭解到,從5.0版開始,Lua使用一種基於寄存器的虛擬機。這裏所說的虛擬機「寄存器」與真正的CPU寄存器並不相同,由於後者難於移植,並且數量很是有限。Lua使用一個棧(經過一個數組和若干索引來實現)來提供寄存器。每一個活動的函數都有一個激活記錄,也就是棧上的一個可供該函數存儲寄存器的片斷。所以,每一個函數都有本身的寄存器[1]。一個函數可使用最多250個寄存器,由於每一個指令只有8位用於引用一個寄存器。
因爲寄存器數目衆多,所以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到100000的若干個函數的表:
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秒。
表
通常狀況下,你不須要知道Lua實現表的細節,就可使用它。實際上,Lua花了不少功夫來隱藏內部的實現細節。可是,實現細節揭示了表操做的性能開銷狀況。所以,要優化使用表的程序(這裏特指Lua程序),瞭解一些表的實現細節是頗有好處的。
Lua的表的實現使用了一些很聰明的算法。每一個Lua表的內部包含兩個部分:數組部分和哈希部分。數組部分以從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
作的事情相似,只不過增長的是哈希部分的大小。
對於大的表來講,初期的幾回從新哈希的開銷被分攤到整個表的建立過程當中,一個包含三個元素的表須要三次從新哈希,而一個有一百萬個元素的表也只須要二十次。可是當建立幾千個小表的時候,從新哈希帶來的性能影響就會很是顯著。
舊版的Lua在建立空表時會預選分配大小(4,若是我沒有記錯的話),以防止在初始化小表時產生的這些開銷。可是這樣的實現方式會浪費內存。例如,若是你要建立數百萬個點(表現爲包含兩個元素的表),每一個都使用了兩倍於實際所需的內存,就會付出高昂的代價。這也是爲何Lua再也不爲新表預分配數組。
若是你使用C編程,能夠經過Lua的API函數lua_createtable來避免從新哈希;除lua_State以外,它還接受兩個參數:數組部分的初始大小和哈希部分的初始大小[1]。只要指定適當的值,就能夠避免初始化時的從新哈希。須要警戒的是,Lua只會在從新哈希時收縮表的大小,所以若是在初始化時指定了過大的值,Lua可能永遠不會糾正你浪費的內存空間。
當使用Lua編程時,你可能可使用構造式來避免初始化時的從新哈希。當你寫下
{true, true, true}
時,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從新哈希時從新計算,從新哈希則只會發生在表徹底填滿後,Lua須要插入新的元素之時。所以,若是你遍歷一個表並清除其全部項(也就是所有設爲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 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,所以next函數將會花費愈來愈長的時間來尋找第一個非nil元素。這樣的結果是,這個「聰明」的循環須要20秒來清除一個有100,000個元素的表,而使用pairs實現的循環則只須要0.04秒。
經過使用閉包,咱們能夠避免使用動態編譯。下面的代碼只須要十分之一的時間完成相同的工做:
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