【问题标题】:how to optimize this section of code?如何优化这部分代码?
【发布时间】:2013-09-08 10:35:28
【问题描述】:
double diff, dsq = 0;
double *descr1, *descr2;
int i, d;

for (i = 0; i < d; ++i)
{
  diff = descr1[i] - descr2[i];
  dsq += diff * diff;
}
return dsq;

我想优化这部分代码,这在我的程序中占用了大部分时间。 如果这种双重乘法以优化的方式执行,我的程序可以运行得非常快。 是否有其他乘法方法而不是使用 * 运算符导致程序运行得更快? 非常感谢。

【问题讨论】:

  • 你能切换到整数吗? descr1/2的内容会覆盖哪个值域?
  • 你还没有初始化d!
  • 如果依赖关系允许,您可以在 GPU 上运行内核
  • 您可以使用左移运算符来乘以 2。
  • 您在问是否有比乘法更好的运算符来执行乘法?如果有,它将在汇编级别,编译器的优化器会为您提供。没有比@jev 建议的更快的通用解决方案(缺少专用硬件,正如@jev 建议的那样),但如果您的数据是事先知道的,您可以生成一个已预先计算的答案查找表。

标签: c performance optimization statistics profile


【解决方案1】:

这绝对是Duff's Device的情况。

这是我的实现,基于 Duff 的设备。
(注意:仅经过轻微测试...这必须在调试器中逐步完成以确保正确的行为)

void fnc(void)
{
    double dsq = 0.0;
    double diff[8] = {0.0};
    double descr1[115];
    double descr2[115];
    double* pD1 = descr1;
    double* pD2 = descr2;
    int d = 115;

    //Fill with random data for testing
    for(int i=0; i<d; ++i)
    {
        descr1[i] = (double)rand() / (double)rand();
        descr2[i] = (double)rand() / (double)rand();
    }

    // Duff's Device: Step through this in a debugger, its AMAZING.
    int c = (d + 7) / 8;
    switch(d % 8) {
    case 0: do {    diff[0] = *pD1++ - *pD2++; diff[0] *= diff[0];
    case 7:         diff[7] = *pD1++ - *pD2++; diff[7] *= diff[7];
    case 6:         diff[6] = *pD1++ - *pD2++; diff[6] *= diff[6];
    case 5:         diff[5] = *pD1++ - *pD2++; diff[5] *= diff[5];
    case 4:         diff[4] = *pD1++ - *pD2++; diff[4] *= diff[4];
    case 3:         diff[3] = *pD1++ - *pD2++; diff[3] *= diff[3];
    case 2:         diff[2] = *pD1++ - *pD2++; diff[2] *= diff[2];
    case 1:         diff[1] = *pD1++ - *pD2++; diff[1] *= diff[1];
                    dsq += diff[0] + diff[1] + diff[2] + diff[3] + diff[4] + diff[5] + diff[6] + diff[7]; 
               } while(--c > 0);
    }
}


说明
正如其他人所说,您几乎无法优化浮点运算。 但是,在您的原始代码中,程序花费了大量时间检查i 的值。

执行步骤大致如下:

Is i < d? ==> Yes
Do some math.
Is i < d? ==> Yes
Do some math.
Is i < d? ==> Yes
Do some math.
Is i < d? ==> Yes
Do some math.

您可以看到每隔一步都在检查i

使用 Duff 的设备,您可以在检查计数器之前进行 八次 操作(在本例中为 c)。

现在的执行步骤大致是:

Is c > 0? ==> Yes
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Is c > 0? ==> Yes
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Do some math.
Is c > 0? ==> Yes
[...]

换句话说,您实际完成工作所花费的 CPU 大约是其 8 倍,而检查计数器值的时间要少得多。这是一个的胜利。

我怀疑您甚至可以将循环进一步展开到 16 或 32 次操作,以获得更大的胜利。这实际上取决于代码中d 的可能值。

请测试并分析此代码,并告诉我它对您的效果。
我有一种强烈的感觉,这将是一个很大的改进。

【讨论】:

  • Duff 的设备显然被实现为从一个内存位置复制到另一个内存位置,这比 OP 问题中的算法更简单的练习优化。此外,D's d 是在 1983 年发明的,当时管道衬里在 80286 上还处于起步阶段,而在 68k 上不存在(?)。所以“绝对”在 1983 年是相关的,但考虑到 30 年的硬件开发,今天可能不那么重要了。如果你提供一个他们认可的优化方案,C 编译器今天会更聪明,否则它们就不会那么笨了。
【解决方案2】:

您可以通过严格的别名规则帮助编译器:

double calc_ssq(double *restrict descr1, double *restrict descr2, size_t count)
{
double ssq;

ssq = 0.0;
for ( ;count;  count--) {
        double diff;
        diff = *descr1++ - *descr2++;
        ssq += diff * diff;
        }
return ssq;
}

【讨论】:

  • 并非如此,代码中的任何指针都没有写入访问权限,因此别名不能阻止编译器优化原始代码。如果这对性能有影响,那只能说明编译器不够聪明。
  • 在原始代码sn-p中没有,但实际代码可能足够复杂,无法进行别名分析。
  • 我很抱歉这个贡献,它只是为了让讨论继续下去。也许const 会在这里做同样的事情,因为数组永远不会被写入。我不是这方面的专家,但我感觉这种机制是在 C99 中引入此功能的驱动力。 (在此处插入 dmr 引用 ...)顺便说一句:内联可能也会有所帮助。
【解决方案3】:

如果您的计算确实不需要双精度,您可以尝试将它们转换为单精度,然后相乘。

我想,在 32 位处理器的情况下,单精度乘法会比双精度乘法更快,因为常规的 float 只需要一个处理器寄存器,double 需要两个。

我不确定强制转换不会“吃掉”所有速度改进,你会从单精度乘法中获得。

【讨论】:

  • 强制转换不会吃任何速度提升,因为浮点数在浮点寄存器中总是表示为long double,强制转换是一个 noop。不同之处在于,编译器将使用算术指令的单精度变体,而不是双精度指令,后者的执行速度通常是后者的两倍。
  • @cmaster 不知道long double。谢谢。
【解决方案4】:

总而言之,您有一个非常紧凑的循环来访问大量数据。循环展开可能有助于隐藏延迟,但在现代硬件上,像这样的循环受限于内存带宽,而不是计算能力。

因此,优化的唯一真正希望是:a) 使用 float 的数组而不是 double 的数组来减少从内存加载的数据量一半,并且 b) 避免将此代码称为尽可能的。

这里有一些数字:

您的内部循环中有三个双算术指令,大约是 6 个周期。这些需要 16 个字节的数据。在 3 GHz 处理器上,内存带宽为 8 GB/s。 DDR3-1066 模块提供 8.5 GB/s。因此,即使您使用 SSE 之类的东西,也不会变得更快,除非您改用 float

【讨论】:

  • 从您的评论中得出的结论是使算法对缓存友好,以最大限度地利用内存系统带宽。知道示例中每个循环需要多少个时钟周期会很有趣。
【解决方案5】:

假设您使用的是现代 Intel/AMD 处理器(带有 AVX)并且您希望保持相同的算法,您可以尝试以下代码。它使用 AVX 和 OpenMP 进行并行化。使用GCC foo.c -mavx -fopenmp -O3 编译。如果您不想使用 OpenMP,只需将两个 #pragma 语句注释掉即可。

速度将取决于数组大小和缓存大小。对于适合 L1 缓存的阵列,您可以预期大约 6 倍的加速(由于其开销,您应该禁用 OpenMP)。提升将随着每个缓存级别而不断下降。当它到达系统内存时,它仍然会得到提升(在我的两个核心常春藤桥系统上运行超过 10M 双倍 (2*80MB) 仍然快 70% 以上)。

#include <stdio.h>
#include <stdlib.h>
#include <immintrin.h>
#include <omp.h>

double foo_avx_omp(const double *descr1, const double *descr2, const int d) {
    double diff, dsq = 0;
    int i;
    int range;
    __m256d diff4_v1, diff4_v2, dsq4_v1, dsq4_v2, t1, l1, l2, l3, l4;
    __m128d t2, t3;
    range = (d-1) & -8; //round down to multiple of 8
    #pragma omp parallel private(i,l1,l2,l3,l4,t1,t2,t3,dsq4_v1,dsq4_v2,diff4_v1,diff4_v2) \
    reduction(+:dsq)
    {
        dsq4_v1 = _mm256_set1_pd(0.0);
        dsq4_v2 = _mm256_set1_pd(0.0); //two sums to unroll the loop once

        #pragma omp for
        for(i=0; i<(range/8); i++) {
            //load one cache line of descr1
            l1 = _mm256_load_pd(&descr1[8*i]);
            l3 = _mm256_load_pd(&descr1[8*i+4]);
             //load one cache line of descr2
            l2 = _mm256_load_pd(&descr2[8*i]);
            l4 = _mm256_load_pd(&descr2[8*i+4]);
            diff4_v1 = _mm256_sub_pd(l1, l2);
            diff4_v2 = _mm256_sub_pd(l3, l4);
            dsq4_v1 = _mm256_add_pd(dsq4_v1, _mm256_mul_pd(diff4_v1, diff4_v1));
            dsq4_v2 = _mm256_add_pd(dsq4_v2, _mm256_mul_pd(diff4_v2, diff4_v2));
        }
        dsq4_v1 = _mm256_add_pd(dsq4_v1, dsq4_v2);
        t1 = _mm256_hadd_pd(dsq4_v1,dsq4_v1);
        t2 = _mm256_extractf128_pd(t1,1);
        t3 = _mm_add_sd(_mm256_castpd256_pd128(t1),t2);
        dsq += _mm_cvtsd_f64(t3);
    }

    //finish remaining elements if d was not a multiple of 8
    for (i=range; i < d; ++i) {
      diff = descr1[i] - descr2[i];
      dsq += diff * diff;
    }
    return dsq;
}

double foo(double *descr1, double *descr2, int d) {
    double diff, dsq = 0;
    int i;

    for (i = 0; i < d; ++i)
    {
      diff = descr1[i] - descr2[i];
      dsq += diff * diff;
    }
    return dsq;
}

int main(void)
{
    double result1, result2, result3, dtime;
    double *descr1, *descr2;
    const int n = 2000000;
    int i;
    int repeat = 1000;

    descr1 = _mm_malloc(sizeof(double)*n, 64); //align to a cache line 
    descr2 = _mm_malloc(sizeof(double)*n, 64); //align to a cache line

    for(i=0; i<n; i++) {
        descr1[i] = 1.0*rand()/RAND_MAX;
        descr2[i] = 1.0*rand()/RAND_MAX;
    }
    dtime = omp_get_wtime();
    for(i=0; i<repeat; i++) {
        result1 = foo(descr1, descr2, n);
    }
    dtime = omp_get_wtime() - dtime;
    printf("foo %f, time %f\n", result1, dtime);

    dtime = omp_get_wtime();
    for(i=0; i<repeat; i++) {
        result1 = foo_avx_omp(descr1, descr2, n);
    }
    dtime = omp_get_wtime() - dtime;
    printf("foo_avx_omp %f, time %f\n", result1, dtime);

    return 0;
}

【讨论】:

    【解决方案6】:

    如果 d 可以被 2 整除,我会尝试这样的操作:

    for(i=0;i<d;i+=2)
    {
      diff0 = descr1[i]   - descr2[i];
      diff1 = descr1[i+1] - descr2[i+1];
      dsq += diff0 * diff0 + diff1 * diff1;
    }
    

    这会暗示优化器可以交错六个操作。即使 d 是奇数,您也可以在每个向量的末尾附加一个 0.0 值(给出偶数个值),因为考虑到所涉及的操作,这对结果没有影响。

    下一步可能是将向量附加到可被 4 整除,在 i+=4 迭代之前进行四次减法、四次乘法和四次加法;

    能被 8 整除使得向量完全适合 64 的高速缓存行大小。

    浮点乘法只需要一个或两个时钟周期即可完成,加法和减法也是如此(根据 Agner Fog 的说法)。因此,对于您的示例,减少迭代开销应该会加快速度。

    【讨论】:

    • 我得说,如果这段代码加速了你的程序,那么 Duff 的设备(在我的回答中)将提供至少 4 倍的增长。你真的应该试一试。
    • @abalenky:我不相信,因为优化器可能或(可能)可能不理解什么样的操作交错是可能的。本质上,每个案例都需要是原子的,因此案例 7 不能与案例 0 的优化、案例 7 的案例 6 等的优化交错。在求和步骤之前,每个中间值都需要自己存储。在我看来,您实际上是在用 switch-case 开销替换循环开销。
    • 我得说,如果这段代码加速了你的程序,那么使用 SSE/AVX 和多核(以及展开这个答案所做的循环)将加快它的速度。跨度>
    • 如果您检查过 cmaster 的答案(如下),也许您不会那么肯定。多核?我想不是。内存系统可能会被第一个内核饱和,因此添加另一个内核会减慢速度 - 什么? 50% 到 150%?您还忘了提到我的回答也启用了指令重新排序。
    【解决方案7】:

    看起来您正在计算两个向量的均方误差。

    使用BLAS,您将能够利用手动优化的代码,其效率远超我们任何人编写的。

    【讨论】:

    • 检查 cmaster 的答案。每个循环必须加载两个 8 字节实体。 PC3-12800(cmaster 使用 PC3-8500)每秒将读取相当于 2 亿条缓存线。因此,每秒通过 RAM 和缓存维持 16 亿个 8 字节实体。每次迭代需要两个,并对它们执行三个操作(减法、乘法和加法)。每秒 8 亿次迭代对我来说似乎并非不可能。我不相信手工编写的代码会“更有效率”。但我想这取决于你如何定义“更多”。
    • 当然。随意对“效率更高”提出质疑。我最初的观点是 OP 应该利用之前的工作。不要重新发明轮子,除非你真的想成为一名车轮匠。
    • 为什么不“重新发明轮子”?由于允许处理更多数据的外部因素、新算法或有时只是为了了解事物如何运行的简单、老式的乐趣,代码序列经常被重新发明。
    【解决方案8】:

    descr1descr2 的两个双值放入一个结构中,使它们在内存中彼此相邻。这样可以更好地使用缓存和访问内存。

    对于diffdsq 也使用register

    【讨论】:

    • 使用寄存器没用,编译器忽略它
    • 我会说同样的话略有不同:如果你有那种需要register提示的编译器,你可以免费获得的最大速度提升是升级到最先进的艺术编译器。
    • @GregoryPakosz - 这是对编译器的提示。有任何参考解释它被所有编译器忽略吗?
    • @EdHEal 确定:stackoverflow.com/a/10675111/216063 -- 是的,试试吧,添加 register 并查看生成的代码是否更改
    • 如果您阅读您的参考资料,它会说(我引用)“提示可以被忽略,而在大多数实现中它将被忽略”(我的重点)。即并非总是,也不是全部实现
    【解决方案9】:

    警告:以下代码未经测试。

    如果您的硬件和编译器都支持它们,您可能希望使用向量并行化某些操作。我过去在 GCC 4.6.x 编译器(x86-64 Ubuntu 机器)上使用过类似于以下内容的东西。如果使用不同的编译器/体系结构,某些语法可能会略有不同或可能会有所不同。但是,我希望它足够接近让您朝着目标更进一步。

    typedef double v2d_t __attribute__((vector_size (16)));
    
    typedef union {
        v2d_t    vector;
        double   d[2];
    } v2d_u;
    
    v2d_u    vdsq = (v2d_t) {0.0, 0.0};  /* sum of square of differences */
    v2d_u    vdiff;              /* difference */
    v2d_t *  vdescr1;            /* pointer to array of aligned vector of doubles */
    v2d_t *  vdescr2;            /* pointer to array of aligned vector of doubles */
    int      i;                  /* index into array of aligned vector of doubles */
    int      d;                  /* # of elements in array */
    
    /*
     * ...
     * Assuming that <d> is getting initialized appropriately somewhere
     * ...
     */
    
    for (i = 0; i < d; i++) {
        vdiff.vector = vdescr1[i] - vdescr2[i];
        vdsq.vector += vdiff.vector * vdiff.vector;
    }
    
    return vdsq.d[0] + vdsq.d[1];
    

    以上内容可能可以进行更多调整以获得更好的性能。也许一些循环展开。或者,如果您可以利用 256 位向量(例如某些 x86 处理器上的 YMMx)而不是此示例使用的 128 位向量,那也可能会加快速度(需要对代码进行一些调整)。

    希望这会有所帮助。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-02-15
      • 2016-01-01
      • 2017-07-13
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多