C++ 中的粒子池性能优化

Particle Pool Performance Optimization In C++

我有一个存储粒子指针的粒子池

它有大量的粒子

每个粒子都有 IsActive 变量

根据活动状态对每帧向量进行分区时,cpu 使用率很高

那么在工作线程上每隔几秒对向量进行一次分区是不是一种好方法?

由于活性粒子的顺序无关紧要,并且它们不会经常变为活性或非活性,因此您可以使用技巧将活性粒子保持在最前面:

假设nActive为活性粒子数

当一个非活动粒子变为活动状态时,执行 std::swap(vector[nParticle], vector[nActive]); nActive++; 使其成为最后一个活动粒子 - 任何非活动粒子已经在那个槽中,去哪里粒子曾经是,我们不关心,因为我们不关心顺序。

当一个活动粒子变为非活动状态时,执行 nActive--; std::swap(vector[nParticle], vector[nActive]); 使其成为第一个非活动粒子 - 任何活动粒子已经在那个槽中,去哪里粒子曾经是,我们不关心,因为我们不关心顺序。

您可以通过记住使粒子处于活动状态或不活动状态很容易 - 如果我们不关心它是哪个 - 因为我们只需从 nActive 中加或减 1 即可记住此技巧。但是我们不知道我们刚刚改变了哪个粒子。所以我们将该粒子与我们 打算 改变的粒子交换,我们不关心该粒子去哪里,只要它保持非活动状态或保持活动状态(不改变)。

像这样:

+-----------+-----------+-----------+-----------+
| Particle0 | Particle1 | Particle2 | Particle3 |
| active    | active    | inactive  | inactive  |
+-----------+-----------+-----------+-----------+
                        ^
                        |
                    nActive==2

激活粒子 3:交换粒子 3 和 2,然后递增 nActive:

+-----------+-----------+-----------+-----------+
| Particle0 | Particle1 | Particle3 | Particle2 |
| active    | active    | active    | inactive  |
+-----------+-----------+-----------+-----------+
                                    ^
                                    |
                                nActive==3

使粒子 0 不活动:递减 nActive,然后交换粒子 3(在插槽 2 中)和 0。

+-----------+-----------+-----------+-----------+
| Particle3 | Particle1 | Particle0 | Particle2 |
| active    | active    | inactive  | inactive  |
+-----------+-----------+-----------+-----------+
                        ^
                        |
                    nActive==2

现在粒子顺序乱七八糟,不过你不在乎吧?

虽然 user253751 的答案对于优化您的分区方案是绝对正确的,但听起来您将遇到与粒子数量和缓存一致性相关的更大问题。

我猜测所使用的数据结构类似于

struct Particle
{
    vec2 position;
    vec2 velocity;
    float scale;
    float scaleVelocity;
    float lifeRemaining;
    vec4 color;
    // Other attributes
};

std::vector<Particle> particles;

void updateParticles(const float delta)
{
    for(size_t i = 0; i < particles.size(); ++i)
    {
        particles[i].position += particles[i].velocity;
        particles[i].scale += particles[i].scaleVelocity;
        particles[i].lifeRemaining -= delta;
    }
}

这导致每个粒子元素一个接一个地被打包。这在多线程环境中的性能会更差,因为同步可能必须在单个缓存行中的内核之间发生(假设您正在单独处理每个组件而不是块,无论哪种方式能够矢量化仍然会更好)。

Position Velocity Scale Scale Velocity Life Remaining Color
position_0 velocity_0 scale_0 scaleVelocity_0 lifeRemaining_0 color_0
position_1 velocity_1 scale_1 scaleVelocity_1 lifeRemaining_1 color_1
position_2 velocity_2 scale_2 scaleVelocity_2 lifeRemaining_2 color_2
position_3 velocity_3 scale_3 scaleVelocity_3 lifeRemaining_3 color_3

缓存一致性差,不利于矢量化。

在这种情况下,您可以通过切换到类似于

的面向数据编程方法来显着提高性能
struct ParticleContainer
{
    std::vector<vec2> position;
    std::vector<vec2> velocity;
    std::vector<float> scale;
    std::vector<float> lifeRemaining;
    std::vector<vec4> color;
};

ParticleContainer particles;

void updateParticles(const float delta)
{
    for(size_t i = 0; i < particles.size(); ++i)
    {
        particles.position[i] += particles.velocity[i];
    }

    for(size_t i = 0; i < particles.size(); ++i)
    {
        particles.scale[i] += particles.scaleVelocity[i];
    }

    for(size_t i = 0; i < particles.size(); ++i)
    {
        particles.lifeRemaining[i] -= delta;
    }
}

这导致相同的元素被打包在一起,允许您一次对多个元素进行操作

Particle 0 Particle 1 Particle 2 Particle 3
position_0 position_1 position_2 position_3
velocity_0 velocity_1 velocity_2 velocity_3
scale_0 scale_1 scale_2 scale_3
scaleVelocity_0 scaleVelocity_1 scaleVelocity_2 scaleVelocity_3
lifeRemaining_0 lifeRemaining_1 lifeRemaining_2 lifeRemaining_3
color_0 color_1 color_2 color_3

这种内存布局更有助于矢量化,并且由于缓存一致性可以显着提高性能。它还为您提供了以类似于

的方式手动矢量化代码的选项
void updateParticles(const float delta)
{
    // A vec2 of type float requires 64 bits, allowing us to pack 2 into a 128 bit vector.
    for(size_t i = 0; i < particles.size() / 2; i += 2)
    {
        const __m128 positionVector = _mm_loadu_ps(&particles.position[i]);
        const __m128 velocityVector = _mm_loadu_ps(&particles.velocity[i]);
        const __m128 newPosition = _mm_add_ps(positionVector, velocityVector);
        _mm_storeu_ps(&particles.position[i], newPosition);
    }

    // A float requires 32 bits, allowing us to pack 4 into a 128 bit vector.
    for(size_t i = 0; i < particles.size() / 4; i += 4)
    {
        const __m128 scaleVector = _mm_loadu_ps(&particles.scale[i]);
        const __m128 scaleVelocityVector = _mm_loadu_ps(&particles.scaleVelocity[i]);
        const __m128 newScale = _mm_add_ps(positionVector, scaleVelocityVector);
        _mm_storeu_ps(&particles.scale[i], newScale);
    }

    const __m128 deltaVector = _mm_set_ps1(delta);

    // A float requires 32 bits, allowing us to pack 4 into a 128 bit vector.
    for(size_t i = 0; i < particles.size() / 4; i += 4)
    {
        const __m128 lifeRemainingVector = _mm_loadu_ps(&particles.lifeRemaining[i]);
        const __m128 newLifeRemaining = _mm_sub_ps(lifeRemainingVector, deltaVector);
        _mm_storeu_ps(&particles.lifeRemaining[i], newLifeRemaining);
    }
}

此代码显然需要进行一些合理性检查,例如确保每个数组大小始终是向量大小的倍数。此外,如果您可以确保内存从 16 字节边界开始,您可以使用对齐的加载和存储功能。如果可用,您还可以使用 AVX 和 AVX2 256 位寄存器。

内存访问是现代计算机中最慢的部分,这样做可以确保在给定时间操作尽可能多的数据。这也使得 CPU 更容易急切地加载内存行。您现在也可以在多核环境中使用它,性能显着提高并且不会破坏缓存行的使用,这可以通过 CPU 上的多个线程完成,也可以使用 CUDA、OpenCL 或计算着色器。