1. 程式人生 > >高性能 Lua 技巧(譯)

高性能 Lua 技巧(譯)

卓有成效 包含字符串 mode a + b 垃圾收集 移動 htm 一個棧 工作

高性能 Lua 技巧(譯)

來源 https://segmentfault.com/a/1190000004372649

此為 Lua Programming Gems 一書的第二章:Lua Performance Tips,作者為 Roberto Ierusalimschy。

我的翻譯以 網上別人的翻譯 為基礎,做了比較大的修改,讀起來更通順。


關於性能優化的兩條格言:

  • 規則 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)。只有通過仔細的實驗,才能為這些參數找到最佳的值。

高性能 Lua 技巧(譯)