数值计算优化方法C/C++(二)——表达式模板

表达式模板

1、概述

其实表达式模板实际是一种模板元编程技术。它是利用模板类来实现在调用某些运算的函数时先不进行计算,而是把运算符和参与运算的变量的引用保存成一个模板类记录下来,直到最后需要真正计算结果的时候再进行计算,从而延迟计算。

那么什么时候我们需要延迟计算呢?最简单的两个例子:
1)

double f(double a,double b){
	if(b>0) return a;
	else return b;
}

这个函数在b为正时返回a,b为负时返回b,很简单,那么如果我现在有一个变量c,而且按照f(pow(3*c+2,5)/10,-1)来调用这个函数呢?我们知道在C++中运行这个语句时将会先计算出f传入参数中第一个表达式的值,并把其结果作为真正的参数传入函数中,但是我们又发现明显这里b<0,所以a是多少跟函数返回值完全没有关系,那一长串运算是完全没有意义的。这时我们如果能够把运算延迟,就可以节省计算量了。
2)
我们自定义了一个数组类型vec,同时我们以如下方式重载了+运算符

vec operator+(const vec&y){
		vec tmp(num);
		for(int i=0;i<y.num;i++) tmp.x[i]=x[i]+y.x[i];
		return tmp;  
}

使得vec的实例a、b、c可以实现c=a+b的矢量运算。那么对于c=a+b这个计算来讲,我们将要先构造tmp这个对象,然后进行矢量相加得到tmp的值。对于C++来说如果函数返回的是一个对象,而这个对象又是在函数内构造的临时对象,那么要返回出来就还要在函数外再对tmp做一次拷贝构造得到一个外部的临时对象,从而真正的将结果返回出来。这个过程要执行需要a,b,c,tmp外加一个隐藏的临时外部对象,总共五个对象。而这其中真正有意义的只有a、b、c,剩余两个对象都是额外开销,如果a、b、c的维度很小的话,这些额外开销是可以忍受的,但是当a、b、c维度很大时,这些额外开销将十分消耗内存和机器计算能力。不过幸运的是通过返回值优化和移动赋值我们可以减少一个临时对象和一次拷贝赋值的开销,但是仍然会有一个临时对象无法优化掉。与直接使用for循环进行矢量相加相比,依旧是很浪费的。但是如果我们在重载加号时不进行运算而是把运算过程保存下来,等到赋值时再进行运算,这时就可以直接利用赋值函数中c的内存地址直接将计算结果写入c对应的内存空间,从而避免了额外变量的构造。

以上两个是需要延时计算的例子,表达式模板还有一个用法,就是实现类似符号运算的操作。用matlab的时候我们有一个syms可以定义符号变量,并使用eval来计算一个带有符号变量的表达式的值。利用表达式模板,我们同样可以用syms定义一个符号变量,然后同样用eval来计算一个带有符号变量的表达式的值。当然其实这个过程说到底还是延迟计算,也就是说我们利用syms定义一个占位符,然后把运算过程和参与的变量都保存下来,而在使用eval函数时才进行真正的计算。

2、简单使用

符号变量

使用表达式模板时我们首先需要一些类来记录运算符、符号变量和封装二元表达式

struct Syms{};
struct Plus{};
template <class ExprA, class ExprB, class OP>
struct BinExpr{};

其中Plus是用来记录加法运算的,这里用的是结构体,其实使用枚举类型或者int类型也是可以的。Syms是定义符号变量(占位符)的,它们都是普通类,并不是模板类,同时它们也不需要有任何成员变量和成员函数,我们仅仅是需要它们的名字做一个区分而已。而BinExpr是一个模板类,用于记录二元表达式,ExprA是二元表达式的第一个变量的类型,ExprB是第二个变量的类型,OP用于记录运算符(即前面声明的Plus,如果想加入减法、乘法的功能再声明相应类即可)。

既然是元编程那就少不了特化

//将常数和占位符相加的二元运算保存为一个模板类
template <>
struct BinExpr<double,Syms,Plus>{
    explicit BinExpr(const double k,const Syms& x):_k(k){}
    inline double operator()(double x) const {return _k+x;}
    private:
    const double _k;
};
//将常数和二元运算的模板类相加的表达式保存为一个模板类
template <class ExprB>
struct BinExpr<double,ExprB,Plus>{
    explicit BinExpr(const double k,const ExprB& B):_k(k),_B(B){}
    inline double operator()(double x) const {return _k+_B(x);}
    private:
    const double _k;
    const ExprB _B; 
};
//将二元运算模板类和二元运算模板类相加的表达式保存为一个模板类
template <class ExprA,class ExprB>
struct BinExpr<ExprA,ExprB,Plus>{
    explicit BinExpr(const ExprA& A,const ExprB& B):_A(A),_B(B){}
    inline double operator()(double x) const {return _A(x)+_B(x);}
    private:
    const ExprA _A;
    const ExprB _B;
};

可以看到通过特化表达式模板类,就能够把二元运算转化为一个模板类保存下来,同时通过()的重载就可以实现将表达式模板转化为实际的数值运算。

现在我们已经有了一个可以定义占位符的类型,以及一个可以保存表达式的模板类了,接下来我们就可以去实现把真实的运算替换成模板类了。

//常数与占位符(或表达式模板)相加
template <class ExprB>
BinExpr<double,ExprB,Plus> operator +(const double k,const ExprB& B){
    return BinExpr<double,ExprB,Plus>(k,B);
}
//常数与占位符(或表达式模板)相加
template <class ExprA>
BinExpr<double,ExprA,Plus> operator +(const ExprA& A,double k){
    return BinExpr<double,ExprA,Plus>(k,A);
}
//占位符(表达式模板)和占位符(表达式模板)相加
template <class ExprA,class ExprB>
BinExpr<ExprA,ExprB,Plus> operator+(const ExprA& A,const ExprB& B){
    return BinExpr<ExprA,ExprB,Plus>(A,B);
}

这里的运算符重载可以看到,实际上就是所有的运算都不是真的计算,而是转换为一个相应模板类保存下来。

这样表达式模板的一套准备工作都做好了,接下来就是定义eval函数来实现计算了

template <class ExprA,class ExprB,class Op>
void eval(BinExpr<ExprA,ExprB,Op> F,double x){
        cout<<F(x)<<'\n';
}

来测试一下

int main(){
	Syms x;
	eval(1+x,2);
	return 1;
}

返回结果是3没有错误。

矢量运算优化

矢量运算的优化的方法基本没有什么变化同样先定义辅助类

struct Plus{};
template <class ExprA,class ExprB,class Op>
struct BinExpr{};

然后我们需要先定义矢量类,因为BinExpr的特化中需要用到我们定义的矢量类,和矢量类的成员,所以我们这里必须先定义矢量类。

static int nt=0;//用于记录矢量的构造次数
class valvector{
	double* vec;
	unsigned num;
	public:
	valvector():num(0){//默认构造
		vec=nullptr;
		nt++;std::cout<<"构造"<<nt<<std::endl;
	}
	valvector(int n):num(n){//构造一个n维的矢量
		vec=(double*)malloc(sizeof(double)*num);
	}
	valvector(int n,double x):num(n){//构造一个n维矢量并初始化为x
		vec=(double*)malloc(sizeof(double)*num);
		for(int i=0;i<num;i++) vec[i]=x;
		nt++;std::cout<<"构造"<<nt<<std::endl;
	}
	valvector(const valvector& x):num(x.num){//拷贝构造
		vec=(double*)malloc(sizeof(double)*num);
		for(int i=0;i<x.num;i++) vec[i]=x[i];nt++;
		std::cout<<"构造"<<nt<<std::endl;
	}
	~valvector(){free(vec);}//析构
	const unsigned& size() const {return num;}//获取矢量维度
	inline const double operator[](unsigned i) const{return vec[i];}//访问矢量第i个分量
	inline double& operator[](unsigned i) {return vec[i];}//访问矢量第i个分量
	template <class ExprA,class ExprB,class Op>
	valvector& operator=(const BinExpr<ExprA,ExprB,Op>& x){//当赋值时传入的是表达式模板时,进行相应的处理
		if(x.num>num){
			free(vec);
			vec=(double*)malloc(sizeof(double)*x.num);;
			num=x.num;
		}
		double* p=vec;
		for(int i=0;i<x.num;i++) *(p++)=x[i];
		return *this;
	}
};

现在我们也定义好一个矢量类型了,接下来就是特化了

//处理两个矢量相加的情况,为了避免拷贝构造,这个特化的类中的成员都是引用
template <>
struct BinExpr<valvector,valvector,Plus>{
	const unsigned num;
	BinExpr(const valvector& A,const valvector& B):_A(A),_B(B),num(B.size()){};
	inline const double operator[](unsigned i) const{return _A[i]+_B[i];}
	inline unsigned size() const {return num;}
	private:
	const valvector& _A;
	const valvector& _B;
};
//处理表达式模板和矢量类相加的情况
template <class ExprB>
struct BinExpr<valvector,ExprB,Plus>{
	const unsigned num;
	BinExpr(const valvector& A,const ExprB& B):_A(A),_B(B),num(B.size()){};
	inline const double operator[](unsigned i) const{return _A[i]+_B[i];}
	inline unsigned size() const {return num;}
	private:
	const valvector& _A;
	ExprB _B;
};
template <class ExprA>
struct BinExpr<ExprA,valvector,Plus>{
	const unsigned num;
	BinExpr(const ExprA& A,const valvector& B):_A(A),_B(B),num(B.size()){};
	inline const double operator[](unsigned i) const{return _A[i]+_B[i];}
	inline unsigned size() const {return num;}
	private:
	ExprA _A;
	const valvector& _B;
};
//处理表达式模板和表达式模板相加的情况
template <class ExprA,class ExprB>
struct BinExpr<ExprA,ExprB,Plus>{
	const unsigned num;
	BinExpr(const ExprA& A,const ExprB& B):_A(A),_B(B),num(B.size()){};
	inline const double operator[](unsigned i) const{return _A[i]+_B[i];}
	inline unsigned size() const {return num;}
	private:
	ExprA _A;
	ExprB _B;
};

特化完成后接下来就是重载运算符将相应的运算转化为对应的模板类

//矢量相加
inline BinExpr<valvector,valvector,Plus> operator+(const valvector &A,const valvector &B){
	typedef BinExpr<valvector,valvector,Plus> Exprtmp;
	return Exprtmp(A,B);
}
//矢量与表达式模板相加
template <class ExprA>
inline BinExpr<ExprA,valvector,Plus> operator+(const ExprA &A,const valvector &B){
	typedef BinExpr<ExprA,valvector,Plus> Exprtmp;
	return (Exprtmp(A,B));
}
template <class ExprB>
inline BinExpr<valvector,ExprB,Plus> operator+(const valvector &A,const ExprB &B){
	typedef BinExpr<valvector,ExprB,Plus> Exprtmp;
	return (Exprtmp(A,B));
}
//表达式模板和表达式模板相加
template <class ExprA,class ExprB>
inline BinExpr<ExprA,ExprB,Plus> operator+(const ExprA &A,const ExprB &B){
	typedef BinExpr<ExprA,ExprB,Plus> Exprtmp;
	return (Exprtmp(A,B));
}

这样我们的一个用表达式模板优化矢量运算的矢量类就完成了,测试一下

#include <iostream>
using namespace std;
#define N 10000000
int main(int argc,char* argv[]){
	
	valvector x(N,1),y(N,2),z(N,3);
	
	z=x+y+z;
	std::cout<<z[0]<<std::endl;
	std::cout<<z[N-1]<<std::endl;
	return 1;
}

返回的结果为两个6,显示的矢量构造次数为3,计算是正确的,同时没有任何额外对象的构造。然后我们再写一个没有用表达式模板优化的矢量类

static int nt=0;
class vect{
	public:
	double *x;
	int num;
	vect(int n){
		num=n;
		x=(double*)malloc(sizeof(double)*n);
		nt++;std::cout<<"构造"<<nt<<std::endl;
	}
	vect(int n,double k){
		num=n;
		x=(double*)malloc(sizeof(double)*n);
		for(int i=0;i<n;i++) x[i]=k;
		nt++;std::cout<<"构造"<<nt<<std::endl;
	}
	vect(const vect& y){
		x=(double*)malloc(sizeof(double)*y.num);
		for(int i=0;i<y.num;i++) x[i]=y.x[i];
		num=y.num;
		nt++;std::cout<<"构造"<<nt<<std::endl;
	}
	~vect(){free(x);}
	vect& operator=(vect&& y){
		free(x);
		x=y.x;
		num=y.num;
		y.x=nullptr;
		return *this;
	}
	vect& operator=(const vect& y){
		if(num<y.num){
			free(x);
			x=(double*)malloc(sizeof(double)*y.num);
		}
		for(int i=0;i<y.num;i++) x[i]=y.x[i];
		num=y.num;
		return *this;
	}
	vect operator+(const vect&y){
		vect tmp(num);
		for(int i=0;i<y.num;i++) tmp.x[i]=x[i]+y.x[i];
		return tmp;  
	}
	const double& operator[](unsigned i){return x[i];}
};

同样测试一下

#include <iostream>
using namespace std;
#define N 10000000
int main(int argc,char* argv[]){
	
	vect x(N,1),y(N,2),z(N,3);
	
	z=x+y+z;
	std::cout<<z[0]<<std::endl;
	std::cout<<z[N-1]<<std::endl;
	return 1;
}

返回结果同样是两个6,但是显示的构造次数是5次,有额外对象的构造。然后我们再看一下时间,在使用g++的O0优化下进行测试,使用表达式模板优化的矢量加法耗时是

real	0m0.316s
user	0m0.279s
sys		0m0.036s

而未使用表达式模板优化的矢量加法耗时是

real	0m0.280s
user	0m0.176s
sys		0m0.104s

可以看到未优化的用时是小于优化用时的,这么来看好像表达式模板只能优化内存不能优化执行效率。那么我们开启g++的O3优化再测一下,使用表达式模板时用时为

real	0m0.114s
user	0m0.051s
sys		0m0.063s

而未使用表达式模板时用时为

real	0m0.160s
user	0m0.028s
sys		0m0.132s

我们可以看到表达式模板可以在一定程度上提高计算性能,由于本身这个计算的耗时并不是很多,因此二者时差的绝对值并不大只有不到50ms。但是从比例上来看,表达式模板提高的性能百分比可以达到28.75%,在进行大量的矢量运算的情况下二者性能差距还是比较大的。

不过话又说回来,为什么同样的代码O0和O3会得到相反的结果呢?因为使用表达式模板时会将运算先进行封装,而计算时要一层一层的去解开它。当数组较大时按理来讲,这个封装和解开的过程是相当耗时的,这也是为什么O0条件下表达式模板反而慢。那么O3为什么又快了呢?因为O3优化时会把简单的函数进行内联展开,而我们在写表达式模板类的时候其中的[]重载也是inline的,所以经过O3优化之后我们就不会再需要运行时去一层层解开之前的封装了,而是直接在编译中内联了,这时计算自然就快了。这也提醒了我们,在使用STL时,编译应当使用优化选项,这才能真正发挥出STL的全部性能,否则STL中所做的那些性能优化可能并没有作用,甚至拖慢运行效率。

上一篇:数值计算优化方法C/C++(一)——模板元编程
下一篇:数值计算优化方法C/C++(三)——SIMD

发布了22 篇原创文章 · 获赞 9 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/artorias123/article/details/86510454