【问题标题】:Why ARM NEON not faster than plain C++?为什么 ARM NEON 不比普通 C++ 快?
【发布时间】:2011-08-09 10:53:44
【问题描述】:

这是一段 C++ 代码:

#define ARR_SIZE_TEST ( 8 * 1024 * 1024 )

void cpp_tst_add( unsigned* x, unsigned* y )
{
    for ( register int i = 0; i < ARR_SIZE_TEST; ++i )
    {
        x[ i ] = x[ i ] + y[ i ];
    }
}

这是一个霓虹灯版本:

void neon_assm_tst_add( unsigned* x, unsigned* y )
{
    register unsigned i = ARR_SIZE_TEST >> 2;

    __asm__ __volatile__
    (
        ".loop1:                            \n\t"

        "vld1.32   {q0}, [%[x]]             \n\t"
        "vld1.32   {q1}, [%[y]]!            \n\t"

        "vadd.i32  q0 ,q0, q1               \n\t"
        "vst1.32   {q0}, [%[x]]!            \n\t"

        "subs     %[i], %[i], $1            \n\t"
        "bne      .loop1                    \n\t"

        : [x]"+r"(x), [y]"+r"(y), [i]"+r"(i)
        :
        : "memory"
    );
}

测试功能:

void bench_simple_types_test( )
{
    unsigned* a = new unsigned [ ARR_SIZE_TEST ];
    unsigned* b = new unsigned [ ARR_SIZE_TEST ];

    neon_tst_add( a, b );
    neon_assm_tst_add( a, b );
}

我已经测试了这两种变体,这里有一份报告:

add, unsigned, C++       : 176 ms
add, unsigned, neon asm  : 185 ms // SLOW!!!

我还测试了其他类型:

add, float,    C++       : 571 ms
add, float,    neon asm  : 184 ms // FASTER X3!

问题: 为什么 32 位整数类型的 neon 速度较慢?

我使用了最新版本的 GCC for Android NDK。 NEON 优化标志已打开。 这是一个反汇编的 C++ 版本:

                 MOVS            R3, #0
                 PUSH            {R4}

 loc_8
                 LDR             R4, [R0,R3]
                 LDR             R2, [R1,R3]
                 ADDS            R2, R4, R2
                 STR             R2, [R0,R3]
                 ADDS            R3, #4
                 CMP.W           R3, #0x2000000
                 BNE             loc_8
                 POP             {R4}
                 BX              LR

这里是霓虹灯的反汇编版本:

                 MOV.W           R3, #0x200000
.loop1
                 VLD1.32         {D0-D1}, [R0]
                 VLD1.32         {D2-D3}, [R1]!
                 VADD.I32        Q0, Q0, Q1
                 VST1.32         {D0-D1}, [R0]!
                 SUBS            R3, #1
                 BNE             .loop1
                 BX              LR

这是所有基准测试:

add, char,     C++       : 83  ms
add, char,     neon asm  : 46  ms FASTER x2

add, short,    C++       : 114 ms
add, short,    neon asm  : 92  ms FASTER x1.25

add, unsigned, C++       : 176 ms
add, unsigned, neon asm  : 184 ms SLOWER!!!

add, float,    C++       : 571 ms
add, float,    neon asm  : 184 ms FASTER x3

add, double,   C++       : 533 ms
add, double,   neon asm  : 420 ms FASTER x1.25

问题: 为什么 32 位整数类型的 neon 速度较慢?

【问题讨论】:

  • @Cody 这个主题有问题,也许是这样?
  • C++ 对所有整数类型都更快吗?我认为您的程序集并不像您希望的整数类型那样最佳。
  • 问题是为什么 neon 在 32 位整数类型中速度较慢?
  • @rubenvb 我已经更新了所有类型的基准报告。
  • 对于那些感到困惑的人:NEON 是 ARM 的 SIMD 扩展,允许 128 位操作,即一次 4 个 32 位操作。人们会期望它在所有情况下都比非 SIMD 指令更快。 arm.com/products/processors/technologies/neon.php

标签: c++ arm simd neon cortex-a8


【解决方案1】:

Cortex-A8 上的 NEON 管道是按顺序执行的,并且未命中命中率有限(无重命名),因此您会受到内存延迟的限制(因为您使用的缓存大小超过 L1/L2 )。您的代码直接依赖于从内存加载的值,因此它会不断地等待内存。这可以解释为什么 NEON 代码比非 NEON 代码稍慢(一点点)。

您需要展开组装循环并增加加载和使用之间的距离,例如:

vld1.32   {q0}, [%[x]]!
vld1.32   {q1}, [%[y]]!
vld1.32   {q2}, [%[x]]!
vld1.32   {q3}, [%[y]]!
vadd.i32  q0 ,q0, q1
vadd.i32  q2 ,q2, q3
...

有很多霓虹灯寄存器,所以你可以展开很多。整数代码也会遇到同样的问题,但程度较轻,因为 A8 整数具有更好的命中率而不是停滞。对于与 L1/L2 缓存相比如此大的基准测试,瓶颈将是内存带宽/延迟。您可能还希望以较小的大小 (4KB..256KB) 运行基准测试,以查看数据完全缓存在 L1 和/或 L2 中时的效果。

【讨论】:

  • 感谢您的回复。我在一次迭代中使用 16 个 128 位寄存器展开了一个循环。它加速了 32 位整数。现在时间是:add, unsigned, C++ : 180 ms add, unsigned, neon asm : 117 ms
【解决方案2】:

虽然在这种情况下您会受到主内存延迟的限制,但 NEON 版本会比 ASM 版本慢并不是很明显。

在这里使用循环计算器:

http://pulsar.webshaker.net/ccc/result.php?lng=en

在缓存未命中处罚之前,您的代码应该需要 7 个周期。它比您预期的要慢,因为您使用的是未对齐的加载以及 add 和 store 之间的延迟。

同时,编译器生成的循环需要 6 个周期(通常也没有很好地安排或优化)。但它只做四分之一的工作。

脚本中的循环计数可能并不完美,但我没有看到任何看起来明显错误的地方,所以我认为它们至少会接近。如果最大化获取带宽(如果循环不是 64 位对齐),则可能会在分支上花费额外的循环,但在这种情况下,有很多停顿可以隐藏。

答案不是 Cortex-A8 上的整数有更多机会隐藏延迟。事实上,由于 NEON 的交错管道和问题队列,它通常具有较少的内容。当然,这仅在 Cortex-A8 上是正确的 - 在 Cortex-A9 上,情况可能会完全相反(NEON 是按顺序调度并与整数并行,而整数具有无序功能)。既然你标记了这个 Cortex-A8,我假设这就是你正在使用的。

这需要更多调查。以下是为什么会发生这种情况的一些想法:

  • 您没有在数组上指定任何类型的对齐方式,虽然我希望 new 与 8 字节对齐,但它可能未与 16 字节对齐。假设您确实得到了不是 16 字节对齐的数组。然后,您将在缓存访问的行之间进行拆分,这可能会产生额外的惩罚(尤其是在未命中时)
  • 缓存未命中发生在存储之后;我不相信 Cortex-A8 有任何内存消歧,因此必须假设负载可能来自与存储相同的行,因此需要在 L2 丢失负​​载发生之前耗尽写缓冲区。因为 NEON 加载(在整数管道中启动)和存储(在 NEON 管道末端启动)之间的管道距离比整数负载大得多,所以可能会有更长的停顿时间。
  • 因为每次访问加载 16 个字节而不是 4 个字节,所以关键字的大小更大,因此从主存进行关键字优先行填充的有效延迟会更高(L2 到 L1应该是在 128 位总线上,所以不应该有同样的问题)

您问过在这种情况下 NEON 有什么好处 - 实际上,NEON 特别适用于您在内存中进行流式传输的情况。诀窍是您需要使用预加载来尽可能隐藏主内存延迟。预加载会提前将内存放入 L2(而不是 L1)缓存。在这里 NEON 比整数有很大的优势,因为它可以隐藏很多 L2 缓存延迟,这是由于它交错的管道和问题队列,还因为它有一个直接的路径。我希望你看到有效的 L2 延迟低至 0-6 个周期,如果你有更少的依赖并且不会耗尽负载队列,那么你会看到更少的延迟,而在整数上你可能会被困在一个你无法避免的大约 16 个周期(可能取决于 Cortex-A8)。

因此,我建议您将数组与缓存行大小(64 字节)对齐,展开循环以一次至少执行一个缓存行,使用对齐的加载/存储(在地址后放置 :128)并添加一条 pld 指令,该指令将几个缓存线加载出去。至于有多少行:从小处开始并不断增加,直到您不再看到任何好处。

【讨论】:

  • 这不是由于未对齐的负载 - 这不能解释巨大的差异,尤其是整数也未对齐。 Cortex-A8 确实具有消歧功能,并且会允许多次加载/存储未命中。根本原因是 A8 NEON 管道没有 hit-under-miss,所以需要展开循环。
  • 整数管道也没有命中。另一方面,NEON 可以无序地填充其加载队列(在 NEON 管道开始之前),这允许它在 L2 未命中时命中 L1。整数存储不会未对齐,因为 malloc 不会返回未对齐 4 个字节的内存。因此,整数存储不会跨越缓存线边界。但是这比整数版本慢的根本原因不是由于缺乏展开,因为整数版本也没有展开。
  • 另一个合理的问题是源和目标是否重叠(特别是如果它们相同)。我怀疑 NEON 是否有任何类型的存储来加载转发,这将是一个很大的往返,比整数更大。
  • 我认为与对齐无关。 neon 指令的子字符串自动帮助对齐缓存中的数据。如果我错了,请帮助我。 :)
【解决方案3】:

您的 C++ 代码也没有优化。

#define ARR_SIZE_TEST ( 8 * 1024 * 1024 )

void cpp_tst_add( unsigned* x, unsigned* y )
{
    unsigned int i = ARR_SIZE_TEST;
    do
    {
        *x++ += *y++;
    } (while --i);
}

此版本消耗的周期/迭代少了 2 个。

此外,您的基准测试结果一点也不让我吃惊。

32 位:

这个功能对于NEON来说太简单了。没有足够的算术运算留下任何优化空间。

是的,它是如此简单,以至于 C++ 和 NEON 版本几乎每次都遭受管道危害,而没有任何真正的机会从双重问题功能中受益。

虽然 NEON 版本可能会受益于一次处理 4 个整数,但它也会因各种危险而遭受更多损失。就是这样。

8位:

ARM 从内存中读取每个字节的速度非常慢。 这意味着,虽然 NEON 显示出与 32 位相同的特性,但 ARM 严重滞后。

16 位: 和这里一样。除了 ARM 的 16 位读取还不错。

浮动: C++ 版本将编译成 VFP 代码。 Coretex A8 上没有完整的 VFP,但 VFP lite 不会流水线任何糟糕的东西。

并不是说 NEON 在处理 32 位时表现异常。只有 ARM 符合理想条件。 由于其简单性,您的函数非常不适合进行基准测试。尝试一些更复杂的东西,比如 YUV-RGB 转换:

仅供参考,我完全优化的 NEON 版本的运行速度大约是我完全优化的 C 版本的 20 倍,是我完全优化的 ARM 汇编版本的 8 倍。 我希望这能让您了解 NEON 的强大功能。

最后但同样重要的是,ARM 指令 PLD 是 NEON 最好的朋友。放置得当,将带来至少 40% 的性能提升。

【讨论】:

  • 你的基准值看起来很有趣。!你有提到 YUV-RGB 转换的数字吗?我得到的速度提高了 7-8 倍。 20次很有趣!
  • @Anoop : 也许我的 C 版本不够好? :) 我忘了提到它是 YUV420,平面 Y 和打包 UV。在打包的 YUV422 上,我可能不会得到那种性能提升。在我的 iPhone4 上转换 VGA 图像只需不到 1 毫秒。
  • 过去几个月我一直在学习 NEON,但从未使用过 PLD 指令。您的基准测试非常有趣,将在此处更新我获得的性能提升。顺便说一句,我正在研究 beagleboard。
  • PLD,如果放置得当,假设您正在处理足够大的数据块,单手将带来大约 40% 的速度提升。只是读得很远。 pld [pSrc, #64] 在循环开始时最常见。
  • 感谢您的帮助。会很期待的。 :)
【解决方案4】:

您可以尝试一些修改来改进代码。

如果可以的话: - 使用第三个缓冲区来存储结果。 - 尝试对齐 8 个字节的数据。

代码应该是这样的(对不起,我不知道 gcc 内联语法)

.loop1:
 vld1.32   {q0}, [%[x]:128]!
 vld1.32   {q1}, [%[y]:128]!
 vadd.i32  q0 ,q0, q1
 vst1.32   {q0}, [%[z]:128]!
 subs     %[i], %[i], $1
bne      .loop1

正如 Exophase 所说,您有一些管道延迟。 可能你可以试试

vld1.32   {q0}, [%[x]:128]
vld1.32   {q1}, [%[y]:128]!

sub     %[i], %[i], $1

.loop1:
vadd.i32  q2 ,q0, q1

vld1.32   {q0}, [%[x]:128]
vld1.32   {q1}, [%[y]:128]!

vst1.32   {q2}, [%[z]:128]!
subs     %[i], %[i], $1
bne      .loop1

vadd.i32  q2 ,q0, q1
vst1.32   {q2}, [%[z]:128]!

最后,很明显你会饱和内存带宽

你可以尝试加个小

PLD [%[x], 192]

进入你的循环。

告诉我们是否更好......

【讨论】:

    【解决方案5】:

    8ms 的差异如此很小,您可能正在测量缓存或管道的工件。

    编辑:您是否尝试过与类似这样的类型进行比较,例如 float 和 short 等?我希望编译器能够更好地优化它并缩小差距。同样在您的测试中,您首先执行 C++ 版本,然后执行 ASM 版本,这可能会对性能产生影响,因此为了更公平,我会编写两个不同的程序。

    for ( register int i = 0; i < ARR_SIZE_TEST/4; ++i )
    {
        x[ i ] = x[ i ] + y[ i ];
        x[ i+1 ] = x[ i+1 ] + y[ i+1 ];
        x[ i+2 ] = x[ i+2 ] + y[ i+2 ];
        x[ i+3 ] = x[ i+3 ] + y[ i+3 ];
    }
    

    最后,在你的函数签名中,你使用unsigned* 而不是unsigned[]。后者是首选,因为编译器假定数组不重叠并且允许重新排序访问。尝试使用 restrict 关键字也可以更好地防止混叠。

    【讨论】:

    • 是的,但为什么不是快 2 或 3 倍?
    • 因为内存带宽。在公共汽车接送方面,您可能会尽可能快。
    • 我不是专家,但我想说你需要更复杂的例子才能真正看到优势,无论是在你对数据所做的工作量方面(简单的 + 不是 CPU密集)和操作数量(几亿而不是几百万)。我预计会有 10-30% 的改进,而不是 200%。
    • 200% 对于某些工作负载来说是现实的。这些例子只是病态的案例:负载-使用分离差,以及 100% 的缓存未命中。
    • 我不认为这是工作量的问题,它更像是某种“你对数据所做的不是 CPU 密集型”的问题。
    猜你喜欢
    • 2018-12-10
    • 2020-08-29
    • 2021-12-19
    • 2011-01-20
    • 2012-06-25
    • 2019-11-14
    • 1970-01-01
    • 2012-02-22
    • 2016-02-13
    相关资源
    最近更新 更多