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

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

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

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


一、一个反直觉的事实

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

js 复制代码
// 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] 是什么。

js 复制代码
const row = {
  styleNo: "12225202133-3-1",
  qty: 10.5,
  lineNo: 1,
};

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

scss 复制代码
┌─────────────────────────────┐
│ 隐藏类指针 (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 在内存里的样子:

kotlin 复制代码
对象数组(行式)------ 每个对象是独立堆块,散落在堆各处
┌──────────────────┐
│ 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 里的累加:

js 复制代码
group[sums[s]] += data[i][sums[s]] || 0;

拆开看每一步:

scss 复制代码
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 里没有这个问题:

js 复制代码
g[s] += sumCols[s][i];
  • gFloat64Arrayg[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。

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

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

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

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

4.2 行式存储的访问模式

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

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

遍历 data[0].qtydata[1].qtydata[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].qtydata[1].qty 时,qty 在对象内的 offset 虽然相同,但两个对象是不同的 cache line。所以对象数组的字段访问天然是"跳线"模式,硬件预取器抓不到规律。

4.3 列式存储的访问模式

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

ini 复制代码
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+ 字节),分组时要做字符串比较。

字典编码的做法:

js 复制代码
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;
}

转换后:

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

6.1 字符串在 V8 里的代价

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

  • SeqString:连续存储的 UTF-16,访问最快
  • ConsStringa + 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[分组时 字符串拼接] C[遍历每行] --> D[查 Map 取 idx] D --> E[indices 存 idx] D --> F[dictionary 存值] G[indices 连续] --> H[整数 key 拼接] I[dictionary 唯一值] --> H

6.4 类型保真

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

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


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

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

js 复制代码
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万 26ms 7ms 26ms 4ms
10万 268ms 68ms 269ms 31ms
30万 854ms 199ms 870ms 93ms
50万 1.59s 319ms 1.55s 156ms
80万 2.45s 700ms 2.41s 258ms
100万 3.09s 899ms 3.18s 326ms

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)不在我们的控制范围内。
相关推荐
码少女1 小时前
数据结构——快速排序
数据结构
闲坐含香咀翠1 小时前
把 AntV S2 列头计算从 O(n²) 降到 O(n):一次开源组件的性能改造
前端·性能优化
闲坐含香咀翠1 小时前
Worker 常驻 + 零拷贝:postMessage 的结构化克隆算法与 Transferable 的真实代价
前端·性能优化
shehuiyuelaiyuehao1 小时前
算法39,位运算,消失的两个数字
java·数据结构·算法
JunjunZ2 小时前
Naive UI 虚拟级联选择器适配 Element Plus 风格
前端·javascript·vue.js
ynchyong2 小时前
微信小程序启动顺序遇到的一个坑
前端·微信小程序
用户233376852182 小时前
接口卡死排查实录-缺失return的UB死循环
前端·后端
光影少年3 小时前
如何实现RN 多环境、多渠道打包
前端·react native·react.js
闲坐含香咀翠3 小时前
百万行数据透视表,我是怎么把 Vue 响应式开销砍到零的
前端·vue.js·性能优化