跳转至

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 f850dfdcargo 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 的"冷启"通路跳过了真实 mf CLI 的 click 启动和 Datus 配置解析, 所以真实的 mf query 调用会比这里测得的更慢一些。
  • 时间维度那个用例的 group-by 写法不同(MF 用 ds,osi 用 bookings_source.ds), 语义上是同一条查询,只是各用各引擎的原生语法。
  • 数字来自单机(共享的 EC2 机器),请把相对比值而非绝对耗时当作结论。

Arrow IPC vs JSON:结果序列化

对于同一条查询,列式出口通路(POST /v1/query/executeAccept: 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 MBparse 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 基数下也只是近似恒定。