PAYATHON 2026

ASCII [0,127] 大小写转换:lookup table 比算术运算更快吗?

技术老齐

结论:优先使用算术版本,查表未必更快

对于约 10 KB、字符范围为 [0,127] 的 ASCII 字符串,建议先用简单的算术实现,并开启编译优化。查表不一定更快;编译器能够自动向量化时,算术版本往往更有优势。

如果只比较逐字符处理的标量代码,查表可能更快,也可能更慢,取决于 CPU、编译器生成的指令和缓存状态。哪种实现更快,需要在目标环境中测量。

为什么不能按“访存”和“函数调用”直接比较?

lowercase(src[i]) 虽然写成函数调用,运行时却未必有调用开销。函数定义对编译器可见且开启优化时,这类小函数通常会被内联到循环中。不过,inline 关键字本身不保证内联。

原表达式中的乘法也不一定生成乘法指令:

c + 0x20 * (c >= 'A' && c <= 'Z')

编译器可以用比较、移位、掩码或条件选择等指令实现它。&& 在语言层面具有短路语义,但生成的机器码仍可能没有条件跳转,因此不能仅凭字符随机就断定分支预测一定很差。

查表版本需要:

  1. 读取当前字符;
  2. 根据字符值计算表项地址;
  3. 读取表项,再写入结果。

读取表项需要先知道字符值,两次读取之间存在数据依赖。不过,处理器可以重叠处理多个字符,所以不能简单地把每次加载的延迟相加。

128 个单字节表项只占 128 字节,在常见处理器上很容易缓存在 L1 数据缓存中,通常不需要为每个字符访问主内存。

两种方法更明显的区别在于 SIMD。比较和加法适合同时处理多个字节,而普通的 table[c] 按数据值索引,自动向量化往往更困难。循环是否完成了向量化,仍需查看编译器输出。

如何实现一个简单、快速的查找表?

输入严格限定在 [0,127] 时,用一个连续的 128 项数组就够了。表可以在编译期生成,省去手写整张表的麻烦。

下面的实现使用 C++17。数值常量直接对应 ASCII 编码,只转换 0x41 到 0x5A,其余字符保持原值:

#include <array>
#include <cstddef>
#include <string>

constexpr unsigned char ascii_lower(unsigned char c) noexcept
{
    return static_cast<unsigned char>(
        c + 0x20u * (c >= 0x41u && c <= 0x5Au)
    );
}

inline constexpr auto lower_table = [] {
    std::array<unsigned char, 128> table{};

    for (std::size_t i = 0; i < table.size(); ++i) {
        table[i] = ascii_lower(static_cast<unsigned char>(i));
    }

    return table;
}();

void lowercase_arithmetic(std::string& text)
{
    for (char& c : text) {
        c = static_cast<char>(
            ascii_lower(static_cast<unsigned char>(c))
        );
    }
}

// 前提:text 中每个字符的数值都在 [0,127]。
void lowercase_lookup(std::string& text)
{
    for (char& c : text) {
        const auto index = static_cast<unsigned char>(c);
        c = static_cast<char>(lower_table[index]);
    }
}

两种实现都可以原地修改字符串:

std::string src = "Hello, ASCII! 123";
lowercase_lookup(src);
// src == "hello, ascii! 123"

这里的 constexpr 保证表可以在编译期完成初始化。运行时执行 lower_table[index],仍需读取表项。

查表访问需要做哪些优化?

实现可以保持简单:

  • 使用连续数组,每项存放一个 unsigned char。
  • 将表放在循环外,复用同一张只读表。
  • 索引前转换为 unsigned char,避免有符号 char 产生负下标。
  • 使用普通循环,让编译器看到完整的处理过程。

这样的小表通常不需要预取或特殊对齐。额外调整应以测量结果为依据,有明确收益再采用。

转换为 unsigned char 只解决负下标问题。如果输入包含数值为 128 到 255 的字节,访问这张 128 项表仍会越界。在常见的 8 位字节平台上,可以把表扩展为 256 项,让高半区保持原值。这样能扩大可处理的字节范围,但不具备 Unicode 大小写转换能力。

如何测出目标环境中的结果?

应比较完整的字符串转换循环。只测一个字符时,函数调用和计时开销很容易掩盖两种实现的差异。

测量时按以下步骤验证:

  1. 使用相同的优化设置,例如 -O2 或 -O3,并保持目标架构选项一致。
  2. 在计时外生成测试数据,让两种实现处理长度和内容都相同的输入。
  3. 先验证输出一致,再重复测量。确保后续代码会使用转换结果,避免优化器删除转换操作。
  4. 查看优化报告或反汇编,确认是否内联、是否存在条件跳转,以及循环是否向量化。

原地转换时,同一个字符串处理一次后就不再含有大写字母。反复处理它,会改变后续测量的输入分布。因此,每轮都应恢复原始数据,两种实现也应采用相同的方式计入或排除复制成本。

转换约 10 KB 的字符串可能耗时很短,需要重复测量足够多次。还应区分两种场景:反复处理同一块缓冲区、缓存已经预热,以及持续处理不同缓冲区。

哪些写法需要避免?

不能对所有字符直接执行:

c | 0x20

它能把 ASCII 大写字母转换为小写,也会改变其他字符。例如,'@' 的编码是 0x40,按位或之后会变成编码为 0x60 的反引号,因此不能省略范围判断。

如果改用 std::tolower,需要注意它受 locale 影响,语义与这里固定的 ASCII 映射不同。传入普通 char 时,还应先转换为 unsigned char:

c = static_cast<char>(
    std::tolower(static_cast<unsigned char>(c))
);

只有在目标编译器和处理器上的测量显示查表更快时,再用它替换算术循环。

备注:内容仅供参考。