如上一篇预告的,本篇我们对游戏引擎当中的内存管理进行一些初步的探讨。
首先,关于游戏引擎内存管理的必要性,除了为了实现加载远大于物理内存容量的内容(比如开放世界游戏)之外,还有很多性能和调试方面的考虑。关于这方面在参考引用1当中有比较详细且风趣的阐述。
当然参考引用1成文时间是差不多两年以前,很多参数在今天看来已经有了一些变化。比如当代CPU和内存之间的带宽一般在几十个Gbps,而GPU与内存(显存)之间的带宽已经飙升到几百个Gbps的水准。但是这并没有改变内存访问依然远远落后于CPU/GPU计算能力的状况。
而且尤为重要的是,对于游戏引擎(运行时)来说,一切都是数个毫秒(VR游戏要求120fps)到十数个毫秒(60fps),最多也就是33毫秒(30fps)的人生的轮回。在这样的系统当中,每一个毫秒都弥足珍贵,都值得我们去拼。(当然,就如我们前一篇指出的,也并不是所有的处理都需要按照这个节奏去跑)
这也回答了在本系列为什么采用C/C++这种“中级语言”进行编程。因为这是一个在性能/控制力/可维护性上比较理想的折衷点。
这里我补充一下关于malloc/new的知识,为什么说它们比较低效。
我们知道,操作系统的主要功能就是管理计算机系统的各种硬件资源。应用程序需要使用硬件资源到时候,需要向操作系统进行申请。而这种申请的接口,就被称为系统调用。
在近代操作系统当中,出于安全方面的考虑,操作系统与用户程序不是跑在一个级别上的。操作系统拥有所有的特权,而用户程序只是跑在操作系统提供的一个虚拟环境之上。用户程序看到的内存地址并不是真正的物理内存地址,而是一个虚拟的地址空间。这个地址空间是完全为用户程序定制的,不同的用户程序,即使这个地址一样,也不是指向同一个物理内存(或者分页文件)地址。
因此,当我们调用malloc/new进行heap分配的时候,并不是我们的线程直接杀入内核,去领一块内存出来。而是我们提交一个申领申请,放在放申请单的盒子里,然后等。操作系统方面按顺序处理这些申请,处理完了将处理结果放在处理结果盒子里,然后叫我们的号让我们去领。这个过程和我们在生活当中到特权机关去办事很类似。
虽然这些系统API调用看起来都是同步的,但实际上这是一个异步操作,只不过在操作完成之前,我们的线程会被block住,操作完成了,线程unblock,函数返回,看起来就像普通函数调用那样,其实这是一个比较复杂的过程。
而且在这个过程当中的参数传递,一般情况下都会发生拷贝。这是因为操作系统和用户程序分别工作在不同的地址空间,因此直接传递指针(地址)也是没有什么意义的。
因此,提高程序在CPU端的执行效率的一个重要手段,就是要减少系统调用。在程序初始化阶段就一次申领所需的资源,然后自己内部进行分配管理,这就是一种常用的减少系统调用的方法。
参考引用4提供了一种基于块链(block chain)的内存管理方法。我们首先将它引入到我们的引擎当中。这部分属于引擎的核心内容,因此我们将文件创建在Framework/Common下面:
Allocator.hpp
#include <cstddef>
#include <cstdint>
namespace My {
struct BlockHeader {
// union-ed with data
BlockHeader* pNext;
};
struct PageHeader {
PageHeader* pNext;
BlockHeader* Blocks() {
return reinterpret_cast<BlockHeader*>(this + 1);
}
};
class Allocator {
public:
// debug patterns
static const uint8_t PATTERN_ALIGN = 0xFC;
static const uint8_t PATTERN_ALLOC = 0xFD;
static const uint8_t PATTERN_FREE = 0xFE;
Allocator(size_t data_size, size_t page_size, size_t alignment);
~Allocator();
// resets the allocator to a new configuration
void Reset(size_t data_size, size_t page_size, size_t alignment);
// alloc and free blocks
void* Allocate();
void Free(void* p);
void FreeAll();
private:
#if defined(_DEBUG)
// fill a free page with debug patterns
void FillFreePage(PageHeader* pPage);
// fill a block with debug patterns
void FillFreeBlock(BlockHeader* pBlock);
// fill an allocated block with debug patterns
void FillAllocatedBlock(BlockHeader* pBlock);
#endif
// gets the next block
BlockHeader* NextBlock(BlockHeader* pBlock);
// the page list
PageHeader* m_pPageList;
// the free block list
BlockHeader* m_pFreeList;
size_t m_szDataSize;
size_t m_szPageSize;
size_t m_szAlignmentSize;
size_t m_szBlockSize;
uint32_t m_nBlocksPerPage;
// statistics
uint32_t m_nPages;
uint32_t m_nBlocks;
uint32_t m_nFreeBlocks;
// disable copy & assignment
Allocator(const Allocator& clone);
Allocator &operator=(const Allocator &rhs);
};
}
上面的代码相对于参考引用4的改动主要是下面几个方面:
- 根据我们引擎整体的命名风格调整了变量的名字
- 使用更为明确的数据类型,比如uint32_t,以及移植性更好的数据类型,如size_t
- 添加了必须且跨平台的C++标准头文件
- 使用预编译指令将用于调试的代码标出,只在调试版本当中编译这些代码
Allocator.cpp
#include "Allocator.hpp"
#include <cassert>
#include <cstring>
#ifndef ALIGN
#define ALIGN(x, a) (((x) + ((a) - 1)) & ~((a) - 1))
#endif
using namespace My;
My::Allocator::Allocator(size_t data_size, size_t page_size, size_t alignment)
: m_pPageList(nullptr), m_pFreeList(nullptr)
{
Reset(data_size, page_size, alignment);
}
My::Allocator::~Allocator()
{
FreeAll();
}
void My::Allocator::Reset(size_t data_size, size_t page_size, size_t alignment)
{
FreeAll();
m_szDataSize = data_size;
m_szPageSize = page_size;
size_t minimal_size = (sizeof(BlockHeader) > m_szDataSize) ? sizeof(BlockHeader) : m_szDataSize;
// this magic only works when alignment is 2^n, which should general be the case
// because most CPU/GPU also requires the aligment be in 2^n
// but still we use a assert to guarantee it
#if defined(_DEBUG)
assert(alignment > 0 && ((alignment & (alignment-1))) == 0);
#endif
m_szBlockSize = ALIGN(minimal_size, alignment);
m_szAlignmentSize = m_szBlockSize - minimal_size;
m_nBlocksPerPage = (m_szPageSize - sizeof(PageHeader)) / m_szBlockSize;
}
void* My::Allocator::Allocate()
{
if (!m_pFreeList) {
// allocate a new page
PageHeader* pNewPage = reinterpret_cast<PageHeader*>(new uint8_t[m_szPageSize]);
++m_nPages;
m_nBlocks += m_nBlocksPerPage;
m_nFreeBlocks += m_nBlocksPerPage;
#if defined(_DEBUG)
FillFreePage(pNewPage);
#endif
if (m_pPageList) {
pNewPage->pNext = m_pPageList;
}
m_pPageList = pNewPage;
BlockHeader* pBlock = pNewPage->Blocks();
// link each block in the page
for (uint32_t i = 0; i < m_nBlocksPerPage; i++) {
pBlock->pNext = NextBlock(pBlock);
pBlock = NextBlock(pBlock);
}
pBlock->pNext = nullptr;
m_pFreeList = pNewPage->Blocks();
}
BlockHeader* freeBlock = m_pFreeList;
m_pFreeList = m_pFreeList->pNext;
--m_nFreeBlocks;
#if defined(_DEBUG)
FillAllocatedBlock(freeBlock);
#endif
return reinterpret_cast<void*>(freeBlock);
}
void My::Allocator::Free(void* p)
{
BlockHeader* block = reinterpret_cast<BlockHeader*>(p);
#if defined(_DEBUG)
FillFreeBlock(block);
#endif
block->pNext = m_pFreeList;
m_pFreeList = block;
++m_nFreeBlocks;
}
void My::Allocator::FreeAll()
{
PageHeader* pPage = m_pPageList;
while(pPage) {
PageHeader* _p = pPage;
pPage = pPage->pNext;
delete[] reinterpret_cast<uint8_t*>(_p);
}
m_pPageList = nullptr;
m_pFreeList = nullptr;
m_nPages = 0;
m_nBlocks = 0;
m_nFreeBlocks = 0;
}
#if defined(_DEBUG)
void My::Allocator::FillFreePage(PageHeader *pPage)
{
// page header
pPage->pNext = nullptr;
// blocks
BlockHeader *pBlock = pPage->Blocks();
for (uint32_t i = 0; i < m_nBlocksPerPage; i++)
{
FillFreeBlock(pBlock);
pBlock = NextBlock(pBlock);
}
}
void My::Allocator::FillFreeBlock(BlockHeader *pBlock)
{
// block header + data
std::memset(pBlock, PATTERN_FREE, m_szBlockSize - m_szAlignmentSize);
// alignment
std::memset(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize - m_szAlignmentSize,
PATTERN_ALIGN, m_szAlignmentSize);
}
void My::Allocator::FillAllocatedBlock(BlockHeader *pBlock)
{
// block header + data
std::memset(pBlock, PATTERN_ALLOC, m_szBlockSize - m_szAlignmentSize);
// alignment
std::memset(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize - m_szAlignmentSize,
PATTERN_ALIGN, m_szAlignmentSize);
}
#endif
My::BlockHeader* My::Allocator::NextBlock(BlockHeader *pBlock)
{
return reinterpret_cast<BlockHeader *>(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize);
}
上面的代码相对于参考引用4的改动主要是下面几个方面:
- 反映了头文件当中的变化
- 使用了更为高效的对齐计算算法(有前提条件,具体见代码注释)
好了。接下来我们修改我们的CMakeLists.txt,加入新文件
C:UsersTim.AzureADSourceReposGameEngineFromScratch>gvim FrameworkCommonCMakeLists.txt
add_library(Common Allocator.cpp BaseApplication.cpp GraphicsManager.cpp main.cpp )
然后我们就可以尝试编译看看,是否可以通过。具体的编译过程在文章5已经详细叙述过了,这里就不赘述。
但是事实上我们会需要不止一种的Memory Allocator。因为我们的程序当中会使用的对象有着不同的尺寸,我们无法使用一种固定的Block Size来满足各种各样的分配尺寸需求。因为如果Block Size过小,显然无法满足需要;如果过大,则是浪费。参考引用5当中也谈到了这一点。
此外,我们某些buffer是给CPU使用,有些是给GPU使用,有些是给两者使用。有些只需要高速地读,比如贴图;有些需要高速的写,比如Rendering Target;有些则需要保证同步,比如Fence。我们需要实现这些控制。
并且,如上一篇所述,我们的各个模块将采用一种异步并行的方式执行各自的任务。近代CPU都是多核的,我们需要充分地利用这个特性,就需要多线程。但是目前我们的Allocator还不是线程安全的。线程安全的代码需要一种排他锁定的机制,但是这种机制又往往是低效和容易带来死锁问题的。我们需要平衡这些问题。
我们将在后文继续讨论这些问题并改善我们的内存管理代码。
参考引用
- Writing a Game Engine from Scratch – Part 2: Memory
- Gallery of Processor Cache Effects
- Game Engine Architecture
- Memory Management part 1 of 3: The Allocator | Ming-Lun “Allen” Chou
- Memory Management part 2 of 3: C-Style Interface | Ming-Lun “Allen” Chou

本作品采用知识共享署名 4.0 国际许可协议进行许可。