灵活粒子系统 - 代码优化





5.00/5 (1投票)
使我的粒子系统运行得更快的几项代码更改:SIMD、随机数生成器、指针别名、内存对齐
在玩弄了这些工具之后,我们有了更多选项来提高粒子系统的性能。这一次,我们需要重写部分代码。
总的来说,粒子系统的运行速度比最初快了将近**两倍**!请继续阅读,了解哪些代码片段得到了修改。
系列文章
- 初始粒子演示
- 引言
- 粒子容器 1 - 问题
- 粒子容器 2 - 实现
- 生成器和发射器
- 更新器
- 渲染器 (Renderer)
- 软件优化入门
- 工具优化
- 代码优化(本篇文章)
- 渲染器优化
- 摘要
本文计划
Start
我们从这些数字开始,查看上一篇文章(上次结果)。
Core i5 Sandy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 429.195 | 608.598 | 460.299 |
181000 | 460.649 | 647.825 | 490.412 |
191000 | 489.206 | 688.603 | 520.302 |
Core i5 Ivy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 529.188 | 746.594 | 570.297 |
181000 | 565.648 | 792.824 | 605.912 |
191000 | 593.956 | 832.478 | 640.739 |
(时间单位为毫秒)
SIMD 准备
之前,我尝试强制编译器使用 SSE2 或 AVX 指令。正如我们所见,这带来了不错的性能提升(AVX 大约 10%)。但是……SIMD 应该能以 4 倍或 8 倍的速度计算,那为什么我们只获得了微小的提升?
在现实生活中,事情并非如此简单
- SIMD 一次可以执行 4 或 8 条指令,但我们仍然需要等待内存。有关更多信息,请参阅我对讲座 "现代 CPU 上的原生代码性能" 的总结。总的来说,假设我们的代码是理想的“可向量化”代码,使用 SSE2/4 最多可以获得 2.5 倍的速度提升。并非所有代码都处于如此完美的状态。
- 当前的 CPU 是超标量的,这意味着 CPU 可以并行运行多个不同的指令。有时,SIMD 代码的速度甚至会比编译器生成的原始代码还要慢。
- 另一个小问题:SIMD 寄存器需要 128 位(16 字节对齐)的内存块。我们在分配新内存时需要注意这一点。因此,并非所有变量或数组都适合 SSE 代码。
我们能做什么?
- 由于粒子主要操作
glm::vec4
,因此很有可能利用 SSE 的全部功能。我们每个向量使用 4 个浮点数,即 16 字节。 glm
添加了一个非常好的功能glm::simdVec4
,它基本上为常见的向量函数添加了 SSE 代码。所以我只是将glm::vec4
改为了glm::simdVec4
。- 内存必须对齐,所以我使用了
_aligned_malloc
和_aligned_free
。
一些代码示例
// particles.h, in ParticleData class declaration
glm::simdVec4 *m_pos;
glm::simdVec4 *m_col;
// in particles.cpp, generate() method:
m_pos = (glm::simdVec4 *)_aligned_malloc(sizeof(glm::vec4)*maxSize, 16);
m_col = (glm::simdVec4 *)_aligned_malloc(sizeof(glm::vec4)*maxSize, 16);
// particles.cpp, destructor
_aligned_free(m_pos);
_aligned_free(m_col);
更改后的结果(Visual Studio)
Sandy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 387.563 | 495.281 | 394.641 |
181000 | 417.320 | 529.660 | 426.330 |
191000 | 447.665 | 563.833 | 450.416 |
Ivy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 476.625 | 596.313 | 483.656 |
181000 | 514.328 | 639.664 | 523.332 |
191000 | 552.666 | 682.333 | 558.667 |
哇:提升了近20%!这都是通过正确的数据结构(用于向量)和内存对齐实现的。
SSE 和 AVX 指令
到目前为止,我们获得了不错的加速……现在,让我们为最关键的循环编写一些 SSE 代码。它会运行得更快吗?
欧拉更新,SSE
__m128 ga = globalA.Data;
__m128 *pa, *pb, pc;
__m128 ldt = _mm_set_ps1(localDT);
size_t i;
for (i = 0; i < endId; i++)
{
pa = (__m128*)(&p->m_acc[i].x);
*pa = _mm_add_ps(*pa, ga);
}
for (i = 0; i < endId; i ++)
{
pa = (__m128*)(&p->m_vel[i].x);
pb = (__m128*)(&p->m_acc[i].x);
pc = _mm_mul_ps(*pb, ldt);
*pa = _mm_add_ps(*pa, pc);
}
for (size_t i = 0; i < endId; i++)
{
pa = (__m128*)(&p->m_pos[i].x);
pb = (__m128*)(&p->m_vel[i].x);
pc = _mm_mul_ps(*pb, ldt);
*pa = _mm_add_ps(*pa, pc);
}
在这种情况下,可读性大大降低。
结果
Sandy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 386.453 | 492.727 | 393.363 |
181000 | 416.182 | 529.591 | 423.795 |
191000 | 444.398 | 564.199 | 450.099 |
Ivy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 481.172 | 584.086 | 486.543 |
181000 | 516.271 | 623.136 | 514.068 |
191000 | 547.034 | 656.517 | 541.258 |
不幸的是,提升不大。这是因为 glm::simdVec4
使用了 SSE 代码。所以没有必要重写它。我们会失去可读性,并且性能提升是值得怀疑的。
指针别名:__restrict 关键字
在我上一篇文章中,我收到了来自 Matías N. Goldberg 的一个非常有趣的评论
……根据我的经验,您可以通过告诉编译器指针之间不会发生别名来获得**巨大的**性能提升……
Matias 建议使用 __restrict
关键字来告诉编译器指针不会发生别名。例如
glm::vec4 * __restrict acc = p->m_acc;
glm::vec4 * __restrict vel = p->m_vel;
glm::vec4 * __restrict pos = p->m_pos;
然后,使用 pos
指针而不是 p->m_pos
。
当我将所有更新器(和生成器)代码进行此类更改后,我得到了以下结果
Sandy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 372.641 | 476.820 | 376.410 |
181000 | 401.705 | 508.353 | 404.176 |
191000 | 427.588 | 542.794 | 432.397 |
Ivy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 475.609 | 591.805 | 480.402 |
181000 | 502.201 | 620.601 | 512.300 |
191000 | 534.150 | 667.575 | 541.788 |
这不是巨大的提升,但仍然值得测试。
随机数生成器
目前,调用的是标准的 C rand()
函数。对于粒子系统,我们可能不需要使用更高级的东西(例如正态分布随机生成器)——均匀分布就可以了……也许有一些比默认生成器更快的生成器?到目前为止,我主要关注了更新器部分。但是,生成器也可以稍作改进。在此模块中,随机数生成器被大量使用。如果我们更改它呢?
我尝试使用这个生成器
// http://www.rgba.org/articles/sfrand/sfrand.htm
static unsigned int mirand = 1;
float sfrand(void) {
unsigned int a;
mirand *= 16807;
a = (mirand & 0x007fffff) | 0x40000000;
return(*((float*)&a) - 3.0f);
}
它具有均匀分布和 23 位精度(C rand()
只有 16 位)。
结果
Sandy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 334.633 | 443.816 | 348.908 |
181000 | 363.954 | 474.477 | 372.739 |
191000 | 384.869 | 501.435 | 394.217 |
Ivy Bridge
计数 | 隧道 | 吸引子 | 喷泉 |
171000 | 412.172 | 531.586 | 429.293 |
181000 | 450.146 | 573.073 | 463.037 |
191000 | 473.518 | 606.759 | 484.880 |
哇!现在 Sandy Bridge 的总提升约为 28%,Ivy Bridge 几乎相同。
总结
最终结果
CPU | 计数 | 隧道 | 吸引子 | 喷泉 |
Sandy | 191000 | 384.869 (-21.3%) | 501.435 (-27.2%) | 394.217 (-24.2%) |
Ivy | 191000 | 473.518 (-20.3%) | 606.759 (-27.1%) | 484.880 (-24.3%) |
总计(以工具优化前的耗时为基准)
CPU | 隧道 | 吸引子 | 喷泉 |
Sandy | 35.5% | 43,5% | 39,7% |
Ivy | 33.2% | 38,2% | 35,6% |
我们可以“反转”这些数字,说现在的吸引子效果运行速度快了近两倍!不算差!
结论
- 内存对齐和正确的数据结构是关键因素。
- 仅在需要时编写 SIMD 代码,通常最好依赖编译器和第三方库。
- 更好地描述您的代码:例如使用
__restrict
关键字。这样,编译器就可以生成更好的代码。 - 随机数生成器可能会带来差异。
下一步
到目前为止,渲染器非常简单。也许有一些方法可以改进它的代码。当然,我们必须关注 CPU 到 GPU 的内存传输以及更好的缓冲区使用。
参考文献