高效的模255计算

21
我正在尝试找到计算32位无符号整数模255的最有效方法。我的主要重点是找到适用于x86和ARM平台并具有适用性的算法。首先,我试图避免内存操作(可能会很昂贵),因此我正在寻找位运算的方法,同时避免使用表格。我还试图避免潜在的昂贵操作,如分支和乘法,并最小化所使用的操作和寄存器数量。
下面的ISO-C99代码捕捉了我迄今为止尝试的八个变体。它包括一个详尽测试的框架。我添加了一些“粗略”的执行时间测量,这似乎足以获得第一次性能印象。在我尝试过的少数平台上(都具有快速整数乘法),变体WARREN_MUL_SHR_2、WARREN_MUL_SHR_1和DIGIT_SUM_CARRY_OUT_1似乎是性能最好的。我的实验表明,在Compiler Explorer上尝试的x86、ARM、PowerPC和MIPS编译器都非常好地利用了特定于平台的功能,例如三输入LEA、字节扩展指令、乘积累加和指令预测。
变体NAIVE_USING_DIV使用整数除法,后跟除数的反向乘法和减法。这是基线情况。现代编译器知道如何有效地实现无符号整数除以255(通过乘法),并将在适当时使用离散替换来进行反向乘法。要计算模base-1,可以对base位数求和,然后折叠结果。例如3334 mod 9:求和3+3+3+4 = 13,折叠1+3 = 4。如果折叠后的结果为base-1,则需要生成0。DIGIT_SUM_THEN_FOLD使用此方法。
A. Cockburn,“Efficient implementation of the OSI transport protocol checksum algorithm using 8/16-bit arithmetic”,ACM SIGCOMM Computer Communication Review,Vol. 17,No. 3,July/Aug. 1987,pp. 13-20
展示了一种在模255的checksum计算中高效地添加数字取模base-1的不同方法。对数字进行逐字节求和,在每次加法后,也要加上任何进位。因此,这将是一个ADD a, bADC a, 0序列。使用base 256数字编写出此方法的加法链时,清楚地表明该计算基本上是使用0x0101 ... 0101进行乘法运算。结果将位于最高有效数字位置,除了需要单独捕获该位置的加法进位。当base数字包括2k位时,此方法仅适用。在这里,我们有k=3。我尝试了三种不同的重映射base-1结果为0的方法,分别得到变体DIGIT_SUM_CARRY_OUT_1DIGIT_SUM_CARRY_OUT_2DIGIT_SUM_CARRY_OUT_3
Joe Keane在1995年7月9日的comp.lang.c新闻组中演示了一种计算模63的有趣方法。虽然参与讨论的Peter L. Montgomery证明了该算法的正确性,但不幸的是,Keane先生未能回应请求解释其推导过程。这个算法也在H. Warren的Hacker's Delight 2nd ed中复制了出来。我能够将其扩展到模-127和模-255,这就是(适当命名为)KEANE_MAGIC变体。更新:自从我最初发布这个问题以来,我已经成功地找出了Keane的方法基本上是以下代码的巧妙固定点实现:return (uint32_t)(fmod (x * 256.0 / 255.0 + 0.5, 256.0) * (255.0 / 256.0));。这使它成为下一个变体的近亲。
Henry S. Warren在第二版Hacker's Delight 2nd ed.的第272页展示了一种“乘-移位”算法,推测该算法是由作者自己设计的,它基于数学性质:n mod 2k-1=floor(2k/2k-1*n) mod 2k。使用固定点计算乘以因子2k/2k-1。我构建了两个变体,它们在如何处理将base-1的初步结果映射到0方面有所不同。这些是变量WARREN_MUL_SHR_1WARREN_MUL_SHR_2

有没有比我目前已经确定的三个最佳候选算法更加高效的模255计算算法,尤其是对于整数乘法速度较慢的平台?针对这种情况,对Keane的无乘法算法进行有效修改,以便对四个base 256数字进行求和,似乎具有特别的意义。

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

#define NAIVE_USING_DIV       (1)
#define DIGIT_SUM_THEN_FOLD   (2)
#define DIGIT_SUM_CARRY_OUT_1 (3)
#define DIGIT_SUM_CARRY_OUT_2 (4)
#define DIGIT_SUM_CARRY_OUT_3 (5)
#define KEANE_MAGIC           (6)  // Joe Keane, comp.lang.c, 1995/07/09
#define WARREN_MUL_SHR_1      (7)  // Hacker's Delight, 2nd ed., p. 272
#define WARREN_MUL_SHR_2      (8)  // Hacker's Delight, 2nd ed., p. 272

#define VARIANT (WARREN_MUL_SHR_2)

uint32_t mod255 (uint32_t x)
{
#if VARIANT == NAIVE_USING_DIV
    return x - 255 * (x / 255);
#elif VARIANT == DIGIT_SUM_THEN_FOLD
    x = (x & 0xffff) + (x >> 16);
    x = (x & 0xff) + (x >> 8);
    x = (x & 0xff) + (x >> 8) + 1;
    x = (x & 0xff) + (x >> 8) - 1;
    return x;
#elif VARIANT == DIGIT_SUM_CARRY_OUT_1
    uint32_t t;
    t = 0x01010101 * x;
    t = (t >> 24) + (t < x);
    if (t == 255) t = 0;
    return t;
#elif VARIANT == DIGIT_SUM_CARRY_OUT_2
    uint32_t t;
    t = 0x01010101 * x;
    t = (t >> 24) + (t < x) + 1;
    t = (t & 0xff) + (t >> 8) - 1;
    return t;
#elif VARIANT == DIGIT_SUM_CARRY_OUT_3
    uint32_t t;
    t = 0x01010101 * x;
    t = (t >> 24) + (t < x);
    t = t & ((t - 255) >> 8);
    return t;
#elif VARIANT == KEANE_MAGIC
    x = (((x >> 16) + x) >> 14) + (x << 2);
    x = ((x >> 8) + x + 2) & 0x3ff;
    x = (x - (x >> 8)) >> 2;
    return x;
#elif VARIANT == WARREN_MUL_SHR_1
    x = (0x01010101 * x + (x >> 8)) >> 24;
    x = x & ((x - 255) >> 8);
    return x;
#elif VARIANT == WARREN_MUL_SHR_2
    x = (0x01010101 * x + (x >> 8)) >> 24;
    if (x == 255) x = 0;
    return x;
#else
#error unknown VARIANT
#endif
}

uint32_t ref_mod255 (uint32_t x)
{
    volatile uint32_t t = x;
    t = t % 255;
    return t;
}

// timing with microsecond resolution
#if defined(_WIN32)
#if !defined(WIN32_LEAN_AND_MEAN)
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
double second (void)
{
    LARGE_INTEGER t;
    static double oofreq;
    static int checkedForHighResTimer;
    static BOOL hasHighResTimer;

    if (!checkedForHighResTimer) {
        hasHighResTimer = QueryPerformanceFrequency (&t);
        oofreq = 1.0 / (double)t.QuadPart;
        checkedForHighResTimer = 1;
    }
    if (hasHighResTimer) {
        QueryPerformanceCounter (&t);
        return (double)t.QuadPart * oofreq;
    } else {
        return (double)GetTickCount() * 1.0e-3;
    }
}
#elif defined(__linux__) || defined(__APPLE__)
#include <stddef.h>
#include <sys/time.h>
double second (void)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (double)tv.tv_sec + (double)tv.tv_usec * 1.0e-6;
}
#else
#error unsupported platform
#endif

int main (void)
{
    double start, stop;
    uint32_t res, ref, x = 0;

    printf ("Testing VARIANT = %d\n", VARIANT);
    start = second();
    do {
        res = mod255 (x);
        ref = ref_mod255 (x);
        if (res != ref) {
            printf ("error @ %08x: res=%08x ref=%08x\n", x, res, ref);
            return EXIT_FAILURE;
        }        
        x++;
    } while (x);
    stop = second();
    printf ("test passed\n");
    printf ("elapsed = %.6f seconds\n", stop - start);
    return EXIT_SUCCESS;
}

1
@PeterCordes 我正在寻找映射到 C 代码的算法,以编译为高效的机器码(主要使用 clang 和 gcc;我认为 Intel 编译器目前正在过渡到 clang?)。我绝对不想使用汇编语言,因为我大约 15 年前就停止使用了。浏览我的八个变体生成的代码时,我觉得 Compiler Explorer 提供的编译器在这段代码上做得非常好。 - njuffa
1
我不确定我能解释它的工作原理...但是 x = ((x + x / 255u) & 0xFFu); 似乎总是给出正确的答案,并且在我的本地机器上(clang-cl/Visual Studio,64位Windows-10)始终优于所有其他方法。一个除法,一个加法和一个微不足道的掩码。 - Adrian Mole
2
@AdrianMole 让 x = 255 q + r,其中 0 ≤ r < 255,则公式计算为 (255 q + r + q) mod 256 = (256 q + r) mod 256 = r,正如所期望的那样。 - David Eisenstat
@AdrianMole 谢谢,这很好用。如果您能将其写成答案,我会非常感激。如果它也适用于形式为2 ** k-1的其他除数,那将是一个奖励。至于性能:在我编写此评论的平台上(旧x86),它的速度与WARREN_MUL_SHR_1相同,比WARREN_MUL_SHR_2稍慢。太棒了。 - njuffa
@AdrianMole 我确认在一台搭载Intel Xeon W-2133和MSVS 2019 /Ox /arch:AVX2的计算机上,你的解决方案比我问题中的任何变体都要快。 - njuffa
显示剩余7条评论
5个回答

13
对于任意的无符号整数xn,求解模运算表达式x % n至少涉及三个操作:除法、乘法和减法。
quotient = x / n;
product = quotient * n;
modulus = x - product;

然而,当n是2的幂(n=2p)时,可以通过屏蔽除了低p位以外的所有位来更快速地确定模数。

在大多数CPU上,加法、减法和位屏蔽都是非常“廉价”的(快速)操作,乘法则比较“昂贵”,而除法则非常“昂贵”–但请注意,大多数优化编译器将通过编译时常量将除法转换为乘法(通过不同的常量)和位移(见下文)。

因此,如果我们可以将模数255转换为模数256,而不需要太多的开销,我们很可能可以加速这个过程。我们可以通过注意到x % n等于(x + x / n) % (n + 1)来实现这一点。因此,我们的概念操作现在是:除法、加法和屏蔽。

在掩码最低8位的特定情况下,基于x86/x64的CPU(和其他?)可能还能够执行进一步的优化,因为它们可以访问(大多数)寄存器的8位版本。

以下是clang-cl编译器为一个简单的模数255函数生成的代码(参数传递在ecx中,返回值在eax中):

unsigned Naive255(unsigned x)
{
    return x % 255;
}
    mov     edx, ecx
    mov     eax, 2155905153 ;
    imul    rax, rdx        ; Replacing the IDIV with IMUL and SHR
    shr     rax, 39         ;
    mov     edx, eax
    shl     edx, 8
    sub     eax, edx
    add     eax, ecx

这是使用上述“技巧”生成的(明显更快的)代码:

unsigned Trick255(unsigned x)
{
    return (x + x / 255) & 0xFF;
}
    mov     eax, ecx
    mov     edx, 2155905153
    imul    rdx, rax
    shr     rdx, 39
    add     edx, ecx
    movzx   eax, dl         ; Faster than an explicit AND mask?

在Windows-10 (64位)平台(Intel® Core™ i7-8550U CPU)上测试此代码表明它在问题中提出的其他算法中表现显著(但不是极大的)。


David Eisenstat给出的答案解释了为什么这种等价性是有效的。


3
既然你指出来了,编译器应该学会这个“窥视孔/特殊情况”技巧,对于 unsigned % 255 这样的操作,当你像一个正常人一样写 x % 255 时,编译器就可以为你完成。毕竟,像这样的东西才是提前编译器的重点,它可以花时间寻找这样的优化。一旦这个问答活动结束,最好的选项(针对每个ISA和/或微架构)应该在 https://bugs.llvm.org/ 和 https://gcc.gnu.org/bugzilla/ 上报告为未优化的错误。 - Peter Cordes
回复:测试:幸运的是,计算机足够快,可以使用“Naive255(x)== Trick255(x)”对每个uint32_t输入进行暴力测试。 - Peter Cordes
1
是的,movzx eax, dl 至少和 and edx, 0xff 一样好,即使我们愿意将结果留在 EDX 而不是 EAX 中。在某些 CPU 上,任何执行端口(如 AND)都可以复制 + zext,但在 Intel IvyBridge 及更高版本上,mov-elimination 可以处理 8->32 位零扩展 mov,只要它在不同寄存器之间。因此它具有 0 延迟,前端为一个 uop,但后端为零。x86 的 MOV 真的可以是“免费”的吗?为什么我完全无法重现?。(AMD 的 mov-elim 仅处理 mov eax,edxmov rax,rdx - Peter Cordes
1
我们正在谈论的是编译器,它们已经可以将整个循环转换为 popcnt,或者将 sum += i 循环转换为闭合形式的高斯公式(至少在 clang 中)!并且使用移位或 AND 运算来进行 2 的幂除法。特殊处理 255,使用预定义的操作序列,例如 (x + x / 255) & 0xFF,就好像你在源代码中编写了它一样,并不是什么大问题,可以在目标无关的优化过程中完成。 - Peter Cordes
2
更相关的是,最近的GCC版本已经针对像x%7 == 0这样的特殊情况进行了优化以比return x % 7更高效:https://godbolt.org/z/6qco9rjPc,自GCC9开始。(使用[此技巧](https://stackoverflow.com/a/40062768/224132))。相关链接:https://lemire.me/blog/2019/02/08/faster-remainders-when-the-divisor-is-a-constant-beating-compilers-and-libdivide/ - Peter Cordes
显示剩余2条评论

9
这是最快方法的解释。目前我还不知道Keane是否能被改进或容易推广。
对于一个整数x ≥ 0,令q = ⌊x/255⌋(在C中,q = x / 255;)和r = x − 255 q(在C中,r = x % 255;),使得q ≥ 0且0 ≤ r < 255为整数,且x = 255 q + r。

Adrian Mole’s method

该方法评估(x + ⌊x/255⌋) mod 28(在C中,(x + x / 255) & 0xff),它等于(255 q + r + q) mod 28 = (28 q + r) mod 28 = r。

Henry S. Warren’s method

注意x + ⌊x/255⌋ = ⌊x + x/255⌋ = ⌊(28/255) x⌋,其中第一步是由于x是整数。该方法使用乘数(20 + 2−8 + 2−16 + 2−24 + 2−32)而不是28/255,它是20 + 2−8 + 2−16 + 2−24 + 2−32 + …的无限级数之和。由于近似略小,因此该方法必须检测剩余值28 − 1 = 255。

Joe Keane’s method

这种方法的直觉是计算y = (28/255) x mod 28,它等于(28/255) (255 q + r) mod 28 = (28 q + (28/255) r) mod 28 = (28/255) r,并返回y − y/28,它等于r。
由于这些公式并不使用 ⌊(28/255) r⌋ = r 这个事实,Keane 可以在两个守卫位上从 28 切换到 210。理想情况下,这些位始终为零,但由于定点截断和对 210/255 的近似,它们不是。 Keane 添加了 2 来切换截断到舍入,这也避免了 Warren 中的特殊情况。
这种方法有点类似于乘数 22 (20 + 2−8 + 2−16 + 2−24 + 2−32 + 2−40) = 22 (20 + 2−16 + 2−32) (20 + 2−8)。C 语句 x = (((x >> 16) + x) >> 14) + (x << 2); 计算 x′ = ⌊22 (20 + 2−16 + 2−32) x⌋ mod 232。然后 ((x >> 8) + x) & 0x3ff 是 x′′ = ⌊(20 + 2−8) x′⌋ mod 210
我现在没有时间正式进行误差分析。非正式地说,第一个计算的误差区间宽度小于 1;第二个是小于 2 + 2−8;第三个是小于 ((2 − 2−8) + 1)/22 小于 1,这允许正确舍入。
关于改进,近似值的 2−40 项似乎不必要 (?), 但除非我们可以删除 2−32 项,否则我们可能也有它。去掉 2−32 会使近似质量超出规格。

9

猜想您可能不需要快速的64位乘法解决方案,但是仅供参考:

return (x * 0x101010101010102ULL) >> 56;

你是怎么得到那个数字的?所有其他2的幂次减1的值是否存在等效值? - Noah
1
@Noah 在8.56固定点中,它是(2^8)/(2^8-1),向上舍入。我无法脱口而出地重现其他答案中的误差分析,但其他指数应该有相应的等效项。Lemire提供了更多细节,并推广到其他除数。 - David Eisenstat

7

这种方法(自上次编辑后略有改进)混合了Warren和Keane的特点。在我的笔记本电脑上,它比Keane更快,但不如64位乘法和移位快。它避免了乘法,但受益于单个旋转指令。与原始版本不同,它可能可以在RISC-V上正常运行。

像Warren一样,这种方法在8.24固定点中逼近了⌊(256/255) x mod 256⌋。对于模256,每个字节b都会贡献一个项(256/255)b,大约是b.bbb基于256。这种方法的原始版本只是将所有四个字节旋转之和。这个总和总是低估了真实值,但误差小于最后一位的4个单位。通过在截断之前添加4/2-24,我们保证了正确答案,就像Keane一样。

修订版本通过降低近似精度来节省工作。我们编写(256/255)x = (257/256)(65536/65535)x,以16.16固定点评估(65536/65535)x(即将x加到其16位旋转中),然后乘以257/256并对256取模进入8.24固定点。第一个乘法的误差小于16.16的最后一位的2个单位,第二个乘法是精确的(!)。总和低估了不到(2/216)(257/256),因此514/2-24的常量项足以修正截断。如果使用不同的立即操作数更有效,则还可以使用更大的值。

uint32_t mod255(uint32_t x) {
  x += (x << 16) | (x >> 16);
  return ((x << 8) + x + 514) >> 24;
}

你是在测试吞吐量还是延迟?(例如,使用x = mod255(x) + 0xabcdef进行循环测试延迟,使用 tmp = mod255(x) / Benchmark::DoNotOptimize(tmp)x 进行测试吞吐量)。你使用的编译器/ CPU 是什么? https://godbolt.org/z/Wohrenbd8 具有几个版本的编译器输出,显示 x86-64 可以从 BMI2 rorx 中受益,以一条指令复制和旋转,而不需要 mov / ror - Peter Cordes
@Peter 在手机上,很难阅读Godbolt,但我并不是特别关注x64性能,因为在该平台上有更好的选择。 - David Eisenstat
2
@DavidEisenstat 太棒了!在我的x86上比KEANE_MAGIC快,而且在32位ARM上只需要5个指令(无论是clang还是gcc)。这在提供通过字节重新排列指令进行高效字节旋转的GPU上也应该非常有效(我将不得不看看编译器是否可以在这种情况下生成它)。我假设这与数字求和有关?期待解释(它看起来与数字求和有关)。 - njuffa
2
据我目前所知,修订版在ARM和x86上的表现优于以前基于ROL的版本。它仅映射到我的Turing架构GPU的四个指令。 - njuffa
1
@DavidEisenstat 三个答案都很有帮助/有用,所以我不得不选择一个来附加奖金。我选择了具有最大直接效用的那个答案。 - njuffa
显示剩余7条评论

2
如果我们有一个内置的、固有的或者优化为单指令addc的方法,那么我们可以使用32位算术来进行如下操作:
uint32_t carry = 0;
// sum up top and bottom 16 bits while generating carry out
x = __builtin_addc(x, x<<16, carry, &carry);
x &= 0xffff0000;
// store the previous carry to bit 0 while adding
// bits 16:23 over bits 24:31, and producing one more carry
x = __builtin_addc(x, x << 8, carry, &carry);  
x = __builtin_addc(x, x >> 24, carry, &carry);  
x &= 0x0000ffff;   // actually 0x1ff is enough
// final correction for 0<=x<=257, i.e. min(x,x-255)
x = x < x-255 ? x : x - 255;  

在Arm64中,常规的加法指令可以采用以下形式:add r0, r1, r2 LSL 16; 使用立即数进行屏蔽或清除连续位是一条单独的指令bfi r0, wzr, #start_bit, #length
对于并行计算,不能有效地使用扩展乘法。相反,可以在计算进位时采用分治方法-从16个uint32_t元素开始解释为16 + 16个uint16_t元素,然后转到uint8_t算术,可以在略少于一条指令的时间内计算出一个结果。
a0 = vld2q_u16(ptr);     // split input to top16+bot16 bits
a1 = vld2q_u16(ptr + 8); // load more inputs
auto b0 = vaddq_u16(a0.val[0], a0.val[1]);
auto b1 = vaddq_u16(a1.val[0], a1.val[1]);
auto c0 = vcltq_u16(b0, a0.val[1]); // 8 carries
auto c1 = vcltq_u16(b1, a1.val[1]); // 8 more carries
b0 = vsubq_u16(b0, c0);
b1 = vsubq_u16(b1, c1);
auto d = vuzpq_u8(b0, b1);
auto result = vaddq_u8(d.val[0], d.val[1]);
auto carry = vcltq_u8(result, d.val[1]);
result = vsubq_u8(result, carry);
auto is_255 = vceqq_u8(result, vdupq_n_u8(255));
result = vbicq_u8(result, is_255);

通常我没有单指令addc的内在功能(在x86上有_addcarry_u64可能适用),但我使用了传统的仿真来确认本答案顶部的代码是否正确。#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) - njuffa
1
Clang支持uint32_t __builtin_addc(uint32_t a, uint32_t b, uint32_t c_in, uint32_t *c_out)进行32位加法,但生成的代码并不理想。 - Aki Suihkonen

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