简单、直观的解法,仍然可能不是最优解,哪怕它的算术部分只编译成一条指令。写下 n / denominator,在汇编里看到一条除法指令,很容易就认定已经没有什么可优化的了。

处理器能用一条指令表达除法,并不意味着除法便宜。当同一个分母被反复使用时,可以先计算一次倒数,再用一串乘法、移位和加法替代每次除法,反而花更少的时间。这个机会来自程序对负载的了解:分母会保持不变。

在 Intel Xeon E5-4657L v2 上,预先计算参数的版本处理一个小型数组时,吞吐量是普通运行时除法的 2.43 倍。两个循环都是标量形式。付过一次除数的准备成本之后,更长的算术指令序列没有依赖 SIMD,也照样胜出。我还测了 Apple M4 Max 和 Loongson 3A6000,并单独测试了需要复用多少次,才能收回准备成本。

起因是这篇讨论 C++26 契约的 LinkedIn 帖子。示例把两个整数相除,前置条件是分母不能为零。引用的后续说明说,忽略契约时只剩下除法,而 quick_enforce 会多出一次测试和一次条件跳转。给出的 Google Benchmark 结果是 1.18 和 1.94 纳秒。

这个观察说明了检查前置条件的成本。“零开销”说的是关闭契约检查后,检查本身不再花时间。除法仍然有成本,而有些负载可以避免为每一个值都付一次这个成本。

指令条数并不能完整反映 CPU 做了多少工作。不同指令有不同的延迟,占用不同的执行资源,也允许不同程度的重叠执行。一串乘法、移位和加法,可能比一条除法指令更快。数组循环还给了编译器用 SIMD 指令同时处理多个值的机会。

编译器可以利用常量除数。在 M4 Max 上写 n / 3,Apple Clang 会生成乘法和符号修正。对于一个有符号 32 位标量分子,它采用的办法相当于:

auto product = std::int64_t(n) * 0x55555556;
auto quotient = (product >> 32) + (n < 0);

这些常量编码了一个整数倒数。结果是精确的,负数也会正确地向零截断。这里没有浮点近似。

有意思的情况是,除数来自配置、文件或者请求。编译器没法提前代入一个特定的倒数,但程序可能知道:接下来的一百万个值都会使用同一个除数。那就可以在运行时把倒数算一次,然后反复使用。

接口可以是这样:

auto d = Divide(3);
d(7);    //  2
d(-7);   // -2

Divide 在构造函数里计算乘数、移位位数和符号掩码。调用运算符使用宽位乘法、算术右移和符号修正。构造函数本身会做一次除法;这笔成本需要靠重复使用对象来摊销。

我用同一个数值除数 3 测了三种情况:普通的 n / denominator,其中除数到运行时才确定;字面量形式的 n / 3;以及调用一个已经构造好的 Divide 对象。

机器使用 Apple M4 Max,系统是 macOS 26.5.1,编译器是 Apple Clang 21.0.0。主要构建参数是 -std=c++26 -O3 -DNDEBUG -mcpu=apple-m4 -fno-lto。第二个版本额外加上 -fno-vectorize -fno-slp-vectorize,用来观察标量结果。

每个计算函数读取 4,096 个有符号 32 位输入,写出 4,096 个商。输入包含正数、负数和整数边界值。数组一直留在缓存里。每种情况采集 15 个样本,打乱执行顺序,并在正式采样前调整重复次数,让每批计时至少持续 25 毫秒。

下面是中位数,单位是纳秒/元素,越小越好:

操作 正常 -O3 禁用向量化
n / denominator,运行时除数 0.451 0.449
n / 3,编译期除数 0.062 0.293
d(n),预先计算的运行时除数 0.280 0.332

在正常构建中,这个类把每个元素的耗时减少了约 38%。禁用向量化后,仍然减少了约 26%。两种比较中,单条除法指令都更慢。

0.062 纳秒这个数字衡量的是整个数组的吞吐量,不表示一次有数据依赖的除法能在 0.062 纳秒内完成。CPU 会重叠执行工作,常量除数的循环也会同时处理多个值。这些结果包含数组读取、写入和循环开销,描述的是这台机器上的这个负载,而不是指令延迟或者整个应用能快多少。它们测量的问题也和原来的契约基准测试不同,所以不能直接拿来比较那里的 1.18 和 1.94 纳秒。

我检查了生成的代码。普通运行时除法使用 ARM64 的 sdiv。常量版本使用基于乘法的 SIMD 运算。这个类也生成了向量化循环,不过这个实现中的宽位乘法有一部分被拆回标量运算。知道字面量除数给了编译器更多自由:它可以自己选择乘数,使用固定的移位位数,去掉除数的符号处理,并生成更高效的向量循环。标量那一列说明,即使没有 SIMD,更便宜的算术也能带来收益。

我又把同一份源代码放到一台配有四颗 Intel Xeon E5-4657L v2 的机器上运行。这仍然是单线程测试,固定在逻辑 CPU 2 上。安装的编译器是 GCC 11.4,参数为 -std=c++20 -O3 -DNDEBUG -march=native -fno-lto;这个类使用的特性在 C++20 中已经可用。GCC 选择了 Ivy Bridge 目标。

Xeon 的中位数如下,单位同样是纳秒/元素:

操作 正常 -O3 禁用向量化
n / denominator,运行时除数 3.415 3.414
n / 3,编译期除数 0.353 1.042
d(n),预先计算的运行时除数 1.408 1.406

在这里,这个类把每个元素的耗时减少了约 59%。它的循环在两个构建中都保持标量形式,而字面量除法使用了向量乘法。普通运行时循环使用 x86 的 idivl。因此,这台机器上这个类的收益,并不依赖对它进行向量化。测到的是特定 CPU 与编译器组合的表现;比较机器时,工具链的区别也必须算进去。Xeon 原始数据和汇编中也保留了编译器的向量化报告。

第三台机器使用 Loongson 3A6000。它的指令集包含向量整数除法,正好可以用来检验:替换除法是否仍然有用。我使用 GCC 15.2,以 C++26 模式编译,并固定在逻辑 CPU 2 上。使用 -march=native 时,GCC 选择了 la664,并启用了 256 位 LASX 指令。系统报告的最高频率是 2.0 GHz,运行前就已经使用 performance 调频策略。

Loongson 的中位数如下,单位是纳秒/元素:

操作 正常 -O3 禁用向量化
n / denominator,运行时除数 0.906 5.817
n / 3,编译期除数 0.095 5.817
d(n),预先计算的运行时除数 0.345 1.392

正常构建中的运行时循环确实使用了 xvdiv.w:每条指令对八个有符号 32 位元素做除法。编译器还展开了循环,每次迭代处理 32 个值。这是真正的硬件向量整数除法,相比标量循环有很大提升。但预先计算参数的类,每个元素仍然比这个向量除法循环少用约 62% 的时间。它也被向量化了,使用的是乘法、移位和符号修正指令。GCC 在 LASX 内建函数中提供了向量除法操作,而本次运行的汇编表明,实际计时的计算函数确实使用了它。

标量常量版本的结果值得单独看。禁用向量化时,这个 GCC 构建即使面对字面量 / 3,也保留了 div.w。在向量化的常量循环中,它则使用 xvmuh.w 和符号修正。所以这两个结果之间的巨大差距,既包含 SIMD 的作用,也包含除法算法本身的变化。常量除数让编译器有机会使用倒数乘法;检查生成的代码,才能知道它有没有抓住这个机会。Loongson 测试结果保留了两个版本。

有两种很容易毁掉这个比较的做法。把字面量暴露给编译器,会让所谓的运行时版本变成另一份常量除法测试。不使用计算结果,则可能让编译器把整个工作删掉。这里的运行时除数从命令行解析,计算函数放在独立的编译单元中,并关闭链接时优化。驱动程序用普通除法核对输出,并在每次计时结束后使用结果计算校验和。算术循环里没有 volatile 访问。基准测试源代码、计算函数和原始结果都附在这里,方便检查测量过程。也可以下载完整源代码和测试结果。

把构造成本算进去,账就不一样了。我还测试了另一种批处理方式:每批只构造一次新的 Divide 对象,并把构造过程放在计时范围内。下表使用正常优化的 M4 Max 构建,同样报告每个元素的耗时:

共用一个除数的元素数 普通 / 构造一次,再使用 Divide
1 0.710 纳秒 1.472 纳秒
4 0.483 纳秒 0.543 纳秒
16 0.461 纳秒 0.312 纳秒
64 0.459 纳秒 0.291 纳秒
256 0.471 纳秒 0.287 纳秒
1,024 0.459 纳秒 0.282 纳秒
4,096 0.465 纳秒 0.281 纳秒

在测过的批量大小里,这个类在一个和四个元素时落后,从十六个开始胜出。这是这个特定循环、编译器和除数组合下的转折点。函数调用开销、向量化门槛和调度,也都会影响这些小批次。十六并不是通用的盈亏平衡点。Xeon 在测过的大小里也是同样的规律:一个和四个元素时,构造成本让它落后;十六个时开始胜出。十六个元素时,包含准备工作的耗时是 2.365 纳秒/元素,普通除法则是 3.365 纳秒/元素。Loongson 首次胜出的已测批量大小是四个元素:包含构造为 1.760 纳秒/元素,普通除法为 1.814。到十六个元素时,差距扩大为 0.594 对 1.798 纳秒/元素。不同的向量循环门槛,再次说明应该测应用实际使用的批量大小。

我会在除数到运行时才确定、随后又在大量工作中保持不变的场景使用它。比如,用从文件中读出的块大小,把偏移量换算成块编号;或者对一批数据应用同一个选定的整数缩放系数。构造应该放在循环外面:

const Divide divide(denominator_from_config);

for (auto& value : values) {
    value = divide(value);
}

如果分母本来就是编译期常量,普通 / 已经给了编译器所需的信息。如果每个值都换一个分母,反复构造这个类只会增加准备工作,却几乎没有机会收回成本。这里测的除数是 3;其他除数和 CPU 需要各自测量。在更大的应用中,内存带宽和其他工作也可能占主导。

实现很短,可以完整贴出来。输入是有符号 32 位整数,中间结果是有符号 64 位整数。设分母的正数幅值为 a,构造时计算:

k = ceil(log2(a))
s = 31 + k
m = floor(2^s / a) + 1

这个倒数刻意采用严格向上取整,即使 a 是二的幂也一样。对于正分子,误差小到不足以让结果跨进下一个商。对于负分子,算术右移向下取整,加一则把结果修正为向零截断。对 Divide(3),这个公式得到的乘数是 0xAAAAAAAB,移位位数是 33。编译器自己实现 / 3 时采用了另一个同样正确的乘数;这个类对所有支持的除数统一使用一个公式。

#pragma once

#include <bit>
#include <cassert>
#include <cstdint>
#include <limits>
#include <stdexcept>

// Reusable signed 32-bit division, rounding toward zero.
// Construction may divide; operator() only multiplies, shifts, and corrects signs.
class Divide {
public:
    using value_type = std::int32_t;

    // Zero is rejected once, when the object is constructed.
    explicit constexpr Divide(value_type denominator)
        : sign_(static_cast<std::int8_t>(denominator < 0 ? -1 : 0)) {
        if (denominator == 0) {
            throw std::invalid_argument("Divide: denominator must not be zero");
        }

        // Widen before negating so that INT32_MIN is handled correctly.
        const auto wide = static_cast<std::int64_t>(denominator);
        const auto magnitude = static_cast<std::uint32_t>(wide < 0 ? -wide : wide);

        // k = ceil(log2(magnitude)); shift is in [31, 62].
        const auto k = std::bit_width(magnitude - 1u);
        shift_ = static_cast<std::uint8_t>(31 + k);

        const auto scale = std::uint64_t{1} << shift_;
        // This reciprocal must be STRICTLY greater than scale / magnitude,
        // even for powers of two. See README.md for the error bound.
        multiplier_ = static_cast<std::uint32_t>(scale / magnitude + 1);
    }

    // Precondition, as for built-in int32 division: INT32_MIN / -1 is excluded.
    // A debug assertion checks this; NDEBUG removes it from the hot path.
    [[nodiscard]] constexpr value_type operator()(value_type numerator) const noexcept {
        // shift == 31 means |denominator| == 1.
        assert(!(numerator == std::numeric_limits<value_type>::min()
                 && sign_ == -1 && shift_ == 31));

        const auto product = static_cast<std::int64_t>(numerator) * multiplier_;
        auto quotient = product >> shift_; // Arithmetic shift (C++20 and later).
        quotient += (numerator < 0);        // Correct to truncation toward zero.

        // sign_ is 0 or -1: conditionally negate without another multiply.
        return static_cast<value_type>((quotient ^ sign_) - sign_);
    }

private:
    std::uint32_t multiplier_{};
    std::uint8_t shift_{};
    std::int8_t sign_{};
};

分母检查只在构造时发生一次。调用运算符保留了普通有符号除法的前置条件:INT32_MIN / -1 无法用返回类型表示。发布版基准测试关闭了这项调试断言,但仍然会拒绝零分母。构造和求值都是 constexpr。代码可以在 C++26 模式下编译,不过用到的特性在 C++20 中就已经存在,也不需要编译器原生支持契约。

这个实现与内建除法比较,在三台机器上各通过了 43,143,231 次检查。M4 Max 和 Xeon 还在地址与未定义行为检测工具启用时,通过了同一套测试。选用的 Loongson GCC 15 安装没有提供这些检测工具所需的运行库,因此那台机器上无法进行这一项额外检查。推导解释了误差界,以及为什么中间乘积能装进 64 位整数;测试覆盖了符号、二的幂、商的边界和整数极限值。

对运行时不变除数进行这种优化,已经是一项成熟技术。如果需要支持更多整数类型、专门实现 SIMD 的生产级库,可以看看 libdivide。这个小类展示了机会从哪里来:除数在执行过程中变成了固定值,因此原本要为每个分子重复做的工作,可以搬到构造阶段。

只数剩下多少条指令,会错过这个机会。知道除数会保持不变多久,才是这项优化有用的原因。