为什么这段代码效率不高?

时间:2011-12-15 11:42:33

标签: c++ performance arm simd neon

我想改进下一个代码,计算平均值:

void calculateMeanStDev8x8Aux(cv::Mat* patch, int sx, int sy, int& mean, float& stdev)
{

    unsigned sum=0;
    unsigned sqsum=0;
    const unsigned char* aux=patch->data + sy*patch->step + sx;
    for (int j=0; j< 8; j++) {
        const unsigned char* p = (const unsigned char*)(j*patch->step + aux ); //Apuntador al inicio de la matrix           

        for (int i=0; i<8; i++) {
            unsigned f = *p++;
            sum += f;
            sqsum += f*f;
        }           
    }       

    mean = sum >> 6;
    int r = (sum*sum) >> 6;
    stdev = sqrtf(sqsum - r);

    if (stdev < .1) {
        stdev=0;
    }
}

我还使用NEON内在函数改进了下一个循环:

 for (int i=0; i<8; i++) {
            unsigned f = *p++;
            sum += f;
            sqsum += f*f;
        }

这是为其他循环改进的代码:

        int32x4_t vsum= { 0 };
        int32x4_t vsum2= { 0 };

        int32x4_t vsumll = { 0 };
        int32x4_t vsumlh = { 0 };
        int32x4_t vsumll2 = { 0 };
        int32x4_t vsumlh2 = { 0 };

        uint8x8_t  f= vld1_u8(p); // VLD1.8 {d0}, [r0]

        //int 16 bytes /8 elementos
        int16x8_t val =  (int16x8_t)vmovl_u8(f);

        //int 32 /4 elementos *2 
        int32x4_t vall = vmovl_s16(vget_low_s16(val));
        int32x4_t valh = vmovl_s16(vget_high_s16(val));

        // update 4 partial sum of products vectors

        vsumll2 = vmlaq_s32(vsumll2, vall, vall);
        vsumlh2 = vmlaq_s32(vsumlh2, valh, valh);

        // sum 4 partial sum of product vectors
        vsum = vaddq_s32(vall, valh);
        vsum2 = vaddq_s32(vsumll2, vsumlh2);

        // do scalar horizontal sum across final vector

        sum += vgetq_lane_s32(vsum, 0);
        sum += vgetq_lane_s32(vsum, 1);
        sum += vgetq_lane_s32(vsum, 2);
        sum += vgetq_lane_s32(vsum, 3);

        sqsum += vgetq_lane_s32(vsum2, 0);
        sqsum += vgetq_lane_s32(vsum2, 1);
        sqsum += vgetq_lane_s32(vsum2, 2);
        sqsum += vgetq_lane_s32(vsum2, 3);

但它或多或少30毫秒更慢。有谁知道为什么?

所有代码都正常运作。

4 个答案:

答案 0 :(得分:3)

添加到Lundin。是的,像ARM那样的指令集,你有一个基于寄存器的索引或一些带有立即索引的覆盖,你可能会受益,鼓励编译器使用索引。另外,虽然ARM例如可以在加载指令中递增其指针寄存器,但在一条指令中基本上是* p ++。

总是使用p [i]或p [i ++] vs * p或* p ++进行折腾,一些指令集要走哪条路径更为明显。

同样是你的索引。如果你没有使用它倒计时而不是up可以保存每个循环的指令,也许更多。有些人可能会这样做:

inc reg
cmp reg,#7
bne loop_top

如果你倒计时,你可能会为每个循环保存一条指令:

dec reg
bne loop_top

甚至是我知道的一个处理器

decrement_and_jump_if_not_zero  loop_top

编译器通常知道这一点,你不必鼓励他们。但是如果你使用p [i]形式,其中内存读取顺序很重要,那么编译器不能或者至少不应该随意改变读取的顺序。因此,对于这种情况,您可能希望将代码倒计时。

所以我尝试了所有这些:

unsigned fun1 ( const unsigned char *p, unsigned *x )
{
    unsigned sum;
    unsigned sqsum;
    int i;
    unsigned f;


    sum = 0;
    sqsum = 0;
    for(i=0; i<8; i++)
    {
        f = *p++;
        sum += f;
        sqsum += f*f;
    }
    //to keep the compiler from optimizing
    //stuff out
    x[0]=sum;
    return(sqsum);
}

unsigned fun2 ( const unsigned char *p, unsigned *x  )
{
    unsigned sum;
    unsigned sqsum;
    int i;
    unsigned f;


    sum = 0;
    sqsum = 0;
    for(i=8;i--;)
    {
        f = *p++;
        sum += f;
        sqsum += f*f;
    }
    //to keep the compiler from optimizing
    //stuff out
    x[0]=sum;
    return(sqsum);
}

unsigned fun3 ( const unsigned char *p, unsigned *x )
{
    unsigned sum;
    unsigned sqsum;
    int i;

    sum = 0;
    sqsum = 0;
    for(i=0; i<8; i++)
    {
        sum += (unsigned)p[i];
        sqsum += ((unsigned)p[i])*((unsigned)p[i]);
    }
    //to keep the compiler from optimizing
    //stuff out
    x[0]=sum;
    return(sqsum);
}

unsigned fun4 ( const unsigned char *p, unsigned *x )
{
    unsigned sum;
    unsigned sqsum;
    int i;

    sum = 0;
    sqsum = 0;
    for(i=8; i;i--)
    {
        sum += (unsigned)p[i-1];
        sqsum += ((unsigned)p[i-1])*((unsigned)p[i-1]);
    }
    //to keep the compiler from optimizing
    //stuff out
    x[0]=sum;
    return(sqsum);
}

同时使用gcc和llvm(clang)。当然,两者都展开循环,因为它是一个常量。 gcc,对于每个实验产生相同的代码,在微妙的寄存器混合变化的情况下。我会争论一个错误,因为其中至少有一个读取不符合代码所描述的顺序。

所有四个的gcc解决方案都是这样,有一些读取重新排序,请注意源代码中的读取乱序。如果这是针对依赖于代码所描述的顺序的读取的硬件/逻辑,那么你将遇到一个大问题。

00000000 <fun1>:
   0:   e92d05f0    push    {r4, r5, r6, r7, r8, sl}
   4:   e5d06001    ldrb    r6, [r0, #1]
   8:   e00a0696    mul sl, r6, r6
   c:   e4d07001    ldrb    r7, [r0], #1
  10:   e02aa797    mla sl, r7, r7, sl
  14:   e5d05001    ldrb    r5, [r0, #1]
  18:   e02aa595    mla sl, r5, r5, sl
  1c:   e5d04002    ldrb    r4, [r0, #2]
  20:   e02aa494    mla sl, r4, r4, sl
  24:   e5d0c003    ldrb    ip, [r0, #3]
  28:   e02aac9c    mla sl, ip, ip, sl
  2c:   e5d02004    ldrb    r2, [r0, #4]
  30:   e02aa292    mla sl, r2, r2, sl
  34:   e5d03005    ldrb    r3, [r0, #5]
  38:   e02aa393    mla sl, r3, r3, sl
  3c:   e0876006    add r6, r7, r6
  40:   e0865005    add r5, r6, r5
  44:   e0854004    add r4, r5, r4
  48:   e5d00006    ldrb    r0, [r0, #6]
  4c:   e084c00c    add ip, r4, ip
  50:   e08c2002    add r2, ip, r2
  54:   e082c003    add ip, r2, r3
  58:   e023a090    mla r3, r0, r0, sl
  5c:   e080200c    add r2, r0, ip
  60:   e5812000    str r2, [r1]
  64:   e1a00003    mov r0, r3
  68:   e8bd05f0    pop {r4, r5, r6, r7, r8, sl}
  6c:   e12fff1e    bx  lr

加载和微妙寄存器混合的索引是gcc函数之间的唯一区别,所有操作都是相同的顺序。

LLVM /铛:

00000000 <fun1>:
   0:   e92d41f0    push    {r4, r5, r6, r7, r8, lr}
   4:   e5d0e000    ldrb    lr, [r0]
   8:   e5d0c001    ldrb    ip, [r0, #1]
   c:   e5d03002    ldrb    r3, [r0, #2]
  10:   e5d08003    ldrb    r8, [r0, #3]
  14:   e5d04004    ldrb    r4, [r0, #4]
  18:   e5d05005    ldrb    r5, [r0, #5]
  1c:   e5d06006    ldrb    r6, [r0, #6]
  20:   e5d07007    ldrb    r7, [r0, #7]
  24:   e08c200e    add r2, ip, lr
  28:   e0832002    add r2, r3, r2
  2c:   e0882002    add r2, r8, r2
  30:   e0842002    add r2, r4, r2
  34:   e0852002    add r2, r5, r2
  38:   e0862002    add r2, r6, r2
  3c:   e0870002    add r0, r7, r2
  40:   e5810000    str r0, [r1]
  44:   e0010e9e    mul r1, lr, lr
  48:   e0201c9c    mla r0, ip, ip, r1
  4c:   e0210393    mla r1, r3, r3, r0
  50:   e0201898    mla r0, r8, r8, r1
  54:   e0210494    mla r1, r4, r4, r0
  58:   e0201595    mla r0, r5, r5, r1
  5c:   e0210696    mla r1, r6, r6, r0
  60:   e0201797    mla r0, r7, r7, r1
  64:   e8bd41f0    pop {r4, r5, r6, r7, r8, lr}
  68:   e1a0f00e    mov pc, lr

更容易阅读和遵循,也许考虑缓存并一次性读取所有内容。至少有一个案例中的llvm也使得这些读数无序。

00000144 <fun4>:
 144:   e92d40f0    push    {r4, r5, r6, r7, lr}
 148:   e5d0c007    ldrb    ip, [r0, #7]
 14c:   e5d03006    ldrb    r3, [r0, #6]
 150:   e5d02005    ldrb    r2, [r0, #5]
 154:   e5d05004    ldrb    r5, [r0, #4]
 158:   e5d0e000    ldrb    lr, [r0]
 15c:   e5d04001    ldrb    r4, [r0, #1]
 160:   e5d06002    ldrb    r6, [r0, #2]
 164:   e5d00003    ldrb    r0, [r0, #3]

是的,为了平衡某些来自ram的值,订单不是问题,继续前进。

因此编译器选择展开的路径并且不关心微观优化。由于循环的大小,两者都选择烧掉一堆寄存器,每个循环保存一个加载的值,然后执行这些临时读取或乘法的加法。如果我们增加循环的大小,我会期望在展开的循环中看到sum和sqsum累积,因为它会用完寄存器,或者在他们选择不展开循环的情况下达到阈值。

如果我传入长度,并将上面代码中的8替换为传入的长度,则强制编译器对此进行循环。您可以看到优化,使用这样的说明:

  a4:   e4d35001    ldrb    r5, [r3], #1

作为武器,他们在一个地方对循环寄存器进行修改,如果之后的分支不等于分支......因为它们可以。

当然这是一个数学函数,但使用float是很痛苦的。使用multplies是痛苦的,分歧更糟糕,幸运的是使用了一个转变。幸运的是,这是无符号的,因此你可以使用移位(如果你对有符号数使用除法,编译器将/应该知道使用算术移位)。

所以基本上只关注内循环的微观优化,因为它可以多次运行,如果这可以改变,那么它就会变成移位并添加,如果可能的话,或者安排数据,这样你就可以把它带出循环(如果可能的话,不要在其他地方浪费其他副本循环来执行此操作)

const unsigned char* p = (const unsigned char*)(j*patch->step + aux );
你可以获得一些速度。我没有尝试,但因为它是循环中的循环,编译器可能不会展开该循环...

长话短说,你可能会获得一些收益,这取决于针对dumber编译器的指令集,但这段代码并不是很糟糕,因此编译器可以尽可能优化它。

答案 1 :(得分:1)

首先,如果你在Code review发帖,你可能会得到非常好的,详细的答案。

关于效率和可疑变量类型的一些评论:

unsigned f = *p++;如果通过数组索引访问p然后使用p [i]访问数据,您可能会更好。这高度依赖于编译器,高速缓存内存优化等(在这个问题上,一些ARM专家可以提供比我更好的建议)。

顺便说一下整个const char到int看起来非常可疑。我认为那些字符被视为8位无符号整数?标准C uint8_t可能是更好的类型,char有各种未定义的签名问题,您希望避免这些问题。

另外,为什么要对unsignedint进行野性混合?您要求隐式整数平衡错误。

stdev < .1。只是一个小问题:将此更改为.1f或者强制将浮动的隐式提升加倍,因为.1是双字面值。

答案 2 :(得分:1)

当您的数据以8字节为一组进行读取时,根据您的硬件总线和数组本身的对齐情况,您可以通过一次长读取读取内部循环获得一些收益,然后手动将数字拆分为单独的值,或使用ARM内在函数使用add8指令与某些内联asm并行添加(在1个寄存器中一次添加4个数字)或者进行移位操作并使用add16允许值溢出到16位的空间。还有一个双重签名乘法和累加指令,只需一点帮助就可以通过ARM几乎完全支持你的第一个累加循环。此外,如果进入的数据可以按摩为16位值,那么也可以加快这一速度。

至于为什么NEON速度较慢,我的猜测是设置向量的开销以及你用更大类型推送的附加数据正在扼杀用这么小的一组数据可能获得的任何性能。原始代码开始时非常友好,这意味着设置开销可能会让您失望。如有疑问,请查看装配输出。这将告诉你真正发生了什么。当尝试使用内在函数时,编译器可能会推送和弹出数据 - 这不是我第一次看到这种行为。

答案 3 :(得分:0)

感谢Lundin,dwelch和Michel。 我做了下一个改进,它似乎是我的代码最好的。 我试图减少改善缓存访问的周期数,因为只访问缓存一次。

int step=patch->step;
 for (int j=0; j< 8; j++) {
        p = (uint8_t*)(j*step + aux ); /

        i=8;
        do {                
            f=p[i];
            sum += f;
            sqsum += f*f;

        } while(--i);

}