高性能 Lua 技巧(譯)

此爲 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 中訪問局部變量是很快的。舉個例子, 若是 ab 是局部變量,語句 a = a + b 只生成一條指令:ADD 0 0 1(假設 ab 分別在寄存器 01 中)。對比之下,若是 ab 是全局變量,生成上述加法運算的指令便會以下:優化

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 個函數的表,表中的函數分別返回常量 110000

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 就不能訪問變量 limitdelta 了。

不少字符串的處理,均可以經過在現有字符串上使用下標,來避免建立沒必要要的新字符串。例如,函數 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

對於一個給定的函數 fmemoize(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)。只有經過仔細的實驗,才能爲這些參數找到最佳的值。

相關文章
相關標籤/搜索