Skip to content

Commit a570947

Browse files
committed
perf: accelerate warm incremental compiles
1 parent 322e185 commit a570947

21 files changed

Lines changed: 1635 additions & 248 deletions

File tree

‎.github/workflows/verify.yml‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,53 @@ jobs:
281281
make test-e2e-cold-compile-performance \
282282
SQLBUILD_BENCHMARK_MODELS="${BENCHMARK_MODELS}"
283283
284+
cache_compile_performance:
285+
name: Fresh-process compile cache (${{ matrix.models }} models)
286+
needs: [changes, lockfile]
287+
if: needs.changes.outputs.performance == 'true'
288+
runs-on: ubuntu-24.04
289+
timeout-minutes: 15
290+
strategy:
291+
fail-fast: false
292+
matrix:
293+
models: [3000, 5000, 10000]
294+
steps:
295+
- uses: actions/checkout@v4
296+
- uses: astral-sh/setup-uv@v5
297+
with:
298+
version: "0.12.3"
299+
enable-cache: true
300+
cache-dependency-glob: |
301+
uv.lock
302+
Cargo.lock
303+
- uses: dtolnay/rust-toolchain@master
304+
with:
305+
toolchain: stable
306+
- name: Install Python
307+
run: uv python install 3.12
308+
- name: Install dependencies
309+
run: uv sync --locked --all-groups --all-extras
310+
- name: Run fresh-process compile cache guard under 4 GiB
311+
env:
312+
BENCHMARK_MODELS: ${{ matrix.models }}
313+
MEMORY_LIMIT_BYTES: "4294967296"
314+
run: |
315+
sudo systemd-run \
316+
--unit="sqlbuild-cache-${BENCHMARK_MODELS}-${GITHUB_RUN_ID}" \
317+
--uid="$(id -u)" \
318+
--gid="$(id -g)" \
319+
--same-dir \
320+
--setenv="HOME=${HOME}" \
321+
--setenv="PATH=${PATH}" \
322+
--setenv="SQLBUILD_REQUIRE_MEMORY_LIMIT_BYTES=${MEMORY_LIMIT_BYTES}" \
323+
--property="MemoryMax=${MEMORY_LIMIT_BYTES}" \
324+
--property="MemorySwapMax=0" \
325+
--wait \
326+
--collect \
327+
--pipe \
328+
make test-e2e-cache-compile-performance \
329+
SQLBUILD_BENCHMARK_MODELS="${BENCHMARK_MODELS}"
330+
284331
rules_performance:
285332
name: Rules performance guards
286333
needs: [changes, lockfile]
@@ -373,6 +420,7 @@ jobs:
373420
e2e_shards,
374421
performance,
375422
cold_compile_performance,
423+
cache_compile_performance,
376424
rules_performance,
377425
postgres,
378426
]
@@ -386,6 +434,7 @@ jobs:
386434
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
387435
PERFORMANCE_RESULT: ${{ needs.performance.result }}
388436
COLD_COMPILE_PERFORMANCE_RESULT: ${{ needs.cold_compile_performance.result }}
437+
CACHE_COMPILE_PERFORMANCE_RESULT: ${{ needs.cache_compile_performance.result }}
389438
RULES_PERFORMANCE_RESULT: ${{ needs.rules_performance.result }}
390439
POSTGRES_RESULT: ${{ needs.postgres.result }}
391440
run: |
@@ -399,6 +448,7 @@ jobs:
399448
"${E2E_SHARDS_RESULT}" \
400449
"${PERFORMANCE_RESULT}" \
401450
"${COLD_COMPILE_PERFORMANCE_RESULT}" \
451+
"${CACHE_COMPILE_PERFORMANCE_RESULT}" \
402452
"${RULES_PERFORMANCE_RESULT}" \
403453
"${POSTGRES_RESULT}"; do
404454
if [[ "${result}" != "success" && "${result}" != "skipped" ]]; then

‎Makefile‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SHELL := /bin/bash
55
test-e2e-duckdb-build-incremental test-e2e-duckdb-build-virtual \
66
test-e2e-duckdb-cli-data test-e2e-duckdb-cli test-e2e-duckdb-virtual \
77
test-e2e-duckdb-integrations test-e2e-performance \
8-
test-e2e-cold-compile-performance
8+
test-e2e-cold-compile-performance test-e2e-cache-compile-performance
99

1010
format:
1111
uv run ruff format .
@@ -224,7 +224,7 @@ test-e2e-performance:
224224
@log="/tmp/opencode/test-e2e-performance-$$(date +%Y%m%d-%H%M%S).log"; \
225225
echo "Logging to $$log"; \
226226
env PYTHONUNBUFFERED=1 SQLBUILD_CONCURRENCY=$(SQLBUILD_CONCURRENCY) uv run pytest tests/e2e \
227-
-m "performance and not cold_compile_performance and not real_warehouse and not dbt" \
227+
-m "performance and not cold_compile_performance and not cache_compile_performance and not real_warehouse and not dbt" \
228228
-vv --log-cli-level=INFO --color=yes 2>&1 | tee "$$log"; \
229229
status=$${PIPESTATUS[0]}; \
230230
echo "TEST_E2E_PERFORMANCE_EXIT=$$status (log: $$log)" | tee -a "$$log"; \
@@ -238,6 +238,13 @@ test-e2e-cold-compile-performance:
238238
-m cold_compile_performance -k "models_$(SQLBUILD_BENCHMARK_MODELS)" \
239239
-vv --log-cli-level=INFO --color=yes
240240

241+
test-e2e-cache-compile-performance:
242+
test -n "$(SQLBUILD_BENCHMARK_MODELS)"
243+
env PYTHONUNBUFFERED=1 SQLBUILD_CONCURRENCY=4 uv run pytest \
244+
tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_cache_performance.py \
245+
-m cache_compile_performance -k "models_$(SQLBUILD_BENCHMARK_MODELS)" \
246+
-vv --log-cli-level=INFO --color=yes
247+
241248

242249
test-virtual:
243250
@mkdir -p /tmp/opencode

‎docs/compiler-cache.md‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Compiler cache architecture
2+
3+
The compiler cache exists to avoid proving unchanged facts again. It must never turn an uncertain
4+
input into a cache hit, and a missing, stale, corrupt, oversized, or contended cache must behave like
5+
an ordinary uncached compile.
6+
7+
This document defines the target architecture. The current vertical slice implements versioned
8+
model-analysis entries, dependency-aware compact analysis generations, shared SQL-test closure
9+
indexes, bounded compact-generation retention, corruption fallback, and analysis hit/miss telemetry.
10+
Project inventory, discovery/attachment reuse, and command snapshots remain later layers; their
11+
sections below are design constraints rather than claims of current implementation.
12+
13+
## Goals
14+
15+
- Make unchanged fresh-process compiles proportional to project inventory and requested output, not
16+
total SQL complexity.
17+
- Make a small edit proportional to the changed resource and its semantic dependents.
18+
- Reuse downstream analysis when an upstream implementation changes without changing its exported
19+
column interface.
20+
- Preserve diagnostics, bindings, types and nullability, lineage, tests, audits, hooks, macros,
21+
ordering, artifacts, and exit codes exactly.
22+
- Keep cache data project-local, bounded, inspectable, non-executable, and safe under concurrent
23+
compiler processes.
24+
25+
## Non-goals
26+
27+
- The cache is not a second source of project truth.
28+
- The cache does not make arbitrary Python execution deterministic. A stage with dynamic inputs that
29+
are not represented in its identity must bypass persistent reuse.
30+
- The cache does not store pickles, code objects, callables, imported modules, adapter instances, or
31+
open connections.
32+
- The cache does not replace warehouse execution state or change build selection semantics.
33+
34+
## Layers
35+
36+
### 1. Project inventory
37+
38+
The inventory records every compiler-owned authored input as a normalized project-relative path,
39+
resource role, content digest, size, and filesystem identity metadata. Configuration, ignore files,
40+
adapter identity, compiler and SQL-analysis engine versions, selected target, CLI variables, and
41+
compile modes are explicit identity inputs.
42+
43+
Filesystem metadata may identify an unchanged candidate, but reusable semantic facts remain
44+
content-addressed. Ambiguous or concurrently changing observations are misses. Added, removed,
45+
renamed, and role-changing paths are first-class changes.
46+
47+
### 2. Static discovery facts
48+
49+
Parsing results for SQL, YAML, CSV metadata, and statically inspected Python declarations are keyed
50+
by content digest, parser version, resource role, and parse options. These records contain only
51+
validated JSON-compatible values. Runtime Python discovery still imports current project code and
52+
is never reconstructed from executable cache data.
53+
54+
### 3. Attached resource facts
55+
56+
Attachment resolves configuration, declarations, macros, hooks, schemas, tests, and references.
57+
Each record carries the exact identities it observed:
58+
59+
- authored resource content;
60+
- effective target, variables, and relevant configuration;
61+
- visible enum and constant declarations;
62+
- macro implementation and dependency closure;
63+
- referenced schema or hook declarations;
64+
- adapter rendering contract.
65+
66+
If any observed identity is absent or unstable, that resource is attached normally. A global
67+
configuration change is broad only when the affected value was observed broadly.
68+
69+
### 4. Semantic analysis facts
70+
71+
Model SQL analysis remains keyed by expanded SQL, analysis profile, referenced relation interfaces,
72+
binding schema, and engine versions. The persistent representation should retain compact interned
73+
facts so warm reads do not reconstruct duplicate lineage objects entry by entry.
74+
75+
Each model publishes a deterministic interface signature containing the output columns, types,
76+
nullability, wildcard status, and any other fact consumed by downstream analysis. A changed model
77+
invalidates downstream semantic analysis only when this exported interface changes. Its own compiled
78+
artifact still changes whenever its implementation changes.
79+
80+
### 5. Shared derived indexes
81+
82+
Project-wide indexes are built once per compile and shared by all consumers. They include model
83+
dependencies, test model closures, declaration visibility, selector indexes, and artifact identities.
84+
No per-test, per-rule, or per-artifact path may rebuild an unchanged whole-project index.
85+
86+
### 6. Command snapshots
87+
88+
A command snapshot records validated, non-executable output facts after all lower layers complete.
89+
It may provide a no-op fast path only when every input observed by that command is represented in
90+
the snapshot identity. Commands involving untracked runtime values bypass this layer while still
91+
using safe lower-layer facts.
92+
93+
## Invalidation
94+
95+
Invalidation follows recorded dependency edges rather than directory-wide guesses:
96+
97+
1. Compare the current inventory with the last committed inventory.
98+
2. Invalidate directly changed static facts.
99+
3. Follow declaration, macro, configuration, schema, hook, and test dependency edges.
100+
4. Reattach directly affected resources.
101+
5. Reanalyze changed models and compare exported interface signatures.
102+
6. Reanalyze downstream models only when a consumed interface changed.
103+
7. Rebuild tests, audits, rules, and artifacts only when their recorded inputs changed.
104+
105+
Removing an edge is itself an invalidating change. Cache readers must not infer validity from the
106+
absence of a newly introduced dependency record.
107+
108+
## Storage and publication
109+
110+
- Records are schema-versioned and include algorithm and package fingerprints.
111+
- Payloads have explicit size and collection-count limits before semantic acceptance.
112+
- Digests cover keys and canonical serialized payloads.
113+
- Writers publish in one transaction or by atomic replacement; readers observe one committed
114+
generation.
115+
- Concurrent lock or I/O failure is a cache miss, never a compile failure.
116+
- Cache growth is bounded by generation and byte limits. Pruning removes old immutable records and
117+
does not mutate current semantic state.
118+
- `--no-cache`, target configuration, and the compile-cache environment override bypass every
119+
persistent layer and do not publish new records.
120+
121+
## Observability and guards
122+
123+
The target machine-readable contract reports per-layer hits, misses, invalidations, bytes read and
124+
written, and phase timings. The current vertical slice reports model-analysis batch hits, entry
125+
hits, misses, bypasses, and phase timings. Counts describe semantic records, not low-level database
126+
operations.
127+
128+
Fresh-process performance guards use representative semantic projects and cover:
129+
130+
- cold compile with a clean target;
131+
- unchanged warm compile;
132+
- leaf-model implementation edit;
133+
- upstream implementation edit with an unchanged interface;
134+
- interface-changing upstream edit;
135+
- test-only and macro edits;
136+
- broad project-configuration edit;
137+
- cache corruption and `--no-cache` fallback.
138+
139+
Every path verifies exact resource counts, zero unexpected diagnostics, deterministic semantic
140+
fingerprints, bounded peak RSS, and expected hit and invalidation counts. Wall-time ceilings are
141+
calibrated only after the behavior and identity assertions are stable.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ markers = [
116116
"motherduck: tests that require live MotherDuck credentials",
117117
"performance: opt-in performance guard tests",
118118
"cold_compile_performance: fresh-process semantic compile scaling guards",
119+
"cache_compile_performance: fresh-process warm and incremental compile cache guards",
119120
]
120121

121122
[tool.ruff]

‎src/sqlbuild/cli/commands/_helpers/compile/target_writer.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@
2727
compiled_sql_test_output_path,
2828
)
2929
from sqlbuild.cli.paths.main._sql_test_output_path import sql_test_output_path
30-
from sqlbuild.compiler.compile.models import CompiledModel, CompiledProject, CompiledSqlTest
30+
from sqlbuild.compiler.compile.models import CompiledObjectKey, CompiledProject, CompiledSqlTest
3131
from sqlbuild.compiler.compile.types import FunctionLanguage
3232
from sqlbuild.compiler.planner.main.execution.sql_test_artifacts import (
3333
plan_and_render_sql_test_artifacts,
3434
)
3535
from sqlbuild.compiler.planner.main.execution.sql_test_model_chain import (
36-
sql_test_model_chain_names,
36+
sql_test_model_chain_names_by_key,
3737
)
3838
from sqlbuild.compiler.planner.models import AuditPlanEntry, NativeSqlTestArtifact, PlanOutput
3939
from sqlbuild.compiler.profiling.main.record import record_compile_timing
@@ -332,7 +332,12 @@ def _write_static_tests(
332332
if project.compile_cache_dir is not None
333333
else None
334334
)
335-
model_map: dict[str, CompiledModel] = {model.name: model for model in project.models}
335+
model_chain_names_by_key: dict[CompiledObjectKey, tuple[str, ...]] = (
336+
sql_test_model_chain_names_by_key(
337+
project=project,
338+
tests=project.sql_tests,
339+
)
340+
)
336341
pending: list[tuple[CompiledSqlTest, str | None, str | None]] = []
337342
for test in project.sql_tests:
338343
record_key: str | None = None
@@ -341,9 +346,7 @@ def _write_static_tests(
341346
record_key = sql_test_artifact_record_key(test=test)
342347
artifact_identity = sql_test_artifact_identity(
343348
test=test,
344-
model_chain_names=sql_test_model_chain_names(
345-
test=test, project=project, model_map=model_map
346-
),
349+
model_chain_names=model_chain_names_by_key.get(test.key, ()),
347350
context=identity_context,
348351
)
349352
cached_record: SqlTestArtifactCacheRecord | None = cached_records.get(record_key)

0 commit comments

Comments
 (0)