【问题标题】:g++ vs. optimization by hand for complex number multiplicationg++ 与手动优化复数乘法
【发布时间】:2018-08-17 18:12:27
【问题描述】:

在我们的代码库中,我们有很多类似 j*ω*X 的运算,其中 j 是虚数单位,ω 是实数,X 是复数。实际上很多循环可能看起来像:

#include <complex>
#include <vector>

void mult_jomega(std::vector<std::complex<double> > &vec, double omega){
    std::complex<double> jomega(0.0, omega);
    for (auto &x : vec){
        x*=jomega;
    }
}

但是,我们利用jomega 的实部为零这一事实,并将乘法写为:

void mult_jomega_smart(cvector &vec, double omega){
    for (auto &x : vec){
        x={-omega*x.imag(), omega*x.real()};
    }
}

一开始,我对这个“智能”版本不屑一顾,因为

  1. 很难理解。
  2. 出现错误的概率更高。
  3. “编译器无论如何都会优化它”。

但是,正如一些性能回归表明的那样,第三个论点并不成立。在比较这两个函数时(参见下面的列表),智能版本的性能始终优于 -O2-O3

size    orig(musec)   smart(musec)  speedup
10      0.039928      0.0117551     3.39665
100     0.328564      0.0861379     3.81439
500     1.62269       0.417475      3.8869
1000    3.33012       0.760515      4.37877
2000    6.46696       1.56048       4.14422
10000   32.2827       9.2361        3.49528
100000  326.828       115.158       2.8381
500000  1660.43       850.415       1.95249

智能版本在我的机器 (gcc-5.4) 上大约快 4 倍,并且只有随着阵列大小的增加任务变得越来越受内存限制,速度才会下降到 2 倍。

我的问题是,是什么阻止了编译器优化不太智能但更易读的版本,毕竟编译器可以看到,jomega 的实部为零?是否可以通过提供一些额外的编译标志来帮助编译器进行优化?

注意:其他编译器也存在加速:

compiler      speedup
g++-5.4          4
g++-7.2          4
clang++-3.8      2  [original version 2-times faster than gcc]

列表:

mult.cpp - 防止内联:

#include <complex>
#include <vector>

typedef std::vector<std::complex<double> > cvector;
void mult_jomega(cvector &vec, double omega){
    std::complex<double> jomega(0.0, omega);
    for (auto &x : vec){
        x*=jomega;
    }
}

void mult_jomega_smart(cvector &vec, double omega){
    for (auto &x : vec){
        x={-omega*x.imag(), omega*x.real()};
    }
}

main.cpp:

#include <chrono>
#include <complex>
#include <vector>
#include <iostream>

typedef std::vector<std::complex<double> > cvector;
void mult_jomega(cvector &vec, double omega);
void mult_jomega2(cvector &vec, double omega);
void mult_jomega_smart(cvector &vec, double omega);


const size_t N=100000;   //10**5
const double OMEGA=1.0;//use 1, so nothing changes -> no problems with inf & Co

void compare_results(const cvector &vec){
   cvector m=vec;
   cvector m_smart=vec;
   mult_jomega(m, 5.0);
   mult_jomega_smart(m_smart,5.0);
   std::cout<<m[0]<<" vs "<<m_smart[0]<<"\n";
   std::cout<< (m==m_smart ? "equal!" : "not equal!")<<"\n";

}

void test(size_t vector_size){

     cvector vec(vector_size, std::complex<double>{1.0, 1.0});

     //compare results, triger if in doubt
     //compare_results(vec);


     //warm_up, just in case:
     for(size_t i=0;i<N;i++)
        mult_jomega(vec, OMEGA);

     //test mult_jomega:
     auto begin = std::chrono::high_resolution_clock::now();
     for(size_t i=0;i<N;i++)
        mult_jomega(vec, OMEGA);
     auto end = std::chrono::high_resolution_clock::now();
     auto time_jomega=std::chrono::duration_cast<std::chrono::nanoseconds>(end-begin).count()/1e3;


     //test mult_jomega_smart:
     begin = std::chrono::high_resolution_clock::now();
     for(size_t i=0;i<N;i++)
        mult_jomega_smart(vec, OMEGA);
     end = std::chrono::high_resolution_clock::now();
     auto time_jomega_smart=std::chrono::duration_cast<std::chrono::nanoseconds>(end-begin).count()/1e3;

     double speedup=time_jomega/time_jomega_smart;
     std::cout<<vector_size<<"\t"<<time_jomega/N<<"\t"<<time_jomega_smart/N<<"\t"<<speedup<<"\n";
}


int main(){
   std::cout<<"N\tmult_jomega(musec)\tmult_jomega_smart(musec)\tspeedup\n";    
   for(const auto &size : std::vector<size_t>{10,100,500,1000,2000,10000,100000,500000})
        test(size);          
}

构建和运行:

g++ main.cpp mult.cpp -O3 -std=c++11 -o mult_test
./mult_test

【问题讨论】:

  • jomega 不是const。此外,operator * 最有可能采用const &amp; 并被允许将const_castconst 分开并更改jomega,因此不能确定jomega 的真实部分是0.0。看看将const 添加到jomega 是否有任何作用。
  • 添加 const 并没有多大作用。
  • @nwp 我不认为将某些东西声明为const 有助于编译器优化您的代码。 0.0 是文字,无论如何都应该进行持续传播。有一个旧的 gotw 声明 const 不会为优化做任何事情(constexpr 当然可以):gotw.ca/gotw/081.htm.
  • @Jens const 不做任何优化通常只适用于添加const (例如,采用const &amp;&amp; 的函数并不重要),但是顶级const 实际上意味着const,因为将其丢弃是UB。那显然是我错了,它在这里也没有多大作用。

标签: c++ gcc optimization clang


【解决方案1】:

使用标志-ffast-math 进行编译可以提高性能。

N       mult_jomega(musec)      mult_jomega_smart(musec)        speedup
10      0.00860809              0.00818644                      1.05151
100     0.0706683               0.0693907                       1.01841
500     0.29569                 0.297323                        0.994509
1000    0.582059                0.57622                         1.01013
2000    1.30809                 1.24758                         1.0485
10000   7.37559                 7.4854                          0.98533

编辑:更具体地说,它是-funsafe-math-optimizations 编译器标志。 According to the documentation,此标志用于

允许优化浮点算术 (a) 假设 参数和结果是有效的,并且 (b) 可能违反 IEEE 或 ANSI 标准。当

编辑 2:更具体地说,它是 -fno-signed-zeros 选项,它:

允许对忽略 符号为零。 IEEE 算法指定了不同的行为 +0.0-0.0 值,然后禁止简化表达式,例如 x+0.00.0*x(即使使用 -ffinite-math-only)。 此选项意味着零结果的符号不重要。

【讨论】:

  • -fno-signed-zeros 是一个非常好的发现。既然这样就足够了,我鼓励只使用该标志而不是 -ffast-math,除非大量测试表明它不会破坏任何东西。
  • 我理解正确吗,其中一个问题是omega=0.0 的情况,这可能会导致结果中出现不同符号的零,例如答案的实部的符号取决于实部和虚部输入?
  • @ead:正确。如果没有该标志,编译器无法优化零的乘法,因为x*0.0 的结果可以是两个“不同”数字之一:0.0-0.0。该标志打破了这个 IEEE 754 标准规则,并告诉编译器将零视为无符号。因此,x*0.0 始终为0.0,编译器可以从那里进行优化。
  • @ead 考虑到这个问题,似乎根本问题在于处理涉及纯实数和复数的算术运算以及涉及纯虚数和复数的算术运算时的不对称性。对于浮点数/复数运算,浮点数的虚部在概念上是没有符号的零,对于虚数/复数运算,虚数的实部的零被认为是有符号的。所以我相信你已经指出了常见复杂实现中的一个漏洞:它们应该将纯虚数实现为独立类型。
  • @Oliv 我认为这是一个很好的见解。可能会推出我自己的pure_imaginary 实现,以便兼具:可读性和性能(无论哪个编译器标志)
【解决方案2】:

我对 Aziz 使用 Godbolt 编译器资源管理器的回答中提到的编译器选项进行了更多调查。示例代码实现了三个版本的内循环:

  1. mult_jomega 示例。
  2. 同一循环的手写版本,其中对 operator*= 的调用已被替换为计算
  3. mult_jomega_smart 示例

The code from godbolt:

// 1. mult_jomega
std::complex<double> const jomega(0.0, omega);
for (auto &x : v){
    x*=jomega;
}

// 2. hand-written mult_jomega
for (auto &x : v3){
    double x1 = x.real() * jomega.real();
    double x2 = x.imag() * jomega.imag();
    double x3 = x.real() * jomega.imag();
    double x4 = x.imag() * jomega.real();
    x = { x1 - x2 , x3 + x4};
}

// 3. mult_jomega_smart
for (auto &x : v2){
    x={-omega*x.imag(), omega*x.real()};
}

检查三个循环的汇编代码:

mult_jomega

 cmp %r13,%r12
 je 4008ac <main+0x10c>
 mov %r12,%rbx
 nopl 0x0(%rax)
 pxor %xmm0,%xmm0
 add $0x10,%rbx
 movsd -0x8(%rbx),%xmm3
 movsd -0x10(%rbx),%xmm2
 movsd 0x8(%rsp),%xmm1
 callq 400740 <__muldc3@plt>
 movsd %xmm0,-0x10(%rbx)
 movsd %xmm1,-0x8(%rbx)
 cmp %rbx,%r13
 jne 400880 <main+0xe0>

手写乘法

 cmp %rdx,%rdi
 je 40090c <main+0x16c>
 pxor %xmm3,%xmm3
 mov %rdi,%rax
 movsd 0x8(%rsp),%xmm5
 nopl 0x0(%rax,%rax,1)
 movsd (%rax),%xmm0
 movapd %xmm5,%xmm4
 movsd 0x8(%rax),%xmm1
 add $0x10,%rax
 movapd %xmm0,%xmm2
 mulsd %xmm5,%xmm0
 mulsd %xmm1,%xmm4
 mulsd %xmm3,%xmm2
 mulsd %xmm3,%xmm1
 subsd %xmm4,%xmm2
 addsd %xmm1,%xmm0
 movsd %xmm2,-0x10(%rax)
 movsd %xmm0,-0x8(%rax)
 cmp %rax,%rdx
 jne 4008d0 <main+0x130>

mult_jomega_smart

cmp %rcx,%rdx
 je 400957 <main+0x1b7>
 movsd 0x8(%rsp),%xmm2
 mov %rcx,%rax
 xorpd 0x514(%rip),%xmm2 # 400e40 <_IO_stdin_used+0x10>
 nopl 0x0(%rax)
 add $0x10,%rax
 movsd 0x8(%rsp),%xmm0
 movsd -0x8(%rax),%xmm1
 mulsd -0x10(%rax),%xmm0
 mulsd %xmm2,%xmm1
 movsd %xmm1,-0x10(%rax)
 movsd %xmm0,-0x8(%rax)
 cmp %rax,%rdx
 jne 400930 <main+0x190>

我对汇编代码的理解相当有限,但我明白

  • operator*= 不会在 mult_jomega 中内联
  • x1x4 被计算,尽管它们总是 0.0 因为 jomega.real()==0.0

我不知道为什么 operator*=doe snot 会被内联。源代码很简单,只包含三行。

The computation of x1 and x4 can be explained when you consider that 0.0 * x == 0.0 is not always true for values of type double. 除了其他答案中提到的带符号零定义之外,还有无限值 naninf 其中 x * 0.0 = 0.0 不成立。

如果使用-fno-signed-zeros-ffinite-math-only 编译,则应用优化并删除x1x4 的计算。

【讨论】:

    【解决方案3】:

    正如其他答案所指出的那样,我的错误是假设纯虚构 j*ω 与复杂 0.0+j*ω 具有相同的行为 - 与纯真实 1.0 的行为相同行为复杂1.0+0.0j,例如(live with gcc)

    1.0*(inf+0.0j) = inf+0.0j
    (1.0 +0.0j)*(inf+0.0j) = inf - nanj
    

    这是因为复数乘法是more complex,而不是学校公式建议的。

    基本上,c++ 编译器处理复数的方式是不对称的:有纯实数(即double),但没有纯虚数。

    C++ 标准没有定义复数乘法必须如何发生。大多数编译器回退到他们的 C99 实现。 C99 是第一个定义如何在附件 G 中执行复数运算的 C 格式。但是,如 G.1.1 中所述,它支持它是可选的:

    G.1.1 ...虽然这些规格经过精心设计, 几乎没有现有的实践来验证设计决策。 因此,这些规范不是规范性的,但应该是 更多地被视为推荐的做法......

    C99 还定义了纯虚数数据类型float _Imaginarydouble _Imaginarylong double _Imaginary (G.2),这正是我们所需要的j*ω。 G.5.1 将纯虚数的乘法语义定义为

    xj*(v+wj) = -xw+(xv)j
    (v+wj)*xj = -wx+(vx)j
    

    即学校公式已经足够好了(与两个复数相乘不同)。

    问题是,到目前为止,没有一个已知的编译器(gcc-9.2、clang-9.0)支持_Imaginary 类型(因为它是可选的)。

    因此,我的解决方案是实现 pure_imaginary 类型并重载 G.5.1 之后的运算符。

    【讨论】:

      猜你喜欢
      • 2010-09-14
      • 2021-06-05
      • 2015-12-25
      • 2019-05-18
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多