Database Hot Loader 最終曲:Tuning and Measuring Performance

歷經了漫長的 DHL 系統一二三部曲後,終於來到了這一系列文章的最終曲。

本篇的重點在於對 DHL 系統進行效能的優化以及量測。首先,對於 LuaDatabaseManager 類別提出幾項效能優化的可能性。以資料庫系統的作用來說,一般使用者會期望在索取資料的程序中,得到最佳化的執行效能,才不會因為對於資料庫的頻繁讀取而拖累了遊戲系統的整體效能表現。在二部曲中曾經提過,為了取得資料庫中的數值,使用者需要利用 LuaDatabaseManager 類別中的 GetData() 函式:

void LuaDatabaseManager::GetData(std::string& sheetName, std::string& fieldName, unsigned int id, std::string& value)
{
	int oldTop = lua_gettop(m_DBState);

	lua_getglobal(m_DBState, sheetName.c_str());
	lua_pushstring(m_DBState, fieldName.c_str());
	lua_gettable(m_DBState, LUA_GETTABLE_INDEX);
	lua_pushnumber(m_DBState, id);
	lua_gettable(m_DBState, LUA_GETTABLE_INDEX);
	value = luaL_checkstring(m_DBState, lua_gettop(m_DBState));

	lua_settop(m_DBState, oldTop);
}

在 GetData() 函式中,必須進行三次取出 table 結構的步驟;首先以 lua_getglobal() 函式取得最上層的 table,然後傳入欄位名稱以 lua_gettable() 函式取得第二層 table,最後再傳入資料 ID 以 lua_gettable() 函式以取得使用者要求的資料值。

熟悉 Lua 語言的讀者應該很清楚,除了使用 lua_gettable() 函式取得 table 結構中的數值以外,還有另外一項替代方案:使用 lua_rawget() 函式。預設情形下,在 lua_gettable() 函式中,會喚起 table 的 metamethod,而對於將 table 單純當成陣列結構處理的資料表格來說,其實並不需要這層額外的彈性,所以如果使用 lua_rawget() 函式取代原來的 lua_gettable() 函式,理論上應該能夠獲得某種程度的效能提升。

所以在 LuaDatabaseManager 類別中,以此實作出一個使用 lua_rawget() 版本的 GetDataRawed() 函式:

void LuaDatabaseManager::GetDataRawed(std::string& sheetName, std::string& fieldName, unsigned int id, std::string& value)
{
	int oldTop = lua_gettop(m_DBState);

	lua_getglobal(m_DBState, sheetName.c_str());
	lua_pushstring(m_DBState, fieldName.c_str());
	lua_rawget(m_DBState, LUA_GETTABLE_INDEX);
	lua_pushnumber(m_DBState, id);
	lua_rawget(m_DBState, LUA_GETTABLE_INDEX);
	value = luaL_checkstring(m_DBState, lua_gettop(m_DBState));

	lua_settop(m_DBState, oldTop);
}

第二種優化的可能性,考量點在於每次向 Lua 端索取資料值時,都必須將表格名稱與欄位名稱傳遞過去,如果能夠省略部分傳入字串的動作,或許有機會增進索取資料的效能。為了達到這個目標,LuaDatabaseManager 類別裡需要新增兩個成員變數,用來記錄前一次使用的表格名稱與欄位名稱;在向 Lua 端取得資料前,先以字串比較的方法檢查名稱是否相同,只要表格名稱或欄位名稱與之前相同,就能夠省略掉傳遞字串值與取得 table 結構的步驟:

void LuaDatabaseManager::GetDataCached(std::string& sheetName, std::string& fieldName, unsigned int id, std::string& value)
{
	if (sheetName != m_CurrentSheetName)
	{
		m_CurrentSheetName = sheetName;
		m_CurrentFieldName = fieldName;

		if (lua_gettop(m_DBState) >= 2)
		{
			lua_pop(m_DBState, 2);
		}
		
		lua_getglobal(m_DBState, sheetName.c_str());
		lua_pushstring(m_DBState, fieldName.c_str());
		lua_gettable(m_DBState, LUA_GETTABLE_INDEX);
	}
	else if (fieldName != m_CurrentFieldName)
	{
		m_CurrentFieldName = fieldName;

		if (lua_gettop(m_DBState) >= 1)
		{
			lua_pop(m_DBState, 1);
		}
		
		lua_pushstring(m_DBState, fieldName.c_str());
		lua_gettable(m_DBState, LUA_GETTABLE_INDEX);
	}

	lua_pushnumber(m_DBState, id);
	lua_gettable(m_DBState, LUA_GETTABLE_INDEX);
	value = luaL_checkstring(m_DBState, lua_gettop(m_DBState));

	lua_pop(m_DBState, 1);
}

如上述程式碼所示,在 GetDataCached() 函式中,以 std::string 字串比較的代價,換取使用 lua_pushstring() 與 lua_gettable() 函式的成本。接著再使用前述的第一項優化技巧,以 lua_rawget() 取代 lua_gettable() 函式,製作出另外一個 GetDataRawedCached() 版本的函式,以進一步獲得效能增進的可能性。

以上是對 LuaDatabaseManager 類別取得資料程序進行效能改善的優化程序。而為了與一般傳統做法的資料庫系統進行效能測試評比,在此另外創建了一個 MapDatabaseManager 類別,用來模擬以 std::map 結構為基礎的 Database 實作方式。在這個類別中,主要是以資料表格、資料欄位以及資料值三個階層,建立出三層深度的 std::map 結構。其中實作的 GetData() 函式如下所示:

void MapDatabaseManager::GetData(std::string& sheetName, std::string& fieldName, DataId id, DataValue& value)
{
	DataSheetListItor dsItor = m_DatabaseList->find(sheetName);
	if (dsItor == m_DatabaseList->end())
	{
		return;
	}

	DataFieldList* dataSheet = dsItor->second;
	DataFieldListItor dfItor = dataSheet->find(fieldName);
	if (dfItor == dataSheet->end())
	{
		return;
	}

	DataIdList* dataId = dfItor->second;
	DataIdListItor idItor = dataId->find(id);
	if (idItor == dataId->end())
	{
		return;
	}

	value = idItor->second;
}

總結上述所有的效能優化方法與模擬方法,共計有以下五項測試程序:

  • MapDatabaseManager::GetData()
  • LuaDatabaseManager::GetData()
  • LuaDatabaseManager::GetDataRawed()
  • LuaDatabaseManager::GetDataCached()
  • LuaDatabaseManager::GetDataRawedCached()

在測試程序所使用的資料中,共有 5 張資料表格,每張表格裡有 10 項資料欄位,每項欄位中有 30 筆資料。進行測試時,每一次都會以亂數值決定所取的資料表、資料欄位以及資料 ID,接著再以 Performance Counter 實作的 Timer 類別,計算出每一項測試程序所花費的時間。以 MapDatabaseManager::GetData() 測試程序為例:

Timer timer;
timer.Start();

for (unsigned int i = 0; i < testNum; i ++)
{
	GetRandomQuery(sheetName, fieldName, id);
	mapDBMgr.GetData(sheetName, fieldName, id, value);
}

timer.Stop();

其中,testNum 變數為使用者可自行輸入的測試次數。以此程序執行後的測試結果如下所示:

測試次數:100

[MapDatabaseManager]
   GetData(): 0.120127 ms
   
[LuaDatabaseManager]
   GetData(): 0.102527 ms
   GetDataRawed(): 0.0896762 ms
   GetDataCached(): 0.261765 ms
   GetDataRawedCached(): 0.105879 ms
測試次數:5000

[MapDatabaseManager]
   GetData(): 5.40963 ms
   
[LuaDatabaseManager]
   GetData(): 5.00818 ms
   GetDataRawed(): 4.44218 ms
   GetDataCached(): 5.72419 ms
   GetDataRawedCached(): 5.23363 ms
測試次數:250000

[MapDatabaseManager]
   GetData(): 286 ms

[LuaDatabaseManager]
   GetData(): 251 ms
   GetDataRawed(): 219 ms
   GetDataCached(): 287 ms
   GetDataRawedCached(): 277 ms

由測試的結果可知,LuaDatabaseManager::GetDataRawed() 是無庸置疑的第一名!而使用字串比較的方法,效能並沒有如預期般的效果。更重要的是,不論是 LuaDatabaseManager 的哪一個實作版本,幾乎都比使用三層 std::map 結構的 MapDatabaseManager::GetData() 效能更好更快!

最後,這五項效能測試程序的排名為:

第一名:LuaDatabaseManager::GetDataRawed()
第二名:LuaDatabaseManager::GetData()
第三名:LuaDatabaseManager::GetDataRawedCached()
第四名:LuaDatabaseManager::GetDataCached()
備取:MapDatabaseManager::GetData()

在我的工作經驗裡,常遇到許多程式設計者習慣以「直覺」、「感覺」,或是個人的喜好來決定效能優化所使用的方法。然而,通常以「我覺得」或者「我不這麼想」這種方式進行優化程序的取捨,往往容易導致偏離甚至是完全相反的優化成果。在程式系統的優化層面上,除了應該奉行「不要過早進行優化」這條金科玉律以外,正確的優化技術,更應當立基於實際的量測程序與實驗數據上,才能夠以客觀的事實選擇出最合適的解決方案。

從 DHL 系統的需求緣由、設計實作、應用實例到效能量測一路走來,可見得 Database Hot Loader 系統的確有搞頭,不僅能夠減輕修改遊戲數據的繁瑣步驟,加速遊戲企畫者的測試流程,更能夠為資料庫系統提供效能提升的附加效益!在讀完這一系列文章後,你是不是想大聲喊出「異議あり!」?歡迎提出你的看法、意見,或是更好的測試程序與效能改善方法吧!

執行檔下載:DatabaseHotLoader_Ep4_Release.zip(需要先行安裝 vcredist_x86.exe(下載次數: 1088 )
原始碼下載:DatabaseHotLoader_Ep4_Source.zip (下載次數: 1034 )

10 thoughts on “Database Hot Loader 最終曲:Tuning and Measuring Performance”

  1. 我會好奇資料的數量增加後效能分佈是如何,10 項資料是否太少哩?
    如果有 profiling 結果那就更好了 :)

    我「覺得」GetDataRawedCached() 的平均性能有可能會好過 GetDataRawed()。

    優化技術是一門藝術;暨要實際的量測與實驗,也要根據理論與經驗加直覺去構思優化所使用的方法,從以減少試驗的次數。

  2. @Ricky:
    資料數量太少,主要是因為我懶得在 Excel 中產生出上萬筆資料。 XD

    不過,在測試資料裡共有 5 張表格,每張表格裡有 300 筆資料,全部加起來總共有 1500 筆資料喔。雖然比起遊戲中真正使用的資料庫,測試資料的規模數量仍然非常少,但我想應該足以代表某種程度上的驗證成果了。

    在進行效能測試之前,我原先也認為 GetDataRawedCached() 的平均效能應該能夠超越 GetDataRawed() 程序,結果沒想到 Lua 的 stack 與 table operation 如此高效。或許在某些特定的條件與狀況下,GetDataRawedCached() 也能夠勝過 GetDataRawed() 程序吧。

    不知道是否還有其他更好的 profiling 方法?

    感謝你的迴響~ :)

  3. 不知道能不能用VSTO做個資料產生器…模擬製作出實際遊戲的資料量…
    再來進行測試…

  4. 想要比較快要實作StringTable
    我是直接拿lua的StringTable使用
    CString跟ScriptVM共用同一個StringTable

    pushstring 成本降到更低 wwwww

  5. @Aming:
    你的意思是讓 C++ 與 Lua 共享同一份 string table?
    ……… 好殺的方法! O_O

    這樣一來,在使用 lua_pushstring() 時,如果字串已經存在 string table 中,是不是就能夠節省掉處理字串的成本?

  6. 沒錯
    因為C++跟LUA共享string tabl..
    C++ string 立即變成lua string 兩者本質上是一樣的
    所以成本只有產生lua stack var跟ref++…

    這在其他引擎跟SCRIPT VM 交互很常見處理方法
    缺點是你要修改lua source跟把 lua string interface開放給C++ Core
    加上要配合 String 用法..減少直接使用字元陣列 不然開銷還是一樣大

  7. @Aming:
    原來如此!
    果然需要修改 Lua 的源碼,才能夠讓 C++ 與 Lua 共用 string table。
    不過的確是個很不錯的優化技巧,以後有需要可以來實作看看。 -w-

  8. 半路大大,

    GetDataRawedCached中若以lua_getref取代lua_pushstring,應該又可以省掉Lua中的一次字串比對。
    但是前面的c++ 字串比較大概會吃掉這個時間。

    看來還是GetDataRawed簡單好用啊。

  9. @Ray Kuo:
    用 lua_getref 應該是個滿不錯的做法,值得嘗試。歡迎寫個簡單的效能測試程式,實際執行看看,然後再回報結果如何喔~ XD

Leave a Reply