动态内存分配和内存管理


17

在普通游戏中,场景中有成百上千个对象。通过默认的new()动态地为所有对象(包括枪击(子弹))分配内存是否完全正确

我应该为动态分配创建任何内存池,还是不必为此烦恼?如果目标平台是移动设备怎么办?

请在手机游戏中需要一个内存管理器吗?谢谢。

使用的语言:C ++;当前在Windows下开发,但计划在以后移植。


哪种语言?
Kylotan'2

@Kylotan:使用的语言:C ++当前在Windows下开发,但计划在以后移植。
Bunkai.Satori 2011年

Answers:


23

在普通游戏中,场景中有数百个甚至数千个对象。通过默认的new()动态地为所有对象(包括枪击(子弹))分配内存是完全正确的吗?

这确实取决于您所说的“正确”的意思。如果您从字面上完全理解该术语(并且忽略隐含设计的正确性的任何概念),那么是的,它是完全可以接受的。您的程序将编译并正常运行。

它可能会表现欠佳,但仍然可能表现出色,足以成为一款有趣的游戏。

我应该为动态分配创建任何内存池,还是不必为此烦恼?如果目标平台是移动设备怎么办?

剖析并查看。例如,在C ++中,在堆上动态分配通常是“缓慢”的操作(因为它涉及遍历堆以查找适当大小的块)。在C#中,这通常是一个非常快的操作,因为它只涉及增量。关于内存分配,释放时的碎片等等,不同的语言实现具有不同的性能特征。

实施内存池系统肯定会带来性能提升-并且由于移动系统通常相对于台式机系统功率不足,因此与台式机相比,在特定移动平台上可能会获得更多收益。但是再次,您必须进行剖析,看看-当前,您的游戏是否运行缓慢,但是内存分配/释放没有在剖析器中显示为热点,因此实施基础结构来优化内存分配和访问可能不会。不会给您带来很多回报。

请在手机游戏中需要一个内存管理器吗?谢谢。

再次,剖析并查看。您的游戏现在运行良好了吗?然后,您可能不必担心。

抛开所有这些警告,严格说来,不必对所有内容都使用动态分配,因此避免使用它是有利的-既因为潜在的性能提升,又因为分配了需要跟踪并最终释放的内存意味着您必须跟踪并最终发布它,这可能会使您的代码复杂化。

特别是,在您的原始示例中,您引用了“项目符号”,这往往是经常创建和销毁的项目-因为许多游戏涉及大量子弹,并且子弹移动很快,因此很快就达到了生命周期的尽头(而且经常猛烈地!)。因此,为它们及其类似对象(例如粒子系统中的粒子)实现池分配器通常可以提高效率,并且很可能是开始着手使用池分配的第一个地方。

我不清楚您是否正在考虑将内存池实现与“内存管理器”区分开来-内存池是一个相对明确定义的概念,因此可以肯定地说,如果您实现它们,它们将是一个好处。“内存管理器”在其职责方面有些含糊,所以我不得不说是否需要一个“内存管理器”取决于您认为“内存管理器”会做什么。

例如,如果您认为内存管理器只是拦截对new / delete / free / malloc /什么的调用,并提供有关您分配了多少内存,泄漏了什么等等的诊断信息,那么这可能是有用的游戏开发工具,可帮助您调试漏洞并调整最佳内存池大小,等等。


同意 以一种允许您以后进行更改的方式进行编码。如有疑问,请基准或简介。
axel22 2011年

// @乔希:+1很好的答案。我可能需要结合使用动态分配,静态分配和内存池。但是,游戏的性能将引导我正确地综合考虑这三个因素。这是我问题的“ 可接受答案”的明确候选人。但是,我想让这个问题待一会儿,看看其他人会做出什么贡献。
2011年

+1。精巧的制作。几乎每个性能问题的答案始终是“剖析”。如今,硬件太复杂了,无法从基本原理中推断出性能。您需要数据。
优厚

@Munificent:感谢您的评论。因此,目标是使游戏正常运行并保持稳定。在开发过程中,无需过多担心性能。游戏完成后,所有这些都可以而且将被修复。
Bunkai.Satori 2011年

我认为这是对C#分配时间的不公平表示-例如,每个C#分配还包括一个同步块,对象的分配等。此外,C ++中的堆仅在分配和释放时才需要修改,而C#则需要集合。
DeadMG

7

我对Josh的出色回答没有太多补充,但我会对此发表评论:

我应该为动态分配创建任何内存池,还是不必为此烦恼?

内存池和调用new每个分配之间有中间立场。例如,您可以在数组中分配一定数量的对象,然后在它们上设置一个标志以便以后“销毁”它们。当您需要分配更多资源时,可以使用已破坏的标志集覆盖那些资源。这种事情的使用仅比new / delete复杂(因为您将有2个新功能用于此目的),但编写简单,可以为您带来很大的收获。


+1是不错的补充。是的,您是正确的,这是管理较简单的游戏元素(如子弹,粒子,效果)的好方法。特别是对于那些用户,将不需要动态分配内存。
Bunkai.Satori 2011年

3

通过默认的new()动态为所有对象(包括枪击(子弹))分配内存是否完全正确?

不,当然不是。对于所有对象,没有正确的内存分配。运算符new()用于动态分配,也就是说,仅当您需要动态分配时才适用,这是因为对象的生存期是动态的,或者因为对象的类型是动态的。如果对象的类型和生存期是静态已知的,则应静态分配它。

当然,关于分配模式的信息越多,可以通过专家分配器(例如对象池)来更快地进行这些分配。但是,这些是优化,只有在已知有必要的情况下,才应进行优化。


+1为好答案。因此,概括地说,正确的方法是:在开发之初,要计划哪些对象可以静态分配。在开发过程中,仅动态分配那些绝对必须动态分配的对象。最后,分析并调整可能的内存分配性能问题。
Bunkai.Satori 2011年

0

有点像Kylotan的建议,但我建议尽可能在数据结构级别解决此问题,如果可以的话,不要在较低的分配程序级别解决。

这是一个简单的示例,说明如何避免Foos使用带有将元素链接在一起的孔的数组​​重复分配和释放(在“容器”级别而不是“分配器”级别解决):

struct FooNode
{
    explicit FooNode(const Foo& ielement): element(ielement), next(-1) {}

    // Stores a 'Foo'.
    Foo element;

    // Points to the next foo available; either the
    // next used foo or the next deleted foo. Can
    // use SoA and hoist this out if Foo doesn't 
    // have 32-bit alignment.
    int next;
};

struct Foos
{
    // Stores all the Foo nodes.
    vector<FooNode> nodes;

    // Points to the first used node.
    int first_node;

    // Points to the first free node.
    int free_node;

    Foos(): first_node(-1), free_node(-1)
    {
    }

    const FooNode& operator[](int n) const
    {
         return data[n];
    }

    void insert(const Foo& element)
    {
         int index = free_node;
         if (index != -1)
         {
              // If there's a free node available,
              // pop it from the free list, overwrite it,
              // and push it to the used list.
              free_node = data[index].next;
              data[index].next = first_node;
              data[index].element = element;
              first_node = index;
         }
         else
         {
              // If there's no free node available, add a 
              // new node and push it to the used list.
              FooNode new_node(element);
              new_node.next = first_node;
              first_node = data.size() - 1;
              data.push_back(new_node);
         }
    }

    void erase(int n)
    {
         // If the node being removed is the first used
         // node, pop it from the used list.
         if (first_node == n)
              first_node = data[n].next;

         // Push the node to the free list.
         data[n].next = free_node;
         free_node = n;
    }
};

达到这种效果的原因:带有空闲列表的单链接索引列表。索引链接使您可以跳过已删除的元素,在固定时间内删除元素,还可以通过固定时间插入来回收/重用/覆盖空闲元素。要遍历结构,您可以执行以下操作:

for (int index = foos.first_node; index != -1; index = foos[index].next)
    // do something with foos[index]

在此处输入图片说明

而且,您可以使用模板,放置新的和手动dtor调用来通用化上述类型的“孔的链接数组”数据结构,从而避免进行复制分配,在删除元素时调用析构函数,提供正向迭代器等。选择使示例非常类似于C,以更清楚地说明概念,也因为我很懒。

就是说,在您从中间移除很多东西并将其插入中间后,此结构的空间局部性确实会下降。到那时,next链接可能会让您沿着向量来回走动,以相同的顺序遍历重新加载先前从高速缓存行中驱出的数据(这对于任何允许在回收时不删除元素的情况下进行恒定时间删除的数据结构或分配器都是不可避免的从中间插入空格,并进行恒定时间插入,而不使用并行位集或removed标志之类的东西)。为了恢复缓存友好性,您可以实现一个复制ctor和swap方法,如下所示:

Foos(const Foos& other)
{
    for (int index = other.first_node; index != -1; index = other[index].next)
        insert(foos[index].element);
}

void Foos::swap(Foos& other)
{
     nodes.swap(other.nodes):
     std::swap(first_node, other.first_node);
     std::swap(free_node, other.free_node);
}

// ... then just copy and swap:
Foos(foos).swap(foos);

现在,新版本再次对缓存友好,可以遍历。另一种方法是将索引的单独列表存储到结构中,并定期对其进行排序。另一个是使用位集来指示使用了哪些索引。这样一来,您始终可以按顺序遍历位集(要高效地执行此操作,请一次检查64位,例如使用FFS / FFZ)。该位集是最高效且非侵入性的,每个元素仅需要一个并行位即可指示使用了哪些元素,哪些元素被删除了,而不需要32位next索引,但是写得最好的时间最多(不会如果您一次检查一位就可以快速遍历-您需要FFS / FFZ一次一次在32个以上的位中立即找到一个设置或未设置的位,以快速确定占用索引的范围)。

通常,此链接解决方案最容易实现,并且是非侵入式的(不需要修改Foo即可存储一些removed标志),如果您不希望32位数据泛化到任何数据类型,则可以使用此链接解决方案每个元素的开销。

我应该为动态分配创建任何内存池,还是不必为此烦恼?如果目标平台是移动设备怎么办?

需求是一个强有力的词,我偏向于在性能至关重要的领域(例如光线跟踪,图像处理,粒子模拟和网格处理)中工作,但是分配和释放用于非常轻便的处理(如子弹头)的微小对象相对非常昂贵和粒子分别针对通用的可变大小的内存分配器。鉴于您应该能够在一两天内概括上述数据结构以存储所需的任何内容,我认为这是值得进行的交易,以消除为每笔小小的事情而直接支付的此类堆分配/重新分配成本。除了减少分配/重新分配成本之外,您还可以更好地遍历引用遍历结果的位置(即更少的缓存未命中和页面错误)。

至于乔什提到的关于GC的内容,我没有像Java一样仔细研究C#的GC实现,但是GC分配器通常具有初始分配这非常快,因为它使用的是顺序分配器,该分配器无法从中间释放内存(几乎像堆栈一样,您不能从中间删除内容)。然后,它付出了昂贵的成本,实际上是通过复制内存并清除以前分配的内存作为整体,从而允许在单独的线程中删除单个对象(例如一次破坏整个堆栈,而将数据复制到更类似链接结构的对象中),但是由于它是在单独的线程中完成的,因此它不一定会使应用程序的线程停滞不前。但是,这会带来非常高的隐性成本,即需要额外的间接级别,并且在初始GC周期后会丢失LOR。但是,这是加快分配速度的另一种策略-使它在调用线程中更便宜,然后在另一个线程中完成昂贵的工作。为此,您需要两个级别的间接引用来引用您的对象,而不是一个,因为在您最初分配的时间和第一个周期之后,它们最终将在内存中被拖曳。

同样,在C ++中更容易应用的另一种策略是,不必费心在主线程中释放对象。只是不断增加数据结构的末尾,不允许从中间删除东西。但是,标记那些需要删除的东西。然后,一个单独的线程可以处理创建新数据结构而无需删除元素的昂贵工作,然后以原子方式将新的数据结构与旧的交换,例如,分配和释放元素的大部分成本都可以传递给如果您可以假设不必立即满足删除元素的要求,则可以使用单独的线程。这不仅使就线程而言释放更便宜,而且使分配更便宜,因为您可以使用更简单,更笨拙的数据结构,而不必处理中间的删除案例。就像一个容器,只需要一个push_back用于插入的clear功能,用于删除所有元素并swap与新的紧凑型容器交换内容的功能(不包括已删除的元素);就变异而言,仅此而已。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.