luajit官方性能優化指南和註解

原博地址

luajit是目前最快的腳本語言之一,不過深入使用就很快會發現,要把這個語言用到像宣稱那樣高性能,並不是那麼容易。實際使用的時候往往會發現,剛開始寫的一些小test case性能非常好,經常毫秒級就算完,可是代碼複雜度一上去了,動輒幾十上百毫秒的情況就會出現,性能表現非常飄忽。
爲此luajit的mailling list也是有不少人諮詢,作者mike pall的一篇比較完整的回答被放在了官方wiki上:
 
http://wiki.luajit.org/Numerical-Computing-Performance-Guide
 
不過原文說了很多怎麼做,卻基本沒有解釋爲什麼。
 
所以這篇文章不是簡單的翻譯官方這個優化指南,最主要還是讓大家瞭解luajit背後的一些原理,因爲原文中只有告訴你怎麼做,卻沒說清楚爲什麼,導致做了這些優化,到底影響多大,原因是啥,十分模糊。瞭解背後的原因往往對我們有很大的幫助。
另外,原生lua、luajit的jit模式(pc和安卓可用)、luajit的interpreter模式(ios下只能運行這個),他們執行lua的原理是有很大的不同的,也導致一些lua優化技巧並不見得是通用的。而這篇文章主要針對luajit的jit模式。

 
 

1.Reduce number of unbiased/unpredictable branches.

減少不可預測的分支代碼

分支代碼就是根據條件會跳轉的代碼(最典型就是if..else),那什麼是不可預測的分支代碼?簡單說:
if 條件1 then
elseif 條件2 then
假如條件1或者條件2其中一方達成的概率非常高(>95%),那我們認爲這是可預測的分支代碼。
這是被mike pall放到第一位的性能優化點(事實上確實應該如此),究其原因是luajit使用了trace compiler的特性,爲了生成的機器碼儘可能高效,它會根據代碼的運行情況進行一些假設,比如上面的例子如果luajit發現,條件2的達成概率非常高,那麼luajit會生成按條件2達成執行最快的代碼。
有一點可能大家會問,luajit真的能知道運行過程中的一些情況?
是的
這也是trace compiler的特徵:先運行字節碼,針對熱點代碼做profile,瞭解了可以優化的點後再優化出最高效的機器碼。這就是luajit目前的做法。
爲什麼要這樣呢?給一個比較好理解的例子:luajit是動態類型語言,面對一個a+b,你根本不知道a和b是什麼類型,如果a+b只是兩個整數相加,那麼編譯機器碼做求和速度自然是飛快的。可是如果你無法確認這點,結果你只能假定它是任意類型,先去動態檢查類型(看看到底是兩個表,還是兩個數值,甚至是其他情況),再跳根據類型做相應的處理,想想都知道比兩個整數相加慢了幾十倍。
所以luajit爲了極限級的性能,就會大膽進行假設,如果發現a+b就是兩個數值相加,就編譯出數值求和的機器碼。
但是如果某一時刻a+b不是數值相加,而是變成了兩個表相加呢?這機器碼豈不是就導致錯誤了?因此每次luajit做了假設時,都會加上一段守護代碼(guard),檢查假設是不是對的,如果不對,就會跳轉出去,再根據情況,來決定要不要再編譯一段新的機器碼,來適配新的情況。
這就是爲什麼你的分支代碼一定要可預測,因爲如果經常不符合luajit假設的東西,就會經常從編譯好的機器碼中跳出來,甚至會因爲好幾次假設失敗而連跳好幾次。所以,luajit是一個對分支情況極度敏感的語言。
這是luajit的第一性能大坑,作者建議可以藉助math.min/max或者bitop來繞過if else這樣的分支代碼。不過實際情況往往更復雜,所有涉及到跳轉代碼的地方,都是潛在的性能坑。
另外,在interpreter模式下(ios的情況),luajit就變成了老老實實動態檢查動態跳轉的執行模式,對分支預測反而並不敏感,並不需要過分注重這方面的優化。
 
 

2.Use FFI data structures.

如果可以,將你的數據結構用ffi實現,而不是用lua table實現

luajit的ffi是一個常被大家忽略的功能,或者只被當做一個更好用的c導出庫,但事實上這是一個超級性能利器。
 
比如要實現unity中的Vector3,分別用lua table和用ffi實現,我們測試下來,內存佔用是10:1,運算x+y+z的耗時也是大概8:1,優化效率驚人。
代碼如下:
local ffi = require("ffi")
ffi.cdef[[
typedef struct { float x, y, z; } vector3c;
]]
local count = 100000
 
local function test1() -- lua table的代碼
  local vecs = {}
  for i = 1, count do
    vecs[i] = {x=1, y = 2, z = 3}
  end
  local total = 0
  -- gc後記錄下面for循環運行時的時間和內存佔用,這裏省略
  for i = 1, count do
    total = total + vecs[i].x + vecs[i].y + vecs[i].z
  end
end
local function test2() -- ffi的代碼
  local vecs = ffi.new("vector3c[?]", count)
  for i = 1, count do
    vecs[i] = {x=1, y = 2, z = 3}
  end
  local total = 0
  -- gc後記錄下面for循環運行時的時間和內存佔用,這裏省略
  for i = 1, count do
    total = total + vecs[i].x + vecs[i].y + vecs[i].z
  end
end
爲何有這麼大的差距?因爲lua table本質是一個hash table,在hash table訪問字段固然是緩慢的並且要存儲大量額外的東西。而ffi可以做到只分配xyz三個float的空間就能表示一個Vector3,自然內存佔用要低得多,而且jit會利用ffi的信息,實現訪問xyz的時候直接讀內存,而不是像hash table那樣走一次key hash,性能也高得多。
不幸的是ffi只在有jit模式的時候纔能有很好的運行速度,現在做手遊基本都要做ios,而ios下由於只能運行解釋模式,ffi的性能很差(比純table反而更慢),僅僅內存優勢得到保留,所以如果要考慮ios這樣的平臺,這個優化點基本可以忽略,或者只在安卓下針對少數核心代碼進行優化。
 
 

3.Call C functions only via the FFI.

儘可能用ffi來調用c函數。

同樣的,ffi也可以用於調用已經extern c的c函數。大家表面上都以爲這樣做只是省掉了用tolua之類的工具做導出的麻煩,但ffi更大的好處,是在於性能上質的提升。
這是因爲,使用ffi導出c函數,你需要提供c函數的原型,有了c函數的原型信息,luajit可以知道每個參數的準確類型,返回值的準確類型。瞭解編譯器知識的同學都知道函數調用和返回一般都是用棧來實現的,而要做到這點必須要知道整個參數列表和返回值類型,才能生成出出棧入棧的代碼。因此luajit在擁有這些信息之後就可以生成機器碼,跟c編譯器一樣做到無縫的調用,而不需要像標準的lua與c交互那樣需要調用pushint等等函數來傳參了。
如果不通過ffi調用c導出函數,那麼因爲luajit缺乏這個函數的信息,無法生成用於調用c函數的jit代碼,自然會降低性能。而且在2.1.0版本之前,這會直接導致jit失敗,整段相關的代碼都無法jit化,性能會收到極大的影響。
 
 

4.Use plain 'for i=start,stop,step do ... end' loops.

實現循環時,最好使用簡單的for i = start, stop, step do這樣的寫法,或者使用ipairs,而儘量避免使用for k,v in pairs(x) do

首先,直到目前最新的luajit2.1.0beta2,for k,v in pairs(t) do end是不支持jit的(即無法生成機器碼運行)。至於這個坑的存在主要還是因爲按kv遍歷table的彙編比較難寫,但至少可以知道,目前如果想高效遍歷數組或者做for循環,直接使用數值做索引是最佳的方法。
其次,這樣的寫法更利於做循環展開。
 
 

5.Find the right balance for unrolling.

循環展開,有利有弊,需要自己去平衡

在早期的c++時代,手工將循環代碼展開成順序代碼是一種常見的優化方法,但是後來編譯器都集成了一定的循環展開優化能力,代替手工做這種事情。而luajit本身也帶有這塊的優化(可以參考其實現函數lj_opt_loop),可以對循環進行展開。
不過這個展開是在運行時做的,所以也有利有弊。作者舉例,如果在一個兩層循環中,內循環的循環次數不夠10次,這個部分會被嘗試展開,但是由於嵌套在外部的大循環,外部大循環可能會導致內部循環多次進入,多次展開,導致展開次數過大,最終jit會取消展開。
至於這方面的性能未做深入測試,作者也只是給出了一些比較感性的優化建議(最後來了一句,You may have to experiment a bit),有了解的同學歡迎交流。
 
 
 

6.Define and call only 'local' (!) functions within a module.

7.Cache often-used functions from other modules in upvalues.

這兩點都可以拿到一起說,即調用任何函數的時候,保證這個函數是local function,性能會更好,比如:
local ms = math.sin
function test()
  math.sin(1)
  ms(1)
end
這兩行調用math.sin有什麼區別呢?
事實上math是一個表,math.sin本身就做了一次表查找,key是sin,這裏消耗了一次。而math又是一個全局變量,那還要在全局表中做一次查找(_G[math])
而local ms緩存過之後,math.sin查找就可以省掉了,另外,對於function上一層的變量,lua會有一個upvalue對象進行存儲,在找ms這個變量的時候就只需要在upvalue對象內找,查找範圍更小更快捷
當然,jit化後的代碼有可能會進一步優化這個過程,但是更好的辦法依然是自行local緩存
總之,如果某個函數只在本文件內用到,就將其local,如果是一個全局函數,用之前用local緩存一下。
 
 

8.Avoid inventing your own dispatch mechanisms.

避免使用你自己實現的分發調用機制,而儘量使用內建的例如metatable這樣的機制

編程的時候爲了結構優雅,常常會引入像消息分發這樣的機制,然後在消息來的時候根據我們給消息定義的枚舉來調用對應的實現,過去我們也習慣寫成:
if opcode == OP_1 then
elesif opcode == OP_2 then
...
但在luajit下,更建議將上面實現成table或者metatable
local callbacks = {}
callbacks[OP_1] = function() ... end
callbacks[OP_2] = function() ... end
這是因爲表查找和metatable查找都是可以參與jit優化的,而自行實現的消息分發機制,往往會用到分支代碼或者其他更復雜的代碼結構,性能上反而不如純粹的表查找+jit優化來得快
 
 

9.Do not try to second-guess the JIT compiler.

無需過多去幫jit編譯器做手工優化。

作者舉了一個例子
z = x[a+b] + y[a+b],這在luajit是性能ok的寫法,不需要先local c = a+b然後z = x[c] + y[c]
後面的寫法其實本身沒什麼問題,但是luajit的另一個坑,即爲了提升運算效率,local變量會儘可能用cpu寄存器存儲,這樣比頻繁讀內存要快得多(現代cpu這可以達到幾百倍的差距),但luajit在這方面不完善,一旦local變量太多,可能會找不到足夠的寄存器分配(這個問題在armv7上非常明顯,在調用層次深的時候,幾個變量就會炸掉),然後jit會直接放棄編譯。這裏要說明一點是,很多local變量可能只是聲明瞭放在那裏沒有用,但是luajit的編譯器不一定能夠準確確定這個變量是否可以不再存儲,所以適當控制一個函數作用域內的local變量的數量是必須的。
當然,不得不說這樣寫代碼還要猜luajit的行爲確實比較痛苦,一般來說進行profile然後對性能熱點代碼做針對測試和優化基本已經可以。
 
 
 

10.Be careful with aliasing, esp. when using multiple arrays.

變量的別名可能會阻止jit優化掉子表達式,尤其是在使用多個數組的時候。

作者舉了一個例子
x[i] = a[i] + c[i]; y[i] = a[i] + d[i]
我們可能會認爲兩a[i]是同一個東西,編譯器可以優化成
local t = a[i]; x[i] = t + c[i]; y[i] = t + d[i]
實則不然,因爲可能會出現,x和a就是同一個表,這樣,x[i] = a[i] + c[i]就改變了a[i]的值,那麼y[i] = a[i] + d[i]就不能再使用之前的a[i]的值了
這裏跟優化點9描述的情形的本質區別是,優化點9裏頭z/a/b都是值類型,而這裏x/a都是引用類型,引用類型就有引用同一個東西的可能(變量別名),因此編譯器會放棄這樣的優化。
 
 

11.Reduce the number of live temporary variables.

減少存活着的臨時變量的數量

原因在9中已經說明,即過多的存活着的臨時變量可能會耗盡寄存器導致jit編譯器無法利用寄存器做優化。這裏注意live temporary variables是指存活的臨時變量,假如你提前結束了臨時變量的生命週期,編譯器還是會知道這一點的。比如:
function foo()
  do
   local a = "haha"
  end
  print(a)
end
這裏print是會print出nil,因爲a離開了do ... end就結束了生命週期,通過這種方式可以避免過多臨時變量同時存活。
此外,有一個很常見的陷阱,例如我們實現了一個Vector3的類型用於表達立體空間中的矢量,常常會重載他的一些元函數,比如__add
Vector3.__add = function(va, vb)
    return Vector3.New(va.x + vb.x, va.y + vb.y, va.z + vb.z)
end
然後我們就會在代碼中大肆地使用a + b + c來對一堆的Vector3做求和運算。
這其實對luajit是有很大的隱患的,因爲每個+都產生了一個新的Vector3,這將會產生大量的臨時變量,且不考慮這裏的gc壓力,光是爲這些變量分配寄存器就已經十分容易出問題。
所以這裏最好在性能和易用性上進行權衡,每次求和如果是將結果寫會到原來的表中,那麼壓力會小很多,當然代碼的易用性和可讀性上就可能要犧牲一些。
 

12.Do not intersperse expensive or uncompiled operations.

減少使用高消耗或者不支持jit的操作

這裏要提到一個luajit文檔中的屬於:NYI(not yet implement),意思就是,作者還沒有把這個功能做完。。
luajit快是快在能把代碼編譯爲機器碼執行,但是並非所有代碼都可以jit化,除了前面提到的for in pairs外,還有很多這樣的東西,最常見的有:
for k, v in pairs(x):主要是pairs是無jit實現的,儘可能用ipairs替代。
print():這個是非jit化的,作者建議用io.write。
字符串連接符:打日誌很容易會寫log("haha "..x)這樣的方式,然後通過屏蔽log的實現來避免消耗。事實上真的可以屏蔽掉嗎?然並卵。因爲"haha"..x這個字符串鏈接依然會被執行。在2.0.x的時候這個代碼還不支持jit,2.1.x雖然終於支持了,但是多餘的連接字符串運算以及內存分配依然發生了,所以想要屏蔽,可以用log("haha %s", x)這樣的寫法。
table.insert:目前只有從尾部插入纔是jit實現的,如果從其他地方插入,會跳轉到c實現。
發佈了157 篇原創文章 · 獲贊 340 · 訪問量 59萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章