使用数组时的抽象与性能
Abstraction vs. performance when working with arrays
这是一个关于我在处理数组时在更好的性能和更清晰的代码(更好的抽象)之间做出选择的担忧的问题。我试图将它提炼成一个玩具示例。
C++ 特别擅长允许抽象而不损害性能。问题是这在类似于下面的示例中是否可行。
考虑一个简单的任意大小的矩阵 class,它使用连续的行优先存储:
#include <cmath>
#include <cassert>
class Matrix {
int nrow, ncol;
double *data;
public:
Matrix(int nrow, int ncol) : nrow(nrow), ncol(ncol), data(new double[nrow*ncol]) { }
~Matrix() { delete [] data; }
int rows() const { return nrow; }
int cols() const { return ncol; }
double & operator [] (int i) { return data[i]; }
double & operator () (int i, int j) { return data[i*ncol + j]; }
};
它有一个二维索引 operator ()
以便于使用。它也有 operator []
用于连续访问,但是更好抽象的矩阵可能没有这个。
让我们实现一个函数,它接受一个 n×2 矩阵,本质上是一个二维向量列表,并就地对每个向量进行归一化。
清除方式:
inline double veclen(double x, double y) {
return std::sqrt(x*x + y*y);
}
void normalize(Matrix &mat) {
assert(mat.cols() == 2); // some kind of check for correct input
for (int i=0; i < mat.rows(); ++i) {
double norm = veclen(mat(i,0), mat(i,1));
mat(i,0) /= norm;
mat(i,1) /= norm;
}
}
快速但不太清楚的方法:
void normalize2(Matrix &mat) {
assert(mat.cols() == 2);
for (int i=0; i < mat.rows(); ++i) {
double norm = veclen(mat[2*i], mat[2*i+1]);
mat[2*i] /= norm;
mat[2*i+1] /= norm;
}
}
第二个版本 (normalize2
) 有可能更快,因为它的编写方式明确表明循环的第二次迭代将不会访问在第一次迭代中计算的数据.因此它可以更好地利用 SIMD 指令。 Looking at godbolt, this seems to be what happens(除非我误读了程序集)。
在第一个版本(normalize
)中,编译器无法知道输入矩阵不是n×1,这会导致重叠数组访问。
问题: 是否有可能以某种方式告诉编译器输入矩阵实际上是 normalize()
中的 n×2 以允许它优化为相同的normalize2()
?
中的水平
处理评论:
John Zwinck:我去做了基准测试。 normalize2()
相当快(2.4 对 1.3 秒),但 仅当 我删除 assert
宏或如果我定义 NDEBUG
。这是 -DNDEBUG
的一种相当违反直觉的效果,不是吗?它会降低性能而不是提高性能。
Max:证据是我链接到的 Godbolt 输出和上面的基准。我也对无法内联这两个函数的情况感兴趣(例如,因为它们位于单独的翻译单元中)。
Jarod42 和 bolov:这就是我一直在寻找的答案。由第一点中提到的基准确认。不过,了解这一点很重要,以防有人实现自己的 assert
(这正是我在我的应用程序中所做的)。
我相信模板可以让您同时获得性能和可读性。
通过模板化矩阵的大小(就像流行的数学库所做的那样),您可以让编译器在编译时知道很多信息。
我稍微修改了一下你的class:
template<int R, int C>
class Matrix {
double data[R * C] = {0.0};
public:
Matrix() = default;
int rows() const { return R; }
int cols() const { return C; }
int size() const { return R*C; }
double & operator [] (int i) { return data[i]; }
double & operator () (int row, int col) { return data[row*C + col]; }
};
inline double veclen(double x, double y) {
return std::sqrt(x*x + y*y);
}
template<int R>
void normalize(Matrix<R, 2> &mat) {
for (int i = 0; i < R; ++i) {
double norm = veclen(mat(i, 0), mat(i, 1));
mat(i, 0) /= norm;
mat(i, 1) /= norm;
}
}
template<int R>
void normalize2(Matrix<R, 2> &mat) {
for (int i = 0; i < R; ++i) {
double norm = veclen(mat[2 * i], mat[2 * i + 1]);
mat[2 * i] /= norm;
mat[2 * i + 1] /= norm;
}
}
我也更喜欢将数据作为普通成员(=没有指针),因此您可以在矩阵构造期间选择内存所在的位置(堆栈或堆)。
额外的好处是您现在可以在编译时确定归一化函数只接受 n×2 矩阵。
我没有在编译器资源管理器上测试我的代码,因为老实说我无法破译 asm.所以,是的,我声称我的版本更好但不确定 ;)
最后一句话:不要推出自己的矩阵,使用库,如 glm 或 eigen。
最后一句话²:如果您不知道该选择什么,请选择可读性。
@bolov 和@Jared42 在评论中给出了一个我可以接受的答案。既然他们没有post,那我就自己做。
要让编译器知道矩阵的大小为 n×2,只需将代码添加到函数的开头,使其余代码在矩阵大小不正确时无法访问。
例如添加
if (mat.cols() != 2)
throw std::runtime_error("Input array is not of expected shape.");
到 normalize()
的开头允许它 运行 与 normalize2()
一样快(在我的 clang 5.0 基准测试中是 1.3 秒而不是 2.4 秒)。
我们也可以添加一个 assert(mat.cols() == 2)
,但这会导致违反直觉的效果,即在编译期间定义 -DNDEBUG
会使函数显着变慢(因为它删除了断言)。
这是一个关于我在处理数组时在更好的性能和更清晰的代码(更好的抽象)之间做出选择的担忧的问题。我试图将它提炼成一个玩具示例。
C++ 特别擅长允许抽象而不损害性能。问题是这在类似于下面的示例中是否可行。
考虑一个简单的任意大小的矩阵 class,它使用连续的行优先存储:
#include <cmath>
#include <cassert>
class Matrix {
int nrow, ncol;
double *data;
public:
Matrix(int nrow, int ncol) : nrow(nrow), ncol(ncol), data(new double[nrow*ncol]) { }
~Matrix() { delete [] data; }
int rows() const { return nrow; }
int cols() const { return ncol; }
double & operator [] (int i) { return data[i]; }
double & operator () (int i, int j) { return data[i*ncol + j]; }
};
它有一个二维索引 operator ()
以便于使用。它也有 operator []
用于连续访问,但是更好抽象的矩阵可能没有这个。
让我们实现一个函数,它接受一个 n×2 矩阵,本质上是一个二维向量列表,并就地对每个向量进行归一化。
清除方式:
inline double veclen(double x, double y) {
return std::sqrt(x*x + y*y);
}
void normalize(Matrix &mat) {
assert(mat.cols() == 2); // some kind of check for correct input
for (int i=0; i < mat.rows(); ++i) {
double norm = veclen(mat(i,0), mat(i,1));
mat(i,0) /= norm;
mat(i,1) /= norm;
}
}
快速但不太清楚的方法:
void normalize2(Matrix &mat) {
assert(mat.cols() == 2);
for (int i=0; i < mat.rows(); ++i) {
double norm = veclen(mat[2*i], mat[2*i+1]);
mat[2*i] /= norm;
mat[2*i+1] /= norm;
}
}
第二个版本 (normalize2
) 有可能更快,因为它的编写方式明确表明循环的第二次迭代将不会访问在第一次迭代中计算的数据.因此它可以更好地利用 SIMD 指令。 Looking at godbolt, this seems to be what happens(除非我误读了程序集)。
在第一个版本(normalize
)中,编译器无法知道输入矩阵不是n×1,这会导致重叠数组访问。
问题: 是否有可能以某种方式告诉编译器输入矩阵实际上是 normalize()
中的 n×2 以允许它优化为相同的normalize2()
?
处理评论:
John Zwinck:我去做了基准测试。
normalize2()
相当快(2.4 对 1.3 秒),但 仅当 我删除assert
宏或如果我定义NDEBUG
。这是-DNDEBUG
的一种相当违反直觉的效果,不是吗?它会降低性能而不是提高性能。Max:证据是我链接到的 Godbolt 输出和上面的基准。我也对无法内联这两个函数的情况感兴趣(例如,因为它们位于单独的翻译单元中)。
Jarod42 和 bolov:这就是我一直在寻找的答案。由第一点中提到的基准确认。不过,了解这一点很重要,以防有人实现自己的
assert
(这正是我在我的应用程序中所做的)。
我相信模板可以让您同时获得性能和可读性。
通过模板化矩阵的大小(就像流行的数学库所做的那样),您可以让编译器在编译时知道很多信息。
我稍微修改了一下你的class:
template<int R, int C>
class Matrix {
double data[R * C] = {0.0};
public:
Matrix() = default;
int rows() const { return R; }
int cols() const { return C; }
int size() const { return R*C; }
double & operator [] (int i) { return data[i]; }
double & operator () (int row, int col) { return data[row*C + col]; }
};
inline double veclen(double x, double y) {
return std::sqrt(x*x + y*y);
}
template<int R>
void normalize(Matrix<R, 2> &mat) {
for (int i = 0; i < R; ++i) {
double norm = veclen(mat(i, 0), mat(i, 1));
mat(i, 0) /= norm;
mat(i, 1) /= norm;
}
}
template<int R>
void normalize2(Matrix<R, 2> &mat) {
for (int i = 0; i < R; ++i) {
double norm = veclen(mat[2 * i], mat[2 * i + 1]);
mat[2 * i] /= norm;
mat[2 * i + 1] /= norm;
}
}
我也更喜欢将数据作为普通成员(=没有指针),因此您可以在矩阵构造期间选择内存所在的位置(堆栈或堆)。
额外的好处是您现在可以在编译时确定归一化函数只接受 n×2 矩阵。
我没有在编译器资源管理器上测试我的代码,因为老实说我无法破译 asm.所以,是的,我声称我的版本更好但不确定 ;)
最后一句话:不要推出自己的矩阵,使用库,如 glm 或 eigen。
最后一句话²:如果您不知道该选择什么,请选择可读性。
@bolov 和@Jared42 在评论中给出了一个我可以接受的答案。既然他们没有post,那我就自己做。
要让编译器知道矩阵的大小为 n×2,只需将代码添加到函数的开头,使其余代码在矩阵大小不正确时无法访问。
例如添加
if (mat.cols() != 2)
throw std::runtime_error("Input array is not of expected shape.");
到 normalize()
的开头允许它 运行 与 normalize2()
一样快(在我的 clang 5.0 基准测试中是 1.3 秒而不是 2.4 秒)。
我们也可以添加一个 assert(mat.cols() == 2)
,但这会导致违反直觉的效果,即在编译期间定义 -DNDEBUG
会使函数显着变慢(因为它删除了断言)。