ASCII [0,127] 大小写转换:lookup table 比算术运算更快吗?
结论:优先使用算术版本,查表未必更快
对于约 10 KB、字符范围为 [0,127] 的 ASCII 字符串,建议先用简单的算术实现,并开启编译优化。查表不一定更快;编译器能够自动向量化时,算术版本往往更有优势。
如果只比较逐字符处理的标量代码,查表可能更快,也可能更慢,取决于 CPU、编译器生成的指令和缓存状态。哪种实现更快,需要在目标环境中测量。
为什么不能按“访存”和“函数调用”直接比较?
lowercase(src[i]) 虽然写成函数调用,运行时却未必有调用开销。函数定义对编译器可见且开启优化时,这类小函数通常会被内联到循环中。不过,inline 关键字本身不保证内联。
原表达式中的乘法也不一定生成乘法指令:
c + 0x20 * (c >= 'A' && c <= 'Z')
编译器可以用比较、移位、掩码或条件选择等指令实现它。&& 在语言层面具有短路语义,但生成的机器码仍可能没有条件跳转,因此不能仅凭字符随机就断定分支预测一定很差。
查表版本需要:
- 读取当前字符;
- 根据字符值计算表项地址;
- 读取表项,再写入结果。
读取表项需要先知道字符值,两次读取之间存在数据依赖。不过,处理器可以重叠处理多个字符,所以不能简单地把每次加载的延迟相加。
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 大小写转换能力。
如何测出目标环境中的结果?
应比较完整的字符串转换循环。只测一个字符时,函数调用和计时开销很容易掩盖两种实现的差异。
测量时按以下步骤验证:
- 使用相同的优化设置,例如
-O2或-O3,并保持目标架构选项一致。 - 在计时外生成测试数据,让两种实现处理长度和内容都相同的输入。
- 先验证输出一致,再重复测量。确保后续代码会使用转换结果,避免优化器删除转换操作。
- 查看优化报告或反汇编,确认是否内联、是否存在条件跳转,以及循环是否向量化。
原地转换时,同一个字符串处理一次后就不再含有大写字母。反复处理它,会改变后续测量的输入分布。因此,每轮都应恢复原始数据,两种实现也应采用相同的方式计入或排除复制成本。
转换约 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))
);
只有在目标编译器和处理器上的测量显示查表更快时,再用它替换算术循环。
备注:内容仅供参考。
