Skip to content

Commit ee12488

Browse files
authored
ci: benchmark dbt v2 partial parse and dirty compiles (#1189)
* ci(bench): benchmark dbt v2 partial parse and dirty compiles * style(bench): wrap a long docstring line * ci(bench): add strict dirty dbt v2 runs and lead with cached dbt v2 rows * ci(bench): create every model relation in the benchmark warehouse * ci(bench): prove dbt v2 strict analysis ran and declare source columns locally
1 parent 41029d9 commit ee12488

7 files changed

Lines changed: 438 additions & 78 deletions

File tree

‎.github/workflows/benchmark-dbt.yml‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Benchmark dbt comparison
22

3-
# Manual only. Times SQLBuild, dbt v2 and dbt Core on the same generated dense DuckDB project with
3+
# Manual only. Times SQLBuild and dbt v2 (dbt Core on request) on the same generated dense DuckDB project with
44
# released packages from PyPI, and publishes the tables as an artifact and in the job summary.
55
# See benchmarks/dbt-compare/README.md.
66
on:
@@ -17,12 +17,12 @@ on:
1717
default: "2.0.9"
1818
type: string
1919
dbt_core_version:
20-
description: "dbt-core version from PyPI"
20+
description: "dbt-core version from PyPI (installed only for core_* variants)"
2121
required: false
2222
default: "1.12.6"
2323
type: string
2424
dbt_duckdb_version:
25-
description: "dbt-duckdb version from PyPI"
25+
description: "dbt-duckdb version from PyPI (installed only for core_* variants)"
2626
required: false
2727
default: "1.11.0"
2828
type: string
@@ -39,7 +39,7 @@ on:
3939
variants:
4040
description: "Space-separated bench.sh variants"
4141
required: false
42-
default: "sqb_rules sqb_norules sqb_rules_full v2_parse v2_compile v2_compile_strict core_parse core_compile"
42+
default: "sqb_norules sqb_norules_full sqb_rules sqb_rules_full v2_compile_cached v2_compile_strict_cached v2_compile_dirty v2_compile_strict_dirty v2_compile v2_compile_strict v2_compile_strict_remote"
4343
type: string
4444

4545
permissions:
@@ -87,7 +87,6 @@ jobs:
8787
echo "RESULTS=$RUNNER_TEMP/dbt-compare-results"
8888
echo "SQB=$RUNNER_TEMP/dbt-compare/venv-sqb/bin/sqb"
8989
echo "DBT_V2=$RUNNER_TEMP/dbt-compare/venv-dbt2/bin/dbt"
90-
echo "DBT_CORE=$RUNNER_TEMP/dbt-compare/venv-dbt/bin/dbt"
9190
echo "PYTHON=$RUNNER_TEMP/dbt-compare/venv-sqb/bin/python"
9291
} >>"$GITHUB_ENV"
9392
- name: Install released packages
@@ -99,9 +98,13 @@ jobs:
9998
uv pip install -q -p "$WORK/venv-sqb/bin/python" "$sqlbuild_spec" pytest duckdb
10099
uv venv -q -p 3.12 "$WORK/venv-dbt2"
101100
uv pip install -q -p "$WORK/venv-dbt2/bin/python" "dbt==$DBT_VERSION"
102-
uv venv -q -p 3.12 "$WORK/venv-dbt"
103-
uv pip install -q -p "$WORK/venv-dbt/bin/python" \
104-
"dbt-core==$DBT_CORE_VERSION" "dbt-duckdb==$DBT_DUCKDB_VERSION"
101+
# dbt Core is a reference only; install it when a core_* variant is requested.
102+
if [[ " $VARIANTS " == *" core_"* ]]; then
103+
uv venv -q -p 3.12 "$WORK/venv-dbt"
104+
uv pip install -q -p "$WORK/venv-dbt/bin/python" \
105+
"dbt-core==$DBT_CORE_VERSION" "dbt-duckdb==$DBT_DUCKDB_VERSION"
106+
echo "DBT_CORE=$WORK/venv-dbt/bin/dbt" >>"$GITHUB_ENV"
107+
fi
105108
- name: Record versions and runner
106109
run: |
107110
{
@@ -113,8 +116,10 @@ jobs:
113116
echo "- Python: $("$WORK/venv-sqb/bin/python" -c 'import platform; print(platform.python_version())')"
114117
echo "- sqlbuild: $("$WORK/venv-sqb/bin/python" -c 'import importlib.metadata as m; print(m.version("sqlbuild"))')"
115118
echo "- dbt (v2): $("$WORK/venv-dbt2/bin/dbt" --version 2>&1 | head -1)"
116-
echo "- dbt-core: $("$WORK/venv-dbt/bin/python" -c 'import importlib.metadata as m; print(m.version("dbt-core"))')"
117-
echo "- dbt-duckdb: $("$WORK/venv-dbt/bin/python" -c 'import importlib.metadata as m; print(m.version("dbt-duckdb"))')"
119+
if [[ -n ${DBT_CORE:-} ]]; then
120+
echo "- dbt-core: $("$WORK/venv-dbt/bin/python" -c 'import importlib.metadata as m; print(m.version("dbt-core"))')"
121+
echo "- dbt-duckdb: $("$WORK/venv-dbt/bin/python" -c 'import importlib.metadata as m; print(m.version("dbt-duckdb"))')"
122+
fi
118123
echo "- Models: ${MODELS}, rounds: ${ROUNDS}, variants: ${VARIANTS}"
119124
echo
120125
} | tee "$RESULTS/environment.md"
@@ -126,10 +131,17 @@ jobs:
126131
run: |
127132
"$BENCH/correctness.sh" "$WORK/correctness" | tee "$RESULTS/correctness.md"
128133
cp -r "$WORK/correctness/logs" "$RESULTS/correctness-logs"
134+
# Strict analysis is only in the `dbt` distribution and can fall back silently; stop before
135+
# the long benchmark unless it demonstrably ran.
136+
- name: Prove dbt v2 strict analysis ran (correctness)
137+
run: python3 "$BENCH/check_strict.py" correctness "$WORK/correctness" | tee "$RESULTS/strict-check.md"
129138
- name: Benchmark
130139
run: |
131140
read -ra variants <<<"$VARIANTS"
132141
"$BENCH/bench.sh" "$WORK/project" "$RESULTS/runs" "$ROUNDS" "${variants[@]}"
142+
- name: Prove dbt v2 strict analysis ran (benchmark)
143+
if: ${{ contains(inputs.variants, 'strict') }}
144+
run: python3 "$BENCH/check_strict.py" runs "$RESULTS/runs" | tee -a "$RESULTS/strict-check.md"
133145
- name: Summarize
134146
if: ${{ !cancelled() }}
135147
run: |
@@ -139,6 +151,8 @@ jobs:
139151
echo
140152
cat "$RESULTS/correctness.md" 2>/dev/null || echo "Not run."
141153
echo
154+
cat "$RESULTS/strict-check.md" 2>/dev/null || echo "Strict check not run."
155+
echo
142156
echo "## Benchmark (${MODELS} models, medians of ${ROUNDS} rounds)"
143157
echo
144158
python3 "$BENCH/summarize.py" "$RESULTS/runs" 2>&1 || echo "No benchmark results."

‎benchmarks/dbt-compare/README.md‎

Lines changed: 115 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
# SQLBuild vs dbt: dense project compile benchmark
22

33
This harness generates one dense synthetic project in both SQLBuild and dbt form, then times the
4-
same edit loop in each tool. It compares SQLBuild with dbt v2 (the `dbt` package on PyPI), with
5-
dbt Core as a reference. Published results are on the
4+
same edit loop in each tool. The comparison is with dbt v2 (the `dbt` package on PyPI); dbt Core
5+
variants are available as a reference but don't run by default. Published results are on the
66
[Benchmarks](https://sqlbuild.com/docs/benchmarks) page.
77

88
## Files
99

1010
| File | Purpose |
1111
|---|---|
12-
| `write_dbt_dense.py` | Writes `sqlbuild/`, `sqlbuild-norules/`, `dbt/`, `warehouse.duckdb` and `profiles/` for N models |
12+
| `write_dbt_dense.py` | Writes `sqlbuild/`, `sqlbuild-norules/`, `dbt/`, `dbt-remote/`, `warehouse.duckdb` and `profiles/` for N models |
1313
| `edit_models.py` | Changes one constant in the SQL body of N evenly spaced models, in either project |
1414
| `bench.sh` | Times cold, warm, one-model edit and 100-model edit runs per variant, in alternating order |
1515
| `summarize.py` | Prints median wall time, CPU time and peak RSS per variant and scenario |
1616
| `correctness.sh` | Runs every tool on 50-model projects: clean, a missing `ref`, and a missing column |
17+
| `check_strict.py` | Fails unless dbt v2 strict static analysis demonstrably ran (see below) |
1718

1819
## The project
1920

@@ -38,12 +39,26 @@ average.
3839
`sqlbuild-norules/` is the same project with `[rules] select = []`. That turns off configurable Rules;
3940
column binding, type inference and contract checks still run.
4041

42+
In `dbt/`, the source declares `config: {schema_origin: local}`, so dbt v2 takes source columns
43+
from their declared `data_type`s, as SQLBuild takes them from its source declarations, without
44+
reading the warehouse. `dbt-remote/` is the same project without that setting: dbt's default, which
45+
downloads every source's schema from the warehouse. Only `v2_compile_strict_remote` uses it.
46+
47+
`warehouse.duckdb` looks like the warehouse after `dbt build`: every source table and every model
48+
relation exists, empty, with the declared columns, types and `not null` constraints. Each model is a
49+
table or a view to match its materialization (all dense models are tables). dbt reads it through
50+
`profiles/profiles.yml`; every dbt variant uses the same file. dbt v2 reads columns of unselected
51+
upstream models from it in `--static-analysis strict --dirty` runs. `sqb compile` doesn't open it:
52+
compile is offline, and its output is identical with the file present or removed.
53+
4154
## Running it on GitHub Actions
4255

4356
The `Benchmark dbt comparison` workflow (`.github/workflows/benchmark-dbt.yml`) runs only when
44-
started by hand. It installs the requested released versions from PyPI, runs `correctness.sh`, then
45-
`bench.sh`, and uploads the results directory with a `summary.md` (environment, correctness table,
46-
benchmark table). The same summary appears on the run page.
57+
started by hand. It installs the requested released versions from PyPI (dbt Core only when a
58+
`core_*` variant is requested), runs `correctness.sh` and `check_strict.py correctness`, then
59+
`bench.sh` and `check_strict.py runs`, and uploads the results directory with a `summary.md`
60+
(environment, correctness table, strict check, benchmark table). The same summary appears on the
61+
run page.
4762

4863
```bash
4964
gh workflow run benchmark-dbt.yml -f sqlbuild_version=0.164.2 -f rounds=3
@@ -53,11 +68,11 @@ gh workflow run benchmark-dbt.yml -f sqlbuild_version=0.164.2 -f rounds=3
5368
|---|---|
5469
| `sqlbuild_version` | empty: the latest release |
5570
| `dbt_version` | `2.0.9` |
56-
| `dbt_core_version` | `1.12.6` |
57-
| `dbt_duckdb_version` | `1.11.0` |
71+
| `dbt_core_version` | `1.12.6` (only for `core_*` variants) |
72+
| `dbt_duckdb_version` | `1.11.0` (only for `core_*` variants) |
5873
| `models` | `10000` |
5974
| `rounds` | `3` |
60-
| `variants` | all eight variants below |
75+
| `variants` | `sqb_norules sqb_norules_full sqb_rules sqb_rules_full v2_compile_cached v2_compile_strict_cached v2_compile_dirty v2_compile_strict_dirty v2_compile v2_compile_strict v2_compile_strict_remote` |
6176

6277
## Running it locally
6378

@@ -66,17 +81,22 @@ Use the released packages in fresh virtual environments. From the repository roo
6681
```bash
6782
uv venv -p 3.12 /path/to/venv-sqb && uv pip install -p /path/to/venv-sqb/bin/python sqlbuild pytest duckdb
6883
uv venv -p 3.12 /path/to/venv-dbt2 && uv pip install -p /path/to/venv-dbt2/bin/python dbt
69-
uv venv -p 3.12 /path/to/venv-dbt && uv pip install -p /path/to/venv-dbt/bin/python dbt-core dbt-duckdb
7084

7185
export SQB=/path/to/venv-sqb/bin/sqb DBT_V2=/path/to/venv-dbt2/bin/dbt \
72-
DBT_CORE=/path/to/venv-dbt/bin/dbt PYTHON=/path/to/venv-sqb/bin/python
86+
PYTHON=/path/to/venv-sqb/bin/python
7387

7488
"$PYTHON" benchmarks/dbt-compare/write_dbt_dense.py --models 10000 --out /path/to/p10k
7589
benchmarks/dbt-compare/correctness.sh /path/to/correctness
90+
python3 benchmarks/dbt-compare/check_strict.py correctness /path/to/correctness
7691
benchmarks/dbt-compare/bench.sh /path/to/p10k /path/to/results 3
92+
python3 benchmarks/dbt-compare/check_strict.py runs /path/to/results
7793
python3 benchmarks/dbt-compare/summarize.py /path/to/results
7894
```
7995

96+
For the dbt Core reference rows, install `dbt-core dbt-duckdb` in another environment, set
97+
`DBT_CORE` to its `dbt`, and pass `core_parse core_compile` to `bench.sh`. `correctness.sh` adds dbt
98+
Core rows when `DBT_CORE` is set.
99+
80100
The generator imports the CI helpers from the checkout, so it needs a Python with `sqlbuild`,
81101
`duckdb` and `pytest` installed, and works best with a sqlbuild release close to the checkout.
82102
Nothing is built from source. Run on an otherwise idle machine: other load changes wall times far
@@ -88,41 +108,106 @@ For every variant and round, `bench.sh` copies the pristine project into `work/V
88108
five seconds, then runs:
89109

90110
1. **cold**: the command with no `target/` and no caches.
91-
2. **warm**: the same command again with nothing changed.
111+
2. **warm**: the same command again with nothing changed. SQLBuild's default replays the stored
112+
result of the previous compile; the summary labels these rows "warm (replay)".
92113
3. **edit1**: one model's SQL body edited, then the command.
93114
4. **edit100**: 100 models' SQL bodies edited (1% of 10,000), then the command.
94115

95116
Each edit changes one constant in a projection, so the query changes but its columns, types and
96117
references don't. Rounds alternate the variant order. `SCENARIOS` limits which of warm, edit1 and
97118
edit100 run.
98119

99-
Commands, run from the project copy:
120+
Commands, run from the project copy. Every dbt command also gets
121+
`--profiles-dir ../profiles --no-version-check`.
100122

101123
| Variant | Command |
102124
|---|---|
103-
| `sqb_rules` | `sqb --no-color compile` in `sqlbuild/` |
104-
| `sqb_norules` | `sqb --no-color compile` in `sqlbuild-norules/` |
105-
| `sqb_rules_full` | `sqb_rules` with `SQLBUILD_DISABLE_COMPILE_REUSE=1 SQLBUILD_DISABLE_DELTA_COMPILE=1`: every run is a full compile that uses the per-model caches |
106-
| `v2_parse` / `v2_compile` | dbt v2 `parse` / `compile` with `--profiles-dir ../profiles --no-version-check` |
107-
| `v2_compile_strict` | dbt v2 `compile --static-analysis strict`, which checks columns like SQLBuild does |
108-
| `core_parse` / `core_compile` | the same `parse` / `compile` commands with dbt Core |
109-
110-
Both tools use their defaults otherwise: SQLBuild's compile cache, reuse and delta compile, and
111-
dbt's partial parsing. `DBT_SEND_ANONYMOUS_USAGE_STATS=false` is set. The DuckDB profile sets
112-
`threads: 4`.
125+
| `sqb_norules` | SQLBuild default (compile with column and type checks): `sqb --no-color compile` in `sqlbuild-norules/` |
126+
| `sqb_norules_full` | `sqb_norules` with `SQLBUILD_DISABLE_COMPILE_REUSE=1 SQLBUILD_DISABLE_DELTA_COMPILE=1`: every run is a full compile with per-model caches |
127+
| `sqb_rules` | SQLBuild with every built-in rule and a custom Python rule: `sqb --no-color compile` in `sqlbuild/` |
128+
| `sqb_rules_full` | `sqb_rules` as a full compile with per-model caches, as for `sqb_norules_full` |
129+
| `v2_compile_cached` | dbt v2 with parse cache (opt-in; compiles the whole project): `compile --partial-parse --partial-load` |
130+
| `v2_compile_strict_cached` | the same with `--static-analysis strict` |
131+
| `v2_compile_dirty` | dbt v2 `--dirty` (changed models and descendants only). Cold: `compile --partial-parse --partial-load`; then `compile --partial-load --dirty` |
132+
| `v2_compile_strict_dirty` | the same with `--static-analysis strict` on every run |
133+
| `v2_compile` | dbt v2 default: `compile` |
134+
| `v2_compile_strict` | dbt v2 default with `--static-analysis strict`, which binds columns and types; contract and nullability checks differ |
135+
| `v2_compile_strict_remote` | `v2_compile_strict` in `dbt-remote/`: source schemas downloaded from the warehouse on every run. Default mode, because the parse cache doesn't skip the download either, so the difference from `v2_compile_strict` is the introspection cost |
136+
| `v2_parse` | dbt v2 `parse` (not run by default) |
137+
| `core_parse` / `core_compile` | dbt Core `parse` / `compile` (not run by default) |
138+
139+
SQLBuild uses its defaults: the compile cache, replaying an unchanged compile, and compiling only
140+
changed files after an edit. In SQLBuild's default, configurable rules are off (`sqb_norules`). dbt Core uses its default partial parsing.
141+
`DBT_SEND_ANONYMOUS_USAGE_STATS=false` is set. The DuckDB profile sets `threads: 4`.
142+
143+
### dbt v2 modes
144+
145+
dbt v2 2.0.9 has three ways to run `compile`. The benchmark times each, with and without
146+
`--static-analysis strict`, in this order:
147+
148+
- **With parse cache** (`*_cached`): `--partial-parse` keeps a parse cache under
149+
`target/private/metadata/` and `--partial-load` loads it faster. The cache is opt-in, and
150+
`--partial-parse` is hidden from `dbt --help` in 2.0.9. The cold run builds the cache. Every later
151+
run still compiles the whole project.
152+
- **`--dirty`** (`*_dirty`): selects only models whose files changed, by modification time, since the
153+
last `--partial-parse` run, plus all their downstream dependents. It needs that cache, so the cold
154+
run is a `--partial-parse --partial-load` compile and is timed as cold. With nothing changed it
155+
reports `NoNodesSelected` ("Nothing to do"). Each run refreshes the cache, so each edit is compared
156+
with the run before it. Dense models form long `ref` chains, so one edited model can select dozens
157+
of descendants.
158+
- In both cached modes, when more than 100 files changed since the cache was written, dbt 2.0.9 logs
159+
`[partial-parse] ... falling back to full parse`. The summary marks those runs "full parse", and
160+
`check_strict.py` ignores `[partial-parse]` lines.
161+
- **Default** (`v2_compile`, `v2_compile_strict`): no cache between runs. Every run parses and
162+
compiles the whole project.
163+
164+
### Proving strict analysis ran
165+
166+
Strict static analysis is only in the `dbt` distribution, and a run without it can fall back to
167+
baseline analysis. `check_strict.py` fails the workflow unless:
168+
169+
- `correctness`: in the `column` project, strict exits non-zero with `UnresolvedIdentifier (dbt0227)`
170+
in default mode, with sources from the warehouse, and on a warm run after the error is introduced
171+
in the cached and `--dirty` modes. The same project exits 0 with plain `dbt compile`, and the
172+
`clean` project exits 0 under strict in every mode.
173+
- `runs`: every timed strict run exits 0.
174+
- Both: no strict log or output line matches any of these patterns (case-insensitive):
175+
`did not run`, `fall back` / `falling back` / `fallback`, `baseline`, `login` / `log in`,
176+
`authenticat`, `does not include the static analysis`, `requires the full dbt distribution`,
177+
`static_analysis' to off`, `dbt1014`, `Skipping analysis`.
113178

114179
`/usr/bin/time -f '%e %U %S %M'` records wall time, user and system CPU, and the peak RSS of the
115180
largest process. `summarize.py` also reports what each tool said it did: SQLBuild prints whether it
116-
reused the previous compile or compiled only the changed files, and dbt Core logs when it falls back
117-
to a full parse. The 1-minute load average before and after each run is recorded too.
181+
replayed the previous compile or compiled only the changed files, dbt Core logs when it falls back
182+
to a full parse, and dbt v2 prints how many models it processed. For dbt v2 the column also counts
183+
models it skipped static analysis for. The 1-minute load average before and after each run is
184+
recorded too.
118185

119186
## What isn't equivalent
120187

121-
- SQLBuild compile binds every column, infers types and checks contracts. dbt v2 does this with
122-
`--static-analysis strict`; its default compile doesn't report unknown columns. dbt Core compile
123-
renders Jinja and resolves references without analysing the SQL.
188+
- SQLBuild compile binds every column, infers types and checks contracts. dbt v2 binds columns and
189+
types with `--static-analysis strict`; its default compile doesn't report unknown columns. dbt
190+
Core compile renders Jinja and resolves references without analysing the SQL.
191+
- SQLBuild compile reports contract, type and nullability problems as errors. dbt v2 strict warns on
192+
contract type mismatches and checks enforced contracts at run time.
193+
- SQLBuild compile also plans and binds its 10,000 SQL tests. dbt `compile` excludes unit tests; they
194+
are rendered in `dbt test` or `dbt build`.
195+
- SQLBuild compile computes column lineage by default.
196+
- The measured dbt is the `dbt` distribution from PyPI, under dbt Labs' licence. Strict static
197+
analysis isn't in the Apache-2.0 build.
198+
- `dbt/` declares source columns locally, as SQLBuild does. `v2_compile_strict_remote` shows dbt's
199+
default of downloading source schemas from the warehouse.
200+
- `--dirty` compiles the changed nodes and all their descendants, and leaves the rest of `target/`
201+
from earlier runs; its run summary covers only the selected nodes. SQLBuild's delta compile
202+
re-analyses the changed models plus any model whose inputs' columns, types or nullability changed,
203+
re-runs project-wide Rules, and reports the same result as a full compile.
204+
- With `--static-analysis strict --dirty`, dbt v2 checks the selected models against the columns of
205+
unselected upstream relations in the warehouse, so it depends on the warehouse matching the
206+
project. When an upstream relation is missing, dbt warns `RemoteError (dbt1014)` and turns static
207+
analysis off for that model; the summary counts such models, and with this warehouse it should
208+
report none.
209+
- dbt v2's parse cache and `--dirty` are opt-in; SQLBuild's caching is on by default. SQLBuild's warm
210+
run replays the stored result.
124211
- SQLBuild compile also runs configurable Rules (`sqb_rules`). dbt has no built-in counterpart.
125-
- SQLBuild compile prepares its 10,000 SQL tests. dbt `compile` parses the unit tests but doesn't
126-
render them; that happens in `dbt test` or `dbt build`.
127212
- dbt renders every model through Jinja; SQLBuild expands references and macros in its own compiler.
128213
That cost is part of each tool and is included.

0 commit comments

Comments
 (0)