实体系统中的缓存未命中和可用性


18

最近,我一直在为我的框架研究和实现一个实体系统。我认为我阅读了我能找到的大多数文章,reddits和有关它的问题,到目前为止,我认为我对这个想法已经足够了解。

但是,它提出了有关总体C ++行为,我在其中实现实体系统的语言以及一些可用性问题的一些问题。

因此,一种方法是直接在实体中存储组件数组,而我没有这样做,因为它在遍历数据时破坏了缓存的局部性。因此,我决定每个组件类型只有一个数组,因此相同类型的所有组件在内存中都是连续的,这应该是快速迭代的最佳解决方案。

但是,当我要在实际游戏实现中从系统迭代组件数组以对其进行处理时,我注意到几乎总是同时使用两个或多个组件类型。例如,渲染系统将Transform和Model组件一起使用以实际进行渲染调用。我的问题是,由于在这种情况下不会一次线性地迭代一个连续的数组,我是否立即牺牲了通过这种方式分配组件的性能收益?当我在C ++中迭代两个不同的连续数组并在每个循环中使用两个数组中的数据时,这是否会产生问题?

我想问的另一件事是,应该如何保留对组件或实体的引用,因为组件的本质是如何放置在内存中,它们可以轻松切换数组中的位置,或者可以重新分配数组以进行扩展或扩展。缩小,使组件指针或句柄无效。您建议如何处理这些情况,因为我经常发现自己想在每一帧上对变换和其他组件进行操作,并且如果我的句柄或指针无效,那么在每一帧进行查找都非常麻烦。


4
我不会费心将组件放入连续的内存中,而只是为每个组件动态分配内存。连续内存不太可能使您获得任何缓存性能提升,因为无论如何您都可能以非常随机的顺序访问组件。
JarkkoL 2014年

@Grimshaw这是一篇有趣的文章,可阅读:有害.cat
v.org

@JarkkoL -10分。如果您构建友好的系统缓存并以随机方式访问它,那确实会损害性能,仅凭声音是愚蠢的。它以线性方式访问它的点。ECS和性能提升的技巧在于编写以线性方式访问的C / S。
温德拉2014年

@Grimshaw不要忘记缓存大于一个整数。您可以获得几个KB的L1高速缓存可用(以及其他MB的可用),如果您不做任何怪异的事情,则应该可以一次访问少量系统,同时保持对缓存的友好性。
温德拉2014年

2
@wondra您将如何确保线性访问组件?假设我收集了用于渲染的组件,并且希望实体从相机降序进行处理。这些实体的渲染组件不会在内存中线性访问。虽然您说的是理论上的好消息,但我认为它在实践中不会起作用,但是很高兴您能证明我做错了(:
JarkkoL 2014年

Answers:


13

首先,我不会说在这种情况下,根据您的用例进行优化的时间过早。但是无论如何,您都会提出一个有趣的问题,根据我自己的经验,我会加倍考虑。我将尝试解释我最终做事的方式以及在途中发现的事情。

  • 每个实体都有一个通用的组件句柄向量,可以代表任何类型。
  • 可以取消引用每个组件句柄以产生原始T *指针。*见下文。
  • 每个组件类型都有其自己的池,即连续的内存块(在我的情况下为固定大小)。

应该注意的是,不,您将无法始终遍历组件池并进行理想的清洁操作。正如您已经说过的,组件之间存在不可避免的链接,您实际上需要一次处理一个实体的事物。

但是,在某些情况下(如我所发现的),确实可以为特定的组件类型编写for循环并充分利用CPU缓存行。对于那些不知道或希望了解更多信息的人,请查看https://en.wikipedia.org/wiki/Locality_of_reference。同样,请尽可能尝试使组件大小小于或等于CPU缓存行大小。我的行大小为64字节,我相信这很常见。

就我而言,付出努力来实施该系统是值得的。我看到了明显的性能提升(当然已经介绍了)。您需要自己决定这是否是一个好主意。我在1000多个实体中看到的最大绩效提升。

我想问的另一件事是,应该如何保留对组件或实体的引用,因为组件的本质是如何放置在内存中,它们可以轻松切换数组中的位置,或者可以重新分配数组以进行扩展或扩展。缩小,使我的组件指针或句柄无效。您建议如何处理这些情况,因为我经常发现自己想在每一帧上对变换和其他组件进行操作,并且如果我的句柄或指针无效,那么在每一帧进行查找都非常麻烦。

我也亲自解决了这个问题。我最终有了一个系统,其中:

  • 每个组件句柄都包含对池索引的引用
  • 当某个组件从某个池中“删除”或“删除”时,该池中的最后一个组件被移动(实际上是使用std :: move)到现在可用的位置;如果您刚刚删除了最后一个组件,则不移动。
  • 当发生“交换”时,我有一个回调通知所有侦听器,以便它们可以更新任何具体的指针(例如T *)。

*我发现尝试在运行时总是在高使用率代码的某些部分中以我正在处理的实体数来取消对组件句柄的引用是性能问题。因此,我现在在项目的性能关键部分中维护了一些原始的T指针,但除此之外,我确实使用了通用组件句柄,应尽可能使用它们。如上所述,我使用回调系统使它们保持有效。您可能不需要走那么远。

最重要的是,尝试一下。在您获得真实场景之前,任何人在这里所说的只是做事的一种方式,可能不适合您。

有帮助吗?我将尝试澄清所有不清楚的地方。还可以进行任何更正。


赞成,这是一个很好的答案,虽然这可能不是万灵丹,但仍然很高兴看到有人有类似的设计想法。我在ES中也实现了一些技巧,它们似乎很实用。非常感谢!如果有其他想法,请随时发表评论。
Grimshaw

5

为了回答这个问题:

我的问题是,由于在这种情况下不会一次线性地迭代一个连续的数组,我是否立即牺牲了通过这种方式分配组件的性能收益?当我在C ++中迭代两个不同的连续数组并在每个循环中使用两个数组中的数据时,这是否会产生问题?

不(至少不一定)。在大多数情况下,高速缓存控制器应能够有效地处理从多个连续数组中读取数据的问题。重要的部分是尝试尽可能线性地访问每个数组。

为了证明这一点,我编写了一个小基准(适用常规基准警告)。

从简单的向量结构开始:

struct float3 { float x, y, z; };

我发现一个循环,将两个单独的数组的每个元素求和,并将结果存储在第三个数组中,与将源数据插入单个数组中并在第三个数组中存储结果的版本完全相同。但是,我确实发现,如果将结果与源进行交错处理,则性能会受到影响(大约2倍)。

如果我随机访问数据,性能将受到10到20的影响。

时间(10,000,000个元素)

线性访问

  • 单独的数组0.21s
  • 交错源0.21s
  • 交错的源和结果0.48s

随机访问(取消注释random_shuffle)

  • 分离数组2.42s
  • 交错源4.43s
  • 交错的源和结果4.00s

来源(与Visual Studio 2013编译):

#include <Windows.h>
#include <vector>
#include <algorithm>
#include <iostream>

struct float3 { float x, y, z; };

float3 operator+( float3 const &a, float3 const &b )
{
    return float3{ a.x + b.x, a.y + b.y, a.z + b.z };
}

struct Both { float3 a, b; };

struct All { float3 a, b, res; };


// A version without any indirection
void sum( float3 *a, float3 *b, float3 *res, int n )
{
    for( int i = 0; i < n; ++i )
        *res++ = *a++ + *b++;
}

void sum( float3 *a, float3 *b, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = a[*index] + b[*index];
}

void sum( Both *both, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = both[*index].a + both[*index].b;
}

void sum( All *all, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        all[*index].res = all[*index].a + all[*index].b;
}

class PerformanceTimer
{
public:
    PerformanceTimer() { QueryPerformanceCounter( &start ); }
    double time()
    {
        LARGE_INTEGER now, freq;
        QueryPerformanceCounter( &now );
        QueryPerformanceFrequency( &freq );
        return double( now.QuadPart - start.QuadPart ) / double( freq.QuadPart );
    }
private:
    LARGE_INTEGER start;
};

int main( int argc, char* argv[] )
{
    const int count = 10000000;

    std::vector< float3 > a( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > b( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > res( count );

    std::vector< All > all( count, All{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );
    std::vector< Both > both( count, Both{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );

    std::vector< int > index( count );
    int n = 0;
    std::generate( index.begin(), index.end(), [&]{ return n++; } );
    //std::random_shuffle( index.begin(), index.end() );

    PerformanceTimer timer;
    // uncomment version to test
    //sum( &a[0], &b[0], &res[0], &index[0], count );
    //sum( &both[0], &res[0], &index[0], count );
    //sum( &all[0], &index[0], count );
    std::cout << timer.time();
    return 0;
}

1
这对我对缓存局部性的疑问很有帮助,谢谢!
Grimshaw

我也感到放心的简单而有趣的答案:)我很想看看这些结果对于不同的项目计数(即1000而不是10,000,000?)有什么不同?或者如果您有更多的值数组(即3的求和元素) -5个单独的数组,并将值存储到另一个单独的数组中)。
Awesomania 2014年

2

简短的答案: 配置文件然后优化。

长答案:

但是,当我要在实际游戏实现中从系统迭代组件数组以对其进行处理时,我注意到几乎总是同时处理两个或多个组件类型。

当我在C ++中迭代两个不同的连续数组并在每个循环中使用两个数组中的数据时,这是否会产生问题?

C ++对高速缓存未命中概不负责,因为它适用于任何编程语言。这与现代CPU架构的工作方式有关。

您的问题可能是所谓的过早优化的一个很好的例子

我认为您为缓存局部性进行过早优化,而没有查看程序内存访问模式。但是更大的问题是您真的需要这种优化(参考位置)吗?

Agner的Fog建议您在配置应用程序和/或确定瓶颈在哪里之前就不要进行优化。(所有这些都在他的出色指南中提到。下面的链接)

如果您正在编写具有大数据结构且具有非顺序访问的程序,并且想要防止缓存争用,则了解缓存的组织方式很有用。如果您对更多启发式准则感到满意,则可以跳过本节。

不幸的是,您所做的实际上是假设为每个数组分配一个组件类型将为您提供更好的性能,而实际上,您可能会导致更多的缓存未命中甚至缓存争用。

您绝对应该看一下他出色的C ++优化指南

我想问的另一件事是,应该如何保留对组件或实体的引用,因为组件如何被放置在内存中。

我个人将最常用的组件一起分配在一个内存块中,因此它们的地址“接近”。例如,数组将如下所示:

[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..] 如果性能不够好,则从那里开始进行优化。


我的问题是关于我的体系结构可能对性能产生的影响,并不是要优化,而是要选择一种内部组织方式的方法。不管它发生在内部的方式如何,我都希望我的游戏代码能够以一种同质的方式与之交互,以备日后更改时使用。即使它可以提供有关如何存储数据的其他建议,您的回答也是不错的。已投票。
Grimshaw 2014年

从我的角度来看,有三种主要的存储组件的方式,每个组件都耦合在一个数组中,每个实体都按类型耦合在一起,如果我理解正确的话,建议将不同的实体连续存储在一个大数组中,并且每个实体的所有组成部分都在一起吗?
Grimshaw 2014年

@Grimshaw正如我在答案中提到的那样,不能保证您的体系结构会比常规分配模式提供更好的结果。由于您实际上并不了解应用程序的访问模式。这种优化通常是在一些研究/证据之后完成的。根据我的建议,请将相关组件存储在同一内存中,并将其他组件存储在不同位置。这是全有或全无之间的中间立场。但是,我仍然认为,鉴于有多少条件起作用,很难预测您的体系结构将如何影响结果。
concept3d 2014年

唐纳德在乎的解释?只需在我的答案中指出问题。更好的答案。
concept3d

1

我的问题是,由于在这种情况下不会一次线性地迭代一个连续的数组,我是否立即牺牲了通过这种方式分配组件的性能收益?

可以说,与在“水平”可变大小的块中将附加到实体的组件交织在一起相比,每种组件类型使用单独的“垂直”数组的总体缓存命中率要少。

原因是,首先,“垂直”表示将倾向于使用较少的内存。您不必担心连续分配的同类数组的对齐问题。在将非同类类型分配到内存池后,您确实需要担心对齐问题,因为数组中的第一个元素的大小和对齐要求可能与第二个元素完全不同。因此,您经常需要添加填充,例如一个简单的示例:

// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
    // 1 byte
    char a;

    // 1 byte
    char b;
};

struct Bar
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

比方说,我们要交错FooBar和它们在内存旁边彼此存储:

// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'

    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

现在,与其花费18个字节将Foo和Bar存储在单独的内存区域中,不如花费24个字节来融合它们。交换订单无关紧要:

// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;

    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'
};

如果您在顺序访问上下文中占用更多内存,而又没有显着改善访问模式,那么通常会导致更多的缓存未命中。最重要的是,从一个实体到下一个实体的步幅增加,并且大小可变,这使得您必须在内存中进行可变大小的跳跃才能从一个实体到下一个实体,只是要查看哪些实体具有您所需要的组件重新感兴趣。

因此,与“水平”替代方案相比,像存储组件类型一样使用“垂直”替代方案实际上更可能是最优的。也就是说,垂直表示的高速缓存未命中的问题可以在此处举例说明:

在此处输入图片说明

箭头仅表示实体“拥有”一个组件。我们可以看到,如果尝试访问具有两者的实体的所有运动和渲染组件,我们最终将在内存中的所有位置跳转。这种零星的访问模式可以让您将数据加载到高速缓存行中以访问(例如,运动组件),然后访问更多组件并逐出先前的数据,而仅再次加载已经为另一次运动而逐出的同一内存区域零件。因此,将完全相同的内存区域不止一次地加载到高速缓存行中只是为了循环访问和访问组件列表,可能会非常浪费。

让我们稍微清理一下混乱,以便我们可以更清楚地看到:

在此处输入图片说明

请注意,如果遇到这种情况,通常是在游戏开始运行很长时间之后,才添加和删除了许多组件和实体。通常,当游戏开始时,您可以将所有实体和相关组件添加在一起,这时它们可能具有非常有序的顺序访问模式,并且具有良好的空间局部性。经过大量的删除和插入之后,您可能最终会得到类似上述混乱的信息。

改善这种情况的一种非常简单的方法是简单地根据拥有它们的实体ID /索引对您的组件进行基数排序。到那时,您将得到如下内容:

在此处输入图片说明

这是一种更加缓存友好的访问模式。这不是完美的,因为我们看到我们不得不在这里和那里跳过一些渲染和运动组件,因为我们的系统只对同时具有这两个实体的实体感兴趣实体的实体,而有些实体仅具有运动组件,而一些实体仅具有,但是您至少可以处理一些连续的组件(实际上,更多的是,实际上,由于经常会附加感兴趣的相关组件,例如,系统中更多具有运动组件的实体将具有渲染组件,而不是不)。

最重要的是,一旦对这些数据进行了排序,就不会将一个内存区域的数据加载到高速缓存行中,而只是在一个循环中重新加载它。

而且,这不需要进行非常复杂的设计,只需时不时进行一次线性时间基数排序,也许是在针对特定组件类型插入和删除了一堆组件之后,此时可以将其标记为需要排序。合理实现的基数排序(您甚至可以并行化它,我可以这样做)可以在我的四核i7上约6毫秒内对一百万个元素进行排序,如下所示:

Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]

以上是对一百万个元素进行32次排序(包括memcpy排序前后的结果时间)。而且我假设大多数情况下您实际上没有要分类的100万个以上的组件,因此您应该可以很容易地在任何地方进行潜入而不会引起任何明显的帧频停顿。

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.