Dosi vs MetricFlow:性能基准¶
把 dosi CLI 与老的 MetricFlow 引擎做同口径对比。复现方式:
cargo build --release
python3 scripts/bench_mf_vs_osi.py \
--mf-model ~/src/metricflow/metricflow/test/fixtures/model_yamls/simple_model \
--mf-python .venv-mf/bin/python \
--seed scripts/diff_seed_simple_model.sql \
--execute --json bench_results.json
带时间戳的运行报告(含原始 JSON)归档在
tests/benchmarks/;
最新一次:
2026-07-11。
方法¶
两个引擎处理的是同一份语义模型(MetricFlow 的 simple_model fixtures,
由 scripts/mf_fixtures_to_osi.py 1:1 转成 OSI),跑的是同样的查询,
打在同一个灌好数据的 DuckDB 库上。查询矩阵就是 CI 差异测试套件的矩阵,
这些查询在两个引擎间的结果集相等性已由 scripts/diff_python.py 强制保证,
所以这个基准测的是等价工作量的速度。
| 测量项 | Dosi | MetricFlow |
|---|---|---|
| 冷启 CLI | 一个 dosi query 进程:启动 → 加载模型 → 编译 → 打印 SQL |
一个 python 子进程:解释器启动 + import + 模型解析 + MetricFlowClient.explain()。与 mf query --explain 做的是同样的事,但不含 click/config 开销(这是一个偏向 MetricFlow 的界) |
| 热态 API | 同样是完整进程调用(osi 没有常驻模式;纯进程启动的下限单独报告) | 一次预热调用之后的进程内 explain() 循环,解释器、import 和模型解析的代价都已付过 |
| 执行 | dosi query --execute --db <file>(冷启,完整进程) |
进程内 query() 循环(热态)以及冷启子进程 |
| 峰值 RSS | 冷启子进程的最大常驻集大小(wait4 的 rusage) |
同左 |
冷启轮次按 A/B 交错进行,丢弃 1 次预热后计时 5 次;热态循环 30 次迭代。报告中位数。
环境¶
- 2026-07-11,dosi-engine
f850dfd,cargo build --release - AWS EC2,4 vCPU Intel Xeon Platinum 8175M @ 2.50GHz,15 GiB 内存,Linux (al2023)
- MetricFlow:Datus fork,可编辑安装,CPython 3.12.12(uv venv)
- DuckDB CLI v1.4.4;种子数据:
scripts/diff_seed_simple_model.sql
结果¶
osi 的进程启动下限(dosi --version):中位数 2.0 ms。
SQL 编译 —— 冷启 CLI(进程启动 → 打印出 SQL)¶
| 查询 | dosi | MetricFlow | 加速比 |
|---|---|---|---|
| simple_agg_by_dim (bookings ⟨is_instant⟩) | 10.6 ms | 2,321.9 ms | 220x |
| three_metrics_no_dim | 10.1 ms | 2,403.5 ms | 237x |
| ratio_metric (booking_fees_per_booker) | 10.4 ms | 2,309.2 ms | 222x |
| expr_metric (views_times_booking_value) | 10.4 ms | 2,338.8 ms | 226x |
| simple_agg_by_time (bookings ⟨ds⟩) | 10.5 ms | 2,289.9 ms | 219x |
MetricFlow 的冷启动构成:在任何查询开始编译之前,约 1,004 ms 的 import + 约 742 ms 的模型解析 + 约 161 ms 的客户端初始化,每次 CLI 调用约 1.9 秒固定开销。 dosi 付出约 2 ms 的进程启动,并在剩下的约 8 ms 里完成模型解析。
SQL 编译 —— 热态 API(一切都已驻留)¶
| 查询 | dosi(完整进程) | MetricFlow(进程内) | 加速比 |
|---|---|---|---|
| simple_agg_by_dim | 10.6 ms | 107.6 ms | 10.2x |
| three_metrics_no_dim | 10.1 ms | 218.9 ms | 21.6x |
| ratio_metric | 10.4 ms | 105.3 ms | 10.1x |
| expr_metric | 10.4 ms | 205.0 ms | 19.8x |
| simple_agg_by_time | 10.5 ms | 111.4 ms | 10.6x |
这是对 MetricFlow 最宽容的一种比法:它的数字是纯进程内编译时间, import 和模型解析的代价都已付过,而 dosi 仍是在全新进程里 重新读取、重新编译了一切。即便如此,dosi 依然快 10–22 倍。
DuckDB 上的端到端执行(编译 + 运行 + 打印结果行)¶
| 查询 | dosi 冷启 | MF 冷启 | 加速比 | MF 热态(进程内) | 对比 dosi 冷启 |
|---|---|---|---|---|---|
| simple_agg_by_dim | 34.2 ms | 2,321.1 ms | 68x | 127.0 ms | 3.7x |
| three_metrics_no_dim | 36.2 ms | 2,428.1 ms | 67x | 233.1 ms | 6.4x |
| ratio_metric | 35.7 ms | 2,368.8 ms | 66x | 122.7 ms | 3.4x |
| expr_metric | 33.5 ms | 2,441.2 ms | 73x | 235.9 ms | 7.0x |
| simple_agg_by_time | 32.9 ms | 2,309.1 ms | 70x | 132.8 ms | 4.0x |
加上执行之后,DuckDB 的查询时间(经 CLI 外调约 20 ms,两个引擎工作量相同) 成了共同的下限;即便如此,osi 的端到端冷启运行仍比 MetricFlow 的热态进程内 通路快 3–7 倍。
峰值内存(冷启进程,最大 RSS)¶
| 模式 | dosi | MetricFlow |
|---|---|---|
| 编译 | 约 15.5 MB | 约 157 MB |
| 执行 | 约 26.5 MB | 约 162 MB |
注意事项¶
simple_model很小(约 25 个数据集/指标)。MetricFlow 那约 742 ms 的模型解析 会随模型规模增长;而 osi 整个 10 ms 的预算里已经包含了它自己的解析。- MetricFlow 的"冷启"通路跳过了真实
mfCLI 的 click 启动和 Datus 配置解析, 所以真实的mf query调用会比这里测得的更慢一些。 - 时间维度那个用例的 group-by 写法不同(MF 用
ds,osi 用bookings_source.ds), 语义上是同一条查询,只是各用各引擎的原生语法。 - 数字来自单机(共享的 EC2 机器),请把相对比值而非绝对耗时当作结论。
Arrow IPC vs JSON:结果序列化¶
对于同一条查询,列式出口通路(POST /v1/query/execute 带
Accept: application/vnd.apache.arrow.stream,见
rest-api.md)相比默认的 JSON body
能省下多少。复现方式:
cargo build --release -p dosi-server
python3 scripts/bench_arrow_vs_json.py # human table
python3 scripts/bench_arrow_vs_json.py --json out.json
这个脚本是自包含的:它会在临时目录里生成一个高基数的合成 DuckDB 数据集
(500 万行事实表,20 万个不同的 user_id)和一份配套的 OSI 模型,
针对它们启动 release 版的 dosi-server,并让每个结果集分别走两种 Accept 格式。
方法¶
一个运行中的服务、一条查询、两个 Accept 头,唯一变化的只有响应编码。
每种格式测三项:
| 测量项 | 它反映什么 |
|---|---|
| wire MB | HTTP 响应体的字节数(中位数) |
| e2e ms | 从发出请求到收完整个 body 的墙钟时间:查询执行 + 服务端序列化 + 传输(中位数) |
| parse ms | 客户端把原始 body 变成可用结构的开销:json.loads 对比 pyarrow.ipc.open_stream(...).read_all()(中位数) |
500 万行表上的查询执行在两条通路上是完全相同的工作量, 所以它在 e2e ms 里构成一个共同下限(可以用 SMALL 那一行隔离出来, 那里载荷极小、序列化可以忽略)。wire MB 和 parse ms 没有共同下限, 是纯粹的格式成本。三种结果规模说明优势随结果放大,而不是随扫描量放大。 每个单元格 2 次预热 + 7 次计时运行,报告中位数。
环境¶
- 2026-07-14,dosi-engine
2e23173(工作树版本),cargo build --release - AWS EC2,4 vCPU Intel Xeon Platinum 8259CL @ 2.50GHz,15 GiB 内存,Linux (al2023)
- DuckDB CLI v1.4.4;客户端 CPython 3.9,pyarrow 21.0.0
结果¶
| 结果集 | 格式 | 行数 | wire MB | e2e ms | parse ms |
|---|---|---|---|---|---|
| SMALL —— 365 行 × 3 列 | JSON | 365 | 0.03 | 1483.5 | 0.3 |
| Arrow | 365 | 0.01 | 1463.1 | 0.2 | |
| LARGE —— 20 万行 × 3 列 | JSON | 200,000 | 10.63 | 2051.8 | 198.4 |
| Arrow | 200,000 | 6.50 | 1684.0 | 1.4 | |
| WIDE —— 20 万行 × 4 列 | JSON | 200,000 | 13.99 | 2749.3 | 242.7 |
| Arrow | 200,000 | 8.90 | 1707.9 | 2.0 |
读取大结果集时(Arrow 相对 JSON):
| LARGE(20 万 × 3) | WIDE(20 万 × 4) | |
|---|---|---|
| wire(网络上的字节数) | 小 1.6 倍 | 小 1.6 倍 |
| parse(客户端解码) | 约快 140 倍(1.4 对 198 ms) | 约快 120 倍(2.0 对 243 ms) |
| e2e(含共同的执行下限) | 快 1.2 倍 | 快 1.6 倍 |
| e2e 减去约 1.46 秒的执行下限(只剩序列化 + 传输 + 解析) | 约快 2.7 倍(221 对 589 ms) | 约快 5.2 倍(245 对 1286 ms) |
解读¶
- 解析是最大看点。 JSON 迫使客户端把每个值从字符串里重新解析出来, Arrow 交过来的是带类型的列缓冲区,解码开销基本为零 (20 万行 1–2 ms,对比 200–240 ms)。这个差距在 release 构建下也不会缩小, 它在客户端一侧,是结构性的。
- 序列化是服务端的收益。 去掉执行下限后,Arrow 花在格式本身上的时间 少约 2.7–5.2 倍:没有逐行物化,也没有逐值的 JSON 字符串编码, record batch 从执行器经 IPC writer 直接流进 body。行越宽差距越大 (WIDE > LARGE)。
- 收益随结果规模放大。 365 行时看不出差别,相对查询执行来说载荷只是舍入误差。 恰恰是结果很大的时候,才值得主动选择列式通路。
- 传输体积稳定在约 1.6 倍。真实部署可以在其上再叠加 HTTP 压缩, 解析和序列化上的收益与它无关。
注意事项¶
- 单机、回环传输(没有真实的网络 RTT 或带宽限制),所以 wire MB 低估了远程客户端能看到的传输时间优势,请把相对比值当作结论。
- 合成数据集以数值和短字符串为主。行更宽、字符串列更多时 JSON 的劣势会放大, 只有少数几个宽数值列时则缩小。
- e2e ms 每次请求都带着对全部 500 万行的 DuckDB 扫描(没有结果缓存)。 单独列出的"减去执行下限"那一行是对格式成本更干净的读数, 不过这个下限在不同 group-by 基数下也只是近似恒定。