“遍历”只是意味着循环数据。
是的,关于缓存方式和冲突,你是对的。64B(高速缓存行大小)内存块彼此偏移大 2 次方映射到同一组,因此彼此竞争该组中的方式,而不是缓存在不同组中。(例如 Intel 的 L1 数据缓存是 32kiB,8-way associative,有 64B 行。32kiB / 64 B/line = 512 lines
分为512 lines / 8 ways/set = 64 sets
.
加载 9 个相互偏移 4kiB 的项目(64B/line * 64 sets
不是巧合的页面大小)将驱逐第一个。
L2 和 L3 缓存具有更高的关联性,例如 16 或 24 路,但仍然容易受到这样的“别名”的影响,就像哈希表一样,其中对某些集合(桶)有大量需求,而对其他集合(桶)没有需求)。对于 CPU 缓存,“散列函数”几乎总是使用一些地址位作为索引,而忽略其他位。(地址的高位用作标记,以确定集合中是否有任何方式实际缓存请求的块,低位用于选择缓存行中的字节。)
我认为 SoA 的好处主要来自 SIMD(自动矢量化或手动),但如果您倾向于遍历数据,只查看大多数结构中的一个或两个字段,并且仅在您发现一个基于一个成员的有趣的。
为您一起查看的每个事物(或事物组)使用单独的数组的混合方法可能是有意义的,而每个对象的其余数据都在一个结构数组中。我在想象一个线性搜索循环,其中大多数对象基于查看一个int
字段而被拒绝,但对于通过该测试的少数对象,您查看所有字段。
将最常被访问的字段组合在一起可以为这些访问带来空间局部性的好处,同时仍然让检查关键字段的搜索循环在连续内存上循环(而不是大步前进)。
我目前正在试验一种在 SIMD 矢量大小的组中交错的布局。大多数遍历数据的代码都需要来自每个对象的所有字段,这样做意味着循环只需要一个指针,并且所有内存都被分配为一个块。
这适用于碰撞检测蒙版(在 2D 空间游戏(Endless Sky)中,线段和船轮廓(从精灵自动追踪)之间的所有碰撞,而不是两个多边形之间的碰撞)。这是在 x,y 对的向量上循环的原始double
代码(并使用一些(非内联!)函数将它们作为 16B SIMD 向量进行操作,通常使用缓慢的 SSE3 水平加法指令和类似的东西:()。
如果您无法更改数据布局,XY 对上的 SSE2/SSE3 可能总比没有好,但更改布局消除了并行执行 4 个交叉产品的所有改组。 请参阅Insomniac Games (GDC 2015) 上此 SIMD (SSE) 介绍中的幻灯片。它从非常基本的东西开始,供以前没有使用过 SIMD 的人使用,并准确解释了数组结构的帮助。最后,它涉及中级/高级 SSE 技术,因此即使您已经了解一些 SIMD 知识,也值得翻阅一下。另请参阅sse标签 wiki 以获取其他一些链接。
无论如何,这是我想出的交错数据结构:
class Mask {
...
struct xy_interleave {
static constexpr unsigned vecSize = 4;
static constexpr unsigned alignMask = vecSize-1;
alignas(64) float x[vecSize];
float y[vecSize];
// TODO: reduce cache footprint by calculating this on the fly, maybe with an unaligned load?
float dx[vecSize]; // next - current; next.x = x+dx
float dy[vecSize];
};
std::vector<xy_interleave> outline_simd;
}
然后我可以用类似的东西循环它(这里的真实代码:这是我正在进行的未清理代码,尚未准备好发送到上游)
__m128 minus_point_ps = _mm_cvtpd_ps(-point); // + is commutative, which helps the compiler with AVX
const __m128 minus_px = _mm_set1_ps(minus_point_ps[0]);
const __m128 minus_py = _mm_set1_ps(minus_point_ps[1]);
const __m128 range2 = _mm_set1_ps(float(range*range));
for(const xy_interleave &curr : outline_simd)
{
__m128 dx = _mm_load_ps(curr.x) + minus_px;
__m128 dy = _mm_load_ps(curr.y) + minus_py;
// this is using GNU Vector Extensions for + and *, instead of _mm_add_ps and _mm_mul_ps, since GNU C++ defines __m128 in terms of __v4sf
__m128 cmp = _mm_cmplt_ps(dx*dx - range2, dy*dy); // transform the inequality for more ILP
// load the x and y fields from this group of 4 objects, all of which come from the same cache line.
if(_mm_movemask_ps(cmp))
return true;
}
这编译为非常漂亮的 asm 循环,只有一个指针在 std::vector 上循环,向量从相对于该循环指针的恒定偏移量加载。
但是,相同数据上的标量回退循环不太漂亮。(实际上我j+=4
在手动矢量化部分也使用了这样的循环(带有 ),所以我可以在不破坏代码的情况下更改交错。它完全编译掉,或者变成展开)。
// TODO: write an iterator or something to make this suck less
for(const xy_interleave &curr : outline_simd)
for (unsigned j = 0; j < curr.vecSize; ++j)
{
float dx = curr.x[j] - px;
float dy = curr.y[j] - py;
if(dx*dx + dy*dy < range2)
return true;
}
不幸的是,我没有运气让 gcc 或 clang 自动矢量化它,即使对于没有条件的简单情况(例如,只是找到从查询 x,y 到碰撞掩码中任何点的最小范围,而不是检查是否一个点在范围内)。
我可能会放弃这个想法并使用单独的 x 和 y 数组。(也许将头尾打包在同一个std::vector<float>
(使用对齐的分配器)中以使其成为一个分配的一部分,但这仍然意味着循环需要单独的 x 和 y 指针,因为给定顶点的 x 和 y 之间的偏移量会是运行时变量,而不是编译时常量。)
x
如果我想停止存储 sx[i+1]-x[i]
并即时计算它,让所有s 连续将是一个很大的帮助。使用我的布局,我需要在向量之间进行洗牌,而不是仅仅做一个未对齐的偏移量 1 个浮点数。
它还有望允许编译器自动矢量化某些函数(例如,对于 ARM,或者对于具有更宽矢量的 AVX/AVX2)。
当然,手动矢量化将在这里获胜,因为我正在做类似 XORing 浮点数之类的事情,因为我只关心它们的符号位作为真值,而不是进行比较然后对比较结果进行异或。(到目前为止,我的测试表明,将负 0 视为负数仍然可以为 Mask::Intersect 提供正确的结果,但是在 C 中表达它的任何方式都将遵循 IEEE 规则,其中x >= 0
为 true x=-0.
)。
如果您几乎只需要一次访问大量数据的单个字段 AoS 可能会更高效,而如果您经常需要从同一个对象访问多个字段或处理单个对象而不是一次处理多个对象, SoA 的性能会更高。
你有这个完全倒退。这是一个错字吗?foo[i].key
将所有字段分组到一个foo.key[i]
数组中意味着它们都打包在缓存中,因此仅访问许多对象中的一个字段意味着您正在使用您接触的每个缓存行的所有 64 字节。
你之前写的时候是对的
当仅处理来自许多“人员”记录的部分数据时,只需将这些数据加载到内存中。
(除非我认为您的意思是“从”内存(到缓存),除非您在谈论内存映射文件和从磁盘到内存的错误页面。)
索引寻址模式:
在您查看每个对象中的两个或三个字段的情况下,SoA 布局将占用更多的寄存器,这些寄存器为您循环的每个单独数组保存单独的基地址。
使用多个指针,您要么使用[reg1 + 4*reg2]
x86 上的寻址模式,要么需要在循环中单独增加一堆不同的指针。索引寻址模式在英特尔 SnB 系列上可能会稍微慢一些,因为它们无法与乱序内核中的 ALU 微指令保持微融合(仅在解码器和微指令缓存中)。Skylake 可以让它们保持微融合,但需要进一步测试才能确定英特尔何时进行此更改。当 FMA 以外的三输入指令(如 CMOV 和 ADC)解码为单个 uop 时,也许使用 Broadwell,但这纯粹是猜测。需要对 Haswell 和 Broadwell 进行测试。