# 实验 01：数据布局——行存与列存

> 同一份表，按行连续存放还是按列连续存放，会让不同类型的查询快慢悬殊：本实验用 CSV 文件和一个确定性的 I/O 成本模型，把这种差距量出来、算清楚。

## 一、实验目标

完成本实验后，你应当能够：

1. 能解释行式布局（row-major，又称 NSM，n-ary storage model）与列式布局（column-major，又称 DSM，decomposition storage model）的定义，并画出同一张 3 列表在两种布局下的字节排列顺序。
2. 能对给定查询（投影列数、访问行数、字段宽度、块大小）手工推导两种布局各需要多少次 I/O 请求、读取多少字节。
3. 能运行 notebook 中的 CSV 模拟实验，实测两类查询在两种布局上的耗时，并把结果填入观测记录表。
4. 能判断"一个查询应该用行存还是列存"，并用"访问的列数占全部列数的比例"说明理由。
5. 能说出存储层次（storage hierarchy）中各层延迟的数量级差异，并解释为什么 I/O 请求次数往往比读取的字节总量更能决定查询耗时。

## 二、预备知识

### 2.1 表在磁盘上只是一串字节

数据库的表是二维的：行 × 列。但磁盘（无论机械硬盘还是 SSD）给出的地址空间是一维的。把二维表放进一维空间，必须做一次**线性化（linearization）**——决定"先铺哪一维"。只有两种基本铺法：

- **行式布局（row-major）**：先铺行。第 1 个元组的所有字段连续存放，接着是第 2 个元组的所有字段……
- **列式布局（column-major）**：先铺列。整列 `title` 连续存放，接着整列 `author`，再接着整列 `price`……

用一张 3 行 3 列的表 `books(title, author, price)` 示意（T=标题，A=作者，P=价格，下标是行号）：

```text
行式布局（一条连续字节流）：
| T1 A1 P1 | T2 A2 P2 | T3 A3 P3 | ...

列式布局（一条连续字节流）：
| T1 T2 T3 | A1 A2 A3 | P1 P2 P3 |
```

同一个表、同样多的字节，只是排列顺序不同。查询要"顺着"这个顺序访问才快；访问顺序和存放顺序打架，就要付出随机 I/O 的代价。

### 2.2 块（block）与 I/O 请求

磁盘与内存之间不是按字节交换数据的，而是按**块（block / page）**。常见块大小是 4 KiB ～ 64 KiB（本实验的确定性模型用 64 KiB）。读 1 个字节和读 1 个整块的成本几乎一样：都是一次 **I/O 请求**。

因此衡量一次查询的 I/O 成本有两个量，含义不同：

- **I/O 请求次数**：查询触达了多少个不同的块。每次请求都要付一次"起步价"（寻道、旋转或 SSD 的随机读延迟）。
- **读取字节数**：这些块加起来一共搬了多少数据。它决定"起步价"之外每字节的车费。

存储层次各层访问延迟的数量级（公开经验值汇编，Norvig/Dean 汇编版本的数字，属数量级参考而非硬件实测）：

| 层次 | 典型延迟数量级 |
| --- | --- |
| L1 缓存 | 约 0.5 ns |
| L2 缓存 | 约 7 ns |
| 主存 | 约 100 ns |
| SSD 4 KiB 随机读 | 约 150 μs（即 150000 ns） |
| 机械硬盘寻道 | 约 10 ms（即 10000000 ns） |

从主存到 SSD 随机读差约 3 个数量级，到机械盘寻道差约 5 个数量级。这就是为什么数据库优化首先关心"少碰几次盘"，其次才关心"每次搬多少字节"。另外，CPU 缓存与内存之间以**缓存行（cache line）**为单位交换数据，x86/x86-64 上典型为 64 字节——当数据全在内存里时，缓存行取代磁盘块，成为决定访问顺序好坏的最小单位。

### 2.3 两类查询

本实验对比两类行为完全不同的查询：

- **多列点查**：取一小段连续的行，但要每行的多个字段。例如：

  ```sql
  SELECT author, price FROM books LIMIT 314 OFFSET 100;
  ```

  取第 101～414 行（共 314 行），每行投影 `author` 和 `price` 两列。

- **单列聚合**：扫过某一列的全部取值做聚合。例如：

  ```sql
  SELECT MAX(price) FROM books;
  ```

  只碰 `price` 一列，但要扫全表。

### 2.4 记号与公式

设表有 n 行、c 列，每列定长 w 字节，块大小 B 字节。定长是为了让模型可手算；真实系统中字段变长（如字符串），原理不变，只是换算复杂一些。

一行宽：`R = c × w`（字节）。

整表字节量：

- 行式：`n × R`
- 列式：同样是 `n × R`，但被切成 c 段，第 j 列一段，每段 `n × w` 字节。

某个"连续区间"占用的块数用上取整：

```text
blocks(bytes) = ceil(bytes / B)
```

其中 `ceil(x)` 表示向上取整。下面所有手算都用这两个式子。

## 三、实验原理

### 3.1 为什么布局会决定快慢

查询要读的数据在布局里是否连续，决定了它落在多少个块上。

**多列点查 `LIMIT 314 OFFSET 100`**：314 行是连续的行区间。行式布局下，这 314 行的字节完全连续，落在一个很小的区间里；列式布局下，`author` 和 `price` 分居两段，是两个独立的连续区间，所以要碰两个区域，且每个区域内部取的是"跳着读"（因为每列里只有属于这 314 行的那些值是连续的）。

**单列聚合 `MAX(price)`**：只关心 `price` 一列。列式布局下整列连续，可以顺序扫完；行式布局下 `price` 每 R 字节才出现一次 w 字节，等于被迫把整张表读进来，其中 `2/3` 的字节（title、author）是陪读。

行存/列存的这组经典定义可追溯到 Ailamaki 等人 2001 年的论文《Weaving Relations for Cache Performance》（VLDB 2001），论文中把行式称为 NSM、列式称为 DSM，并提出了折中的 PAX 布局：在一个块内部再按列分组。本实验只对比 NSM 与 DSM 两种极端。

### 3.2 确定性 I/O 模型：手算两种布局的成本

模型参数（与本仓库配套的实测口径一致）：

- n = 200000 行
- c = 3 列（title、author、price），每列定长 w = 8 字节
- 块大小 B = 64 KiB = 65536 字节

一行宽 `R = 3 × 8 = 24` 字节。整表 `n × R = 4800000` 字节 = 4.8 MB。

**查询 1：多列点查（314 行、投影 2 列）**

行式：第 100～413 行（0 起数，OFFSET 100、LIMIT 314）落在字节区间 `[100×24, 414×24)` = `[2400, 9936)`，长 7536 字节（约 7.5 KB）。这个区间没有跨过 65536 字节的块边界，所以：

```text
行式：I/O 请求 1 次，读取约 7.5 KB
```

列式：`author` 列（第 2 列，起点 `1 × n×8 = 1600000`）中这 314 个值位于字节区间 `[1600800, 1603312)`，共 2512 字节；`price` 列（第 3 列，起点 `2 × n×8 = 3200000`）位于 `[3200800, 3203312)`，共 2512 字节。两段各在 1 个块里，合计：

```text
列式：I/O 请求 2 次，需要的字节 = 2 × 2512 = 5024 字节——注意每列段各占 1 块，盘上实际读的是 2 个整块
```

这里要区分"需要的字节"（2 × 2512 = 5024 字节 ≈ 5.0 KB）与"实际读取量"。按本实验配套工具的口径：列式需要 2 次 I/O 请求、约 5.0 KB——即只计"需要的字节"：314 行 × 2 列 × 8 字节 = 5024 字节 ≈ 5.0 KB，I/O 请求 2 次（每列 1 次）。行式同理计 7536 字节 ≈ 7.5 KB、1 次请求。本章的观测一律采用"需要的字节数 + 触达的块数"这一口径，块内多读了多少算布局的浪费，不重复计入。

结论：点查场景行式更省——1 次 I/O 对 2 次 I/O，且行式字节量 7.5 KB > 列式 5.0 KB，但 I/O 次数占优的一方在盘上仍然赢，因为每次请求的"起步价"（约 150 μs 起）远大于几千字节的传输时间。

**查询 2：单列聚合 `MAX(price)`**

行式：`price` 每行都掺在 24 字节的行里。要拿到全部 200000 个 price 值，就得扫过整张表：

```text
行式：blocks(4800000) = ceil(4800000 / 65536) = ceil(73.24) = 74 块，即 74 次 I/O 请求，4.8 MB
```

列式：只扫 price 列那一段：

```text
列式：blocks(200000 × 8) = ceil(1600000 / 65536) = ceil(24.41) = 25 块，即 25 次 I/O 请求，1.6 MB
```

74 对 25：聚合场景列式的 I/O 请求次数约为行式的 1/3，字节量也是 1/3。两种优势叠加，列式在这里是压倒性的。

一般化的判断标准：一个查询触达的列数为 k、全表列数为 c，则

```text
列式读取字节量 ≈ 行式读取字节量 × k / c
```

k/c 越小，列式越划算；k = c（整行取）时两者打平甚至行式更好（行式少一次定位开销）。

### 3.3 两种布局在内存里的表现

数据全部驻留内存时，磁盘块换成了缓存行，结论方向不变：单列聚合在列式布局下是连续访问，CPU 预取器（prefetcher）能稳定命中缓存行，还能向量化（SIMD，single instruction multiple data，单指令多数据流）；行式布局下同一列的值每隔 R 字节才出现一次，一条 64 字节缓存行里只有 w 字节有用。配套图件给出了布局示意与基准测试结果：

![行存与列存的布局示意](../figures/fig_rowcol_layout.png)

![行存与列存的基准测试](../figures/fig_rowcol_bench.png)

本机参考实测（CPython 3.13 + Windows，NumPy 进程内模型，7 次取中位数）：

| 查询 | 行式 | 列式 |
| --- | --- | --- |
| 多列点查（314 行投影 2 列） | 0.0103 ms | 0.0098 ms |
| 单列聚合（20 万行单列） | 0.554 ms | 0.2325 ms |

点查两者几乎打平（314 行太小，内存里微秒级就完成了）；聚合差距约 2.4 倍，方向与 I/O 模型一致。你跑出来的绝对数字会不同，比的是相对关系。

## 四、实验环境与准备

1. 进入仓库根目录 `explaindb-main`（其下应有 `system/` 与 `notebooks/`）。
2. 安装依赖：

   ```bash
   pip install faker pandas numpy matplotlib jupyter
   ```

3. 打开配套 notebook：

   ```bash
   jupyter notebook notebooks/Data-Layout.ipynb
   ```

4. 需要 Python 3.10 及以上（notebook 使用了 `list[Book]` 这类新式类型标注）。
5. 新建一个 notebook 或 `.py` 文件，用于运行第五章的补充代码。第五章代码不依赖 notebook，但各步骤之间按顺序执行：步骤 2、3 会用到步骤 1 定义的 `n` 与写出的 CSV 文件。

## 五、实验步骤

### 步骤 1：造数据并写出两种布局（对应 notebook 前半部分）

```python
import pandas as pd
from faker import Faker

Faker.seed(42)
fake = Faker()

n = 10000  # notebook 中的元组数
rows = [
    {
        "title": fake.sentence(),
        "author": fake.name(),
        "price": fake.pyfloat(min_value=0, max_value=100, right_digits=2),
    }
    for _ in range(n)
]
df = pd.DataFrame(rows, columns=["title", "author", "price"])

# 行式布局：每行一条记录，行内字段连续
# 注意：部分 pandas 版本不接受换行符作分隔符，两种写法产出的文件内容完全相同
try:
    df.to_csv("row.csv", sep="\n", header=False, index=False, mode="w")
except ValueError:
    with open("row.csv", mode="w", encoding="utf-8", newline="") as _f:
        for _row in df.itertuples(index=False):
            for _value in _row:
                _f.write(f"{_value}\n")

# 列式布局：先清空文件，再逐列追加
for i, col in enumerate(df.columns):
    df.to_csv(
        "column.csv",
        columns=[col],
        header=False,
        index=False,
        mode="w" if i == 0 else "a",  # 第一列截断写，其后追加
    )

print(open("row.csv").read(120))
print(open("column.csv").read(120))
```

打开两个文件的前几行对照：`row.csv` 里每行是"标题、作者、价格"挤在一起；`column.csv` 里先是一万条标题，然后一万条作者，最后一万条价格。这就是 2.1 节示意图的文字版。

### 步骤 2：在两种布局上执行多列点查并计时（对应 notebook 中段）

```python
from time import time_ns
from pandas import read_csv

schema = ["title", "author", "price"]
attr_idxs = [1, 2]   # 投影 author、price
offset, limit = 100, 314

t0 = time_ns()
data_row = read_csv(
    "row.csv", header=None,
    skiprows=lambda ln: ln < offset * len(schema)      # OFFSET 之前的行
                      or ln % len(schema) not in attr_idxs,  # 不要的列
    nrows=limit * len(attr_idxs),
)
for i in range(0, len(attr_idxs) * limit, len(attr_idxs)):
    _ = [data_row.iloc[i + j][0] for j in range(len(attr_idxs))]  # 行内连续拼回元组
t1 = time_ns()
print("行式点查:", round((t1 - t0) / 1e6, 1), "ms")

t0 = time_ns()
data_col = read_csv(
    "column.csv", header=None,
    skiprows=lambda ln: ln % n < offset                 # 列内 OFFSET 之前
                      or ln % n >= offset + limit        # 列内 LIMIT 之后
                      or ln // n not in attr_idxs,       # 不要的列
    nrows=limit * len(attr_idxs),
)
for i in range(limit):
    _ = [data_col.iloc[j * limit + i][0] for j in range(len(attr_idxs))]  # 跨列随机拼元组
t1 = time_ns()
print("列式点查:", round((t1 - t0) / 1e6, 1), "ms")
```

注意两个 `skiprows` 的写法：行式的判断是"行号对 3 取余"；列式的判断是"行号整除 10000 得列号、对 10000 取余得列内行号"。这正是两种布局在文件里的坐标换算。

### 步骤 3：在两种布局上执行单列聚合并计时（对应 notebook 后半部分）

```python
attr_idx = 2  # price

t0 = time_ns()
data_row = read_csv("row.csv", header=None,
                    skiprows=lambda ln: ln % len(schema) != attr_idx,
                    nrows=n)
agg_row = data_row.max()[0]
t1 = time_ns()
print("MAX =", agg_row, "| 行式聚合:", round((t1 - t0) / 1e6, 1), "ms")

t0 = time_ns()
data_col = read_csv("column.csv", header=None,
                    skiprows=attr_idx * n,   # 直接跳到 price 列的起点
                    nrows=n)
agg_col = data_col.max()[0]
t1 = time_ns()
print("MAX =", agg_col, "| 列式聚合:", round((t1 - t0) / 1e6, 1), "ms")
```

两种写法读到的都是同一列的同一万个值，`MAX` 结果必然相同——变的只有取数路径。

### 步骤 4：运行确定性 I/O 模型，验证手算结果

```python
import math

n_rows, n_cols, width, block = 200_000, 3, 8, 64 * 1024  # 行数、列数、字段 8 字节、块 64 KiB
row_w = n_cols * width

def blocks(b):          # 一个连续区间占多少块
    return math.ceil(b / block)

# 查询 1：LIMIT 314 OFFSET 100，投影 2 列
q_bytes_row = 314 * row_w                       # 行式需要的连续字节
q_bytes_col = 314 * 2 * width                   # 列式两段各 314 个值
io_row = blocks(100 * row_w + q_bytes_row) - blocks(100 * row_w) + 1  # 区间触达的块数
print("点查 行式: ", io_row, "次 I/O,", q_bytes_row / 1000, "KB")
print("点查 列式: ", 2, "次 I/O,", q_bytes_col / 1000, "KB")

# 查询 2：全表聚合单列
print("聚合 行式: ", blocks(n_rows * row_w), "次 I/O,", n_rows * row_w / 1e6, "MB")
print("聚合 列式: ", blocks(n_rows * width), "次 I/O,", n_rows * width / 1e6, "MB")
```

预期输出：

```text
点查 行式:  1 次 I/O, 7.536 KB
点查 列式:  2 次 I/O, 5.024 KB
聚合 行式:  74 次 I/O, 4.8 MB
聚合 列式:  25 次 I/O, 1.6 MB
```

这与 3.2 节的手算一致，也与配套实测（行式点查 1 次 I/O、7.5 KB；列式点查 2 次、5.0 KB；行式聚合 74 次、4.8 MB；列式聚合 25 次、1.6 MB）一致。

### 步骤 5：进程内 NumPy 微基准（可选，对应 3.3 节）

```python
import numpy as np
from time import perf_counter

rng = np.random.default_rng(0)
arr = rng.random((200_000, 3))   # 20 万行 3 列，C 顺序 = 行式连续

def bench(fn, repeat=7):
    ts = []
    for _ in range(repeat):
        t0 = perf_counter()
        fn()
        ts.append((perf_counter() - t0) * 1e3)
    return sorted(ts)[len(ts) // 2]   # 中位数

# 多列点查：314 行投影 2 列
print("点查 行式风格:", bench(lambda: arr[100:414, [1, 2]]), "ms")
idx = np.arange(100, 414)
print("点查 列式风格:", bench(lambda: np.stack([arr[idx, 1], arr[idx, 2]], axis=1)), "ms")

# 单列聚合
col = np.ascontiguousarray(arr[:, 2])      # 物化列存：一次性代价，不计入查询
print("聚合 行式风格:", bench(lambda: arr[:, 2].max()), "ms")            # 跨行距访问
print("聚合 列式风格:", bench(lambda: col.max()), "ms")                  # 连续
print("（一次性）物化拷贝:", bench(lambda: np.ascontiguousarray(arr[:, 2])), "ms")
```

`col` 是先把该列一次性拷成连续内存，再对拷贝做聚合——**拷贝本身有成本，所以把它放在计时区外**，否则测的是"拷贝 + 聚合"而不是"列存的聚合"，结论会反过来。`arr[:, 2].max()` 直接在行式二维数组上跨行距取值，模拟行存。把中位数记入观测表，与 3.3 节的参考实测对比方向是否一致。

### 步骤 6：清理

```python
import os
os.remove("row.csv")
os.remove("column.csv")
```

## 六、观测记录表

把每次实验的数字填进下表。CSV 实验每次计时前建议重跑一遍（文件系统缓存会影响结果，多跑几次取中位数更稳）。

**表 1：CSV 布局实验（1 万行）**

| 查询 | 布局 | 耗时 (ms) | 与另一布局之比 |
| --- | --- | --- | --- |
| 多列点查 | 行式 |  |  |
| 多列点查 | 列式 |  |  |
| 单列聚合 | 行式 |  |  |
| 单列聚合 | 列式 |  |  |

**表 2：确定性 I/O 模型（20 万行、8 字节定长、64 KiB 块）**

| 查询 | 布局 | I/O 请求次数 | 读取字节量 |
| --- | --- | --- | --- |
| 点查 314 行投影 2 列 | 行式 |  |  |
| 点查 314 行投影 2 列 | 列式 |  |  |
| 全表聚合单列 | 行式 |  |  |
| 全表聚合单列 | 列式 |  |  |

**表 3：NumPy 进程内微基准（7 次中位数，20 万行）**

| 操作 | 行式风格 (ms) | 列式风格 (ms) | 列式/行式 |
| --- | --- | --- | --- |
| 多列点查 |  |  |  |
| 单列聚合 |  |  |  |

## 七、思考题

1. 行式布局和列式布局下，同一张表占用的总字节数是否相同？为什么？
2. `SELECT * FROM books WHERE id = 5000`（按主键取一整行）在哪种布局上更快？用 2.4 节的公式估算 3 列表上的 I/O 次数。
3. 3.2 节的模型里，点查在列式布局上需要的字节更少（5.0 KB 对 7.5 KB），但通常还是行式赢。解释原因。
4. 如果块大小从 64 KiB 改成 4 KiB，查询 2（全表聚合）在两种布局下的 I/O 次数各变成多少？比例关系变化了吗？
5. 把 3 列改成 30 列，仍然只聚合 1 列，列式相对行式的优势是变大还是变小？用 k/c 比值说明。
6. 查询 1 里若 `LIMIT 314` 改成 `LIMIT 100000`，列式点查的 I/O 请求次数还是 2 吗？为什么？
7. NumPy 微基准里，为什么"列式风格"的聚合要先做一次 `ascontiguousarray` 拷贝？拷贝本身有成本，这会不会让列式在数据已经驻留内存时反而吃亏？什么情况下仍然值得拷？
8. 一个分析型报表系统 95% 的查询只访问 5 列宽表中的 1～2 列；一个交易系统几乎每次都取整行。两种系统各应选什么布局？

## 八、思考题答案与解析

1. **相同。** 两种布局只是排列顺序不同，字节一个不少：都是 `n × c × w`。布局改变的是"哪些字节相邻"，不是"有多少字节"。
2. **行式更快。** 取一整行需要该行的全部 3 列。行式：该行 24 字节连续，落在 1 个块里，1 次 I/O。列式：3 列分散在 3 段，3 次 I/O（每列 1 个块）。k = c 时列式没有任何字节优势，只剩 I/O 次数劣势。
3. **因为瓶颈是 I/O 请求次数，不是字节数。** 每次请求的起步价按 150 μs（SSD 随机读）到 10 ms（机械盘寻道）计，而 2.5 KB 的差价按 SSD 顺序读约 500 MB/s 估算只需约 5 μs。1 次请求对 2 次请求，省下的起步价远超多读字节的传输费。
4. **行式：ceil(4800000 / 4096) = 1172 块；列式：ceil(1600000 / 4096) = 391 块。** 比值 1172/391 ≈ 3.0，与 74/25 ≈ 3.0 相同——块变小，两种布局按同样的倍数缩放，比例由数据本身（k/c = 1/3）决定，不由块大小决定。块变小只放大绝对次数。
5. **优势变大，且接近线性。** 列式读取量 ≈ 行式的 k/c = 1/30，I/O 次数比也趋近 1/30（块足够多时上取整的效应可以忽略）。列越多、查询越窄，列式越赚；这正是分析型宽表偏爱列存的算术原因。
6. **不一定。** LIMIT 100000 时，列式的每列区间长 100000 × 8 = 800000 字节，跨 ceil(800000/65536) = 13 个块，两列共 26 次 I/O；行式区间长 2400000 字节，跨 ceil((100×24 + 2400000)/65536) − ceil(2400/65536) + 1 = 37 次左右。列式仍然是 2 个"区域"但块数多了——请求次数随区间变长而增长，不再是 2。点查越短，列式"区域数少"的直觉才成立；区间一长，比的是块数。
7. **拷贝是把跨行距访问变成连续访问的代价。** 跨行距读取时每条 64 字节缓存行只有 8 字节有用，带宽利用率 1/8；连续访问能吃满带宽并启用 SIMD。单次聚合（本实验 20 万行）拷贝成本可能吃掉收益，所以参考实测里两者差距是约 2.4 倍而不是 8 倍；但如果同一列被反复聚合多次，拷一次、扫多次，均摊后必赚。数据库里的物化列存就是"永久拷一次"的版本。是否划算需自行实测确认（与列宽、行数、硬件有关）。
8. **报表系统选列式，交易系统选行式。** 报表：k/c 很小，聚合和窄投影为主，列式的字节与 I/O 双省。交易：整行读写，k = c，行式 1 次 I/O 定位；而且行存对单行插入也更友好（一次追加 24 字节即可，列存要写 3 个不同的区域）。

## 九、扩展任务（选做）

1. **变长字段的成本模型。** 把 8 字节定长改成：title 均匀分布在 20～60 字符、author 均匀分布在 8～20 字符、price 定长 8 字节。写代码估计两种布局下查询 2 的 I/O 次数区间（最坏/最好）。提示：行式要扫的字节量不再确定，给区间；列式只有 price 是定长，依然是 25 块。
2. **PAX 布局。** 实现 3.1 节提到的 PAX：把每 4096 字节当作一个"块页"，页内先按列分段。在 NumPy 里用 `arr.reshape(rows_per_page, -1)` 的思路模拟，重复步骤 5 的单列聚合基准，看 PAX 是否介于两者之间。提示：PAX 的目标是让"按页读、页内按列扫"两头都占，关键参数是每页行数。
3. **扫描多列的交叉点。** 固定 20 万行，令投影列数 k 从 1 取到 3，重复 NumPy 点查基准，画一张"k 越大，行式/列式差距如何变化"的折线图，验证 k = c 时两者收敛。

## 十、常见错误与排查

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `read_csv` 报 `ParserError: NULL byte detected` | CSV 里 `sep="\n"` 写出的多字段行被当作单列读回 | 读的时候必须同样传 `sep="\n"`，与写保持一致 |
| 点查计时结果为 0 ms 或异常小 | 忘了在循环里"拼回元组"，只计了读文件的时间 | 保留 notebook 中拼接元组的 for 循环，它模拟的是取数之后的物化成本 |
| 步骤 2 两种布局结果数量级远大于参考实测 | 第一次运行包含磁盘冷读 | 同一单元格重跑 3 次以上取中位数 |
| 列式聚合结果和行式不一致 | `skiprows=attr_idx * n` 的 n 与造数据时的行数不是同一个变量 | 检查两个单元格是否用了同一个 `n` |
| `faker` 生成速度极慢 | 一次生成 20 万条以上所致 | CSV 演示固定 1 万行；大表交给确定性模型和 NumPy |
| 模型算出的块数与参考值差 1 | 手算用了 `round` 而不是上取整，或块大小按 64000 而非 65536 字节 | 块一律用 64 KiB = 65536 字节，块数一律用 `ceil` |
| notebook 报 `ModuleNotFoundError: system` | 未在仓库根目录启动 Jupyter | 回到含 `system/` 的目录再运行 `jupyter notebook` |
