1. 程式人生 > 實用技巧 >Lua大整數的實現

Lua大整數的實現

大整數

程式中基礎的資料型別,如doubleint64_t之類的,其大小都是有上限的,假如有一個數10000000000...(後面接10000個0),那麼現在的資料型別是表示不了的,這時候就需要可以無限增長的整數,即大整數。作為一個遊戲開發的程式設計師,我怎麼也沒想到需要用到大整數。雖然這幾年遊戲的數值比之前大幅提升(小時候玩的遊戲,攻擊、防禦這些基本都是三位數以下,現在輕鬆達到十幾億),但是用個64位的型別還是可以應付的。然而策劃腦洞大開,要求遊戲中的貨幣上限提高到10000個數字大小。

實現

一開始,我並沒有覺得這個事情有多麻煩,畢竟一個int型別表示不了,我就用兩個,兩個表示不了,我就用三個。。。這樣就用一個int陣列實現了一個大整數,用模擬豎式運算,簡單實現了加減法。豎式運算即平時我們手工運算時用的方式

   99           198
+  99         -  32
------        ------
  198           166

由於我用的是int陣列,當然是不會涉及到每一個數字的加減(也沒有必要),只是模擬了這種方式的進位和退位,例如加法的lua實現(僅示例,沒處理異常和邊界)

local bigint = {0}

-- 實現大整數和一個普通數字相加
local function add(bigint, num)
    bigint[1] = bigint[1] + num

    -- 陣列中每個數字最大為uint32上限,超過即向陣列前一位進位
    local i = 1
    while bigint[i] > 0xFFFFFFFF do
        bigint[i] = bigint[i] - 0xFFFFFFFF
        bigint[i +1] = 1 + (bigint[i +1] or 0)

        i = i + 1
    end
end

不過當我實現了加減法後,棘手的事來了,乘法、除法怎麼實現?怎麼轉換為10進位制字串?我一開始並沒有想到要實現這些功能,但是隨著開發的推進,策劃的需求裡需要計算貨幣N倍加成,除錯時需要輸出10進位制字串,這顯然超出我的知識範疇了。乘除法雖然用得多,但如果需要我去實現,這個我還真不知道是如何實現的,只得硬著頭皮查資料學習。所幸kedixa的部落格裡有比較詳細的C++實現,也有示例程式碼,最終順利地在lua實現了大整數的四則運算及10進位制字串轉換。

到這裡,本來就結束了,然而後期在使用這個大整數的時候,偶爾會出現10進位制字串轉換、乘除法運算出錯的問題。仔細排查後,沒有發現演算法的實現問題,而且只有數字比較大的時候才能重現,不得不把kedixa的C++版本拿下來交叉對比,最終發現是lua 5.3的一些實現和C++是有區別的,比如-2251624706 >> 32

在C++中是-1,在lua中是4294967295,而C++中兩個整數相除,直接得到一個整數,而lua只能用math.floor來轉換,math.floor(253923710799999577 / 100000000) = 2539237107 偶爾返回2539237108,最終不得不把一部分演算法實現放到C++去。

另一個嚴重的問題是效能非常不理想。在數字非常大時(10000個10進位制字串),每秒只能運算1000次不到。轉換為10進位制字串更是需要數秒,原因是lua中的字串是immutable的,每一次拼接字串都需要產生一個新的字串,而10000個10進位制字串就需要拼接10000次,這個完全無法接受。

之所以用lua實現,是因為遊戲伺服器業務邏輯都是用lua實現的,一開始並不想給大整數開這個特例。竟然沒法滿足需求,就不得不尋找替代方案。

其他實現方案

  • faheel
    std::string儲存10進位制字串的實現,即數字123456在內部實現裡就是儲存為字串123456,可以預估這種實現的運算會比較慢,而且佔用記憶體比較大,最大的優點是不需要轉換成10進位制字串。不過這個實現居然是github上搜索big int結果中C++實現得星最多的,實在出乎我的意料。畢竟一開始我沒查任何資料,第一想到的方案是用int陣列來實現。
  • kasparsklavins
    std::vector<int>儲存10進位制數字的實現,即數字123456在內部實現裡就是用一個數字分別儲存了1、2、3、4、5、6這幾個數字,其實和上面的實現差不多。這個庫並沒有實現除法,我把它單獨拿出來對比是因為它的實現資料結構和其他的不一樣。
  • kedixa
    std::vector<uint32_t>按位儲存的實現,即上面陣列中一個數字滿0xFFFFFFFF即向前進1的實現,這是我認為比較正常的一個實現。
  • boost
    沒錯,這個就是大名鼎鼎的boost庫的實現,其內部實現其實和kedixa差不多,不過預設使用的是std::vector<uint64>,編譯時可配置
// cpp_int_config.hpp line 63
typedef detail::largest_unsigned_type<64>::type limb_type;
  • gmp
    GMP是The GNU Multiple Precision Arithmetic Library的簡稱,由GNU維護,號稱Arithmetic without limitations。這個庫原本為科學研究設計的,包含大整數、大有理數、大浮點數三個庫,並且對效能進行了極致的優化,採用了大量的彙編和特定的CPU指令。不過也正因為如此,這個庫的要移植到win下是比較有難度的,因為其中很大一部分是彙編實現的,並且編譯這個庫的時候,會檢測CPU的型別,根據不同的CPU指令集採用不同的彙編。而且其開源協議是GNU LGPL v3GNU GPL v2,不太適合商用。

針對這些庫,我做了一些簡單的測試,其中迴圈10次的測試結果如下

optimize=O2 TIMES=10

# make libperf
g++ -std=c++11 -I../boost_1_74_0 -Wall -g3 -O2 -o test_lib_perf \
        ./lib_perf/kedixa/unsigned_bigint.cpp \
        ./lib_perf/kasparsklavins/bigint.cpp \
        ./lib_perf/lib_perf.cpp \
        -lgmpxx -lgmp
./test_lib_perf
faheel create time elapsed 98us
faheel add time elapsed 38256us
faheel dec time elapsed 43456us
faheel mul time elapsed 12309486us
faheel div time elapsed 191761534us
faheel to_string time elapsed 96us
kasparsklavins create time elapsed 232us
kasparsklavins add time elapsed 325us
kasparsklavins dec time elapsed 119us
kasparsklavins mul time elapsed 71310us
kasparsklavins to_string time elapsed 2100us
kedixa create time elapsed 5383us
kedixa add time elapsed 8us
kedixa dec time elapsed 11us
kedixa mul time elapsed 6604us
kedixa div time elapsed 6813us
kedixa to_string time elapsed 14514us
boost create time elapsed 1914us
boost add time elapsed 203us
boost dec time elapsed 12us
boost mul time elapsed 2625us
boost div time elapsed 10304us
boost to_string time elapsed 137713us
gmp create time elapsed 909us
gmp add time elapsed 9us
gmp dec time elapsed 6us
gmp mul time elapsed 1294us
gmp div time elapsed 1176us
gmp to_string time elapsed 1564us
test done, TIMES = 10, BASE BIT = 10000, MUL BIT = 1000

可以看到,前兩個庫的效率相當差,都不在一個量級的,甚至在迴圈1000次的測試中,faheel因耗時太長無法完成測試。kedixa和boost接近,畢竟實現方式基本一致。而採用了彙編和CPU指令優化的gmp一騎絕塵,效能比boost都要高出一個數量級。

最終,在考慮了程式碼質量、效能、可移植性後,我基於boost寫了一個lua庫,編譯後可直接在lua使用。