32位平台上的无符号64x64->128位整数乘法

6
在探索性活动的背景下,我开始研究32位平台上整数和定点算术构建块。我的主要目标是ARM32(具体来说是armv7),并且关注RISC-V32,我预计它会在嵌入式领域中变得越来越重要。我选择检查的第一个示例构建块是无符号64x64->128位整数乘法。该网站上关于该构建块的其他问题没有提供有关32位平台的详细覆盖。
在过去的三十年中,我已经多次实现了这个和其他算术构建块,但总是在各种架构的汇编语言中实现。然而,此时此刻,我希望这些可以用纯ISO-C编程,而不需要使用intrinsic。理想情况下,单个版本的C代码将在各种架构上生成良好的机器代码。我知道操纵HLL代码以控制机器代码的方法通常很脆弱,但希望处理器架构和工具链已经成熟到使这种方法可行。
一些在汇编语言实现中使用的方法不适合移植到 C 语言。在下面的示例代码中,我选择了六种变体,这些变体似乎适合 HLL 实现。除了生成部分积之外,这些方法有两个基本方法:(1)使用 64 位算术求和部分积,让编译器处理 32 位半之间的进位传播。在这种情况下,存在多种选择来对部分积进行求和。(2)使用 32 位算术进行求和,直接模拟进位标志。在这种情况下,我们可以选择在加法后生成进位 (a = a + b; carry = a < b;) 或在加法之前生成进位 (carry = ~a < b; a = a + b;)。下面的变体 1 到 3 属于前一类,变体 5 和 6 属于后一类。
编译器资源管理器上,我专注于gcc 12.2和clang 15.0这两个平台的工具链。我使用了-O3进行编译。总体发现是,平均而言,clang生成的代码比gcc更高效,并且在clang中,变体之间(指令数和寄存器使用量)的差异更加明显。虽然这在RISC-V作为较新的架构的情况下可以理解,但在armv7的情况下,这让我感到惊讶,因为它已经存在了十多年。
特别值得注意的是三种情况。虽然我以前曾与编译器工程师合作过,并对基本的代码转换、阶段排序问题等有一定的了解,但我所知道的唯一可能适用于此代码的技术是成语识别,而我不认为这本身就能解释这些观察结果。第一个情况是变体3,其中clang 15.0生成了非常紧凑的代码,仅包含10条指令,我认为这是无法改进的:
umul64wide:
        push    {r4, lr}
        umull   r12, r4, r2, r0
        umull   lr, r0, r3, r0
        umaal   lr, r4, r2, r1
        umaal   r0, r4, r3, r1
        ldr     r1, [sp, #8]
        strd    r0, r4, [r1]
        mov     r0, r12
        mov     r1, lr
        pop     {r4, pc}

相比之下,gcc 生成了两倍的指令并需要两倍的寄存器。我假设它不知道如何在这里使用乘加指令umaal,但这是全部原因吗?在变体6中出现了相反的情况,但不太戏剧化,其中gcc 12.2生成了这个包含18条指令的序列,并且寄存器使用率较低:
umul64wide:
        mov     ip, r0
        push    {r4, r5, lr}
        mov     lr, r1
        umull   r0, r1, r0, r2
        ldr     r4, [sp, #12]
        umull   r5, ip, r3, ip
        adds    r1, r1, r5
        umull   r2, r5, lr, r2
        adc     ip, ip, #0
        umull   lr, r3, lr, r3
        adds    r1, r1, r2
        adc     r2, ip, #0
        adds    r2, r2, r5
        adc     r3, r3, #0
        adds    r2, r2, lr
        adc     r3, r3, #0
        strd    r2, r3, [r4]
        pop     {r4, r5, pc}

生成的代码将模拟进位转换为真实进位传播。clang 15.0 使用了九个指令和五个寄存器,如果不花更多时间进行分析,我无法真正理解它试图做什么。第三个观察结果是关于在变体5与变体6产生的机器代码中看到的差异,特别是使用clang时。这些使用相同的基本算法,其中一个变体在加法之前计算模拟进位,另一个在加法之后计算。最终我发现,一种变体,即变体4,在两个工具链和两个架构上都很有效。然而,在我继续处理其他构建块并面临类似的挑战之前,我想询问:

(1) 在下面的代码中,是否有我没有考虑到的编码习惯或算法,可能会导致更优秀的结果?(2) 是否有特定的优化开关,例如假设的-ffrobnicate(请参见here),它没有包含在-O3中,可以帮助编译器更一致地生成有效的代码,以处理这些位操作场景?对于观察到的代码生成显著差异的编译器机制的解释,以及如何影响或解决它们,也可能是有帮助的。

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

#define VARIANT         (3)
#define USE_X64_ASM_REF (0)

/* Multiply two unsigned 64-bit operands a and b. Returns least significant 64 
   bits of product as return value, most significant 64 bits of product via h.
*/
uint64_t umul64wide (uint64_t a, uint64_t b, uint64_t *h)
{
    uint32_t a_lo = (uint32_t)a;
    uint32_t a_hi = a >> 32;
    uint32_t b_lo = (uint32_t)b;
    uint32_t b_hi = b >> 32;
    uint64_t p0 = (uint64_t)a_lo * b_lo;
    uint64_t p1 = (uint64_t)a_lo * b_hi;
    uint64_t p2 = (uint64_t)a_hi * b_lo;
    uint64_t p3 = (uint64_t)a_hi * b_hi;
#if VARIANT == 1
    uint32_t c = (uint32_t)(((p0 >> 32) + (uint32_t)p1 + (uint32_t)p2) >> 32);
    *h = p3 + (p1 >> 32) + (p2 >> 32) + c;
    return p0 + ((p1 + p2) << 32);
#elif VARIANT == 2
    uint64_t s = (p0 >> 32) + p1;
    uint64_t t = (uint32_t)s + p2;
    *h = (s >> 32) + (t >> 32) + p3;
    return (uint32_t)p0 + (t << 32);
#elif VARIANT == 3
    *h = (p1 >> 32) + (((p0 >> 32) + (uint32_t)p1 + p2) >> 32) + p3;
    return p0 + ((p1 + p2) << 32);
#elif VARIANT == 4
    uint64_t t = (p0 >> 32) + p1 + (uint32_t)p2;
    *h = (p2 >> 32) + (t >> 32) + p3;
    return (uint32_t)p0 + (t << 32);
#elif VARIANT == 5
    uint32_t r0, r1, r2, r3, r4, r5, r6;
    r0 = (uint32_t)p0;
    r1 = p0 >> 32;    
    r5 = (uint32_t)p1;
    r2 = p1 >> 32;
    r1 = r1 + r5;
    r6 = r1 < r5;
    r2 = r2 + r6;
    r6 = (uint32_t)p2;
    r5 = p2 >> 32;
    r1 = r1 + r6;
    r6 = r1 < r6;
    r2 = r2 + r6;
    r4 = (uint32_t)p3;
    r3 = p3 >> 32;
    r2 = r2 + r5;
    r6 = r2 < r5;
    r3 = r3 + r6;
    r2 = r2 + r4;
    r6 = r2 < r4;
    r3 = r3 + r6;
    *h =   ((uint64_t)r3 << 32) | r2;
    return ((uint64_t)r1 << 32) | r0;
#elif VARIANT == 6
    uint32_t r0, r1, r2, r3, r4, r5, r6;
    r0 = (uint32_t)p0;
    r1 = p0 >> 32;    
    r5 = (uint32_t)p1;
    r2 = p1 >> 32;
    r4 = ~r1;
    r4 = r4 < r5;
    r1 = r1 + r5;
    r2 = r2 + r4;
    r6 = (uint32_t)p2;
    r5 = p2 >> 32;
    r4 = ~r1;
    r4 = r4 < r6;
    r1 = r1 + r6;
    r2 = r2 + r4;
    r4 = (uint32_t)p3;
    r3 = p3 >> 32;
    r6 = ~r2;
    r6 = r6 < r5;
    r2 = r2 + r5;
    r3 = r3 + r6;
    r6 = ~r2;
    r6 = r6 < r4;
    r2 = r2 + r4;
    r3 = r3 + r6;
    *h =   ((uint64_t)r3 << 32) | r2;
    return ((uint64_t)r1 << 32) | r0;
#else
#error unsupported VARIANT
#endif
}

#if defined(__SIZEOF_INT128__)
uint64_t umul64wide_ref (uint64_t a, uint64_t b, uint64_t *h)
{
    unsigned __int128 prod = ((unsigned __int128)a) * b;
    *h = (uint64_t)(prod >> 32);
    return (uint64_t)prod;
}
#elif defined(_MSC_VER) && defined(_WIN64)
#include <intrin.h>
uint64_t umul64wide_ref (uint64_t a, uint64_t b, uint64_t *h)
{
    *h = __umulh (a, b);
    return a * b;
}
#elif USE_X64_ASM_REF
uint64_t umul64wide_ref (uint64_t a, uint64_t b, uint64_t *h)
{
    uint64_t res_l, res_h;
    __asm__ (
        "movq  %2, %%rax;\n\t"          // rax = a
        "mulq  %3;\n\t"                 // rdx:rax = a * b
        "movq  %%rdx, %0;\n\t"          // res_h = rdx
        "movq  %%rax, %1;\n\t"          // res_l = rax
        : "=rm" (res_h), "=rm"(res_l)
        : "rm"(a), "rm"(b)
        : "%rax", "%rdx");
    *h = res_h;
    return res_l;
}
#else // generic (and slow) reference implementation
#define ADDCcc(a,b,cy,t0,t1) \
  (t0=(b)+cy, t1=(a), cy=t0<cy, t0=t0+t1, t1=t0<t1, cy=cy+t1, t0=t0)

#define ADDcc(a,b,cy,t0,t1) \
  (t0=(b), t1=(a), t0=t0+t1, cy=t0<t1, t0=t0)

#define ADDC(a,b,cy,t0,t1) \
  (t0=(b)+cy, t1=(a), t0+t1)

uint64_t umul64wide_ref (uint64_t a, uint64_t b, uint64_t *h)
{
    uint32_t cy, t0, t1;
    uint32_t a_lo = (uint32_t)a;
    uint32_t a_hi = a >> 32;
    uint32_t b_lo = (uint32_t)b;
    uint32_t b_hi = b >> 32;
    uint64_t p0 = (uint64_t)a_lo * b_lo;
    uint64_t p1 = (uint64_t)a_lo * b_hi;
    uint64_t p2 = (uint64_t)a_hi * b_lo;
    uint64_t p3 = (uint64_t)a_hi * b_hi;
    uint32_t p0_lo = (uint32_t)p0;
    uint32_t p0_hi = p0 >> 32;
    uint32_t p1_lo = (uint32_t)p1;
    uint32_t p1_hi = p1 >> 32;
    uint32_t p2_lo = (uint32_t)p2;
    uint32_t p2_hi = p2 >> 32;
    uint32_t p3_lo = (uint32_t)p3;
    uint32_t p3_hi = p3 >> 32;
    uint32_t r0 = p0_lo;
    uint32_t r1 = ADDcc  (p0_hi, p1_lo, cy, t0, t1);
    uint32_t r2 = ADDCcc (p1_hi, p2_hi, cy, t0, t1);
    uint32_t r3 = ADDC   (p3_hi,     0, cy, t0, t1);
    r1 = ADDcc  (r1, p2_lo, cy, t0, t1);
    r2 = ADDCcc (r2, p3_lo, cy, t0, t1);
    r3 = ADDC   (r3,     0, cy, t0, t1);
    *h =   ((uint64_t)r3 << 32) + r2;
    return ((uint64_t)r1 << 32) + r0;
}
#endif 

/*
  https://groups.google.com/forum/#!original/comp.lang.c/qFv18ql_WlU/IK8KGZZFJx4J
  From: geo <gmars...@gmail.com>
  Newsgroups: sci.math,comp.lang.c,comp.lang.fortran
  Subject: 64-bit KISS RNGs
  Date: Sat, 28 Feb 2009 04:30:48 -0800 (PST)

  This 64-bit KISS RNG has three components, each nearly
  good enough to serve alone.    The components are:
  Multiply-With-Carry (MWC), period (2^121+2^63-1)
  Xorshift (XSH), period 2^64-1
  Congruential (CNG), period 2^64
*/
static uint64_t kiss64_x = 1234567890987654321ULL;
static uint64_t kiss64_c = 123456123456123456ULL;
static uint64_t kiss64_y = 362436362436362436ULL;
static uint64_t kiss64_z = 1066149217761810ULL;
static uint64_t kiss64_t;
#define MWC64  (kiss64_t = (kiss64_x << 58) + kiss64_c, \
                kiss64_c = (kiss64_x >> 6), kiss64_x += kiss64_t, \
                kiss64_c += (kiss64_x < kiss64_t), kiss64_x)
#define XSH64  (kiss64_y ^= (kiss64_y << 13), kiss64_y ^= (kiss64_y >> 17), \
                kiss64_y ^= (kiss64_y << 43))
#define CNG64  (kiss64_z = 6906969069ULL * kiss64_z + 1234567ULL)
#define KISS64 (MWC64 + XSH64 + CNG64)

int main (void)
{
    uint64_t a, b, res_hi, res_lo, ref_hi, ref_lo, count = 0;

    printf ("Smoke test of umul64wide variant %d\n", VARIANT);
    do {
        a = KISS64;
        b = KISS64;
        ref_lo = umul64wide_ref (a, b, &ref_hi);
        res_lo = umul64wide (a, b, &res_hi);
        if ((res_lo ^ ref_lo) | (res_hi ^ ref_hi)) {
            printf ("!!!! error: a=%016llx b=%016llx res=%016llx_%016llx ref=%016llx_%016llx\n",
                    a, b, res_hi, res_lo, ref_hi, ref_lo);
            return EXIT_FAILURE;
        }
        if (!(count & 0xfffffff)) printf ("\r%llu", count);
        count++;
    } while (count);
    return EXIT_SUCCESS;
}

不清楚您是否愿意依赖于扩展,例如gcc和clang可以定义__UINT_FAST64_TYPE__,可用于32 x 32 => 64,让编译器完成工作 - 就像uint64_t一样 - 但这可能会导致函数调用。您是否只想使用纯粹的习惯用法C,只能依赖于uint32_t,没有特殊的32 x 32 => 64或类似于ARM上的umulh的内联汇编,并将其用作64 x 64的构建块? - Brett Hale
2
@BrettHale 我渴望的是纯ISO C99代码,因此stdint.h可用。我在问题中提到了“没有内置函数”。同样地,没有内联汇编。编译器中的供应商扩展不受欢迎(可移植性)。40年前,我手工组装了Z80指令并输入了结果十六进制代码。现在,无论是国际象棋还是围棋,没有人能够与计算机相比,我希望我的编译器能够将C代码转换为精选和排列良好的机器指令。我最后一次使用汇编语言编写这个特定的构建块是大约五年前为一个Maxwell级GPU。 - njuffa
1
@BrettHale:如果你愿意使用扩展,你可以使用Clang _ExtInt(128),这应该可以在32位机器上工作(不像unsigned __int128)。ISO C23可能会标准化unsigned _BitInt(128) foo。(https://en.wikipedia.org/wiki/C2x)。但目前只有clang支持,即使使用`-std=gnu2x`也不支持GCC trunk:https://godbolt.org/z/r3cejeKsr。(一个这么宽的值在32位ISA上通过引用返回,但除此之外,clang似乎对ARM32编译得相当高效,但x86 32看起来很混乱,过早地设置CF并且必须跨mul保存它。) - Peter Cordes
1个回答

1
我避免使用((x += y) < y)的溢出测试,因为并非每个ISA都能有效地处理条件标志,并且在使用标志寄存器的结果时可能会抑制重新排序; x86 [-64]是明显的例子,尽管后来的BMI(2)指令可能有助于缓解这种情况。我还添加了一个32 x 32 -> 64位C实现进行比较 - 但我希望任何现代ISA至少提供像ARM的umulh一样的“高位”乘法。
/******************************************************************************/

/* stackoverflow.com/questions/74713642 */

#include <inttypes.h>
#include <stdio.h>

/* umul_32_32 : 32 x 32 => 64 */

/* force inline (non-portable), or implement it as macro, e.g.,
 * #define umul_32_32(rh, rl, x, y) do { ... } while (0) */

#if (1)

static inline __attribute__((always_inline))
uint64_t umul_32_32 (uint32_t x, uint32_t y)
{
    return (((uint64_t) x) * y);
}

#else

/* if no widening multiply is available, the compiler probably
 * generates something at least as efficient as the following -
 * or (worst case) it calls a builtin function. */

static inline __attribute__((always_inline))
uint64_t umul_32_32 (uint32_t x, uint32_t y)
{
    uint32_t m0, m1, m2, m3; /* (partial products) */
    uint32_t x0, x1, y0, y1;

    x0 = x & UINT16_MAX, x1 = x >> (16);
    y0 = y & UINT16_MAX, y1 = y >> (16);

    m0 = x0 * y0, m1 = x1 * y0;
    m2 = x0 * y1, m3 = x1 * y1;
    m1 += m0 >> (16);
    m3 += m2 >> (16);
    m1 += m2 & UINT16_MAX;

    uint32_t rh = m3 + (m1 >> (16));
    uint32_t rl = m1 << (16) | (m0 & UINT16_MAX);

    return (((uint64_t) rh) << 32 | rl);

    /* 32 x 32 => 64 : no branching or carry overflow tests. */
}

#endif

/* ensure the function is called to inspect code gen / assembly,
 * otherwise gcc and clang evaluate this at compile time. */

__attribute__((noinline)) void umul_64_64 (

    uint64_t *rh, uint64_t *rl, uint64_t x, uint64_t y)
{
    uint64_t m0, m1, m2, m3; /* (partial products) */
    uint32_t x0, x1, y0, y1;

    x0 = (uint32_t) (x), x1 = (uint32_t) (x >> (32));
    y0 = (uint32_t) (y), y1 = (uint32_t) (y >> (32));

    m0 = umul_32_32(x0, y0), m1 = umul_32_32(x1, y0);
    m2 = umul_32_32(x0, y1), m3 = umul_32_32(x1, y1);
    m1 += m0 >> (32);
    m3 += m2 >> (32);
    m1 += m2 & UINT32_MAX;

    *rh = m3 + (m1 >> (32));
    *rl = m1 << (32) | (m0 & UINT32_MAX);

    /* 64 x 64 => 128 : no branching or carry overflow tests. */
}

#if (0)

int main (void)
{
    uint64_t x = UINT64_MAX, y = UINT64_MAX, rh, rl;

    umul_64_64(& rh, & rl, x, y);
    fprintf(stdout, "0x%016" PRIX64 ":0x%016" PRIX64 "\n", rh, rl);

    return (0);
}

#endif

/******************************************************************************/

对于ARM-7,我得到的结果与你的“变种3”代码基本上是相同的,这并不奇怪,因为它们是基于同样的核心思想。我尝试在gcc-12和gcc-trunk上使用了不同的标志,但没有改进。

我猜测由于苹果公司在AArch64芯片上的投资,他们对clang进行了更激进的优化和资金支持,这也使得32位ARM-7受益。但这只是纯粹的推测。对于如此重要的平台来说,这是一个非常明显的差异。


这里的目标架构主要是ARM和其次是RISC-V,均为它们的32位版本。RISC-V更适当地命名为MIPS 5.0,因此不提供进位标志并且必须使用某种形式的SETcc来模拟它。我的变体5和6是对一个问题的探索:如果我以“本地RISC-V”风格编写C代码,那么从ARM编译器中会得到好的代码吗?基于这个案例,答案似乎是“可能,但不太可能”。 - njuffa

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