【问题标题】:C++: Accessing C-array much faster when accessing elements sequentiallyC++:按顺序访问元素时访问 C 数组的速度要快得多
【发布时间】:2016-03-17 22:55:11
【问题描述】:

我想在内存中存储一​​个 3d 卷。为此,我使用线性数组,然后从 3d-index 计算 1d-index。它封装在一个名为Volume 的类中,该类提供了访问数组数据元素的函数。这是访问卷的一个数据元素的函数:

template<typename T>
inline T& Volume<T>::at(size_t x, size_t y, size_t z) {
    if (x >= this->xMax || y >= this->yMax || z >= this->zMax) throw std::out_of_range("Volume index out of bounds");
    return this->volume[x * this->yMax*this->zMax + y*this->zMax + z]
}

现在,这会以 Z 最快的索引顺序线性化 3d 体积。如果在这样的循环中访问卷,则会在卷元素位于内存中时按顺序对其进行迭代:

Volume<float> volume(10, 20, 30); //parameters define size
for(int x = 0; x < volume.xSize(); ++x) {
    for(int y = 0; y < volume.ySize(); ++y) {
        for int z = 0; z < volume.zSize(); ++z) {
            volume.at(x, y, z);  //do sth with this voxel
        }
    }
}

但是,如果我这样编写循环,它们将不会按顺序访问,而是以更“随机”的顺序访问:

Volume<float> volume(10, 20, 30); //parameters define size
for(int z = 0; z < volume.zSize(); ++z) {
    for(int y = 0; y < volume.ySize(); ++y) {
        (for int x = 0; x < volume.zSize(); ++x) {
            volume.at(x, y, z);  //do sth with this voxel
        }
    }
}

现在,第一种情况运行得很快,第二种情况运行得很慢。我的第一个问题是:为什么?我猜这与缓存有关,但我不确定。

现在,我可以像这样重写卷元素的访问函数:

template<typename T>
inline T& Volume<T>::at(size_t x, size_t y, size_t z) {
    if (x >= this->xMax || y >= this->yMax || z >= this->zMax) throw std::out_of_range("Volume index out of bounds");
    return this->volume[x * this->yMax*this->zMax + y*this->zMax + z]
}

那么循环顺序 #2 会很快(因为访问是按顺序进行的),但循环顺序 #1 很慢。

现在,由于某种原因,我的程序中需要两个索引顺序。两者都应该很快。这个想法是可以在创建卷时定义索引排序,然后使用该索引排序。首先,我在 at 函数中尝试了一个简单的 if-else 语句。然而,这似乎并没有奏效。

所以我在设置订购模式时尝试了这样的事情:

template<typename T>
void Volume<T>::setMemoryLayout(IndexOrder indexOrder) {
    this->mode = indexOrder;
    if (indexOrder == IndexOrder::X_FASTEST) {
        this->accessVoxel = [this](size_t x, size_t y, size_t z)->T& {
            return this->volume[z * this->yMax*this->xMax + y*this->xMax + x];
        };
    } else {
        this->accessVoxel = [this](size_t x, size_t y, size_t z)->T& {
            return this->volume[x * this->yMax* this->zMax + y*this->zMax + z];
        };
    }
}

然后当实际访问体素时:

template<typename T>
inline T& Volume<T>::at(size_t x, size_t y, size_t z) {
    if (x >= this->xMax || y >= this->yMax || z >= this->zMax) throw std::out_of_range("Volume index out of bounds");
    return this->accessVoxel(x, y, z);
}

所以我的想法是通过在当前模式更改时动态定义一次 lambda 函数来减少 at 函数内部必需的 if 语句的开销。只有在调用at 时才需要调用它。然而,这并没有达到我想要的效果。

我的问题是为什么我的尝试没有奏效,如果有一种方法可以让我真正做我想做的事:支持 X-fastest 和 Y-fastest 索引排序并提供相应的性能提升的卷当相应地循环时。

注意:我的目标是不能在两种模式之间切换,同时有数据分配给卷,数据仍然被正确读取。

【问题讨论】:

  • 你在缓存中调用它。一旦你开始跳来跳去,你就不会得到可以批量读取的长时间运行的数据。 CPU 不仅会读取你想要的内存,还会读取一些它的邻居,假设如果你想要 N,那么很快你就会想要 N+1。还不如现在加载 N+1 并在以后节省时间。如果你想要 N 然后 N+100,你将读取 N 周围的内存,然后读取 N+100 周围的内存,这会很痛苦。
  • 你的volume 3D 向量真的那么小,还是更大?
  • “这没有达到我想要的”不是一个问题。你说的是快,但什么对你来说足够快?我猜你的代码中有一些错误,例如for int x = 0; x &lt; volume.zSize(); ++x。是的,您可以在编译时指定诸如 row_major 或 column_major 之类的存储模式,例如通过专业化。
  • 一个测试编译时常量的if 将优化到几乎没有,就像它从未存在过一样。当编译器无法证明 accessVoxel 被设置为什么时,评估 lamba 表达式可能不会被优化掉。就像@knivil 所说,将行主要与列主要存储顺序作为模板参数应该可以很好地解决问题。您总是希望此类的函数内联,因此无需生成更多独立版本的函数。
  • stackoverflow.com/questions/12264970/… 有一个答案,其中包含指向其他问题的一些链接,因此,如果您想开始阅读大量有关顺序访问与跨步访问的文本,以及跨步为 2048B 的倍数时的潜在问题(缓存别名)/ 4096B(英特尔访问之间的错误依赖关系),然后开始寻找那里。

标签: c++ arrays performance


【解决方案1】:

在我的 cpu(也可能是你的)上,我有 64 字节的缓存行。每个高速缓存行包含 16 个 4 字节浮点数。当为第一个浮点数获取缓存行时,您不需要在顺序访问时对以下 15 个重复该工作。

请注意,从主内存中获取高速缓存行大约需要 240 个周期。从 L1 缓存中获取大约需要 12 个周期,如果您可以重复访问 L1,这将是一个很大的不同。 (L2 大约 40 个周期,L3 150 个周期)

顺序访问的第二个缓存优势是 CPU 在顺序读取时会为您预取数据到缓存中。因此,如果您从数组的开头开始并按顺序遍历它,您甚至可以避免读取缓存行的惩罚。

L1 通常是 32k 的数据(和 32k 的指令缓存),对我来说这台机器上的 L2 是 256K,L3 是兆字节。因此,您可以保持内存工作集越小,您可以在给定缓存中容纳的内存就越多。将其全部安装在 L1 中是最佳的。

顺序访问是最佳的第三个原因是它使您的编译器有机会向量化指令。即使用 SSE 或 AVX 指令。 AVX 寄存器为 32 字节,因此可以容纳 8 个浮点数。潜在地,您可以一次对数组中的 8 个连续项目进行操作,从而将速度提高 8 倍。

【讨论】:

  • 典型 x86 CPU 中的 L1 负载使用延迟为 4 或 5 个周期。具有每核 L2 缓存的 Intel CPU 的 L2 延迟大约为 12c。见agner.org/optimize。大型共享 L3 速度要慢得多,尤其是。在大型 Xeon 上,它可以在环形总线周围更远,或者如果其他内核也在保持 L3 忙碌。仍然赞成大多数正确的想法;具体数字并不那么重要。请注意,现代硬件预取器可以识别跨步访问模式,因此您不必为每次访问支付全部延迟损失。许多人可以在飞行中。
  • SIMD 仅在内部循环访问连续内存时才有效这一点很重要,不过,除了充分利用每个缓存行提取之外。
  • @PeterCordes 我测量的最后一个 cpu 从 L1 到主内存(一些核心 i9 xeon)的周期数为 12,40,150,240。 Ulrich Drepper 测量了 Pentium M 并得到了 3,14,240(没有 L3?) 指向 Agner 网站的顶层并没有帮助我找到他的号码,我想知道您是否可以更好地链接它们?我不知道哪个 cpu 有你引用的数字。所以是的,绝对是,这些数字取决于 CPU。 Drepper 以防万一读到这篇文章的人没有看到它并关心它 - 这是开创性的:akkadia.org/drepper/cpumemory.pdf btw “大部分是正确的想法[原文如此]”将被视为侮辱。
  • Agner 的 microarch pdf 有缓存延迟数字。我倾向于只链接顶级,因为我可以准确地键入该网址,并且如果我只链接一个,我不希望人们错过其他内容。抱歉链接不准确。 Haswell 的 pg143 上的表 10.11。他的三围是:L1:4c / L2:12c / L3:34c。那是在台式机上,而不是 Xeon,可能是最好的情况。 Xeon 与笔记本电脑的 L1/L2 将是相同的,因为它们是每个内核的。回复:P-M:英特尔直到 Nehalem 才切换到大共享 L3,所以是的,Pentium M 只有 L1 / L2。
  • 很好地呼吁链接 Drepper 的论文,这很棒(但是软件预取建议有点过时了,因为它们是为 P4 编写的。后来的 CPU 具有更智能的硬件预取器,所以据我了解它,软件预取主要用于难以预测的模式。(例如,二进制搜索可以预取这之后迭代的两种可能性(+1/4 或 +3/4))。无论如何,我将它添加到 @987654323 @ 几个月前。
【解决方案2】:

您计算机的物理内存不足以容纳整个阵列。解决您的问题的一种方法是添加更多内存。

您的操作系统使用虚拟内存。只要需要更多内存,就会将虚拟内存页面移动到磁盘。访问磁盘非常耗时,这会影响性能。在更糟糕的情况下,操作系统一直在不停地写入和读取(或只是读取)页面。因此,另一种解决方案是以这样一种方式重新组织您的数据,即无论扫描像素的方向如何,磁盘访问都大致相同。我建议有一个页面大小的 3D 区域(通常是 4KB,因此是一个大小为 16 像素的立方体)。因此,当您在一个方向扫描时,您只会触摸其中几页,而当您在另一个方向扫描时,您会触摸相同数量的不同页面。运气好的话(取决于可用的物理内存)没有页面会不必要地进出交换文件。

最好和最简单的解决方案是仅在一个方向上扫描像素。也许您实际上不必具备横向扫描像素的能力。

【讨论】:

  • OP 给出了小尺寸的例子。这是一个缓存问题,而不是 VM 分页问题。但问题本质上是相同的:只使用每个缓存行或页面中的少量数据,因为它是从主内存或磁盘引入的。
猜你喜欢
  • 1970-01-01
  • 2018-05-21
  • 1970-01-01
  • 2017-09-07
  • 2016-10-01
  • 1970-01-01
  • 2012-10-17
  • 2015-12-14
  • 1970-01-01
相关资源
最近更新 更多