从 132MB 到 25MB:列式存储怎么把 CPU 缓存命中率提上去的

98 阅读15分钟

背景:同一篇,百万行透视表。上篇讲了应用层砍掉 30 万个 Proxy。这篇讲存储层——把行式对象数组换成列式 TypedArray,100 万行从 132MB 散落堆压到 25MB 连续内存,groupBy 从 709ms 降到 326ms。

本文会展开到 V8 隐藏类、HeapNumber 装箱、CPU cache line 这一层。会讲清楚"为什么列式快"不是一句"连续内存快",而是三重开销的消除。

这是这个系列的四篇里的第二篇,讲的是存储层——把行式对象数组换成列式连续内存。第一篇是应用层(Vue 响应式),后面还有线程层和布局层各一篇。


一、一个反直觉的事实

先看两段代码,都是对 100 万行做累加:

// A:对象数组(行式)
for (let i = 0; i < n; i++) {
  group[sums[s]] += data[i][sums[s]] || 0;
}

// B:TypedArray(列式)
for (let i = 0; i < n; i++) {
  g[s] += sumCols[s][i];
}

A 和 B 的源码看起来几乎一样。但实测 A 跑 709ms,B 跑 326ms——差 2.2 倍。

这 2.2 倍从哪来?不是算法差异,是每一轮循环里 V8 和 CPU 干的额外工作不一样。


二、V8 里一个 {} 到底长什么样

先搞清楚 data[i] 是什么。

const row = {
  styleNo: "12225202133-3-1",
  qty: 10.5,
  lineNo: 1,
};

在 V8 堆里,这个对象不是一块连续内存。它由三部分组成:

┌─────────────────────────────┐
│ 隐藏类指针 (Hidden Class)    │  ← 记录有哪些属性、每个属性的位置
├─────────────────────────────┤
│ in-object 槽位               │  ← styleNo, qty, lineNo 直接存在这里
│ (按隐藏类定义的 offset)       │
├─────────────────────────────┤
│ properties 数组 (可能外部)    │  ← 溢出的属性
├─────────────────────────────┤
│ elements 数组                │  ← 数组索引元素
└─────────────────────────────┘

2.1 隐藏类是什么

V8 内部把隐藏类叫 Map(不是 JS 的 Map,是内部数据结构)。每个对象有一个隐藏类指针,描述这个对象有哪些属性、每个属性存在哪个 offset。

关键机制:transition tree。同样属性、同样添加顺序的对象共享同一个隐藏类。如果两个对象的属性添加顺序不同,V8 会沿 transition tree 新建隐藏类,不会合并。

这意味着:如果后端下发的 30 万行数据,每行的字段顺序不完全一致(常见于 JSON 解析后的对象),它们会分散在多个隐藏类上。

2.2 为什么这会影响性能

V8 有一个内联缓存(Inline Cache,IC)优化:连续访问同一个字段时,IC 会记住"上次这个隐藏类的 qty 在 offset 5",下次直接跳过查表步骤。

但 IC 的优化前提是 monomorphic——调用点遇到的隐藏类越单一越好。多隐藏类会让 IC 退化成 polymorphic 甚至 megamorphic,每次访问都要重新查 DescriptorTable 而不是直接取缓存 offset。

问题在于:30 万行数据,每行的隐藏类可能不同(字段顺序、属性数量不完全一致),IC 命中率下降。而且每个对象是独立分配的堆块,地址散落在堆里。

即便 IC 完全命中,属性访问也要:查隐藏类 → 定位 offset → 从对象内存块读值。对比 typed array 的 arr[i]:基地址 + i × 元素字节长度 → 一条 mov 指令。中间差了至少两次间接寻址和一次查表。

2.3 两种存储方式的内存布局对比

用一张图来对比对象数组和 typed array 在内存里的样子:

对象数组(行式)—— 每个对象是独立堆块,散落在堆各处
┌──────────────────┐
│ data[0]  @0x1000 │  ┌─ 隐藏类指针 (8B)
│                  │  ├─ in-object 槽 (32B): styleNo, qty, lineNo...
│                  │  └─ properties (可能外部)
└──────────────────┘
┌──────────────────┐
│ data[1]  @0x5000 │  ← 与 data[0] 相距 4KB,不在同一 cache line
│                  │
└──────────────────┘
┌──────────────────┐
│ data[2]  @0x9000 │  ← 又是另一块
└──────────────────┘
   ... 30 万个对象散落在堆各处,访问一个就 cache miss 一次


TypedArray(列式)—— 同一列的值紧密排列
  qty 列 (Float64Array):
  ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
  │10│11│18│ 9│..│..│..│..│..│..│..│..│  每个 8 字节,紧密排列
  └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
   └──── 一个 cache line (64B) ────┘ └──── 下一个 cache line ────┘
   8 个元素 per line,顺序访问时硬件预取器提前拉取

  styleNo 字典索引 (Uint32Array):
  ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
  │ 0│ 1│ 0│ 2│..│..│..│..│..│..│..│..│  每个 4 字节,紧密排列
  └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
   └──── 一个 cache line (64B) ─────────┘ └──── 下一个 ─────┘
   16 个元素 per line,比 Float64 更省 cache

三、HeapNumber 装箱:被忽略的隐性分配

再看 A 里的累加:

group[sums[s]] += data[i][sums[s]] || 0;

拆开看每一步:

1. data[i]              → 对象指针
2. data[i][sums[s]]     → 属性查找 → 读出的是一个 HeapNumber(堆上的 double 对象)
3. group[sums[s]]       → 同上,又一个 HeapNumber
4. HeapNumber + HeapNumber → 分配一个新的 HeapNumber 存结果
5. group[sums[s]] = ... → 写回属性槽,存新 HeapNumber 指针

每次累加都分配一个新的 HeapNumber。100 万行 × 5 个度量 = 500 万次累加 = 500 万个临时 HeapNumber。这些对象很快变成垃圾,触发 V8 的 minor GC。

3.1 V8 的 number 有两种表示

  • Smi(Small Integer):直接编码在指针里(tagged pointer),无堆分配。整数且在 Smi 范围内(约 ±2³⁰,31 位有符号)用它。lineNo: 1 这种字段就是 Smi。
  • HeapNumber:装在堆上的对象,有隐藏类、有内存块。浮点数(金额、数量带小数)和超出 Smi 范围的整数用它。

3.2 为什么浮点数一定会装箱

group[sums[s]] += item[sums[s]],当度量是浮点(金额、数量带小数,本场景的常见情况)时:

  • 读 group[sums[s]]:属性查找,值是 HeapNumber(因为浮点不能是 Smi)
  • 读 item[sums[s]]:同上
  • 相加:结果是浮点,必须分配一个新 HeapNumber(堆分配 + 隐藏类 transition)
  • 写回 group[sums[s]]:属性槽存新 HeapNumber 指针

100 万行 × 5 度量 = 500 万次累加,产生 500 万个临时 HeapNumber。这些 HeapNumber 是垃圾,触发频繁 minor GC。GC 一停就是几 ms,叠加起来是几十上百 ms 的卡顿,而且 GC 期间线程被暂停。

用图来看每次累加发生了什么:

graph TD
    A[读 group 属性] --> B[读 item 属性] --> C[相加 分配新对象] --> D[写回属性]
    E[读 g 槽] --> F[读 sumCols 槽] --> G[寄存器相加] --> H[写回槽]

3.3 即便都是整数,对象式也慢

即便度量恰好都是 Smi 范围内的整数,对象式仍要付属性查找的代价——只是少了 HeapNumber 这一项。而且 Smi 的 += 也要读-改-写,属性槽的写回仍然走隐藏类的 offset 定位。

B 里没有这个问题:

g[s] += sumCols[s][i];
  • g 是 Float64Array,g[s] 是 8 字节裸内存槽位,不是对象属性
  • sumCols[s][i] 也是 8 字节裸内存
  • 相加是 CPU 寄存器里的 double add,结果直接写回 typed array 槽位
  • 零堆分配、零 HeapNumber、零 GC 压力

四、CPU cache:为什么连续内存这么重要

这是列式存储最底层的收益,也是一句话解释不清的。

4.1 Cache line 是怎么工作的

CPU 读内存不是一个个字节读的,是以 cache line 为单位(x86 是 64 字节)。每次读取会把连续 64 字节加载到 L1 cache。

内存地址:  [0x00] [0x08] [0x10] [0x18] [0x20] [0x28] [0x30] [0x38]
           └────────────── 一个 cache line (64B) ──────────────┘

如果程序访问 0x00,CPU 把 0x00~0x3F 整块加载到 L1。接下来访问 0x08、0x10 就是 cache hit(纳秒级)。如果接下来访问 0x100,CPU 要重新加载另一块 cache line——cache miss(百纳秒级,约 100 倍差距)。

各级 cache 的实际延迟(近似值,因 CPU 架构而异):

存储层级典型延迟说明
L1 cache~1nsCPU 核心私有,最快
L2 cache~3-4nsCPU 核心私有,稍大
L3 cache~10-40ns多核共享
主存(DRAM)~60-100ns真正的内存,比 cache 慢两个数量级

4.2 行式存储的访问模式

对象数组里,data[0]、data[1]、data[2] 是三个独立分配的堆块,地址可能相隔几百字节甚至几 KB:

堆地址:  [data[0]对象 @0x1000]  ...  [data[1]对象 @0x2000]  ...  [data[2]对象 @0x3000]
         └─ 64B ─┘              └─ 64B ─┘              └─ 64B ─┘

遍历 data[0].qty → data[1].qty → data[2].qty:

  • 访问 data[0]:加载 cache line @0x1000(miss)
  • 访问 data[1]:加载 cache line @0x2000(miss)
  • 访问 data[2]:加载 cache line @0x3000(miss)

几乎每次访问都是 cache miss。100 万行遍历 = 100 万次 miss ≈ 100 万 × 100ns = 100ms 纯粹浪费在等内存上。

这里还有第二层:即便 data[0] 和 data[1] 的对象块恰好在相邻地址,访问 data[0].qty 和 data[1].qty 时,qty 在对象内的 offset 虽然相同,但两个对象是不同的 cache line。所以对象数组的字段访问天然是"跳线"模式,硬件预取器抓不到规律。

4.3 列式存储的访问模式

列式下,qty 列的所有值连续存储:

Float64Array(qty): [10.5] [11.2] [18.5] [9.8] ...
                    └────── 一个 cache line (8 个 Float64) ──────┘

遍历时:

  • 访问 qty[0]:加载 cache line(miss),同时把 qty[1]~qty[7] 也拉进 L1
  • 访问 qty[1]:hit(已经在 L1 里了)
  • 访问 qty[2]~qty[7]:全部 hit
  • 访问 qty[8]:加载下一块 cache line(miss),同时拉进 qty[9]~qty[15]

每 8 次访问只有 1 次 miss。硬件预取器(prefetcher)检测到顺序访问模式,会提前把后续 cache line 拉进来,miss 率进一步降低。

4.4 两种模式的 cache 行为对比

用一张图来对比行式和列式遍历时的 cache 命中情况:

graph LR
    A[访问 0] --> B[访问 1] --> C[访问 2] --> D[几乎全是 miss]
    E[访问 0] --> F[访问 1] --> G[访问 2 至 7] --> H[访问 8] --> I[访问 9 至 15] --> J[每 8 次 1 次 miss]

上方对应行式,下方对应列式。

4.5 为什么 Uint32 更划算

一个 cache line = 64 字节:

  • 对 Float64Array:64/8 = 8 个元素每 line
  • 对 Uint32Array:64/4 = 16 个元素每 line

所以字典编码后的 indices(Uint32Array)比原始的 values(Float64Array)更省 cache line。维度字段做字典编码有双重收益:减少内存占用 + 提高 cache 利用率。

4.6 理论估算

存储方式每 8 个元素的 miss 数每元素等效耗时
对象式(散落堆)~8 次 miss~100ns
列式(连续内存)~1 次 miss~12ns
差距~8 倍

实际测出来的差距(2.2 倍)小于理论值(8 倍),因为 V8 的 IC 优化和 GC 也占了部分时间,不完全是 cache 的功劳。但方向一致:列式存储的 cache 友好性是实打实的。


五、内存对比:132MB → 25MB

5.1 为什么对象式那么大

除了访问速度,列式存储的内存占用也小得多:

项对象式(每行)列式(每字段每行)
存储单元1 个对象(隐藏类指针 + in-object 槽 + properties + elements)4 字节(Uint32)或 8 字节(Float64)
100 万行 × 17 字段约 132MB 散落堆约 25MB 连续
访问模式指针跳转,cache miss顺序扫描,cache hit

132MB 的构成(每行):对象头(隐藏类指针 + hash + 其他)约 16-24 字节,in-object 槽位预留 4-8 个 slot 约 32-64 字节,17 个字段的值本身约 17×8=136 字节(字符串另算),加上 GC overhead。散落在堆上还有每个对象块的对齐填充。

25MB 的构成:5 个 Float64 度量 × 8MB + 12 个 Uint32 维度 × 4MB + 字典数组(唯一值,数量远小于行数)。全部连续。

5.2 25MB 能装进 L3 cache

现代 CPU 的 L3 通常是 8~32MB。25MB 能装进 L3 cache,这意味着整个数据集可以完全在 CPU 缓存里跑,不用频繁访问主存。这是列式存储的第二重收益——不仅访问模式友好,数据总量也小到能进缓存。


六、字典编码:字符串变整数

字符串是性能黑洞。300k 行的 styleNo 列,原本是 300k 个长字符串对象(每个 32+ 字节),分组时要做字符串比较。

字典编码的做法:

const dict = new Map();        // String(v) -> idx(分组一致性)
const dictionary = [];          // idx -> 原始值(保留类型)
const indices = new Uint32Array(n);

for (let i = 0; i < n; i++) {
  const v = data[i][field];
  const key = v == null ? '' : String(v);
  let idx = dict.get(key);
  if (idx === undefined) {
    idx = dict.size;
    dict.set(key, idx);
    dictionary.push(v);
  }
  indices[i] = idx;
}

转换后:

  • indices 是 Uint32Array(n),每个值 4 字节,连续存储
  • 分组 key 用整数拼接(比字符串拼接快得多)
  • dictionary 数组保留原始值(保证类型不丢,见下文)

6.1 字符串在 V8 里的代价

V8 字符串内部有几种表示:

  • SeqString:连续存储的 UTF-16,访问最快
  • ConsString:a + b 的结果,惰性拼接(两段指针),访问时才 flatten
  • 字符串的 hash 由 V8 惰性计算,首次用作 Map key 时算并缓存

字符串做 Map key 的查找代价:先算 hash(或取缓存),再定位 bucket,冲突时逐字符比较(先比长度,长度相同再 memcmp)。

而透视图的 groupBy 要拼 key:item[dim1] + SEP + item[dim2] + ...。维度值是真实字符串(如 "2024-12-19吊挂12225202133-3-1..."),拼出来的 key 很长。每行拼一次长字符串加 Map 查一次,长字符串的拼接分配、hash、比较全是开销。

6.2 字典编码的收益

  • groupBy 时再也不碰原始字符串:Worker 聚合只用 indices[i](整数),全在 typed array 上
  • 整数 key 拼接:key += dimCols[d][i] 是整数转短字符串("0", "231"),比真实维度值短几个数量级,拼接快、hash 快、冲突时比较快
  • 唯一值是免费副产物:建字典时 dictionary[] 就是唯一值列表,省去了独立的 computeUniqueValues 函数
  • 结果重建很便宜:聚合末尾要把 786 个组重建为对象行(key.split(SEP) 再查 dictionary),但组的数量远小于 n(786 对 30 万),这部分是 O(组数 × 字段数),相对主循环的 O(n) 可忽略

6.3 字典编码的完整流程

用图来看整个字典编码过程:

graph LR
    A[styleNo 列 长字符串 散落堆] --> B[分组时 字符串拼接]
    A --> C[遍历每行]
    C --> D[查 Map 取 idx]
    D --> E[indices 存 idx]
    D --> F[dictionary 存值]
    E --> G[indices 连续]
    F --> I[dictionary 唯一值]
    G --> H[整数 key 拼接]
    I --> H

6.4 类型保真

Map 做 String(v) -> idx 映射保证分组一致性(两个值 String(v) 相同就映射到同一个 idx),但 dictionary 数组 push 原始值 v。比如 lineNo 是 number,字典里也是 number,S2 拿到后排数值序;如果存 String(v) 就退化成字典序。

类型保真是正确性前提:字典编码压缩的是"重复值的存储",不改变值的语义和类型。如果为了性能把 number 都转成 string,排序就会从数值序退化成字典序——这是性能优化破坏业务语义的典型陷阱。


七、分类打包:避免为度量字段白建字典

不是所有字段都需要字典。度量字段(如 qty、produceQty)永远不当分组键,建字典是纯浪费:

function packColumnar(data, fields, dims, sums) {
  for (const field of fields) {
    const inDims = dims.includes(field);
    const inSums = sums.includes(field);

    if (inDims || !inSums) {
      // 维度/分类字段 → 建字典
      // ...
    }
    if (inSums) {
      // 度量字段 → 只建 Float64Array
      col.values = new Float64Array(n);
      // ...
    }
  }
}

实测:1M 行打包从 1.22s 降到 681ms(-44%),因为省了度量字段的字典构建和去重。

7.1 为什么度量字段不需要字典

字典的作用是把"可能很长的重复值"压缩成"短整数",收益发生在 groupBy 的 key 拼接和 Map 查找环节。度量字段(qty、金额等)在 groupBy 里根本不参与 key 构建——它们只被 += 累加。给度量字段建字典,等于给一个从不参与分组的字段做整数映射,纯浪费。

7.2 为什么度量字段也不需要 Uint32

度量值是浮点(金额、数量带小数),需要 8 字节精度,所以用 Float64Array 而不是 Uint32Array。这是精度需求决定的,不是性能选择。

7.3 packColumnar 的分类决策

graph TD
    A[遍历每个字段] --> B{字段在 dims 中}
    B -->|是| C[建字典 Map + dictionary + Uint32Array]
    B -->|否| D{字段在 sums 中}
    D -->|是| E[只建 Float64Array 不建字典]
    D -->|否| F[未选中字段 建字典]
    C --> G[产出列式数据]
    E --> G
    F --> G

八、微基准数据

Node 环境下(无 Worker/Vue/S2 开销),三代实现的对比:

数据量原始 setData列式 setData原始切换列式切换
1万26ms7ms26ms4ms
10万268ms68ms269ms31ms
30万854ms199ms870ms93ms
50万1.59s319ms1.55s156ms
80万2.45s700ms2.41s258ms
100万3.09s899ms3.18s326ms

Node 数值低于浏览器实测(少了 Worker 通信/Vue/S2 开销),但相对关系可靠。

8.1 数据说明

  • 原始 setData = structuredClone(对象数组)+ lodash groupBy + sumBy
  • 列式 setData = packColumnar(顺序写 typed array)+ 列式 groupBy
  • 原始切换 = 每次切换都重新 structuredClone 全量数据 + groupBy
  • 列式切换 = Worker 常驻数据,切换只发几个字符串,零拷贝 groupBy

8.2 比例关系

从表中可以看出一个规律:列式相比原始,setData 约 3.4× 提升,切换约 9.7× 提升。切换提升更大,因为切换时原始版本要重新克隆全量数据(structuredClone),而列式版本只发几个字段名字符串。


九、这一层没有做的事

  • 没有用 SharedArrayBuffer:需要 COOP+COEP 安全头,部署复杂度高。方案4(transfer)已满足需求,详见线程层那篇。
  • 没有做数据分页:后端全量下发,前端只能全量接收。如果后端支持分页,可以进一步降低前端内存压力。
  • 没有优化 JSON 解析:axios 内部已经做了 JSON 解析,这一步的开销(1M 行约 100-200ms)不在我们的控制范围内。