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 或计算着色器。
我有一个存储粒子指针的粒子池
它有大量的粒子
每个粒子都有 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 或计算着色器。