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<std::vector<int>> 时会变得更糟。
它将需要 N * M * sizeof(int) + N * sizeof(vector<int>) + sizeof(vector<vector<int>>) 字节,即总共 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 处理)或者如果
- 行的列数不同(也就是说,如果您根本没有矩阵,而只有另一个二维结构)。