C++转换优化

12

我想问一下是否有比一个一个遍历所有值并将它们除以32768更快的方式来进行音频转换。

void CAudioDataItem::Convert(const vector<int>&uIntegers, vector<double> &uDoubles)
{
    for ( int i = 0; i <=uIntegers.size()-1;i++)
    {
        uDoubles[i] = uIntegers[i] / 32768.0;
    }
}

我的方法效果还不错,但可能会更快一些。不过我没有找到任何加速它的方法。

谢谢你的帮助!


1
你可以尝试乘以 1 / 32768.0。不确定编译器是否允许自行执行此操作,因为它会破坏 IEEE 标准。 - Mysticial
2
@aaronman 对于这个问题,使用cuda并不值得。转换是微不足道的。移动数据将成为一个巨大的瓶颈。 - harold
2
@Mysticial:我认为这没问题。x/32768和x•(1/32768)的数学结果是相同的,因此它们将在舍入、下溢等方面生成相同的结果。 - Eric Postpischil
1
@ChrisCM 你指的是哪些优化?我还没有使用编译器优化。 - tmighty
@Mysticial 如果一个值不是2的幂,gcc会使用除法而不是乘法,因此这不是一般情况。 - SirGuy
显示剩余17条评论
6个回答

3

如果你的数组足够大,将其并行化可能是值得的。我会使用OpenMP的parallel for语句。
函数将会变成这样:

    void CAudioDataItem::Convert(const vector<int>&uIntegers, vector<double> &uDoubles)
    {
        #pragma omp parallel for
        for (int i = 0; i < uIntegers.size(); i++)
        {
            uDoubles[i] = uIntegers[i] / 32768.0;
        }
    }

使用gcc编译时,需要在编译时传递-fopenmp参数以便使用pragma,而在MSVC中则需使用/openmp参数。由于生成线程会带来明显的开销,只有在处理大型数组时才能获得更快的速度,具体效果因情况而异。

3

为了获得最大的速度,您需要在每个循环迭代中转换多个值。最简单的方法是使用SIMD。以下是如何使用SSE2实现:

void CAudioDataItem::Convert(const vector<int>&uIntegers, vector<double> &uDoubles)
{
    __m128d scale = _mm_set_pd( 1.0 / 32768.0, 1.0 / 32768.0 );
    int i = 0;
    for ( ; i < uIntegers.size() - 3; i += 4)
    {
        __m128i x = _mm_loadu_si128(&uIntegers[i]);
        __m128i y = _mm_shuffle_epi32(x, _MM_SHUFFLE(2,3,0,0) );
        __m128d dx = _mm_cvtepi32_pd(x);
        __m128d dy = _mm_cvtepi32_pd(y);
        dx = _mm_mul_pd(dx, scale);
        dy = _mm_mul_pd(dy, scale);
        _mm_storeu_pd(dx, &uDoubles[i]);
        _mm_storeu_pd(dy, &uDoubles[i + 2]);
    }
    // Finish off the last 0-3 elements the slow way
    for ( ; i < uIntegers.size(); i ++)
    {
        uDoubles[i] = uIntegers[i] / 32768.0;
    }
}

我们每次循环处理四个整数。由于寄存器中只能容纳两个双精度数,因此存在一些重复的工作,但是额外的展开循环将有助于性能提升,除非数组非常小。

将数据类型更改为较小的类型(例如short和float)也应该有助于性能提升,因为它们可以减少内存带宽,并且您可以在SSE寄存器中放入四个float。对于音频数据,您不应该需要双精度的精度。

请注意,我使用了非对齐的加载和存储。如果数据实际上是对齐的(默认情况下不会),则对齐的加载和存储会稍微快一些,而且很难使std :: vector内部的内容对齐。


+1 你的SSE2比我的好 - 同时说明了阻塞操作;-) - FrankH.
谢谢。您可能还想看一下,当输入向量大小为2时,您的代码会写入多少个双精度浮点数 ;) - Adam
注意...我需要一个 if (i < uIntegers.size()) ... 的测试;-) - FrankH.
为了获得最大速度,您需要使用对齐内存、AVX和OpenMP。 - user2088790
对齐的内存速度比非对齐的内存快得多,而不是稍微快一点。 - user2088790
我还没有尝试使用std::vector对齐,但这里有一个关于它的讨论:https://dev59.com/62cs5IYBdhLWcg3wVyal - user2088790

3
你的函数高度可并行化。在现代Intel CPU上,有三种独立的并行化方式:指令级并行(ILP),线程级并行(TLP)和SIMD。我能够使用这三种方式来大幅提升你的函数性能。但是结果取决于编译器。在使用GCC时,提升效果要小得多,因为它已经将函数向量化了。请参见下面的数字表格。
然而,你的函数主要限制因素是其时间复杂度仅为O(n),所以当你运行的数组大小越过每个缓存级别边界时,效率会急剧下降。例如,在大型密集矩阵乘法(GEMM)中,它是一个O(n^3)的操作,因此如果正确处理(例如使用循环平铺),则缓存层次结构不是问题:即使对于非常大的矩阵,你也可以接近最大的flops/s(这似乎表明GEMM是英特尔设计CPU时考虑的事情之一)。在你的情况下,解决这个问题的方法是找到一种方法,在执行更复杂的操作(例如按O(n^2)进行的操作)之前/之后在L1缓存块上执行你的函数,然后移动到另一个L1块。当然,我不知道你正在做什么,所以我不能做到这一点。
CPU硬件已经部分完成了ILP。然而,经常出现的循环依赖性会限制ILP,因此进行循环展开有助于充分利用ILP。对于TLP,我使用OpenMP,对于SIMD,我使用AVX(但下面的代码也适用于SSE)。我使用32字节对齐的内存,并确保数组是8的倍数,这样就不需要清理。
以下是在Visual Studio 2012 64位上使用AVX和OpenMP(显然是发布模式)在SandyBridge EP 4核心(8个硬件线程)@3.6 GHz上的结果。变量n是项目数量。我还重复执行函数几次,所以总时间包括那些时间。函数convert_vec4_unroll2_openmp提供了最佳结果,除了L1区域外。你还可以清楚地看到,每次移动到新的缓存级别时效率都会显著下降,但即使对于主存储器,它仍然更好。
l1 chache, n 2752, repeat 300000
    covert time 1.34, error 0.000000
    convert_vec4 time 0.16, error 0.000000
    convert_vec4_unroll2 time 0.16, error 0.000000
    convert_vec4_unroll2_openmp time 0.31, error 0.000000

l2 chache, n 21856, repeat 30000
    covert time 1.14, error 0.000000
    convert_vec4 time 0.24, error 0.000000
    convert_vec4_unroll2 time 0.24, error 0.000000
    convert_vec4_unroll2_openmp time 0.12, error 0.000000

l3 chache, n 699072, repeat 1000
    covert time 1.23, error 0.000000
    convert_vec4 time 0.44, error 0.000000
    convert_vec4_unroll2 time 0.45, error 0.000000
    convert_vec4_unroll2_openmp time 0.14, error 0.000000

main memory , n 8738144, repeat 100
    covert time 1.56, error 0.000000
    convert_vec4 time 0.95, error 0.000000
    convert_vec4_unroll2 time 0.89, error 0.000000
    convert_vec4_unroll2_openmp time 0.51, error 0.000000

在i5-3317上(ivy bridge)@ 2.4 GHz 2核心(4个HW线程)上使用g++ foo.cpp -mavx -fopenmp -ffast-math -O3的结果。GCC似乎对此进行了向量化,唯一的好处来自OpenMP(但是,在L1区域中给出了更差的结果)。

l1 chache, n 2752, repeat 300000
    covert time 0.26, error 0.000000
    convert_vec4 time 0.22, error 0.000000
    convert_vec4_unroll2 time 0.21, error 0.000000
    convert_vec4_unroll2_openmp time 0.46, error 0.000000

l2 chache, n 21856, repeat 30000
    covert time 0.28, error 0.000000
    convert_vec4 time 0.27, error 0.000000
    convert_vec4_unroll2 time 0.27, error 0.000000
    convert_vec4_unroll2_openmp time 0.20, error 0.000000

l3 chache, n 699072, repeat 1000
    covert time 0.80, error 0.000000
    convert_vec4 time 0.80, error 0.000000
    convert_vec4_unroll2 time 0.80, error 0.000000
    convert_vec4_unroll2_openmp time 0.83, error 0.000000

main memory chache, n 8738144, repeat 100
    covert time 1.10, error 0.000000
    convert_vec4 time 1.10, error 0.000000
    convert_vec4_unroll2 time 1.10, error 0.000000
    convert_vec4_unroll2_openmp time 1.00, error 0.000000

这里是代码。我使用vectorclass http://www.agner.org/optimize/vectorclass.zip 来进行SIMD操作。这将使用AVX一次写入4个双精度数或SSE一次写入2个双精度数。

#include <stdlib.h>
#include <stdio.h>
#include <omp.h>
#include "vectorclass.h"

void convert(const int *uIntegers, double *uDoubles, const int n) {
    for ( int i = 0; i<n; i++) {
        uDoubles[i] = uIntegers[i] / 32768.0;
    }
}

void convert_vec4(const int *uIntegers, double *uDoubles, const int n) {
    Vec4d div = 1.0/32768;
    for ( int i = 0; i<n; i+=4) {
        Vec4i u4i = Vec4i().load(&uIntegers[i]);
        Vec4d u4d  = to_double(u4i);
        u4d*=div;
        u4d.store(&uDoubles[i]);
    }
}

void convert_vec4_unroll2(const int *uIntegers, double *uDoubles, const int n) {
    Vec4d div = 1.0/32768;
    for ( int i = 0; i<n; i+=8) {
        Vec4i u4i_v1 = Vec4i().load(&uIntegers[i]);
        Vec4d u4d_v1  = to_double(u4i_v1);
        u4d_v1*=div;
        u4d_v1.store(&uDoubles[i]);

        Vec4i u4i_v2 = Vec4i().load(&uIntegers[i+4]);
        Vec4d u4d_v2  = to_double(u4i_v2);
        u4d_v2*=div;
        u4d_v2.store(&uDoubles[i+4]);
    }
}

void convert_vec4_openmp(const int *uIntegers, double *uDoubles, const int n) {
    #pragma omp parallel for    
    for ( int i = 0; i<n; i+=4) {
        Vec4i u4i = Vec4i().load(&uIntegers[i]);
        Vec4d u4d  = to_double(u4i);
        u4d/=32768.0;
        u4d.store(&uDoubles[i]);
    }
}

void convert_vec4_unroll2_openmp(const int *uIntegers, double *uDoubles, const int n) {
    Vec4d div = 1.0/32768;
    #pragma omp parallel for    
    for ( int i = 0; i<n; i+=8) {
        Vec4i u4i_v1 = Vec4i().load(&uIntegers[i]);
        Vec4d u4d_v1  = to_double(u4i_v1);
        u4d_v1*=div;
        u4d_v1.store(&uDoubles[i]);

        Vec4i u4i_v2 = Vec4i().load(&uIntegers[i+4]);
        Vec4d u4d_v2  = to_double(u4i_v2);
        u4d_v2*=div;
        u4d_v2.store(&uDoubles[i+4]);
    }
}

double compare(double *a, double *b, const int n) {
    double diff = 0.0;
    for(int i=0; i<n; i++) {
        double tmp = a[i] - b[i];
        //printf("%d %f %f \n", i, a[i], b[i]);
        if(tmp<0) tmp*=-1;
        diff += tmp;
    }
    return diff;
}

void loop(const int n, const int repeat, const int ifunc) {
    void (*fp[4])(const int *uIntegers, double *uDoubles, const int n);

    int *a = (int*)_mm_malloc(sizeof(int)* n, 32);
    double *b1_cmp = (double*)_mm_malloc(sizeof(double)*n, 32);
    double *b1 = (double*)_mm_malloc(sizeof(double)*n, 32);
    double dtime;

    const char *fp_str[] = {
        "covert",
        "convert_vec4",
        "convert_vec4_unroll2",
        "convert_vec4_unroll2_openmp",
    };

    for(int i=0; i<n; i++) {
        a[i] = rand()*RAND_MAX;
    }

    fp[0] = convert;
    fp[1] = convert_vec4;
    fp[2] = convert_vec4_unroll2;
    fp[3] = convert_vec4_unroll2_openmp;

    convert(a, b1_cmp, n);

    dtime = omp_get_wtime();
    for(int i=0; i<repeat; i++) {
        fp[ifunc](a, b1, n);
    }
    dtime = omp_get_wtime() - dtime;
    printf("\t%s time %.2f, error %f\n", fp_str[ifunc], dtime, compare(b1_cmp,b1,n));

    _mm_free(a);
    _mm_free(b1_cmp);
    _mm_free(b1);
}

int main() {
    double dtime;
    int l1 = (32*1024)/(sizeof(int) + sizeof(double));
    int l2 = (256*1024)/(sizeof(int) + sizeof(double));
    int l3 = (8*1024*1024)/(sizeof(int) + sizeof(double));
    int lx = (100*1024*1024)/(sizeof(int) + sizeof(double));
    int n[] = {l1, l2, l3, lx};

    int repeat[] = {300000, 30000, 1000, 100};
    const char *cache_str[] = {"l1", "l2", "l3", "main memory"};
    for(int c=0; c<4; c++ ) {
        int lda = ((n[c]+7) & -8); //make sure array is a multiple of 8
        printf("%s chache, n %d\n", cache_str[c], lda);
        for(int i=0; i<4; i++) {
            loop(lda, repeat[c], i);
        } printf("\n");
    }
}

最后,所有已经读到这里并感觉我的代码更像是C而不是C++的人,请在发表评论前先阅读这篇文章:http://www.stroustrup.com/sibling_rivalry.pdf


1
你可以尝试一下:

uDoubles[i] = ldexp((double)uIntegers[i], -15);

是的,可能是函数调用开销;但是一个非常好的编译器可以将其内联。 - Lee Daniel Crocker
我很想看看编译器如何处理这个。它可能真的足够聪明,只需发出乘以1 / 32768.0的信号。(虽然我不会指望它做到这一点。) - Mysticial
在Core2上,fscale的延迟为43,fmul为5。在Sandy Bridge上,fscale的延迟为12,fmul仍然为5。 - harold
哇。那就乘法了。 - Lee Daniel Crocker
哦,我一直以为 frexpldexp 通常比乘以二的幂要快。即使没有转换,这也不是情况吗?(优化的一个可靠规则:你的猜测经常是错误的。) - aschepler
显示剩余4条评论

1

编辑: 请参见亚当上面的答案,使用SSE指令集版本。比我这里的更好...

为了使其更有用,让我们看看编译器生成的代码。我正在使用gcc 4.8.0,是值得检查您特定编译器(版本)的,因为例如gcc 4.4、4.8、clang 3.2或英特尔的icc输出有相当大的差异。

您原来的代码,使用g++ -O8 -msse4.2 ... 转换成以下循环:

.L2:
    cvtsi2sd        (%rcx,%rax,4), %xmm0
    mulsd   %xmm1, %xmm0
    addl    $1, %edx
    movsd   %xmm0, (%rsi,%rax,8)
    movslq  %edx, %rax
    cmpq    %rdi, %rax
    jbe     .L2

这里的%xmm1保存着1.0/32768.0,因此编译器会自动将除法转换为乘法-倒数。

另一方面,使用g++ -msse4.2 -O8 -funroll-loops ...,循环创建的代码发生了显著变化:

[ ... ]
    leaq    -1(%rax), %rdi
    movq    %rdi, %r8
    andl    $7, %r8d
    je      .L3
[ ... insert a duff's device here, up to 6 * 2 conversions ... ]
    jmp     .L3
    .p2align 4,,10
    .p2align 3
.L39:
    leaq    2(%rsi), %r11
    cvtsi2sd        (%rdx,%r10,4), %xmm9
    mulsd   %xmm0, %xmm9
    leaq    5(%rsi), %r9
    leaq    3(%rsi), %rax
    leaq    4(%rsi), %r8
    cvtsi2sd        (%rdx,%r11,4), %xmm10
    mulsd   %xmm0, %xmm10
    cvtsi2sd        (%rdx,%rax,4), %xmm11
    cvtsi2sd        (%rdx,%r8,4), %xmm12
    cvtsi2sd        (%rdx,%r9,4), %xmm13
    movsd   %xmm9, (%rcx,%r10,8)
    leaq    6(%rsi), %r10
    mulsd   %xmm0, %xmm11
    mulsd   %xmm0, %xmm12
    movsd   %xmm10, (%rcx,%r11,8)
    leaq    7(%rsi), %r11
    mulsd   %xmm0, %xmm13
    cvtsi2sd        (%rdx,%r10,4), %xmm14
    mulsd   %xmm0, %xmm14
    cvtsi2sd        (%rdx,%r11,4), %xmm15
    mulsd   %xmm0, %xmm15
    movsd   %xmm11, (%rcx,%rax,8)
    movsd   %xmm12, (%rcx,%r8,8)
    movsd   %xmm13, (%rcx,%r9,8)
    leaq    8(%rsi), %r9
    movsd   %xmm14, (%rcx,%r10,8)
    movsd   %xmm15, (%rcx,%r11,8)
    movq    %r9, %rsi
.L3:
    cvtsi2sd        (%rdx,%r9,4), %xmm8
    mulsd   %xmm0, %xmm8
    leaq    1(%rsi), %r10
    cmpq    %rdi, %r10
    movsd   %xmm8, (%rcx,%r9,8)
    jbe     .L39
[ ... out ... ]

所以它会阻塞操作,但仍然一次只转换一个值。
如果你将原始循环更改为每次迭代处理几个元素:
size_t i;
for (i = 0; i < uIntegers.size() - 3; i += 4)
{
    uDoubles[i] = uIntegers[i] / 32768.0;
    uDoubles[i+1] = uIntegers[i+1] / 32768.0;
    uDoubles[i+2] = uIntegers[i+2] / 32768.0;
    uDoubles[i+3] = uIntegers[i+3] / 32768.0;
}
for (; i < uIntegers.size(); i++)
    uDoubles[i] = uIntegers[i] / 32768.0;

编译器(gcc -msse4.2 -O8…)可以识别使用CVTDQ2PD/MULPD的潜力,即使没有请求展开循环。因此,循环的核心变成了:
    .p2align 4,,10
    .p2align 3
.L4:
    movdqu  (%rcx), %xmm0
    addq    $16, %rcx
    cvtdq2pd        %xmm0, %xmm1
    pshufd  $238, %xmm0, %xmm0
    mulpd   %xmm2, %xmm1
    cvtdq2pd        %xmm0, %xmm0
    mulpd   %xmm2, %xmm0
    movlpd  %xmm1, (%rdx,%rax,8)
    movhpd  %xmm1, 8(%rdx,%rax,8)
    movlpd  %xmm0, 16(%rdx,%rax,8)
    movhpd  %xmm0, 24(%rdx,%rax,8)
    addq    $4, %rax
    cmpq    %r8, %rax
    jb      .L4
    cmpq    %rdi, %rax
    jae     .L29
[ ... duff's device style for the "tail" ... ]
.L29:
    rep ret

即现在编译器能够识别机会将两个double放入SSE寄存器中,并进行并行乘法/转换。这与Adam的SSE内部函数版本生成的代码非常接近。

总体上来说,该代码(我只展示了大约1/6)比“直接”内部函数复杂得多,因为编译器试图在循环中预置/附加不对齐/不是块倍数的“头部”和“尾部”,这在很大程度上取决于您向量的平均/预期大小是否有益;对于“通用”情况(向量大小超过“最内层”循环处理的块的两倍),它将有所帮助。

这次练习的结果主要是......如果您强制(通过编译器选项/优化)或提示(通过稍微重新排列代码)编译器做正确的事情,那么对于这种特定类型的复制/转换循环,它会生成的代码不会比手写的内部函数差多少。

最后一个实验...让代码:

static double c(int x) { return x / 32768.0; }
void Convert(const std::vector<int>& uIntegers, std::vector<double>& uDoubles)
{
    std::transform(uIntegers.begin(), uIntegers.end(), uDoubles.begin(), c);
}

并且(为了阅读最友好的汇编输出,这次使用带有gcc -O8 -msse4.2 ...的gcc 4.4),生成的汇编核心循环(再次,有一个前/后位)变成:

    .p2align 4,,10
    .p2align 3
.L8:
    movdqu  (%r9,%rax), %xmm0
    addq    $1, %rcx
    cvtdq2pd        %xmm0, %xmm1
    pshufd  $238, %xmm0, %xmm0
    mulpd   %xmm2, %xmm1
    cvtdq2pd        %xmm0, %xmm0
    mulpd   %xmm2, %xmm0
    movapd  %xmm1, (%rsi,%rax,2)
    movapd  %xmm0, 16(%rsi,%rax,2)
    addq    $16, %rax
    cmpq    %rcx, %rdi
    ja      .L8
    cmpq    %rbx, %rbp
    leaq    (%r11,%rbx,4), %r11
    leaq    (%rdx,%rbx,8), %rdx
    je      .L10
[ ... ]
.L10:
[ ... ]
    ret

因此,我们学到了什么?如果你想使用C++,就要真正地使用C++;-)


-O8开关在gcc中的作用是什么?我以为最大的优化级别是-O3 - Piotr99
1
在gcc中,-O...级别选项超出“最高有效”只是做相同的事情(至少gcc 4.x似乎可以使用-O9999999999999999999999999999999 ...)。这是一种习惯;旧版本的gcc曾经拒绝允许超过-O8的值... - FrankH.

0

让我换一种方式尝试:

如果从汇编指令的角度来看,乘法确实更好,那么这应该保证它会被乘。

void CAudioDataItem::Convert(const vector<int>&uIntegers, vector<double> &uDoubles)
{
    for ( int i = 0; i <=uIntegers.size()-1;i++)
    {
        uDoubles[i] = uIntegers[i] * 0.000030517578125;
    }
}

既然原除数的倒数可以精确表示,为什么要使用一个与精确值不同并在转换为二进制浮点数时再次四舍五入的数字呢?只需使用(1 / 32768.)0x1p-15即可。 - Eric Postpischil
使用(1/32768),编译器能够进行转换为除法吗? - ruben2020
由于整数算术,1/32768 等于零,但编译器可能在编译时将 1/32768. 转换为 .000030517578125,特别是因为结果是精确的且不引发浮点异常。其他表达式,如 1/3.,可能会引发异常(例如不精确结果的异常),因此编译器是否在编译时优化它们取决于编译器质量和模式。(一些编译器具有更多或更少符合浮点标准的开关。) - Eric Postpischil
另一个选择是使用十六进制浮点表示法,它提供了一种精确指定浮点值的方式,易于软件进行转换。数字“0x1p-15”表示1乘以2的负15次方。 - Eric Postpischil
好的。谢谢。这是我第一次听说p-符号。 - ruben2020

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接