这是一个关于我在处理数组时在更好的性能和更清晰的代码(更好的抽象)之间进行选择的问题。我试着把它提炼成一个玩具的例子。
C ++特别擅长于在不损害性能的情况下允许抽象。问题是这是否可能在类似于下面的例子中。
考虑一个使用连续行主存储的普通任意大小矩阵类:
#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]; }
};
它具有2D索引operator ()
,以便于使用。对于连续访问,它也有operator []
,但更好抽象的矩阵可能没有这个。
让我们实现一个函数,该函数采用n×2矩阵,本质上是一个2D向量列表,并对每个向量进行归一化。
明确的方式:
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-by-1,这会导致重叠的数组访问。
问题:是否有可能以某种方式告诉编译器输入矩阵在normalize()
中确实是n-by-2,以允许它优化到与在normalize2()
?
发表评论:
John Zwinck:我去做了基准测试。 normalize2()
速度要快得多(2.4 vs 1.3秒),但只有在我删除assert
宏或我定义NDEBUG
时才会。这是-DNDEBUG
的一个相当违反直觉的影响,不是吗?它会降低性能而不是改善性能。
Max:证据既是我链接的神韵输出,也是上述基准。我也对无法内联这两个函数的情况感兴趣(例如,因为它们位于单独的翻译单元中)。
Jarod42和bolov:这是我正在寻找的答案。由第一点提到的基准确认。不过,重要的是要知道如果一个人实现自己的assert
(这正是我在我的应用程序中所做的)。
答案 0 :(得分:1)
我相信模板可以让您实现性能和可读性。
通过模板化矩阵的大小(就像流行的数学库一样),让编译器在编译时知道很多信息。
我修改了你的小课程:
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;
}
}
我也更喜欢将数据作为普通成员(=没有指针),因此您可以在矩阵构造期间选择内存(堆栈或堆)。
另外一个好处就是你现在可以确保在编译时normalize函数只接受n-by-2矩阵。
我没有在编译器资源管理器上测试我的代码,因为说实话,我无法破译asm。所以,是的,我声称我的版本更好而不确定;)
最后一句话:不要滚动自己的矩阵,使用库,如glm或eigen。
最后一个词²:如果您不知道该选择什么,请选择可读性。
答案 1 :(得分:1)
我接受的答案基本上是由@bolov和@Jared42在评论中给出的。由于他们没有发布,我会自己这样做。
为了让编译器知道矩阵的大小为n×2,将代码添加到函数的开头就足够了,当矩阵大小不正确时,代码的其余部分将无法访问。
例如,添加
if (mat.cols() != 2)
throw std::runtime_error("Input array is not of expected shape.");
到normalize()
的开头允许它的运行速度与normalize2()
一样快(1.3而不是基于clang 5.0的基准测试中的2.4秒)。
我们也可以添加一个assert(mat.cols() == 2)
,但这会产生违反直觉的影响,即在编译期间定义-DNDEBUG
会使函数速度变慢(因为它会删除断言)。