Skip to content

数据类型也是缓存变量:缩小类型未必更快

前两篇咱们建立了一套直觉:缓存空间极其宝贵,数据越紧凑、越能塞进缓存,性能越好。顺着这条直觉,很容易推出一个看起来无懈可击的结论——用更小的数据类型int 是 4 字节,short 是 2 字节,uint8_t 是 1 字节,缓存行里能塞下的元素翻倍再翻倍,缓存命中率飙升,程序当然更快。

逻辑完美自洽。然后你跑个实验,被现实打脸。这一篇就拆这个反直觉。

一个看起来理所当然的优化

场景很日常:一个存了几千万人年龄的数组,遍历求平均年龄。年龄的值域是 0 到 255,int 能表示到 20 亿,显然大材小用。直觉上,用 uint8_t 存年龄,内存省 4 倍,缓存行利用率高 4 倍,肯定更快。

咱们把 int32_tint16_tuint8_t 三种存法都比一遍,5000 万元素累加,本机(GCC 16.1.1,-O2)各跑 7 轮取中位数:

text
N=50000000, -O2 (含编译器自动向量化)
  int32_t  : 10301.1 us  (数组 190.7 MB, 每缓存行(64B)装 16 个)
  int16_t  : 10263.2 us  (数组 95.4 MB,  每缓存行(64B)装 32 个)
  uint8_t  : 11270.6 us  (数组 47.7 MB,  每缓存行(64B)装 64 个)

盯着结果看。按"缓存行利用率"的逻辑,uint8_t 每行装 64 个,应该最快;int32_t 每行才装 16 个,应该最慢。可实际跑出来:int16_t 最快,uint8_t 反而最慢,比 int16_t 慢了大约 10%。缓存空间省了 4 倍,速度不升反降。

缓存端赢了,执行单元端输了

问题出在:性能不是单一维度。缓存命中率是瓶颈,但不是唯一的瓶颈。CPU 内部的执行单元,处理不同宽度的整数,吞吐量是不一样的。

在 x86-64 上,int32_t 的加法是最"原生"的操作——寄存器本身就是 32/64 位,一条 add 指令搞定,吞吐量最高。int16_tuint8_t 的算术,运算本身倒是也能做,但窄类型往往需要额外的符号扩展零扩展指令把结果撑回完整寄存器宽度,而且 x86 对 8 位算术的指令编码有些历史包袱,吞吐量确实比 16 位还低一点。

于是局面变成:uint8_t 在缓存端省了 4 倍空间(缓存 miss 少),但在执行单元端,每次加法的吞吐量更差(算术慢)。两相权衡,在这个数据量(5000 万、数组 47 MB 仍超 L3)下,缓存收益没能盖过算术损失,总体反而比 int16_t 慢。int16_t 处在甜蜜点:缓存比 int32 省,算术吞吐量又没像 uint8 那样退化,所以最快。

关掉向量化再确认一次

为了排除是编译器自动向量化的干扰,咱们关掉向量化(-fno-tree-vectorize)再看纯标量表现:

text
-O2 -fno-tree-vectorize (纯标量)
  int32_t  : 11537.6 us   ← 最快
  int16_t  : 12988.0 us
  uint8_t  : 12450.8 us

纯标量下,int32_t 反而最快——因为标量 add 对 32 位是最直接的,窄类型要额外指令处理。这进一步印证了上面的解释:窄类型的算术吞吐量劣势是真实存在的,只是向量化版本里编译器把循环展开了、部分抵消了差异,所以差距没那么夸张,但方向没变。

优化从来不是单一维度

这是缓存系列里最该记住的一条教训:改善了缓存行为,完全可能在指令执行效率上亏回去。你把数据挤得更紧凑,缓存命中率上去了,但如果这同时让 CPU 的算术单元吞吐量下降,净效果可能为零甚至为负。所以千万千万要测量,别靠"缓存友好就一定快"的直觉拍板。

那数据类型到底该怎么选

别走到另一个极端,觉得"缩小类型没用"。上面的实验只是说明"无脑缩到最小未必最快",不是"缩小类型没用"。正确的思路是分场景:

纯存储、不怎么算术的字段,果断缩。最典型的是枚举。C++ 的枚举默认底层类型是 int(4 字节),可大多数枚举就那么几个值,用 4 字节存一个 0/1/2 纯属浪费。而且枚举值你基本不会拿去做算术,不用担心算术吞吐量退化,纯粹省空间:

cpp
enum class Color : uint8_t { Red, Green, Blue };       // 1 字节
enum class Month : uint8_t { Jan = 1, Feb, Mar, /*...*/ Dec };

结构体里有好几个枚举字段时,这一招省下来的空间叠加起来相当可观,数组更紧凑,缓存命中率实打实上升。

参与算术、且数据量没大到撑爆缓存的字段,别盲目缩。算年龄平均、做统计累加这种,int32_t 往往是甜蜜点,或者至多 int16_t。这时候算术吞吐量的权重比缓存空间大。

数据量极大、算术很轻的场景,缩小才稳赚。比如你只是遍历一堆标志位统计有多少个 true,几乎不算术,那 uint8_tint8_t 的缓存优势能充分发挥,执行单元的退化影响很小。

但所有这些判断,最终都得回到一条:在你的目标数据规模和访问模式下实测。本机的数字、笔者这里跑出来的排名,都只给你方向。

一个容易踩的坑:别用 char 存数值

顺带提一个相关的语言陷阱。如果你决定用 1 字节类型存数值,别用 char。C++ 标准里 charsigned charunsigned char三种不同的类型,char 的符号性是编译器实现定义的(x86 上通常有符号,ARM 上常常无符号)。你写 char x = 200;,在不同平台上表现可能不一样,埋跨平台兼容性的雷。

数值场景请明确写 int8_tuint8_t(它们分别是 signed charunsigned char 的别名,但语义明确),把 char 留给字符用。signed intint 是一回事,唯独 char 是例外——这是历史包袱,记住结论就行。

至于 char8_t(C++20 引入的 UTF-8 字符类型),它底层是 unsigned char 但在类型系统里独立,还有一个特别的副作用:它不在 strict aliasing 规则的"万能别名"例外列表里,所以编译器对它更"放心",有时能做更激进的优化。Jonathan 在演讲里展示过 char8_tchar 快两倍多的案例。但这个现象依赖编译器版本和上下文,笔者没在本机稳定复现,这里只作为"类型选择影响优化"的深度彩蛋提一句,不建议你为了这个把数值类型改成 char8_t——语义不对,可读性也差。

这一层的边界

这一篇咱们看到,数据类型的选择不只是"省内存"那么简单,它是缓存命中率和算术吞吐量之间的权衡。缩小类型在缓存端得分,在执行单元端可能失分,净效果要实测才知道。

下一篇把前几篇的零散结论收拢成一套可落地的工程方法:怎么设计数据布局、怎么处理冷热数据分离、什么时候该手动对齐、以及面对一个具体场景怎么一步步做缓存友好的决策。

下一篇:写对缓存友好的代码 →

v0.10.0-6-gbcee94e · bcee94e · 2026-08-20