我想知道如何存储N个小尺寸的矢量(比方说X,Y,Z)以提高效率。
出于缓存局部性的原因,我期望一个接一个地打包向量[N] [3](行主要)将产生比布局[3] [N]更好的结果(其中维度X,Y然后在使用OpenMP进行矢量操作时,Z是连续布局的。
然而,将每个向量乘以3x3矩阵,并使用英特尔MKL BLAS,我发现布局[3] [N]的速度是其两倍。
我认为缓存局部性是由SSE指令适用于非跨步数据的事实来抵消的。这让我想知道人们(例如计算机图形学)如何存储他们的载体以及是否存在其他优点和缺点。
答案 0 :(得分:1)
使用了两种常见的数据布局:结构数组(AoS)和数组结构(SoA)。
AOS:
struct
{
float x;
float y;
float z;
} points[N];
SoA的:
struct
{
float x[N];
float y[N];
float z[N];
} points;
为了将AoS情况下的每个点乘以3x3矩阵M
,循环体看起来像:
r[i].x = M[0][0]*points[i].x +
M[0][1]*points[i].y +
M[0][2]*points[i].z;
// ditto for r[i].y and r[i].z
SSE可以一次乘以4个浮点数(AVX可以乘以8个浮点数)并且它还提供点积运算但问题是在向量寄存器中加载3个浮点数是非常低效的运算。可以添加额外的float
字段来填充结构,但由于两个向量操作数中的第4个浮点数未使用(或者不包含有用信息),因此仍然会丢失1/4的计算能力。您也可以不对点进行矢量化,例如一次处理4个点,因为一次加载points[i].x
到points[i+3].x
需要收集加载,这在x86上尚未支持(尽管这会在支持AVX2的CPU可用时发生变化)。
在SoA案例中,内部循环是:
r.x[i] = M[0][0]*points.x[i] +
M[0][1]*points.y[i] +
M[0][2]*points.z[i];
// ditto for r.y[i] and r.z[i]
它看起来基本相同,但有一个非常重要的区别。现在编译器可以使用向量指令并一次处理4个点(甚至是AVX的8个点)。例如。它可以展开循环并将其转换为以下向量等效项:
<r.x[i], r.x[i+1], r.x[i+2], r.x[i+3]> =
M[0][0]*<x[i], x[i+1], x[i+2], x[i+3]> +
M[0][1]*<y[i], y[i+1], y[i+2], y[i+3]> +
M[0][2]*<z[i], z[i+1], z[i+2], z[i+3]>
这里有三个向量加载,三个标量向量乘法,三个向量加法和一个向量存储。所有这些都利用了SSE的100%矢量功能。唯一的问题是,当点数不能被4整除时,可以轻松填充数组,或者编译器可能生成标量代码以执行串行的剩余迭代。无论哪种方式,如果你有很多分数,那么只剩下1到3分的性能会比在每个点上不断充分利用硬件更有利。
另一方面,如果您经常需要访问随机点的(x,y,z)
元组,那么SoA实现将导致三个缓存行读取(如果数据不适合缓存),而AoS实现需要一个或两个(填充一个可以避开需要两个负载的情况)。所以答案是 - 数据结构取决于算法主导哪种操作。