优化乘法和加法

Posted

技术标签:

【中文标题】优化乘法和加法【英文标题】:Optimize multiply and add 【发布时间】:2019-08-30 03:28:46 【问题描述】:

我正在使用 C 并且我有两个非负整数 n 和 m(都 >= 0,n

n*(n+1)/2 + m

这将需要数亿次,所以我想尽可能地优化它。我目前的实现是:

inline int func(const int n, const int m)  return ( (n*(n+1) >> 1) + m); 

使用inline>> 1 进行除以2。还有其他方法可以加快计算速度吗?

【问题讨论】:

评论不用于扩展讨论;这个对话是moved to chat。 【参考方案1】:

鉴于n 将小于500,您可以预先计算n*(n+1)/2 的所有可能值并将它们放在一个表中,然后使用该表执行计算:

int n_sum[500];

// call this once at program start
void init_sum()

    int i;
    for (i=0;i<500;i++) 
        n_sum[i] = i*(i+1)/2;
    


inline int func(const int n, const int m)
 
    return n_sum[n] + m;

【讨论】:

绝对不要这样做!这是一个糟糕的去优化,而且比原始代码差得多!原始代码将向量化为每 8 或 16 个整数约 6 个操作,具体取决于 AVX2/AVX512(即每个整数 不确定在给定索引处访问查找表 n_sum 是否会比计算值 i*(i+1)/2 快得多 我在 ARM 裸机上做了一次快速的尝试。 godbolt.org/z/Qqyirx。不确定有什么好处(相同数量的 asm 指令,由于流水线而难以推断计算时间但可能相似) 这是一个聪明的想法,并且适用于许多其他内部循环,但您确实需要分析一下它是否在实践中获胜。除非查找表在 CPU 缓存中,否则在任何现代 CPU 上这肯定会更慢,甚至可能,因为它在这里只保存一两条指令。 如果这种重构在循环中抑制了内联函数的自动矢量化,那就太糟糕了。【参考方案2】:

实际上,您要做的是编写一个循环,编译器可以轻松高效地进行向量化和并行化。如果您有两个数组n[i]m[i],那么任何现代编译器都可能在给出正确标志的情况下弄清楚如何优化n[i]*(n[i]+1)/2 + m[i]。试图强制编译器一次对一个词进行优化通常会适得其反。当您并行化关键循环时,现代硬件是最快的。如果您不想使用为此目的而设计的不可移植的内在函数或库,您可以通过最小化数据依赖性和编写易于静态分析的代码来最好地实现这一目标。

您可能无法使用(n*n + n)/2 + m 改进生成的代码,即将多项式转换为嵌套形式。这是高效的,因为它使代码生成器能够仅使用一个向量寄存器作为累加器,从而最大限度地增加 SIMD 可用的数量。您应该酌情使用restrictalignas 以启用最大优化。

(编辑: 负数的右移是实现定义的,因为它可能是逻辑的或算术的。我编写的代码执行无符号数学运算,这让编译器可以优化 /2&gt;&gt;1 给你。在评论中,robthebloke 提出,如果你使用有符号变量而不是无符号变量,并且你知道它们总是非负的,编译器可能无法静态推断这一点,因此可能不会将/2 优化为&gt;&gt;1。在这种情况下,您可以编写&gt;&gt;1 或强制转换(uint32_t)n[i] 来进行更好地定义的无符号数学。一个不安全的数学优化标志也可能重新启用它。)

这种矢量化可能比在每个元素上单独查找表要快。

结果将在 0 到 125,750 的范围内,这对于 unsigned short 来说太大了,因此可以容纳它的最小类型是 int32_tuint32_t。 (或者uint_least32_t,如果你愿意的话。)使用最小类型的数组可以实现最大向量化。

如果您想帮助优化器,您可以启用 OpenMP 并添加 #pragma omp simd,以明确告诉编译器向量化此循环。您还可以使用 OpenMP 来启用多线程。

在 C++ 中,您可以选择 std::valarray&lt;uint32_t&gt; 或表达式模板,它们是表达这种令人尴尬的并行计算的非常优雅的方式。

以下程序compiles to vectorized code 在 GCC、Clang 或 ICC 上给出适当的优化标志。 Clang 编译成一个循环,每次迭代计算 256 个元素。

#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

#define N (1L<<20)
typedef uint_least32_t elem_t;

const elem_t n[N];
const elem_t m[N];
elem_t a[N];

int main(void)

    for ( ptrdiff_t  i = 0; i < N; ++i) 
      a[i] = (n[i]*n[i] + n[i])/2 + m[i];
    

  return EXIT_SUCCESS;

您可以尝试将 alignas 说明符添加到数组中,但这实际上不会导致 GCC、Clang 或 ICC 执行对齐的加载或存储。 (有一个 GCC 扩展来启用这种优化。)

如果启用 OpenMP 库(GCC 或 Clang 中的 -fopenmp),则可以添加该行

#pragma omp for

紧接在for 循环之前,或更复杂的版本,并获得a loop that is both multithreaded and vectorized。如果有一种方法可以通过标准的便携式 C 显着改进这一点,我很想亲自了解它。

我写的 MWE 很简单。在实际代码中,您可能希望将整个循环(该内部循环是其中的一部分)从main() 中移出并移到诸如

之类的函数中
elem_t* func( const ptrdiff_t nelems,
              const elem_t n[nelems],
              const elem_t m[nelems],
              elem_t a[nelems]
            )

    for ( ptrdiff_t  i = 0; i < nelems; ++i) 
      a[i] = (n[i]*n[i] + n[i])/2 + m[i];
    

  return a;

如果你比较生成的程序集,你会发现除非你内联它,否则它的效率并不高,主要是因为编译器不再知道编译时的迭代次数或有任何关于n对齐的信息, ma.

您还可以通过将输入元素存储为uint16_t 来节省一些内存,但可能不会节省计算时间。输入数组使用一半的内存,但循环不能操作比以前更多的元素,因为计算使用相同大小的元素。小心将用于计算的临时值转换为不会溢出的类型!

#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

#define N (1L<<20)

const uint16_t n[N];
const uint16_t m[N];
uint32_t a[N];

int main(void)

    for ( ptrdiff_t  i = 0; i < N; ++i) 
      a[i] = ((uint32_t)n[i]*n[i] + n[i])/2 + m[i];
    

  return EXIT_SUCCESS;

【讨论】:

> 没有理由为 /2 指定诸如 >>1 的转换;编译器足够聪明,可以为您完成它们。不对。更改 elem_t 的定义,使其已签名(而不是未签名)。 很可能是这种情况,但这是 OP 使用的数据类型。 对于有符号类型,负值右移的结果是实现定义的。 (可能是算术或逻辑移位)。由于 OP 指定这些值是非负的,因此在这种情况下使用无符号值是安全的,或者如果编译器无法确定,则使用显式右移。 您确定在任何平台上通过矢量都更快吗?我的意思是,你测试了吗? 另外,我可能错过了重点,但 OP 并没有要求循环计算每个值,而是要求函数有效地计算给定值。您提出的基本上是在数组a 中提前计算每个可能的值。但是,如果它是选择的解决方案,那么优化一个在启动时只执行一次的循环并不重要【参考方案3】:

最后的问题是:你真的能比你做的简单实现进行更多优化吗?

这里是使用带有 -O2 优化级别的 arm-none-eabi-gcc 的快速测试:see here

int func(int n, int m) 
 
    return ( (n*(n+1) >> 1) + m); 

编译:

func(int, int):
        mla     r3, r0, r0, r0
        add     r0, r1, r3, asr #1
        bx      lr

所以有两个汇编指令(不包括将随内联消失的bx lr)。我不知道您如何才能更快地实现。

编辑:只是为了好玩,如果你使用级别 -O0 编译,你会得到:

func(int, int):
        str     fp, [sp, #-4]!
        add     fp, sp, #0
        sub     sp, sp, #12
        str     r0, [fp, #-8]
        str     r1, [fp, #-12]
        ldr     r3, [fp, #-8]
        add     r3, r3, #1
        ldr     r2, [fp, #-8]
        mul     r3, r2, r3
        mov     r2, r3, asr #1
        ldr     r3, [fp, #-12]
        add     r3, r2, r3
        mov     r0, r3
        sub     sp, fp, #0
        ldr     fp, [sp], #4
        bx      lr

GCC 可以很聪明,你只需要告诉他是 ;)

【讨论】:

如果——正如你提到的,情况并非总是如此——您可以对大量数据执行此操作,您可以将其并行化以更快地运行许多倍。 我同意。但我认为这不是这里的用例。【参考方案4】:

我认为更好的方法是询问您是否真的需要计算这么多次。例如,如果 n 在内部循环中是常数,您可以在外部计算 n*(n+1)/2 吗? (尽管优化编译器可能会这样做)。或者,如果您在内循环中增加 n ,也许您可​​以使用

(n+1)*(n+2)/2 = n*(n+1)/2 + n + 1

更新 n*(n+1)/2 而不是每次都重新计算。

【讨论】:

【参考方案5】:

您可以使用直接汇编指令。在 VC++ 中,您可以使用 __asm 关键字来启动汇编部分。您可以使用常规函数并在其中使用此部分。并正常调用该函数。对于基于 gcc,您可以使用 asm()

【讨论】:

使用“直接汇编指令”试图比编译器更聪明是非常困难的,我想说大多数情况下甚至毫无意义。 这是一个通用注释,但它适用于这里吗?我不太确定。【参考方案6】:

你说“这将需要数亿次”,好像这很多。但是现在,数亿次什么都没有

我刚刚写了一个明显的小程序来执行n*(n+1)/2 + m 100,000,000 次。我绝对没有做任何花哨的尝试来使其“高效”。在一台普通的消费级笔记本电脑上,它运行大约半秒——这太快了,甚至无法准确计时。然后我尝试了 100 次:10,000,000,000 次。在这种情况下,大约需要 52 秒,每次计算大约需要 5.2 纳秒。 (并且涉及一些开销,因此每次计算的实际时间更少。)

假设您花了一个小时试图加快此功能。 (您可能已经花费了几乎那么多时间将您的问题发布到 Stack Overflow 并阅读回复,更不用说我们都花在回复上的时间了。)假设您设法将速度提高了 50%(也就是说,两次一样快)。根据我的结果,您必须运行该函数 1.4e12 次(超过一万亿次)才能恢复工作时间。

因此,如果您要运行此计算数万亿次(不仅仅是数亿次),那么也许(也许!)花一些时间来加快它的速度。否则 - 很抱歉对此感到沮丧 - 只是不要打扰。

另请参阅this answer 以了解一些类似的问题。

(我并不是在暗示效率从不重要,但正确看待你的实际情况也很重要。)

【讨论】:

在具有基本 MCU 的嵌入式设备上,单个操作运行 1 亿次根本不可忽略,无论如何,即使是半秒也可能会在工作和不工作之间产生差异(或不是用户友好的)应用程序。花几个小时(甚至几天或几周)来节省每个循环的几个周期是很常见的。 如果有 100 万人下载/安装您的软件,并且每个人(平均)每天运行一次软件,持续 3 年;那么它将被执行 10 亿次;假设计算只在进程启动时进行一次(每次进程启动时)。如果每次启动过程都需要数亿次计算;那么我们将看到“数亿”的总成本。 @Brendan 当然。这就是为什么我小心翼翼地说,“我并不是要暗示效率从不重要”。但是 OP 没有没有说他在嵌入式设备上运行。他确实没有说他预计他的软件会被下载数十亿次,并且每个副本将在每个副本中执行相关指令数亿次。他只是说他必须执行几亿次,如果这是真的,我认为他不必担心。 @GuillaumePetitjean 花几个小时来节省每个循环的几个周期是很常见的。我明白。花同样多的时间对真正不需要的代码进行微优化也是很常见的,除了浪费时间并使代码更容易出错和更难维护之外,最终什么也得不到。所以这是一个平衡的问题。【参考方案7】:

您可以使用this 递归算法,该算法将两个整数相乘,而无需实际使用乘法运算。还使用最少数量的其他算术运算。

请注意,将两个数字相乘的传统方法的复杂度为 O(M*N),但此函数的乘法复杂度为 O(log(N)),其中 N 较小。

还有另一种算法可以将两个整数相乘,称为karatsuba algo,但我认为你不需要这个,因为如果相乘的数字太大,这更适合。

【讨论】:

你的函数不可能比 OP 的更快。如果目标机器上有一条乘法指令,它几乎肯定会比多分支和递归更快。如果没有,编译器会用一个好的替代架构。渐近复杂度在这里无关紧要,因为 OP 数量是有界的,并且可以在一个操作中相乘。

以上是关于优化乘法和加法的主要内容,如果未能解决你的问题,请参考以下文章

DP优化:矩阵乘法

PHP在数据输出时进行乘法和加法运算

在 SYCL 中实现矩阵加法和乘法

在 SYCL 中实现矩阵加法和乘法

快速幂+快速乘法优化

优化的 AssemblyScript 仍然是 4K,用于简单的乘法