SIMD 的 C++ 设计:使 SoA 少一个 PiTA

C++ Designing for SIMD: Making an SoA less of a PiTA

苦乐参半的 SoAs

我最近看到了将手写 SIMD 内在函数与 SoA(数组结构)表示结合使用的乐趣。

与我以前的 AoS(结构数组)代码相比,速度有所提高,至少对于简单的顺序型流操作而言,速度提高了一倍到三倍,简直令人惊叹。作为奖励,除了减少内存使用外,它还简化了排除那些棘手的水平操作和洗牌组件的逻辑。

然而,后来我意识到他们在代码中使用的 PITA 是多么的苦乐参半,尤其是界面设计。

中级界面设计

我经常处理中级界面的设计。它们比 std::vector 级别更高,但比视频游戏中的 Monster class 级别低。对于我来说,这些总是一些最难设计和保持稳定的接口,因为它们不够低级,无法像标准 C++ 容器一样提供简单的 read/write 接口。然而,它们还不够高级(在界面的入口点中缺乏足够的逻辑)来完全隐藏和抽象掉底层表示,只提供高级操作。

我认为中级设计的一个例子是可编程粒子系统 API 它希望在某些场景中尽可能高效和可扩展,同时方便临时场景(例如:脚本编写者).这样的设计必须提供粒子访问,除非它要为与可以想象的粒子相关的每一种可能算法都有一个方法,否则它必须在某个地方公开一些原始的 SoA 细节,让客户从中受益。

设计也不应该一定要一直写SoA类型的代码。更多的日常使用仍然不要求最大的效率,而是方便、简单、生产力。它仅适用于底层 SoA 表示派上用场的那些罕见的、性能关键的场景。

那么你们 API/lib 设计师和大型系统人员如何平衡这些类型的需求?

平衡多重访问模式

由于 SoA 消除了任何基于元素的结构,当用户使用更方便、随机的方式访问 nth 元素时,即时实例化 structs/classes 可能是一个不错的主意访问部分界面?也许一个结构包含 pointers/references 到多个 SoA 数组的第 n 个条目以进行可变访问?

此外,如果更常见的使用模式更多是随机访问标量逻辑而不是顺序访问 SIMD 向量逻辑,但 SIMD 部分被触发到足以使仅使用一种数据结构来更好,这种混合 SoA 表示是否可以更好地平衡所有需求?

struct AoSoA
{
    ALIGN16 float x[4];
    ALIGN16 float y[4];
    ALIGN16 float z[4];
};
ALIGN16 AoSoA elements[n/4];

我不了解高速缓存行的性质,无法很好地了解这种表示是否值得。我注意到它对于我们可以将全部资源用于一个庞大算法的顺序 SIMD 案例没有太大帮助,但它似乎对需要大量跨组件水平逻辑或随机访问的案例有帮助系统可能同时做很多其他事情的标量逻辑情况。

无论如何,我通常在寻找如何有效地设计具有 SoA 后端表示的中层数据结构接口作为实现细节的见解,而不会将复杂性转移给客户,除非他们真的想要它。

我真的很想避免强迫客户总是在每个使用接口的地方编写 SoA 类型的代码,除非他们真的需要那种效率,我很好奇如何平衡那些更日常、随机访问的代码标量使用场景与利用 SoA 表示的罕见但并不少见的场景。

实际上我对软件工程的了解还不够多,无法为你想做的事情制定一个总体策略,但特别是对于 AoS 与 SoA 的问题,我发现 Robert Strzodka 的这篇论文很吸引人:http://asc.ziti.uni-heidelberg.de/sites/default/files/research/papers/public/St11ASX_CUDA.pdf

此抽象的目标是提供一种在 AoS 和 SoA 以及更复杂的嵌套之间切换的简单方法。作者使用它来展示性能如何随着不同的访问模式而变化,而无需触及算法部分,并且无需重新编码所有访问的痛苦。

虽然它更侧重于 GPU 方面,但提供的代码也适用于 CPU。

到目前为止,我在内部找到了这种 "hybrid SoA" 或 "AoSoA" 代表的合适人选。

struct HybridSoA
{
     ALIGN float x[4];
     ALIGN float y[4];
     ALIGN float z[4];
};

它通过为随机访问路径保留合理的空间局部性的设计,平衡了那些使用 SIMD 和随机访问的顺序快速路径以及并不真正关心 SIMD 的较慢路径。

对于接口,我还没有太喜欢,只是为那些快速顺序路径和代理返回指向这些结构的指针,允许 operator[] 等进行标量式访问。

接口类型在 SIMD 路径上泄漏了一些内部结构,但这似乎是不可避免的,因为设计无法预测所有的高级需求,而不会变得越来越单一,并且它以某种方式抽象并具有严格的 ABI担心难以使用更丰富的接口(实际接口是用 C 编写的,顶部有 C++ 包装器)。

如果我提供一种 foreach 接受函数指针的方法(或最终转化为 std::function 之类的东西,虽然我不能使用它,但也许会更好直接由于 ABI 原因)被回调而不是直接公开内部句柄。它可以批量输入 SIMD 所需的 SoA 数据以减轻调用开销,这将缓解我遇到的时间耦合问题,其中对结构的写访问需要显式 commit 调用来记录对申请历史。

如果迭代器兼作以代理样式形式访问数据的方式(减少原始暴露),它们可能会很好。尽管我有点不喜欢通用容器以外的所有迭代器,尤其是在相关算法不属于通用类别的情况下。我只是发现过去为所有内容维护迭代器是一种负担(这种负担超过了使用基于范围的好处的好处 operator[],例如),并且开始支持 C 之间的一种混蛋美学和 C++(仅适用于这些比标准容器更复杂并存储不同类型数据的中级数据结构,但级别不够高,无法对 public 接口施加许多限制通用容器)。

仅对于这些特定类型的数据结构,我发现最有成效的是支持普通的旧 C 风格数组的界面美学,尽管这肯定有偏见,而且肯定只是我自己倾向的结果。对于网格之类的东西,我只是不断发现自己越来越被 C 风格的审美所吸引,即使只是因为过去我一直在为这些情况编写太多代码层而犯错,以至于我对我的代码感到困惑自己的创作。

感谢到目前为止的所有回答和评论!