【问题标题】:1D or 2D array, what's faster?一维或二维阵列,哪个更快?
【发布时间】:2013-06-20 00:56:21
【问题描述】:

我需要表示一个二维字段(轴 x、y),但我遇到了一个问题:我应该使用一维数组还是二维数组?

我可以想象,重新计算一维数组 (y + x*n) 的索引可能比使用二维数组 (x, y) 慢,但我可以想象一维可能在 CPU 缓存中..

我做了一些谷歌搜索,但只找到了有关静态数组的页面(并说明 1D 和 2D 基本相同)。但我的数组必须是动态的。

那么,是什么

  1. 更快,
  2. 更小 (RAM)

动态一维数组还是动态二维数组?

【问题讨论】:

  • 应该没有任何区别,因为您的 2D 数组作为 1D 存储在内存中,所以无论何时您在内部调用 arr[x][y],它仍然会计算 (&arr[0][0])[x * dim + y]
  • @juan 在技术上是正确的,但 OP 可能是在说动态数组(即T**)而不是真正的数组。因此,它不再是连续的。
  • @KonradRudolph 那不会是二维数组吧 :-)
  • @juanchopanza 通常使用它绝对是一个二维数组。事实上,除非有人明确谈论静态长度,否则我总是假设动态数组,而且我几乎总是正确的。此外,OP 明确提到他需要动态数组。
  • 针对现实世界数字的简明专家建议:fftw.org/doc/…。它甚至提供了一种解决方法,可以两全其美。

标签: c++ c arrays


【解决方案1】:

tl;dr :您可能应该使用一维方法。

注意:在比较动态 1d 或动态 2d 存储模式时无法深入研究影响性能的细节,因为代码的性能取决于大量参数。如果可能,配置文件。

1。什么更快?

对于密集矩阵,一维方法可能更快,因为它提供更好的内存局部性和更少的分配和释放开销。

2。哪个更小?

Dynamic-1D 消耗的内存比 2D 方法少。后者也需要更多的分配。

备注

我在下面给出了一个很长的答案,有几个原因,但我想先对你的假设发表一些评论。

我可以想象,重新计算一维数组 (y + x*n) 的索引可能比使用二维数组 (x, y) 慢

让我们比较一下这两个函数:

int get_2d (int **p, int r, int c) { return p[r][c]; }
int get_1d (int *p, int r, int c)  { return p[c + C*r]; }

Visual Studio 2015 RC 为这些函数(已开启优化)生成的(非内联)程序集是:

?get_1d@@YAHPAHII@Z PROC
push    ebp
mov ebp, esp
mov eax, DWORD PTR _c$[ebp]
lea eax, DWORD PTR [eax+edx*4]
mov eax, DWORD PTR [ecx+eax*4]
pop ebp
ret 0

?get_2d@@YAHPAPAHII@Z PROC
push ebp
mov ebp, esp
mov ecx, DWORD PTR [ecx+edx*4]
mov eax, DWORD PTR _c$[ebp]
mov eax, DWORD PTR [ecx+eax*4]
pop ebp
ret 0

区别在于mov (2d) 与 lea (1d)。 前者的延迟为 3 个周期,每个周期的最大吞吐量为 2 个,而后者的延迟为 2 个周期,每个周期的最大吞吐量为 3 个。 (根据Instruction tables - Agner Fog 由于差异很小,我认为索引重新计算不应该有很大的性能差异。我预计将这种差异本身识别为任何程序的瓶颈的可能性很小。

这将我们带到下一个(也是更有趣的)点:

...但我可以想象一维可能在 CPU 缓存中...

没错,但 2d 也可能在 CPU 缓存中。请参阅缺点:内存局部性,了解为什么 1d 仍然更好。

长答案,或者为什么动态二维数据存储(指针到指针或向量的向量)对于简单/小矩阵来说是“坏的”。

注意:这是关于动态数组/分配方案 [malloc/new/vector 等]。静态二维数组是一个连续的内存块,因此不受我将在此处介绍的缺点的影响。

问题

为了能够理解为什么动态数组的动态数组或向量的向量很可能不是首选的数据存储模式,您需要了解此类结构的内存布局。

使用指针语法的例子

int main (void)
{
    // allocate memory for 4x4 integers; quick & dirty
    int ** p = new int*[4];
    for (size_t i=0; i<4; ++i) p[i] = new int[4]; 

    // do some stuff here, using p[x][y] 

    // deallocate memory
    for (size_t i=0; i<4; ++i) delete[] p[i];
    delete[] p;
}

缺点

内存位置

对于这个“矩阵”,你分配了一个由四个指针组成的块和四个由四个整数组成的块。 所有分配都是不相关的,因此可能会导致任意内存位置。

下图将让您了解内存的外观。

对于真正的二维情况

  • 紫色方块是p本身占用的内存位置。
  • 绿色方块将内存区域 p 指向 (4 x int*)。
  • 4 个连续蓝色方块的 4 个区域是绿色区域的每个 int* 所指向的区域

对于 2d 映射到 1d 的情况

  • 绿色方块是唯一需要的指针int *
  • 蓝色方块集合了所有矩阵元素的内存区域 (16 x int)。

这意味着(使用左侧布局时)您可能会发现性能比连续存储模式(如右侧所示)更差,例如缓存。

假设缓存行是“一次传输到缓存中的数据量”,让我们想象一个程序一个接一个地访问整个矩阵。

如果您有一个正确对齐的 4 乘以 4 的 32 位值矩阵,则具有 64 字节缓存线(典型值)的处理器能够“一次性”处理数据(4*4*4 = 64 字节) . 如果您开始处理并且数据尚未在缓存中,您将面临缓存未命中并且数据将从主内存中获取。此负载可以一次获取整个矩阵,因为它适合缓存行,当且仅当它被连续存储(并正确对齐)时。 处理该数据时可能不会再有任何遗漏。

如果是动态的、“真正的二维”系统,每行/列的位置不相关,处理器需要单独加载每个内存位置。 尽管只需要 64 字节,但为 4 个不相关的内存位置加载 4 条高速缓存行 - 在最坏的情况下 - 实际上会传输 256 字节并浪费 75% 的吞吐量带宽。 如果您使用 2d 方案处理数据,您将再次(如果尚未缓存)在第一个元素上面临缓存未命中。 但是现在,在从主内存第一次加载后,只有第一行/列会在缓存中,因为所有其他行都位于内存中的其他位置并且不与第一行相邻。 一旦你到达一个新的行/列,就会再次出现缓存未命中,并执行下一次从主内存加载。

长话短说:2d 模式有更高的缓存未命中率,而 1d 方案由于数据的局部性而提供更好的性能潜力。

频繁分配/释放

  • 需要多达 N + 1 (4 + 1 = 5) 次分配(使用 new、malloc、allocator::allocate 或其他)来创建所需的 NxM (4×4) 矩阵。
  • 还必须应用相同数量的适当的、各自的释放操作。

因此,与单一分配方案相比,创建/复制此类矩阵的成本更高。

随着行数的增加,情况变得更糟。

内存消耗开销

我将假设 int 的大小为 32 位,指针的大小为 32 位。 (注意:系统依赖。)

让我们记住:我们要存储一个 4×4 int 矩阵,这意味着 64 个字节。

对于一个 NxM 矩阵,存储在我们使用的指针对指针方案中

  • N*M*sizeof(int)【实际蓝色数据】+
  • N*sizeof(int*) [绿色指针] +
  • sizeof(int**) [紫变量 p] 字节。

在本示例的情况下,这会产生 4*4*4 + 4*4 + 4 = 84 字节,而在使用 std::vector&lt;std::vector&lt;int&gt;&gt; 时会变得更糟。 它将需要 N * M * sizeof(int) + N * sizeof(vector&lt;int&gt;) + sizeof(vector&lt;vector&lt;int&gt;&gt;) 字节,即总共 4*4*4 + 4*16 + 16 = 144 字节,而不是 4 x 4 int 的 64 字节。

此外-取决于使用的分配器-每个单独的分配很可能(并且很可能会)有另外16字节的内存开销。 (一些“Infobytes”存储分配的字节数,以便正确释放。)

这意味着最坏的情况是:

N*(16+M*sizeof(int)) + 16+N*sizeof(int*) + sizeof(int**)
= 4*(16+4*4) + 16+4*4 + 4 = 164 bytes ! _Overhead: 156%_

开销的份额将随着矩阵大小的增长而减少,但仍会存在。

内存泄漏风险

这堆分配需要适当的异常处理,以避免在其中一个分配失败时发生内存泄漏! 您需要跟踪分配的内存块,并且在释放内存时不能忘记它们。

如果new 耗尽内存并且无法分配下一行(尤其是当矩阵非常大时),new 会抛出std::bad_alloc

示例:

在上面提到的新建/删除示例中,如果我们想避免bad_alloc 异常情况下的泄漏,我们将面临更多代码。

  // allocate memory for 4x4 integers; quick & dirty
  size_t const N = 4;
  // we don't need try for this allocation
  // if it fails there is no leak
  int ** p = new int*[N];
  size_t allocs(0U);
  try 
  { // try block doing further allocations
    for (size_t i=0; i<N; ++i) 
    {
      p[i] = new int[4]; // allocate
      ++allocs; // advance counter if no exception occured
    }
  }
  catch (std::bad_alloc & be)
  { // if an exception occurs we need to free out memory
    for (size_t i=0; i<allocs; ++i) delete[] p[i]; // free all alloced p[i]s
    delete[] p; // free p
    throw; // rethrow bad_alloc
  }
  /*
     do some stuff here, using p[x][y] 
  */
  // deallocate memory accoding to the number of allocations
  for (size_t i=0; i<allocs; ++i) delete[] p[i];
  delete[] p;

总结

在某些情况下,“真正的 2d”内存布局适合且有意义(即,如果每行的列数不是恒定的),但在最简单和常见的 2D 数据存储情况下,它们只会使代码的复杂性膨胀,并且降低程序的性能和内存效率。

另类

您应该使用一个连续的内存块并将您的行映射到该块上。

这样做的“C++ 方式”可能是编写一个类来管理你的内存,同时考虑一些重要的事情,例如

示例

为了说明此类类的外观,这里有一个简单的示例,其中包含一些基本功能:

  • 2d 尺寸可构造
  • 2d 可调整大小
  • operator(size_t, size_t) 用于 2d 行主要元素访问
  • at(size_t, size_t) 用于检查 2d 行主要元素访问
  • 满足 Container 的概念要求

来源:

#include <vector>
#include <algorithm>
#include <iterator>
#include <utility>

namespace matrices
{

  template<class T>
  class simple
  {
  public:
    // misc types
    using data_type  = std::vector<T>;
    using value_type = typename std::vector<T>::value_type;
    using size_type  = typename std::vector<T>::size_type;
    // ref
    using reference       = typename std::vector<T>::reference;
    using const_reference = typename std::vector<T>::const_reference;
    // iter
    using iterator       = typename std::vector<T>::iterator;
    using const_iterator = typename std::vector<T>::const_iterator;
    // reverse iter
    using reverse_iterator       = typename std::vector<T>::reverse_iterator;
    using const_reverse_iterator = typename std::vector<T>::const_reverse_iterator;

    // empty construction
    simple() = default;

    // default-insert rows*cols values
    simple(size_type rows, size_type cols)
      : m_rows(rows), m_cols(cols), m_data(rows*cols)
    {}

    // copy initialized matrix rows*cols
    simple(size_type rows, size_type cols, const_reference val)
      : m_rows(rows), m_cols(cols), m_data(rows*cols, val)
    {}

    // 1d-iterators

    iterator begin() { return m_data.begin(); }
    iterator end() { return m_data.end(); }
    const_iterator begin() const { return m_data.begin(); }
    const_iterator end() const { return m_data.end(); }
    const_iterator cbegin() const { return m_data.cbegin(); }
    const_iterator cend() const { return m_data.cend(); }
    reverse_iterator rbegin() { return m_data.rbegin(); }
    reverse_iterator rend() { return m_data.rend(); }
    const_reverse_iterator rbegin() const { return m_data.rbegin(); }
    const_reverse_iterator rend() const { return m_data.rend(); }
    const_reverse_iterator crbegin() const { return m_data.crbegin(); }
    const_reverse_iterator crend() const { return m_data.crend(); }

    // element access (row major indexation)
    reference operator() (size_type const row,
      size_type const column)
    {
      return m_data[m_cols*row + column];
    }
    const_reference operator() (size_type const row,
      size_type const column) const
    {
      return m_data[m_cols*row + column];
    }
    reference at() (size_type const row, size_type const column)
    {
      return m_data.at(m_cols*row + column);
    }
    const_reference at() (size_type const row, size_type const column) const
    {
      return m_data.at(m_cols*row + column);
    }

    // resizing
    void resize(size_type new_rows, size_type new_cols)
    {
      // new matrix new_rows times new_cols
      simple tmp(new_rows, new_cols);
      // select smaller row and col size
      auto mc = std::min(m_cols, new_cols);
      auto mr = std::min(m_rows, new_rows);
      for (size_type i(0U); i < mr; ++i)
      {
        // iterators to begin of rows
        auto row = begin() + i*m_cols;
        auto tmp_row = tmp.begin() + i*new_cols;
        // move mc elements to tmp
        std::move(row, row + mc, tmp_row);
      }
      // move assignment to this
      *this = std::move(tmp);
    }

    // size and capacity
    size_type size() const { return m_data.size(); }
    size_type max_size() const { return m_data.max_size(); }
    bool empty() const { return m_data.empty(); }
    // dimensionality
    size_type rows() const { return m_rows; }
    size_type cols() const { return m_cols; }
    // data swapping
    void swap(simple &rhs)
    {
      using std::swap;
      m_data.swap(rhs.m_data);
      swap(m_rows, rhs.m_rows);
      swap(m_cols, rhs.m_cols);
    }
  private:
    // content
    size_type m_rows{ 0u };
    size_type m_cols{ 0u };
    data_type m_data{};
  };
  template<class T>
  void swap(simple<T> & lhs, simple<T> & rhs)
  {
    lhs.swap(rhs);
  }
  template<class T>
  bool operator== (simple<T> const &a, simple<T> const &b)
  {
    if (a.rows() != b.rows() || a.cols() != b.cols())
    {
      return false;
    }
    return std::equal(a.begin(), a.end(), b.begin(), b.end());
  }
  template<class T>
  bool operator!= (simple<T> const &a, simple<T> const &b)
  {
    return !(a == b);
  }

}

请注意以下几点:

  • T 需要满足使用的std::vector 成员函数的要求
  • operator() 不进行任何“范围内”检查
  • 无需自己管理数据
  • 不需要析构函数、复制构造函数或赋值运算符

因此您不必为每个应用程序的正确内存处理而烦恼,而只需为您编写的类操心一次。

限制

在某些情况下,动态的“真实”二维结构可能是有利的。例如,如果

  • 矩阵非常大且稀疏(如果任何行甚至不需要分配但可以使用 nullptr 处理)或者如果
  • 行的列数不同(也就是说,如果您根本没有矩阵,而只有另一个二维结构)。

【讨论】:

  • 这是一个很好的答案,但你为什么坚持在你的例子中使用(和讨论)原始指针?在现代 C++ 中没有理由这样做。只需使用std::vector 即可。
  • 我使用指针示例有两个主要原因。首先,它有一个可预测的内存布局(标准不能保证向量本身的样子),第二点是我在 SO 上看到的大多数“幼稚方法”都使用指针。
  • 我最近添加了一个关于普通布局 std::vector 及其在内存中的布局的答案。也许这与这个问题有关。 c++ Vector, what happens whenever it expands/reallocate on stack?
  • 还有另一个原因说明为什么做“2D 动态数组”是不好的,但这更有可能只在大尺寸的情况下咬你:new 可以在内存不足时throw .由于“动态分配”这种风格至少需要 两次 调用new(第一次调用T*[N] 数组,第二次调用T[N*M]),所以你还必须try { } catch {} 在每个周围,否则如果第一个成功而第二个抛出,您将泄漏内存。真正的罪魁祸首是 C++/STL 从来不关心标准的matrix 类。如果 Fortran 对 C/C++ 有任何帮助,那就是……
  • @FrankH 这就是我所说的“如果其中一个分配失败,一堆分配需要适当的异常处理以避免内存泄漏!”@**Risk of内存泄漏**,但我想我会进行审查以进一步推进。
【解决方案2】:

除非你说的是静态数组,一维更快

这是一维数组 (std::vector&lt;T&gt;) 的内存布局:

+---+---+---+---+---+---+---+---+---+
|   |   |   |   |   |   |   |   |   |
+---+---+---+---+---+---+---+---+---+

对于动态二维数组 (std::vector&lt;std::vector&lt;T&gt;&gt;) 也是如此:

+---+---+---+
| * | * | * |
+-|-+-|-+-|-+
  |   |   V
  |   | +---+---+---+
  |   | |   |   |   |
  |   | +---+---+---+
  |   V
  | +---+---+---+
  | |   |   |   |
  | +---+---+---+
  V
+---+---+---+
|   |   |   |
+---+---+---+

很明显,2D 情况会丢失缓存局部性并使用更多内存。它还引入了一个额外的间接寻址(因此还有一个额外的指针跟随),但第一个数组有计算索引的开销,因此这些索引或多或少会变得更均匀。

【讨论】:

  • 好答案。我还考虑过动态二维数组上的缓存未命中
【解决方案3】:

一维和二维静态数组

  • 大小:两者都需要相同的内存量。

  • 速度:您可以假设没有速度差异,因为这两个数组的内存应该是连续的( 整个二维数组应该在内存中显示为一个块,而不是一个 一堆散布在内存中的块)。 (这可能是编译器 但是依赖。)

一维和二维动态阵列

  • 大小: 2D 数组将比 1D 数组需要更多内存,因为 2D 数组中需要指针来指向已分配的 1D 数组集。 (当我们谈论真正的大数组时,这个微小的部分只是微小的。对于小数组,相对而言,微小的部分可能相当大。)

  • 速度:一维数组可能比二维数组快,因为二维数组的内存不是连续的,所以缓存未命中会成为问题。


使用可行且看起来最合乎逻辑的方法,如果遇到速度问题,请重构。

【讨论】:

  • "没有速度差异。"这实际上取决于编译器如何计算二维数组的偏移量。
  • 速度方面,还取决于您使用阵列的方式。计算随机访问元素的偏移量是相同的,但是如果你想迭代所有元素,使用一维数组很容易线性迭代,而不用担心嵌套循环或将多维坐标乘以内存偏移量。跨度>
  • 但是static std::vector&lt;T&gt; 是静态的还是动态的呢?抱歉,我无法区分这些。
  • 动态“二维数组”是指向其他数组的指针数组。所以它比一维数组需要更多的空间。
  • 哦,对了。感谢您做出更正。 @mr5 std::vector 将表现得像一个动态一维数组,因为它是这样编程的。 (当我们说static 时,我们并不是指static 关键字本身)
【解决方案4】:

现有的答案都只比较一维数组和指针数组。

在 C(但不是 C++)中有第三种选择;您可以拥有一个动态分配并具有运行时维度的连续二维数组:

int (*p)[num_columns] = malloc(num_rows * sizeof *p);

访问方式类似于p[row_index][col_index]

我希望这与一维数组情况具有非常相似的性能,但它为您提供了更好的访问单元格的语法。

在 C++ 中,您可以通过定义一个在内部维护一维数组的类来实现类似的目的,但可以使用重载运算符通过二维数组访问语法公开它。我再次希望它与普通的一维数组具有相似或相同的性能。

【讨论】:

  • 老实说这听起来很奇怪,我一直认为几乎所有有效的 C 都是有效的 C++.. g++ 4.8.3 采用此代码pastebin.com/Te2n1XhZ...
  • @Paladin C 和 C++ 是不同的语言,每种语言都有一些其他没有的特性,并且一些共同特性的实现方式不同。尝试在标准模式下调用 g++,你会得到一个诊断,默认情况下它启用了一些扩展。
  • @M.M 在 C(但不是 C++)中有第三个选项 为什么只在 C 中?在 C++ 中,您可以轻松地做到int (*p)[num_cols] = new int[num_rows][num_cols]; delete[] p;
  • 你的 C++ 代码中的@vsoftco num_cols 必须是一个常量表达式,但在我的代码中它可以在运行时确定
  • @M.M 哦,我明白了,谢谢!是的,确实,lhs 是指向 C 中的 VLA 的指针,好点!
【解决方案5】:

一维数组和二维数组的另一个区别出现在内存分配上。我们不能确定二维数组的成员是连续的。

【讨论】:

  • 是的。如果有问题的数组存在于 1% 的性能关键代码中,这可能会产生严重影响。
  • 难道不能通过使用 malloc 分配一个大块然后将该块的连续部分用于二维数组来保证连续内存吗?我相信我听说过它被用于游戏等。
【解决方案6】:

这真的取决于你的二维数组是如何实现的。

考虑下面的代码:

int a[200], b[10][20], *c[10], *d[10];
for (ii = 0; ii < 10; ++ii)
{
   c[ii] = &b[ii][0];
   d[ii] = (int*) malloc(20 * sizeof(int));    // The cast for C++ only.
}

这里有 3 个实现:b、c 和 d

访问b[x][y]a[x*20 + y] 不会有太大区别,因为一个是您进行计算,另一个是编译器为您进行计算。 c[x][y]d[x][y] 比较慢,因为机器必须找到 c[x] 指向的地址,然后从那里访问第 y 个元素。这不是一个直接的计算。在某些机器上(例如,具有 36 字节(非位)指针的 AS400),指针访问非常慢。这完全取决于使用的架构。在 x86 类型的架构上,a 和 b 的速度相同,c 和 d 比 b 慢。

【讨论】:

    【解决方案7】:

    我喜欢Pixelchemist 提供的详尽答案。该解决方案的更简单版本可能如下。首先,声明尺寸:

    constexpr int M = 16; // rows
    constexpr int N = 16; // columns
    constexpr int P = 16; // planes
    

    接下来,创建一个别名、get 和 set 方法:

    template<typename T>
    using Vector = std::vector<T>;
    
    template<typename T>
    inline T& set_elem(vector<T>& m_, size_t i_, size_t j_, size_t k_)
    {
        // check indexes here...
        return m_[i_*N*P + j_*P + k_];
    }
    
    template<typename T>
    inline const T& get_elem(const vector<T>& m_, size_t i_, size_t j_, size_t k_)
    {
        // check indexes here...
        return m_[i_*N*P + j_*P + k_];
    }
    

    最后,可以按如下方式创建和索引向量:

    Vector array3d(M*N*P, 0);            // create 3-d array containing M*N*P zero ints
    set_elem(array3d, 0, 0, 1) = 5;      // array3d[0][0][1] = 5
    auto n = get_elem(array3d, 0, 0, 1); // n = 5
    

    在初始化时定义向量大小提供optimal performance。此解决方案由this answer 修改。这些函数可以重载以支持单个向量的不同维度。该解决方案的缺点是 M、N、P 参数被隐式传递给 get 和 set 函数。这可以通过在一个类中实现解决方案来解决,就像 Pixelchemist 所做的那样。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-01-03
      • 1970-01-01
      • 2011-01-03
      • 2012-04-01
      • 1970-01-01
      相关资源
      最近更新 更多