如何獲取遊戲記憶體地址_遊戲引擎養成《番外》 記憶體管理*譯文
技術標籤:如何獲取遊戲記憶體地址
# WHY
最近在研究U3d webGL ,很多人在吐槽WebGL support啟動時需設定TOTAL_MEMORY. 其實在遊戲引擎設計中,提前申請大塊記憶體並自己管理有諸多的好處。PhantomEngine在一開始的時候也準備設計自己的MemoryManager,並且也參考借鑑了不少的資料,雖然最後沒有實際應用(主要是沒人喜歡關注這個)。Unity在Unite2019上公佈了對現有GC方案的改進:增量式GC,也表明了記憶體管理永遠是引擎需要持續關注的事情。
下面這個系列的文章實現了記憶體管理器的雛形,我做了翻譯留在了印象筆記裡,現在分享出來。
文章轉自:
Memory Management part 1 of 3: The Allocator
為什麼要自找麻煩去構建你自己的記憶體管理系統?
記憶體管理對於遊戲開發是至關重要的,因為記憶體總是有限的,即便是主機遊戲。有很多遊戲開發者依賴一些程式語言本身的垃圾收集器。垃圾收集器有著易用性的同時,它的機制也有無法始終一致、並不總是可靠的缺點。可能在一百多次垃圾回收迴圈後,它需要做一次比較大型的clean up/GC,所以會導致遊戲在某幾幀裡幀頻下降到10Fps。大多數時候,你無法預測這個效能尖刺。你的遊戲大多數時間執行的非常流暢,但是不時地它就會卡那麼一下。遊戲能玩,玩家也許對這個時不時的卡頓並不那麼在意; 但是為了讓每個玩家的體驗都達到完美極致,你應該致力於消滅每一個可能的效能尖刺。
構建你自己的記憶體管理系統是你可以做的一個非常重要的改進。
為了構建一個自定義的記憶體管理系統,程式語言必須允許你直接操作記憶體地址。對於那些有些內建垃圾收集器、不允許操作底層記憶體地址的語言就沒辦法了。很多PC遊戲和主機遊戲都用C++語言開發,我們這裡也從C++展開。
這個系列的第一部分準備搞定固定大小的記憶體分配器,本系列的其餘部分都是建立在此基礎上的。在第二部分,我會展示怎麼用這個記憶體分配器去實現一個C語言風格的、支援動態大小的記憶體分配過程,也會嘗試用C++的模板函式語法糖實現。 最終,第三部分的記憶體分配器的實現會和STL容器相容。重點是: 使用new/delete 不夠好。
如果你用C++寫你的遊戲,你已經使用內建的new和delete操作符去管理你的記憶體了。但是,這個方案不夠好。每一次分配(new)和再分配(delete)都有它的額外開銷,因為預設的記憶體管理器(基於編譯器實現)需要去掃描可用的記憶體,去尋找一塊合適的記憶體。我們將要構建的固定大小記憶體分配器分配一塊記憶體會花費固定的時間;它的每塊記憶體大小都是固定的所以它不需要掃描查詢。第二部分我們會展示,可以通過預算計算的查詢表來實現固定時間獲取變長記憶體分配。
為了加速分配和再分配的過程,我們會使用new操作符去預先申請一大塊記憶體(big char arrays),自己管理這些記憶體塊。這種方法有以下幾個有點:
- 對於new和delete的使用大幅減少了,減少了系統呼叫開銷。
- 我們自己管理所有的記憶體塊,就可以很輕鬆的插入額外的除錯資料了,比如為了檢查緩衝區溢位。
- 我們可以更好地控制我們想對記憶體做什麼,比如決定某一幀裡是否有足夠的時間來執行一個快速的記憶體碎片整理。
Pages & Blocks
我們申請的每個記憶體塊叫做Page,在每個Page中,我們用來儲存一段完整資料的記憶體叫做Block。
本篇中的例子裡,記憶體分配器只能建立固定大小的Pages,分配固定大小的blocks 。每個記憶體分配器都有一個追蹤由自己已分配的Page的列表,也有一個free list去追蹤管理所有能被分配的blocks。
Lists都被設計成單向連結串列,所以每個Page只有一個額外的指標的記憶體開銷。每個Block也有一個指標。但是一旦這個Block被分配了,記憶體空間就可以跟使用者資料共享了(因為當block被分配時,連結串列指標就不需要了。)。
下面是Page和Block結構的標頭檔案:
struct BlockHeader
{
// union-ed with data
BlockHeader *Next;
};
struct PageHeader
{
// followed by blocks in this page
PageHeader *Next;
// helper function that gives the first block
BlockHeader *Blocks(void)
{ return reinterpret_cast<BlockHeader *>(this + 1); }
};
The Allocator 標頭檔案
首先展示一下Allocator標頭檔案:
class Allocator
{
public:
// debug patterns
static const unsigned char PATTERN_ALIGN = 0xFC;
static const unsigned char PATTERN_ALLOC = 0xFD;
static const unsigned char PATTERN_FREE = 0xFE;
// constructor
Allocator
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
);
// destructor
~Allocator(void);
// resets the allocator to a new configuration
void Reset
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
);
// allocates a block of memory
void *Allocate(void);
// deallocates a block of memory
void Free(void *p);
// deallocates all memory
void FreeAll(void);
private:
// fill a free page with debug patterns
void FillFreePage(PageHeader *p);
// fill a free block with debug patterns
void FillFreeBlock(BlockHeader *p);
// fill an allocated block with debug patterns
void FillAllocatedBlock(BlockHeader *p);
// gets the next block
BlockHeader *NextBlock(BlockHeader *p);
// the page list
PageHeader *m_pageList;
// the free list
BlockHeader *m_freeList;
// size-related data
unsigned m_dataSize ;
unsigned m_pageSize ;
unsigned m_alignmentSize;
unsigned m_blockSize ;
unsigned m_blocksPerPage;
// statistics
unsigned m_numPages ;
unsigned m_numBlocks ;
unsigned m_numFreeBlocks;
// disable copy & assignment
Allocator(const Allocator &clone);
Allocator &operator=(const Allocator &rhs);
};
Debug樣式,顧名思義,在測試時用它們把記憶體區域都填充起來便於觀察。Allocator的建構函式和Reset函式決定了Page和Block的大小,還有對齊量(稍後再說)。Allocate方法負責分配,返回被分配的block記憶體的地址。Free方法的目的與Allocate相反。把一個block記憶體返回free-list以便重用。 FreeAll方法會釋放所有由這個Allocator申請的Pages。
Sample Client Code | 示例程式碼
You would create an allocator and use it to allocate and free blocks in client code like this:
// create allocator
Allocator alloc(sizeof(unsigned), 1024, 4);
// allocate memory
unsigned *i = reinterpret_cast<unsigned *>(alloc.Allocate());
// manipulate memory
*i = 0u;
// free memory
alloc.Free(i);
The Constructor & Destructor
建構函式和Reset方法基本差不多,所以就不用驚訝建構函式中直接呼叫了Reset方法了。解構函式和FreeAll方法同理。
Allocator::Allocator
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
)
: m_pageList(nullptr)
, m_freeList(nullptr)
{
Reset(dataSize, pageSize, alignment);
}
Allocator::~Allocator(void)
{
FreeAll();
}
The Reset Method
Reset方法首先釋放了所有的Pages,而且為分配器重新設定了新的data size,page size和alignment size。Data size是客戶端程式碼申請的單個Block的記憶體大小;實際上的Block實體記憶體佔用會因為需要去對齊而可能大於/等於Data size。
> 譯者注: 記憶體對齊是空間換時間,Cpu是按字讀取記憶體,對齊的好處是不會出現某個型別的資料需要兩次讀取記憶體。Alignment size 是額外新增到Block的位元組數,來保證Block size是某個指定的alignment的整數倍。這樣做是為了提升記憶體讀取效率。如果你的機器每次讀取4個位元組的實體記憶體,那麼最好不要讓你的資料從非4的邊界開始。否則,為了處理一次運算,Cpu可能需要額外的記憶體讀取才能把資料載入到暫存器中。
客戶端可能申請比一個Block Header還小的記憶體塊。為了保證我們的Block總是能放得下Block Header,我們使用maxHeaderData和m_alignmentSize來確定實際Block Size尺寸。
void Allocator::Reset
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
)
{
FreeAll();
m_dataSize = dataSize;
m_pageSize = pageSize;
unsigned maxHeaderData =
Max(sizeof(BlockHeader), m_dataSize);
m_alignmentSize =
(maxHeaderData % alignment)
? (alignment - maxHeaderData % alignment)
: (0);
m_blockSize =
maxHeaderData + m_alignmentSize;
m_blocksPerPage =
(m_pageSize - sizeof(PageHeader)) / m_blockSize;
}
The Allocate Method
Allocate方法是Allocator中最重要的部分。它建立Pages,使用Debug資料填充Pages,把新的Page填充到Pages List中,組織連結所有的新的未被使用的Block,把它們放入到未被使用的Block List中。
void *Allocator::Allocate(void)
{
// free list empty, create new page
if (!m_freeList)
{
// allocate new page
PageHeader *newPage =
reinterpret_cast<PageHeader *>
(new char[m_pageSize]);
++m_numPages;
m_numBlocks += m_blocksPerPage;
m_numFreeBlocks += m_blocksPerPage;
FillFreePage(newPage);
// page list not empty, link new page
if (m_pageList)
{
newPage->Next = m_pageList;
}
// push new page
m_pageList = newPage;
// link new free blocks
BlockHeader *currBlock = newPage->Blocks();
for (unsigned i = 0; i < m_blocksPerPage - 1; ++i)
{
currBlock->Next = NextBlock(currBlock);
currBlock = NextBlock(currBlock);
}
currBlock->Next = nullptr; // last block
// push new blocks
m_freeList = newPage->Blocks();
}
// pop free block
BlockHeader *freeBlock = m_freeList;
m_freeList = m_freeList->Next;
--m_numFreeBlocks;
FillAllocatedBlock(freeBlock);
return freeBlock;
}
The Free & FreeAll Methods
The Free method simply puts a block back into the free list.
void Allocator::Free(void *p)
{
// retrieve block header
BlockHeader *block =
reinterpret_cast<BlockHeader *>(p);
FillFreeBlock(block);
// push block
block->Next = m_freeList;
m_freeList = block;
++m_numFreeBlocks;
}
And the FreeAll method frees all pages created by the allocator.
void Allocator::FreeAll(void)
{
// free all pages
PageHeader *pageWalker = m_pageList;
while (pageWalker)
{
PageHeader *currPage = pageWalker;
pageWalker = pageWalker->Next;
delete [] reinterpret_cast<char *>(currPage);
}
// release pointers
m_pageList = nullptr;
m_freeList = nullptr;
// re-init stats
m_numPages = 0;
m_numBlocks = 0;
m_numFreeBlocks = 0;
}
Helper Methods
Finally, the helper methods. I’m just going to post the implementation, because the code is quite self-explanatory.
self-explanatory : 不需要加以說明的
void Allocator::FillFreePage(PageHeader *p)
{
// page header
p->Next = nullptr;
// blocks
BlockHeader *currBlock = p->Blocks();
for (unsigned i = 0; i < m_blocksPerPage; ++i)
{
FillFreeBlock(currBlock);
currBlock = NextBlock(currBlock);
}
}
void Allocator::FillFreeBlock(BlockHeader *p)
{
// block header + data
std::memset
(
p,
PATTERN_FREE,
m_blockSize - m_alignmentSize
);
// alignment
std::memset
(
reinterpret_cast<char *>(p)
+ m_blockSize - m_alignmentSize,
PATTERN_ALIGNMENT,
m_alignmentSize
);
}
void Allocator::FillAllocatedBlock(BlockHeader *p)
{
// block header + data
std::memset
(
p,
PATTERN_ALLOCATED,
m_blockSize - m_alignmentSize
);
// alignment
std::memset
(
reinterpret_cast<char *>(p)
+ m_blockSize - m_alignmentSize,
PATTERN_ALIGNMENT,
m_alignmentSize
);
}
Allocator::BlockHeader *
Allocator::NextBlock
(BlockHeader *p)
{
return
reinterpret_cast<BlockHeader *>
(reinterpret_cast<char *>(p) + m_blockSize);
}
Done!
This concludes the first part of this series. I hope you find this post useful
我寫了一個Test,整個Demo工程就3個檔案:
下面的github上的commit
https://github.com/mh29110/PhantomEngine/commit/33796be696ae4890b6e2f149077b6d1332d3e919