Parsing performance: что замедляет dbt parse
В курсе уже разбирали parsing pipeline на уровне ManifestLoader, tree-sitter, partial_parse.msgpack. Этот урок — про production-реальность: на проекте 1000+ моделей dbt parse может занимать 30-60 секунд. На каждом dbt run, dbt test, dbt compile — то же самое. Если CI запускает 50 джобов в день, parse-overhead — это часы compute и непрерывное раздражение разработчиков.
Понимать, что замедляет parse и как это диагностировать — навык senior analytics engineer. Разберём на конкретных числах.
Manifest storage: где хранить production manifest для Slim CI (dbt II)Реальная база: сколько занимает parse
Прежде чем оптимизировать, надо знать baseline. Реальные измерения с production-проектов:
Без partial parsing — это full parse каждый раз. С partial parsing — incremental, читаем только изменённое.
Цифры зависят от железа, но порядок такой. Если у вас 1000 моделей и parse занимает 2 минуты — это уже сигнал, что что-то конкретно не так (вероятно, partial parsing сломан или есть тяжёлые макросы).
Что dbt делает при parse
Чтобы понять, что замедляет, надо знать, что parse физически выполняет. Шаги по порядку:
- File discovery: рекурсивный обход директорий, формирование списка
.sql,.yml,.md,.csvфайлов. На 1000-моделей проекте это 3000-5000 файлов. - Reading files: чтение содержимого каждого. I/O bound, но обычно быстро (sequential read).
- Jinja compilation: для каждого SQL — рендеринг Jinja без
execute=True, чтобы собратьref(),source(),config()calls. - YAML parsing: разбор
schema.yml,properties.yml— тесты, документация, contracts. - Graph construction: построение DAG через networkx — добавление nodes (models, tests, sources), edges (зависимости через ref/source).
- Manifest serialization: сборка
manifest.jsonв памяти + сериализация вpartial_parse.msgpackдля следующего run.
Самый дорогой шаг — #3 Jinja compilation. На 1000 моделей это 1000 раз rendering Jinja-шаблона. Если макрос делает что-то сложное (например, dbt_utils.get_column_values() с execute=False стабом, который всё равно дорогой) — parse замедляется.
tree-sitter static parser
В dbt 1.4 добавили experimental static parser на tree-sitter. Идея: вместо рендеринга Jinja (что дорого), статически парсить SQL через grammar-based parser и извлекать ref(), source() без выполнения Jinja.
Это работает только для простых моделей — где ref и source написаны прямо в SQL без условий, циклов, переменных:
-- Simple model — tree-sitter справляется
select * from {{ ref('stg_orders') }}
join {{ source('app', 'customers') }} using (customer_id)
-- Сложная модель — tree-sitter сдаётся, fallback к Jinja
{% set tables = ['orders', 'customers', 'products'] %}
{% for t in tables %}
select * from {{ ref('stg_' ~ t) }}
{% if not loop.last %}union all{% endif %}
{% endfor %}
В первом случае tree-sitter за миллисекунды извлекает refs. Во втором — fallback к полному Jinja rendering.
Включение через flags.yml или dbt_project.yml:
flags:
use_experimental_parser: True
static_parser: True
На больших проектах с простыми моделями это даёт 30-50% ускорения parse. На проектах с тяжёлыми Jinja-моделями — почти ничего, потому что большинство моделей идёт через fallback.
Чтобы измерить, какой процент моделей идёт через static parser — установите static_parser: True и запустите dbt parse --warn-error-options. dbt напечатает статистику: сколько успешно распарсилось через tree-sitter и сколько fallback к Jinja.
partial_parse.msgpack — incremental parse
Это главная оптимизация. После полного parse dbt сохраняет state в target/partial_parse.msgpack. На следующем parse:
- Сканирует файлы, считает hash каждого.
- Сравнивает с hash’ами в
partial_parse.msgpack. - Изменилось 5 файлов из 3000? Парсим только эти 5.
- Обновляем граф локально, сериализуем новый
partial_parse.msgpack.
На проекте 1000 моделей с изменением 1-2 файлов partial parse занимает 3-5 секунд вместо 30-60. Это разница в 10x.
Что инвалидирует partial_parse полностью (заставляет full re-parse):
- Изменился
dbt_project.yml— global config меняется, всё пересчитывать. - Изменился
profiles.yml— credentials, target — может повлиять на conditional compilation. - Изменилась
packages.yml— package versions, нужен re-resolve. - Изменилась переменная
vars(через CLI--varsилиdbt_project.yml) — конкретный var мог использоваться в{% if execute %}блоках. - Изменился
dbt-coreversion — внутренние структуры могли поменяться. - Изменился env_var, который используется в Jinja — невозможно определить, что именно зависело, проще full parse.
Самый коварный случай — пункт 4. Команда добавляет один var в dbt_project.yml для одной модели, и теперь весь parse становится full. На проекте 1000 моделей это +25-55 секунд на каждый CI run. Накопительно — часы compute в неделю.
Профилирование dbt parse
Чтобы понять, где именно тратится время — используйте --profile:
dbt parse --profile target/parse.prof
Это создаёт cProfile-файл, который можно открыть через snakeviz:
pip install snakeviz
snakeviz target/parse.prof
В выводе видны:
- Время на
_render(Jinja rendering) — обычно 60-80% от parse time. - Время на
_load_yaml(YAML parsing) — обычно 10-20%. - Время на
_add_node_to_manifest(graph operations) — 5-10%.
Если _render занимает 90%+ — у вас тяжёлые макросы. Если _load_yaml 40%+ — слишком много YAML (например, schema.yml со списком 500 моделей в одном файле — это анти-паттерн, разбивайте).
Альтернативный профайлер: dbt-loom-style timing
Внутри dbt-core есть менее известный flag --debug + --log-level debug, который выдаёт timing-stats для parse этапов. Альтернатива — самописный wrapper:
from dbt.cli.main import dbtRunner
import time
runner = dbtRunner()
start = time.perf_counter()
result = runner.invoke(['parse'])
elapsed = time.perf_counter() - start
print(f"Parse took: {elapsed:.2f}s")
print(f"Models parsed: {len([n for n in result.result.nodes.values() if n.resource_type == 'model'])}")
Это даёт точное время и количество моделей. Запустите 10 раз — получите распределение. Если std/mean > 0.1, что-то нестабильно (часто это I/O contention или fluctuation в network для package downloads).
Anti-паттерны, которые убивают parse
Это реальные кейсы из production-проектов 1000+ моделей.
Каждый из них может удвоить или утроить parse time на больших проектах.
Concrete fix: post-hooks tuning
Самый частый источник медленного parse — глобальные хуки. Например, типичная установка:
# dbt_project.yml
models:
myproject:
+post-hook: "{{ log_model_runtime(this) }}"
Этот макрос log_model_runtime рендерится для каждой модели при parse. На 1000 моделей — это 1000 раз invoke Jinja.
Альтернативы:
- Перенести в отдельную операцию: написать
on-run-endhook, который читаетrun_results.jsonи логирует timing после run. Это бежит один раз вместо 1000. - Условный hook:
+post-hook: "{{ log_model_runtime(this) if var('detailed_logging', false) }}"— на CI hook не выполняется (detailed_logging=false), на ad-hoc — да. - Lighter hook: вместо вызова макроса — inline SQL:
+post-hook: "INSERT INTO runtime_log VALUES ('{{ this }}', CURRENT_TIMESTAMP)". Это не вызывает Jinja-рендеринг.
Результат — parse ускоряется на 30-40%.
Snapshot/seed files
На проектах с большим количеством snapshots (100+) и seeds (200+ CSV) parse тоже замедляется:
- Snapshots — каждый имеет свой Jinja-рендеринг. Если snapshots редко меняются, можно отделить их в отдельную mini-project через dbt Mesh (про это в следующем модуле).
- Seeds (CSV) — dbt при parse читает CSV метаданные (column types). На больших seed-файлах (мегабайты) это I/O bound. Решение — переместить большие seeds в источники warehouse (через
external_locationдля DuckDB, через таблицы для других) и оставить в проекте только маленькие лookup-tables.
Microbatch и parse
Microbatch модели (с incremental_strategy='microbatch', см. урок 04) на parse ведут себя как обычные incremental — никакой дополнительной нагрузки. Parse не строит batch list — это происходит на run-time. Поэтому microbatch не влияет на parse performance.
Что в реальности влияет на parse: чек-лист
Когда parse медленный, проверяйте в порядке убывания вероятности:
- partial_parse.msgpack отсутствует или сломан —
rm -rf target/и сравните первый run vs второй. - vars/env_vars меняются часто — full parse каждый раз.
- Глобальные post-hooks —
+post-hookвdbt_project.ymlна верхнем уровне. - Гигантские schema.yml — больше 200 моделей в одном файле.
- Тяжёлые macros в
config()функциях. - Custom adapter с медленным catalog() — на parse некоторые adapters обращаются к warehouse для catalog info.
- Network/disk I/O — на slow disk или network-mounted FS parse деградирует кратно.
Простой смоук-тест: time dbt parse дважды подряд. Первый раз — cold (без partial_parse). Второй — warm. Если warm > 5 секунд на проекте менее 500 моделей — что-то конкретно не так с partial parsing или есть тяжёлый макрос.
DuckDB-специфика
В контексте курса используем DuckDB как обучающий warehouse. DuckDB-специфичные особенности для parse:
- Локальный duckdb-файл — нет network latency, parse быстрее на 10-15% чем для облачных warehouses (Snowflake/BQ).
- Adapter catalog() — dbt-duckdb может быстро получить catalog (read только таблицы из локального .duckdb файла), не делая API calls. Это плюс.
- External tables — если ваш проект имеет много
external_locationsources на S3 — dbt при parse не читает S3 (только метаданные из YAML). Parse быстрый.
В production вы скорее всего будете на Snowflake/BigQuery, где parse-time tradeoffs другие — в особенности dbt debug и list_relations_without_caching могут добавлять секунды на cold start.
Резюме
- Parse — самый частый bottleneck dev-experience на больших dbt-проектах. На 1000 моделей full parse это 30-60s, partial — 3-5s.
- partial_parse.msgpack — главная оптимизация, но легко ломается через env_var/vars/packages.yml changes.
- tree-sitter static parser даёт 30-50% ускорения на простых моделях, но fallback к Jinja для сложных.
- Профилирование через
--profile+snakevizпоказывает, где время. - Anti-patterns: глобальные post-hooks, гигантские schema.yml, тяжёлые макросы в config, ephemeral chains.
- Concrete fix-rules: разбейте schema.yml по слоям, перенесите post-hooks в on-run-end, минимизируйте var-based conditional logic.