现代图形引擎入门指南(六)— 内存管理
这里有一篇很好的文章详细讲解了内存中需要注意的事项:[*]https://blog.csdn.net/zju_fish1996/article/details/108858577
珠玉在前,本节内容就做一些补充
基础概念
计算机上的各种数据,一般都是 存储 在 磁盘 上面,鼠标右键 点击桌面图标 此电脑 — 管理 — 存储 — 磁盘管理,可以浏览和操作磁盘:
在 任务管理器 和 资源监视器 中,还能看到磁盘当前的运行情况:
当磁盘中的数据被使用时,操作系统会将其加载到内存之中,关于内存,百度百科中有如下说明:
内存是计算机的重要部件,也称内存储器和主存储器,它用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。它是外存与CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。只要计算机开始运行,操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成,CPU将结果传送出来。堆、栈
程序(Windows下的exe)同样也是一种数据,它在启动时,操作系统会给它预分配两块 固定大小 的内存,分别作为:
[*]栈(Stack):主要用于函数栈的调用执行
[*]堆(Memroy):用于存储程序运行时动态申请内存的地址
栈的大小是固定的,而堆的大小特定于实现,并且取决于所使用的编译器,堆可以看做是链式存储了动态分配内存的地址,虽然MSVC 中的堆 预分配 的默认大小为 1MB,但运行时使用的空间是允许超过1MB的不同的编译器和平台有不同的默认大小,以Windows下的MSVC为例,执行下面的程序:
int main() {
char arr;
return 0;
}
由于在栈中创建或使用了超出程序预分配大小的内存,此时会报 Stack Overflow
再执行下面的程序:
int main() {
for (int i = 0; i < 100000000; i++) {
new int;
}
return 0;
}
当没有相应的空闲内存块时,动态分配会失败
可以通过编译器来手动设置程序预分配的栈和堆的空间大小,在 Visual Studio中,你可以在项目配置中进行修改:
笔者使用的VS2022有 汉化异常 ,Stack 被错误翻译为了 堆栈
内存碎片
在C++中,我们一般使用 new/delete 创建和销毁动态内存,实际上,不同操作系统的内存分配接口都不同,比如,Windows实际上使用的是 VirtualAlloc/ VirtualFree, Unix和Mac使用的是mmap/munmap
在程序的执行过程中,频繁的分配与回收内存会导致大量的、连续且小的内存夹杂在已分配内存的中间,由于操作系统的内存分配算法并不能到达很完美的效果,导致这些 空闲的内存无法被继续使用 ,这些内存我们称之为—— 内存碎片
内存对齐
大部分CPU并不是以字节为单位来读写内存,它一般会以2、4、6、8字节作为内存存取粒度,这有利于提升内存的IO效率,具体的细节可查阅:
[*]https://zhuanlan.zhihu.com/p/83449008
不同的操作,不同的硬件,不同的平台,内存的对齐都会有一些差异,对于开发者而言,如果不了解那些黑话,很容易走到坑里,就比如:
[*]使用SIMD必须保证16字节对齐
[*]在GPU上申请的Buffer之间有最小对齐偏移的限制
[*]在不同的渲染API上,向量的对齐方式也存在差异
D3D9:{
{4,4,4,4 }, // Uint8
{4,4,4,4 }, // Uint10
{4,4,8,8 }, // Int16
{4,4,8,8 }, // Half
{4,8, 12, 16 }, // Float
};
D3D11:{
{1,2,4,4 }, // Uint8
{4,4,4,4 }, // Uint10
{2,4,8,8 }, // Int16
{2,4,8,8 }, // Half
{4,8, 12, 16 }, // Float
};
GL、Vulkan、Metal:{
{1,2,4,4 }, // Uint8
{4,4,4,4 }, // Uint10
{2,4,6,8 }, // Int16
{2,4,6,8 }, // Half
{4,8, 12, 16 }, // Float
};高速缓存
内存的读写速度确实很快,但CPU运算速度更快,这就导致CPU需要花费很长时间来等待数据的到来(读取)和送走(存储),为了减少CPU访问内存的平均时间,计算存储体系中还引入的 CPU高速缓存 的部件,在百度百科中,有如下定义:
在计算机系统中,CPU高速缓存(英语:CPU Cache)是用于减少处理器访问内存所需平均时间的部件。
在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。
在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。在Windows下,你可以打开任务管理器,查看电脑的Cache信息:
当程序访问一块内存的时候,可能会把它的周边区域一起给加载到缓存中(因为额外加载一部分内容并没有太多开销),如果我们下次需要访问的内存已经被加载到缓存中(称作缓存命中),CPU就不用再到内存中访问,从而提升速度
这个示例代码可以让你了解到缓存的重要性:
#include <iostream>
#include <chrono>
#include <vector>
class ClockGuard { //用于打印一个作用域的执行耗时
public:
ClockGuard(const std::string& inDest)
:mDesc(inDest)
{
mStartTime = std::chrono::steady_clock::now();
}
~ClockGuard() {
std::chrono::steady_clock::time_point currentTime = std::chrono::steady_clock::now();
double durationNs = std::chrono::duration<double, std::nano>(currentTime - mStartTime).count();
std::cout << mDesc << &#34; Cost: &#34; << durationNs << &#34; Ns&#34; << std::endl;
}
private:
std::string mDesc;
std::chrono::steady_clock::time_point mStartTime;
};
const size_t bigger_than_cachesize = 10 * 1024 * 1024;
long* ptr = new long;
void ClearCPUCache() { //尝试清空缓存
for (int i = 0; i < bigger_than_cachesize; i++){
ptr = rand();
}
}
int main(int argc,char** argv) {
std::vector<int*> continuous(1000); //内存连续的数组
std::vector<int*> dispersive(1000); //内存分散的数组
for (int*& item : continuous) {
item = new int;
}
const int spacing = 10000; //内存间隔,调整该数以查看cache的影响
for (int*& item : dispersive) {
item = new int;
}
ClearCPUCache();
{
ClockGuard guard(&#34;continuous&#34;); //对连续的内存进行赋值,缓存命中率高
for (int*& item : continuous) {
*item = 1;
}
}
ClearCPUCache();
{
ClockGuard guard(&#34;dispersive&#34;); //对离散的内存进行赋值,缓存命中率低
for (int*& item : dispersive) {
*item = 1;
}
}
return 0;
}
尽量避免内存上下文的调整,可以有效增加缓存的命中率,人的大脑其实也有这样的机制,不信你看:
[*]https://www.bilibili.com/video/BV1o8411x7aE
关于缓存,这里有一篇更深入的文章:
[*]https://zhuanlan.zhihu.com/p/490910129
硬件加速
内存除了缓存机制,在使用上,也存在一些 黑魔法 ,比如下面的代码:
#include <iostream>
#include <chrono>
#include <vector>
class ClockGuard {
public:
ClockGuard(const std::string& inDest)
:mDesc(inDest) {
mStartTime = std::chrono::steady_clock::now();
}
~ClockGuard() {
std::chrono::steady_clock::time_point currentTime = std::chrono::steady_clock::now();
double durationNs = std::chrono::duration<double, std::nano>(currentTime - mStartTime).count();
std::cout << mDesc << &#34; Cost: &#34; << durationNs << &#34; Ns&#34; << std::endl;
}
private:
std::string mDesc;
std::chrono::steady_clock::time_point mStartTime;
};
const size_t bigger_than_cachesize = 10 * 1024 * 1024;
long* ptr = new long;
void ClearCPUCache() {
for (int i = 0; i < bigger_than_cachesize; i++) {
ptr[i = rand();
}
}
int main(int argc, char** argv) {
std::vector<int> src(100000);
std::vector<int> dst(100000);
ClearCPUCache();
{
ClockGuard guard(&#34;Copy&#34;); //普通C++拷贝
for (int i = 0; i < src.size(); ++i) {
dst = src;
}
}
ClearCPUCache();
{
ClockGuard guard(&#34;Map&#34;); //内存拷贝
memcpy(dst.data(), src.data(), src.size());
}
return 0;
}
在C++语法中,已经不能写出比 for (int i = 0; i < src.size(); ++i) { dst = src; }更简单的代码了,但使用 memcpy 做同样的事情,性能却能够大幅提升,这主要是因为 memory 使用了汇编级别的优化,并且跟硬件特性有着很强的关联:
[*]ARM64架构下memcpy实现原理:https://blog.csdn.net/m0_46250244/article/details/115055101
在以后学习图形学的路上,在GPU上同样也会接触到许多类似的操作,因此读者需要多深入了解硬件的体系结构
优化方向
综上,内存优化的主要策略其实主要就是围绕着以下几点:
[*]减少内存碎片,增加缓存的命中率
[*]结合应用场景和硬件特性,使用高效的内存操作方法
对象生命周期管理
在现代程序开发过程中,充斥着大量的对象,对于在栈上申请的对象,在离开其作用域之后,会自动进行释放,但对于在堆上申请的对象,就需要我们手动去管理它们的生命周期,在c++中,也就是需要我们保证new和delete情况下一一对应,理想情况下,是这样的:
void Func(){
Object* object = new Object;
//...
delete object;
}
但现实总是残酷的,当代码中出现逻辑分支,或者是嵌套函数,对象的生命周期发生转移,想要再手动操控它的delete就会有些困难:
void Func(){
Object* object = new Object;
if(...){
//Do something
delete object;
return;
}
else if(...){
//Do something
delete object;
return;
}
else if(...){
//Do something
delete object;
return;
}
}
对此,我们往往需要一种自动管理对象生命周期的策略,而当下主要有两种方式:
[*]智能指针
[*]垃圾回收
智能指针
在c++的各种库中,往往提供共享指针的概念,它的核心思想就是:
[*]用一个外部对象包裹实际的对象指针,通过监控外部对象的构造、析构和拷贝,使用一块公共内存来记录指针的引用,当引用为0时,再销毁对象
一个简易的模仿如下:
struct SharedPtrRefData{
uint refCount = 0;
};
template<Typename _Ty>
class SharedPtr{
public:
SharedPtr(_Ty* rawPtr) //构造函数
: mRawPtr(rawPtr)
: mRefData(new SharedPtrRefData); //新建公共内存
{
mRefData->refCount++; //引用计数+1
}
SharedPtr(const SharedPtr<_Ty>& other){ //拷贝函数
mRefData = other.mRefData; //传递公共内存
mRefData->refCount++; //引用计数+1
}
~SharedPtr(){ //析构函数
mRefData->refCount--; //引用计数-1
if(SharedPtrRefData->refCount == 0){
delete mRawPtr; //销毁对象
delete mRefData; //销毁公共内存
}
}
private:
_Ty* mRawPtr;
SharedPtrRefData* mRefData;
};
这个结构在以下框架的实现是:
[*]C++标准库: std::shared_ptr
[*]Unreal Engine: TSharedPtr
[*]Qt: QSharedPointer
使用SharedPtr能保证我们在访问指针对象的时候,一定没有被释放,但有时候我们可能希望 “共享指针&#34;不再延续对象的生命周期,但可以在之后判断对象是否已经被销毁 ,框架中一般会称这个结构为 WeakPtr,在框架中对应:
[*]C++标准库: std::weak_ptr
[*]Unreal Engine: TWeakPtr
[*]Qt: QWeakPointer
以std为例:
#include <iostream>
int main() {
std::shared_ptr<int> sptr(new int);
std::weak_ptr<int> wptr(sptr);
if (const std::shared_ptr<int>& lock = wptr.lock()) {
std::cout << &#34;exist !!!&#34; << std::endl;
}
else {
std::cout << &#34;not exist&#34; << std::endl;
}
sptr.reset();
if (const std::shared_ptr<int>& lock = wptr.lock()) {
std::cout << &#34;exist !!!&#34; << std::endl;
}
else {
std::cout << &#34;not exist !&#34; << std::endl;
}
return 0;
}
/*
exist !!!
not exist !
*/
在上面SharePtr的简易代码中,当 refCount 为0时,同时销毁了对象和公共内存,但实际上, SharedPtrRefData 中存在着两个计数器:refCount 和 weakCount,当 refCount 为0时,只会销毁对象,并不会销毁公共内存,这使得WeakPtr依旧可以访问共享内存从而查询对象是否还存在,只有当 weakCount 也为 0 时,公共内存才会被销毁。
对于裸指针,delete只会销毁对象的内存,指针所指向的地址依然存在,如果管理不当,就会出现野指针,导致内存泄漏,而WeakPtr就解决了这一痛点。
还有一种情况,有时候我们可能希望一个new出来的对象,像普通对象那样,离开它的作用域之后销毁,比如说:
class Example{
public:
Example()
: mObject(new Object)
{}
~Example(){
delete mObject;
}
private:
Object* mObject;
}
这种情况,我们可以使用:
[*]C++标准库: std::unique_ptr
[*]Unreal Engine: TUniquePtr
[*]Qt: QScopedPointer
Qt的命名似乎更能体现它的作用上面的代码用UniquePtr,可以改写为:
class Example{
public:
Example<span class="p">()
: mObject(new Object)
{}
private:
std::unique_ptr<Object> mObject;
};
智能指针能解决开发过程中的绝大部分问题,但它并不是完美的,这里有一些 注意事项 :
[*]使用智能指针,尽量在构造或 reset 函数中 new 对象,尽量不要在智能指针之外去操纵裸指针
std::shared_ptr<int> sptr(new int);//正确的
std::shared_ptr<int> sptr; //正确的
sptr.reset(new int);
int* data = new int; //尽量不要这
std::shared_ptr<int> sptr(data);
int* data = new int; //这么写是要枪毙的
std::shared_ptr<int> sptr1(data);
std::shared_ptr<int> sptr2(data);
[*]不能使用std::shared<int> sptr(this),如果希望使用this创建共享指针,需要让this的类继承自模板enable_shared_from_this
[*]大量使用不必要的SharedPtr会有一定的浪费,使用SharedPtr的本意是为了维护对象的生命周期,如果你在封装接口的时候,不需要接口将对象的生命周期传递出去,那么你不应该将SharedPtr暴露出来,而是WeakPtr或者RawPtr
[*]在多线程环境下使用,应确保所使用的SharedPtr是线程安全的,以保证引用计数能够正常工作
此外,还有一些 隐患 :
[*]使用共享指针时,容易忽略捕获参数的生命周期,如果内部有共享指针,则会导致内存泄漏:
#include <iostream>
#include <functional>
class Exmaple {
public:
Exmaple() {
std::cout << &#34;Create&#34; << std::endl;
}
~Exmaple() {
std::cout << &#34;Destroy&#34; << std::endl;
}
};
int main(int argc, char** argv) {
{
std::function<void()> func0;
{
std::shared_ptr<Exmaple> sptr(new Exmaple);
std::function<void()> func1 = () {}; //将共享指针作为捕获参数
std::cout << &#34;---------0&#34; << std::endl;
//func0 = func1; //取消注释,那么Example将会在 1 2 之间释放
}
std::cout << &#34;---------1&#34; << std::endl;
}
std::cout << &#34;---------2&#34; << std::endl;
return 0;
}
/*
lambda的捕获参数在离开其作用域且没有function引用时才会被释放
如果没有及时销毁或重置function,那么捕获参数将一直存在,导致内存泄漏
*/
[*]SharedPtr存在循环引用的问题
#include <iostream>
struct B;
struct A {
~A() {
std::cout << &#34;destruct A&#34; << std::endl;
}
std::shared_ptr<B> mB;
};
struct B {
~B() {
std::cout << &#34;destruct B&#34; << std::endl;
}
std::shared_ptr<A> mA;
};
int main() {
{
std::shared_ptr<A> a(new A);
std::shared_ptr<B> b(new B);
a->mB = b;
b->mA = a;
}
return 0; //对象无法释放
}
[*]SharedPtr的销毁是立即的,在大项系统中,一次性销毁大量对象将会造成卡顿
关于共享指针,这里有一篇更深入的文章:
[*]https://zhuanlan.zhihu.com/p/532215950
垃圾回收
为了解决共享指针的缺陷,一些高级语言和大型框架就提供了垃圾回收(Garbage Collection)机制,旨在解决循环引用的问题以及实现延迟销毁
由于C++的特性(历史原因),如果让一个蹩脚的垃圾回收机制进入到C++标准中,只会让它的变得更加混乱,因此C++中的垃圾回收往往是由框架自己定制的,就像UE和Qt,它们已经脱离了C++标准的范畴,网友们戏称其为U++和Q++目前,笔者了解较多的GC机制有:
[*]Unreal Engine
[*]Qt、FBX SDK
[*]Lua
于开发者而言,关于GC主要需要了解两点:
[*]如何标记对象之间的引用关系
[*]如何进行垃圾回收
对于一些高级语言,如Java,C#,Python,Lua,因为虚拟机的缘故,想要监控对象之间的引用,很容易做到,而在C++中,对象之间的引用就只能靠手动设置或是通过反射分析。
Qt使用QObject为基类,它的内存回收很简单,就像是下面这样:
class QObject{
public:
QObject(QObject * parent = nullptr);
private:
QVector<QObejct*> children;
QObject *parent;
}
[*]QObject中有一个parent指针,children列表,销毁QObject时会同时销毁children
[*]QObject的还提供一个deleteLater()函数,会将对象添加到一个数组中,在事件处理循环结束之后,才会进行销毁。
FBX SDK使用FbxObject为基类,在创建实例时,必须指定其父对象,就像是这样:
FbxManager* lManager = FbxManager::Create();
FbxIOSettings* lIOSettings = FbxIOSettings::Create(lManager, &#34;&#34;);
FbxScene* scene = FbxScene::Create(lManager,&#34;&#34;);
FbxSkeleton* skeletonNode = FbxSkeleton::Create(scene,&#34;SkeletonRoot&#34;);
FbxCluster* rootCluster = FbxCluster::Create(skeletonNode, &#34;&#34;);
[*]当父对象释放时,会释放子对象
上面两个框架的内存回收的主体结构非常简单,对于环路问题,它们会在指定parent时进行检测,出现环路直接给出报错。
而UE的GC机制,就复杂太多了,在UE中,构建引用主要通过两种方式:
[*]UProperty(反射):UE通过分析UObject的Property,来确定两个对象之间的引用
[*]FGCObject:手动构建引用关系的接口类,UE中的绝大多数框架,比如UMG,Editor,Niagara...,都派生FGCObject实现了一套GC引用的管理机制,使得开发者在调用某些接口的时候,就已经隐式地构建对象之间的引用
UE的回收方式方式比较复杂,在这里不进行展开,一般只需要了解它是定时定量的回收即可
这里介绍一种简单的标记清理算法,它的核心在于:
[*]将所有可回收对象存储到一个数组中。
[*]存储对象间的引用关系。
[*]拥有一个全局的根节点,其他节点如果具有到达该节点的引用关系,那么说明该节点是可达的。
[*]垃圾回收时,需要挂起游戏线程,保证所有对象不被使用。
[*]从根节点遍历,标记所有可达节点。
[*]遍历对象数组,将不可达节点进行销毁。
一个简易版本的实现如下(更多细节请查看:Mark-and-Sweep:垃圾收集算法):
Object.h:
#pragma once
#include <list>
#include <map>
class ObjectBase {
friend class ObjectManagement;
public:
ObjectBase();
virtual ~ObjectBase();
};
class ObjectManagement {
friend class ObjectBase;
public:
static ObjectManagement* Instance();
void AddRefrence(ObjectBase* inObj, ObjectBase* inRef);
void RemoveRefrence(ObjectBase* inObj, ObjectBase* inRef);
void GarbageCollection(ObjectBase* inRoot);
private:
void RegisterObject(ObjectBase* inObj);
void UnregisterObject(ObjectBase* inObj);
void MarkObjects(ObjectBase* inObj,std::map<ObjectBase*,bool>& inVisited);
public:
std::map<ObjectBase*, std::list<ObjectBase*>> mRefList;
};
Object.cpp:
#include &#34;Object.h&#34;
#include <assert.h>
ObjectBase::ObjectBase() {
ObjectManagement::Instance()->RegisterObject(this);
}
ObjectBase::~ObjectBase() {
ObjectManagement::Instance()->UnregisterObject(this);
}
void ObjectManagement::AddRefrence(ObjectBase* inObj, ObjectBase* inRef) {
mRefList.push_back(inRef);
}
void ObjectManagement::RemoveRefrence(ObjectBase* inObj, ObjectBase* inRef) {
mRefList.remove(inRef);
}
void ObjectManagement::GarbageCollection(ObjectBase* inRoot) {
std::map<ObjectBase*, bool> visited;
MarkObjects(inRoot, visited); //从根节点开始遍历,标记可达对象
std::list<ObjectBase*> deleteList;
for (const auto& objPair : mRefList) {
if (!visited) {
deleteList.push_back(objPair.first);
}
}
for (const auto& obj : deleteList) {
delete obj;
}
}
void ObjectManagement::MarkObjects(ObjectBase* inObj, std::map<ObjectBase*, bool>& inVisited) {
if (inObj && !inVisited) {
inVisited = true;
for (auto& refObj : mRefList) {
MarkObjects(refObj, inVisited);
}
}
}
void ObjectManagement::RegisterObject(ObjectBase* inObj) {
mRefList;
}
void ObjectManagement::UnregisterObject(ObjectBase* inObj) {
mRefList.erase(inObj);
}
ObjectManagement* ObjectManagement::Instance() {
static ObjectManagement Instance;
return &Instance;
}
下面编写代码进行简单测试:
#include <iostream>
#include <functional>
#include <list>
#include &#34;Object.h&#34;
class Object : public ObjectBase {
public:
Object(std::string name) :mName(name) {
std::cout << &#34;construct object : &#34; << mName << std::endl;
}
virtual ~Object() {
std::cout << &#34;destruct object : &#34; << mName << std::endl;
}
private:
std::string mName;
};
int main(int argc, char** argv) {
Object* root = new Object(&#34;Root&#34;); //对象根节点
Object* a = new Object(&#34;A&#34;);
Object* b = new Object(&#34;B&#34;);
Object* c = new Object(&#34;C&#34;);
Object* d = new Object(&#34;D&#34;);
Object* e = new Object(&#34;E&#34;);
Object* f = new Object(&#34;F&#34;);
/*构建引用*/
ObjectManagement::Instance()->AddRefrence(root, a); // root
ObjectManagement::Instance()->AddRefrence(root, b); // /\
ObjectManagement::Instance()->AddRefrence(b, c); // A B
ObjectManagement::Instance()->AddRefrence(b, d); // / \
ObjectManagement::Instance()->AddRefrence(c, e); // C D
ObjectManagement::Instance()->AddRefrence(c, f); // / \
ObjectManagement::Instance()->AddRefrence(e, f); // E - F
std::cout<< &#34;Garbage Collection : 0&#34; << std::endl;
ObjectManagement::Instance()->GarbageCollection(root); // 没有对象被回收
std::cout << &#34;-----------------------&#34; << std::endl;
std::cout << &#34;Garbage Collection : 1&#34; << std::endl;
ObjectManagement::Instance()->RemoveRefrence(b, c); // 去除B对C的引用,C、E、F将会被销毁
ObjectManagement::Instance()->GarbageCollection(root);
std::cout << &#34;-----------------------&#34; << std::endl;
return 0;
}
输出:
construct object : Root
construct object : A
construct object : B
construct object : C
construct object : D
construct object : E
construct object : F
Garbage Collection : 0
-----------------------
Garbage Collection : 1
destruct object : F
destruct object : E
destruct object : C
-----------------------
内存调度框架
开源社区涌现了很多内存调度框架旨在 减少内存碎片 以及 增加缓存的命中率,其中比较知名的有:
mimalloc
mimalloc是一种通用分配器,具有出色的性能特征。最初由 Daan Leijen 为Koka和Lean语言的运行时系统开发
[*]小而一致:该库使用简单且一致的数据结构,大约有 8k LOC。这使得它非常适合集成和适应其他项目。对于运行时系统,它提供了用于单调心跳和延迟释放的钩子(对于具有引用计数的有界最坏情况时间)。
[*]空闲列表分片:每个“mimalloc 页”有许多较小的列表,而不是一个大的空闲列表(每个大小类),这减少了碎片并增加了局部性——及时分配的东西在内存中分配得很近。(一个 mimalloc 页包含一个大小级别的块,在 64 位系统上通常是 64KiB)。
[*]自由列表多分片:好主意!我们不仅将每个 mimalloc 页面的空闲列表分片,而且对于每个页面,我们都有多个空闲列表。特别是,有一个用于线程本地free操作的列表,另一个用于并发free 操作。从另一个线程中释放现在可以是单个 CAS,而无需线程之间的复杂协调。由于将有数千个单独的空闲列表,争用自然分布在堆上,并且在单个位置上竞争的机会将很低——这与跳过列表等随机算法非常相似,其中添加随机预言机消除了需要对于更复杂的算法。
[*]急切页面重置:当“页面”变空时(由于空闲列表分片的机会增加),内存被标记为操作系统未使用(“重置”或“清除”)减少(真实)内存压力和碎片,特别是在长时间运行的程序。
[*]安全: mimalloc可以在安全模式下构建,添加保护页、随机分配、加密空闲列表等,以防止各种堆漏洞。性能损失通常比我们的基准平均高出 10% 左右。
[*]一流的堆:有效地创建和使用多个堆来跨不同区域进行分配。堆可以一次销毁,而不是单独释放每个对象。
[*]有界:它不会受到膨胀 的影响,有界最坏情况分配时间 ( wcat ),有界空间开销(约 0.2% 元数据,分配大小最多浪费 12.5%),并且没有内部点仅使用原子操作的争用。
[*]快速:在我们的基准测试中, mimalloc优于其他领先的分配器(jemalloc、tcmalloc、Hoard等),并且通常使用更少的内存。一个不错的特性是它在广泛的基准测试中始终表现良好。对于较大的服务器程序,也有很好的巨大操作系统页面支持。
oneTBB
英特尔 oneAPI 线程构建块 (oneTBB) 是一个灵活的性能库,即使您不是线程专家,也可以简化为跨加速架构的复杂应用程序添加并行性的工作。
[*]指定逻辑性能,而不是线程 运行时库自动将逻辑并行映射到线程上,从而最有效地利用处理器资源。
[*]以线程为目标提高性能 专注于并行化计算密集型工作的特定目标,提供更高级别、更简单的解决方案。
[*]与其他线程包共存 它能够与其他线程无缝兼容,使您可以灵活地保持旧代码不变,并使用 oneTBB 进行新的实现。
[*]强调可扩展的数据并行编程 oneTBB 强调数据并行编程,而不是将程序分解成功能块并为每个功能块分配一个单独的线程,从而使多个线程能够在集合的不同部分上工作。通过将集合分成更小的部分,这可以很好地扩展到更多的处理器。随着您添加处理器和加速器,程序性能会提高。
jemalloc
jemalloc 是一个通用的malloc(3)实现,它强调避免碎片化和可扩展的并发支持。它旨在用作系统提供的内存分配器,如在 FreeBSD 的 libc 库中,以及用于链接到 C/C++ 应用程序。jemalloc 提供了许多超出标准分配器功能的内省、内存管理和调整功能。
tcmalloc
TCMalloc 是 Google 对 Cmalloc()和 C++ 的自定义实现,operator new用于C 和 C++ 代码中的分配内存。此自定义内存分配框架是 C 标准库(在 Linux 上通常通过glibc)和 C++ 标准库提供框架的替代方案。
[*]性能随应用程序的并行程度提升
[*]依附于C++14 和 C++17 进行了优化,并且在保证性能优势的情况下与标准略有不同(这些在TCMalloc 参考中注明)
[*]允许在某些架构下提高性能的扩展,以及其他行为,例如指标收集
[*]通过管理特定大小的内存块(称为“页面”)从操作系统执行分配。
[*]将单独的页面(或在 TCMalloc 中称为“跨度”的页面运行)用于特定的对象大小。例如,所有 16 字节的对象都放置在专门为该大小的对象分配的“跨度”中。在这种情况下获取或释放内存的操作要简单得多。
[*]将内存保存在缓存中以加快对常用对象的访问。如果以后重新分配此类内存,即使在释放后保留此类缓存也有助于避免代价高昂的系统调用。
使用方法
这些框架的使用方法也很简单,它们提供了自己的内存分配和释放接口,我们只需覆盖C++的operator,并使用这些内存接口来操作内存即可:
以mimalloc,使用下面的代码可以覆盖C++的 new 和 delete#include <vcruntime_new.h>
_NODISCARD _Ret_notnull_ _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECLoperator new(size_t Size) { return mi_malloc(Size ? Size : 1); }
_NODISCARD _Ret_notnull_ _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new[](size_t Size) { return mi_malloc(Size ? Size : 1); }
_NODISCARD _Ret_maybenull_ _Success_(return != NULL) _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new(size_t Size, const std::nothrow_t&) { return mi_malloc(Size ? Size : 1); }
_NODISCARD _Ret_maybenull_ _Success_(return != NULL) _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new[](size_t Size, const std::nothrow_t&) { return mi_malloc(Size ? Size : 1); }
void __CRTDECL operator delete(void* Ptr) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, size_t Size) { mi_free(Ptr); }
void __CRTDECL operator delete<span class="p">[](void* Ptr, size_t Size) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, size_t Size, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, size_t Size, const std::nothrow_t&) { mi_free(Ptr); }
_NODISCARD _Ret_notnull_ _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new(size_t Size, std::align_val_t Alignment) { return mi_malloc_aligned(Size ? Size : 1, (std::size_t)Alignment); }
_NODISCARD _Ret_notnull_ _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new[](size_t Size, std::align_val_t Alignment) { return mi_malloc_aligned(Size ? Size : 1, (std::size_t)Alignment); }
_NODISCARD _Ret_maybenull_ _Success_(return != NULL) _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new(size_t Size, std::align_val_t Alignment, const std::nothrow_t&) { return mi_malloc_aligned(Size ? Size : 1, (std::size_t)Alignment); }
_NODISCARD _Ret_maybenull_ _Success_(return != NULL) _Post_writable_byte_size_(_Size) _VCRT_ALLOCATOR
void* __CRTDECL operator new[](size_t Size, std::align_val_t Alignment, const std::nothrow_t&) { return mi_malloc_aligned(Size ? Size : 1, (std::size_t)Alignment); }
void __CRTDECL operator delete(void* Ptr, std::align_val_t Alignment) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, std::align_val_t Alignment) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, std::align_val_t Alignment, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, std::align_val_t Alignment, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, size_t Size, std::align_val_t Alignment) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, size_t Size, std::align_val_t Alignment) { mi_free(Ptr); }
void __CRTDECL operator delete(void* Ptr, size_t Size, std::align_val_t Alignment, const std::nothrow_t&) { mi_free(Ptr); }
void __CRTDECL operator delete[](void* Ptr, size_t Size, std::align_val_t Alignment, const std::nothrow_t&) { mi_free(Ptr); }
在 Unreal Engine 中,它实现了一个内存分配器基类 FMalloc ,派生了以下子类,使用FMemory::Malloc / FMemory::Free调用内存分配器的接口,从而允许在不同内存分配器之间进行切换:
[*]Ansi:标准的c分配器,直接调用malloc、free、realloc。
[*]Stomp:有助于追踪分配过程中读写造成的问题,如野指针,内存越界等。
[*]TBB:Thread Building Blocks,Intel提供的64位可伸缩内存分配器
[*]Jemalloc:主要用于 Unix 的内存分配器。
[*]Binned:装箱内存分配器,主要用于IOS。
[*]Binned:装箱分配器将一块内存(Pool)划分为许多不同大小的槽位,比如Binned在PC上把64KB划分成41个不同的槽位,从尾部往前分配空闲槽位:
[*]Binned2:Binned2分配器支持线程本地内存分配,不需要每次都加锁。这在频繁小块内存分配、释放时可以提升不少性能。
[*]Binned3:使用虚拟内存地址(逻辑内存地址),可以通过排列它来高速执行Pool的Index搜索,以便可以从内存的指针计算出目标大小的Pool 。
[*]Mimalloc:一个优于 tcmalloc 和 jemalloc 的通用内存分配器
Unreal Engine中不同平台内存分配器的支持情况如下
AnsiTBBJemallocBinnedBinned2Binned3MimallocStompAndroid√√default√(64bits)IOS√defaultWindows√defalut√√√(64bits)√√Linux√√√default√Mac√default√√√HoloLens√default基础库优化
在内存的应用级别,C++的基础容器和算法有很大的优化空间,这就不得不谈一下 C++ STL 库,它的缺点很多:
[*]某些 STL 实现(尤其是 Microsoft STL)具有较差的性能特征,使其不适合游戏开发。
[*]STL 有时很难调试,因为大多数 STL 实现都使用神秘的变量名和不寻常的数据结构。
[*]STL 分配器有时使用起来很痛苦,因为它们有很多要求并且一旦绑定到容器就无法修改。
[*]STL 包含过多的功能,这些功能可能会导致代码超出预期。告诉程序员他们不应该使用该功能并不容易。
[*]STL 是通过非常深入的函数调用实现的。这导致在非优化构建中的性能不可接受,有时在优化构建中也是如此。
[*]STL 不支持包含对象的对齐。
[*]STL 容器不允许您在不提供要从中复制的条目的情况下将条目插入容器。这可能效率低下。
[*]在 STLPort 等现有 STL 实现中发现的有用 STL 扩展(例如 slist、hash_map、shared_ptr)不可移植,因为它们在其他 STL 版本中不存在或在 STL 版本之间不一致。
[*]STL 缺少游戏程序员认为有用的有用扩展(例如 intrusive_list),但可以在可移植的 STL 环境中对其进行最佳优化。
[*]STL 的规范限制了我们有效使用它的能力。例如,STL 向量不能保证使用连续内存,因此不能安全地用作数组。
[*]STL 在性能之前强调正确性,而有时您可以通过减少学术上的纯粹性来获得显着的性能提升。
[*]STL 容器具有私有实现,不允许您以可移植的方式处理它们的数据,但有时这却是很重要的(例如节点池)。
[*]所有现有版本的 STL 都至少在其某些容器的空版本中分配内存。这并不理想,并且会阻止优化,例如在某些情况下可以大大提高性能的容器内存重置。
[*]STL 的编译速度很慢,因为大多数现代 STL 实现都非常大。
摘自 https://github.com/electronicarts/EASTL/blob/master/doc/Design.md它最大的缺点就是:
[*]STL在性能之前强调正确性,为了追求学术的纯粹性,它并没有因为性能铤而走险,剑走偏锋
举个例子,相信很多小伙伴在使用 std::vector 的时候,都知道应该尽量使用reserve 提前分配从而避免扩容导致的内存重分配,而对于 set、map 这类容器,其实也可以提前分配好节点的内存,这样不仅可以减少内存的分配次数,还能保证容器元素在内存中是连续的,访问时缓存的命中率可以大幅提升
如果你对比过 STL 和 UE 的内存分配器,可以发现明显的区别:
[*]STL 使用 Allocator 为容器中新增的元素分配内存
[*]UE 的 Allocator 为整个容器存重新分配内存,UE中容器都支持像 std::vector 那样的扩容机制
为了解决上面的问题,很多引擎都维护着一套自己的基础库,而开源的,主要有以下的一些方案:
EASTL
EASTL 是一个包含容器、算法和迭代器的 C++ 模板库,可用于跨多个平台的运行时和工具开发,它的实现相当广泛和健壮,并且强调性能高于所有其他考虑因素。
[*]EASTL 具有简化且更灵活的自定义分配方案。
[*]EASTL 的代码更易于阅读。
[*]EASTL 有扩展容器和算法。
[*]EASTL 具有专为游戏开发而设计的优化。
在上述各项中,唯一与 STL 不兼容的区别是内存分配的情况。
foonathan
foonathan也是为了解决STL存在的缺陷,但与EASTL不同,它并没有尝试更改 STL。
STL C++17 polymorphic memory source
在C++17以前,完成STL容器的内存分配需要借助allocator,它主要有以下缺陷:
[*]allocator是模板签名的一部分,不同allocator的容器,无法混用
vector<int,allocator0> v0;
vector<int,allocator1> v1;
v0 = v1;//Error
[*]allocator内存对齐无法控制,需要传入自定义allocator
[*]allocator没有内部状态
C++17中为容器提供了新的内存分配方法 ——<memory_resource>
它的用法就像是下面这样:
#include <memory_resource>
#include <vector>
#include <string>
#include <iostream>
class CustomMemoryResource :public std::pmr::memory_resource{ //自定义memory_source
public:
CustomMemoryResource() {}
private:
virtual void* do_allocate(const size_t _Bytes, const size_t _Align) override {
return ::operator new(_Bytes,std::align_val_t(_Align));
}
virtual void do_deallocate(void* _Ptr, size_t _Bytes, size_t _Align){
::operator delete(_Ptr,_Bytes, std::align_val_t(_Align));
};
virtual bool do_is_equal(const memory_resource& _That) const noexcept {
return this==&_That;
};
};
int main() {
std::pmr::unsynchronized_pool_resource ms1; //非线程安全的池分配器
std::pmr::synchronized_pool_resource ms2; //同步池分配器
CustomMemoryResource custom_memory_source;//自定义分配器
char buffer;
std::pmr::monotonic_buffer_resource mbr(std::data(buffer),std::size(buffer),&custom_memory_source); //单调缓存分配器,从buffer中分配内存,直到调用mbr.release()内存才会释放,超出缓存会使用上级分配器进行分配
{
std::pmr::vector<std::pmr::string> vec{ &mbr };
vec.push_back(&#34;Hello World&#34;);
vec.push_back(&#34;One Two Three&#34;);
} //vec 的内存不会释放
mbr.release();//此时才会释放mbr中的内存
}
从上面的代码可以看出MemoryResouce完美解决了allocator存在的问题,我们只需使用来自于命名空间std::pmr的容器。
它是怎么做到的呢?
以std::pmr::vector为例,找到它的定义:
namespace pmr {
template <class _Ty>
using vector = std::vector<_Ty, polymorphic_allocator<_Ty>>;
} // namespace pmr
非常简单,它只是一个使用了polymorphic_allocator的std::vector,找到它定义,可以发现如下代码:
template <class _Ty>
class polymorphic_allocator {
public:
//...
_NODISCARD __declspec(allocator) _Ty* allocate(_CRT_GUARDOVERFLOW const size_t _Count) {
// get space for _Count objects of type _Ty from _Resource
void* const _Vp = _Resource->allocate(_Get_size_of_n<sizeof(_Ty)>(_Count), alignof(_Ty));
return static_cast<_Ty*>(_Vp);
}
void deallocate(_Ty* const _Ptr, const size_t _Count) noexcept /* strengthened */ {
// return space for _Count objects of type _Ty to _Resource
// No need to verify that size_t can represent the size of _Ty.
_Resource->deallocate(_Ptr, _Count * sizeof(_Ty), alignof(_Ty));
private:
memory_resource* _Resource = _STD pmr::get_default_resource();
};
不难发现它其实只是在原来的Allocator接口上调用memory_resource的方法。
参考资料
Memory Allocationについて - Qiita
UE4内存分配器概述 - 可可西 - 博客园
UE4 Gamedev Guide - Allocators malloc
游戏引擎开发新感觉!(6) c++17内存管理
页:
[1]