实验 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` |