Learning Platform
Глоссарий Troubleshooting
Урок 11.01 · 25 мин
Продвинутый
parsingpartial-parseperformancetree-sitter

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-проектов:

dbt parse: numbers по размеру проекта

Без partial parsing — это full parse каждый раз. С partial parsing — incremental, читаем только изменённое.

100 моделейМаленький проект: основная команда из 2-3 человек, монолит. Без partial parsing full parse занимает примерно 2-3 секунды. С partial parsing — 200-500ms на типичное изменение (1-2 файла)
500 моделейСредний проект — несколько команд, несколько слоёв (staging/intermediate/marts). Full parse 8-15 секунд — уже ощущается при работе. Partial parsing вытягивает до 1-2 секунд на change
1000 моделейБольшой проект — типичный enterprise. Full parse 30-60 секунд, на больших проектах часто упоминается threshold 60s как порог боли. Partial parsing критичен — без него dev experience неприемлем
3000+ моделейMega-проект — обычно это уже сигнал, что пора разбивать через dbt Mesh. Full parse 90-180 секунд, partial parsing 8-15 секунд. На таких размерах появляются и другие bottleneck-и помимо parse

Цифры зависят от железа, но порядок такой. Если у вас 1000 моделей и parse занимает 2 минуты — это уже сигнал, что что-то конкретно не так (вероятно, partial parsing сломан или есть тяжёлые макросы).

Что dbt делает при parse

Чтобы понять, что замедляет, надо знать, что parse физически выполняет. Шаги по порядку:

  1. File discovery: рекурсивный обход директорий, формирование списка .sql, .yml, .md, .csv файлов. На 1000-моделей проекте это 3000-5000 файлов.
  2. Reading files: чтение содержимого каждого. I/O bound, но обычно быстро (sequential read).
  3. Jinja compilation: для каждого SQL — рендеринг Jinja без execute=True, чтобы собрать ref(), source(), config() calls.
  4. YAML parsing: разбор schema.yml, properties.yml — тесты, документация, contracts.
  5. Graph construction: построение DAG через networkx — добавление nodes (models, tests, sources), edges (зависимости через ref/source).
  6. 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.

TIP

Чтобы измерить, какой процент моделей идёт через 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:

  1. Сканирует файлы, считает hash каждого.
  2. Сравнивает с hash’ами в partial_parse.msgpack.
  3. Изменилось 5 файлов из 3000? Парсим только эти 5.
  4. Обновляем граф локально, сериализуем новый partial_parse.msgpack.

На проекте 1000 моделей с изменением 1-2 файлов partial parse занимает 3-5 секунд вместо 30-60. Это разница в 10x.

Что инвалидирует partial_parse полностью (заставляет full re-parse):

  1. Изменился dbt_project.yml — global config меняется, всё пересчитывать.
  2. Изменился profiles.yml — credentials, target — может повлиять на conditional compilation.
  3. Изменилась packages.yml — package versions, нужен re-resolve.
  4. Изменилась переменная vars (через CLI --vars или dbt_project.yml) — конкретный var мог использоваться в {% if execute %} блоках.
  5. Изменился dbt-core version — внутренние структуры могли поменяться.
  6. Изменился env_var, который используется в Jinja — невозможно определить, что именно зависело, проще full parse.
WARNING

Самый коварный случай — пункт 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+ моделей.

Anti-паттерны parse-performance

Каждый из них может удвоить или утроить parse time на больших проектах.

Глобальные post-hookspost-hook в dbt_project.yml на уровне +post-hook применяется ко всем моделям. Если он рендерит сложный Jinja (например, dbt_utils.something()) — это 1000x rendering на parse
Гигантские schema.ymlschema.yml с 500 моделями в одном файле — каждый раз весь файл re-parsed. Plus YAML libraries не оптимизированы для multi-MB файлов. Разбивайте по слоям: stg_schema.yml, mart_schema.yml
Тяжёлые макросы в configКогда custom_alias или partition_by вычисляется через макрос с лукапами в graph object — это O(N) на каждой модели, итого O(N²) на parse. Пример: get_custom_schema(), который перебирает все модели
Many sources в одном yml1000 источников в одном sources.yml — то же что и schema.yml на 500 моделей. dbt re-parsed весь файл целиком даже при изменении одной таблицы
Cyclic deps через ephemeralКогда ephemeral модель ссылается на ephemeral, которая ссылается на ephemeral — dbt инлайнит SQL в parse. На глубоких цепочках это медленно. Минимизируйте ephemeral в production

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.

Альтернативы:

  1. Перенести в отдельную операцию: написать on-run-end hook, который читает run_results.json и логирует timing после run. Это бежит один раз вместо 1000.
  2. Условный hook: +post-hook: "{{ log_model_runtime(this) if var('detailed_logging', false) }}" — на CI hook не выполняется (detailed_logging=false), на ad-hoc — да.
  3. 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 тоже замедляется:

  1. Snapshots — каждый имеет свой Jinja-рендеринг. Если snapshots редко меняются, можно отделить их в отдельную mini-project через dbt Mesh (про это в следующем модуле).
  2. 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 медленный, проверяйте в порядке убывания вероятности:

  1. partial_parse.msgpack отсутствует или сломанrm -rf target/ и сравните первый run vs второй.
  2. vars/env_vars меняются часто — full parse каждый раз.
  3. Глобальные post-hooks+post-hook в dbt_project.yml на верхнем уровне.
  4. Гигантские schema.yml — больше 200 моделей в одном файле.
  5. Тяжёлые macros в config() функциях.
  6. Custom adapter с медленным catalog() — на parse некоторые adapters обращаются к warehouse для catalog info.
  7. Network/disk I/O — на slow disk или network-mounted FS parse деградирует кратно.
TIP

Простой смоук-тест: time dbt parse дважды подряд. Первый раз — cold (без partial_parse). Второй — warm. Если warm > 5 секунд на проекте менее 500 моделей — что-то конкретно не так с partial parsing или есть тяжёлый макрос.

DuckDB-специфика

В контексте курса используем DuckDB как обучающий warehouse. DuckDB-специфичные особенности для parse:

  1. Локальный duckdb-файл — нет network latency, parse быстрее на 10-15% чем для облачных warehouses (Snowflake/BQ).
  2. Adapter catalog() — dbt-duckdb может быстро получить catalog (read только таблицы из локального .duckdb файла), не делая API calls. Это плюс.
  3. External tables — если ваш проект имеет много external_location sources на S3 — dbt при parse не читает S3 (только метаданные из YAML). Parse быстрый.

В production вы скорее всего будете на Snowflake/BigQuery, где parse-time tradeoffs другие — в особенности dbt debug и list_relations_without_caching могут добавлять секунды на cold start.

Проверка знанийKnowledge check
Команда жалуется: dbt parse занимает 45 секунд на проекте 800 моделей. Junior говорит: "это normal для такого размера". Senior хочет диагностировать. Какие первые 3 действия senior должен сделать?
ОтветAnswer
Senior подход — не accept "это normal", а измерять. Три первых действия: (1) Проверить, работает ли partial parsing. Запустить dbt parse дважды подряд без изменений в коде. Если оба раза 45s — partial_parse.msgpack сломан или инвалидируется чем-то. Это самый частый случай. Возможные причины: вызывается через CI с rm -rf target/, env_var меняется (часто это DBT_DEFER_TO_PROD или подобные runtime-vars), или packages.yml есть в .gitignore и каждый раз dbt deps инвалидирует state. (2) Запустить с профилированием: dbt parse --profile target/profile.prof, потом snakeviz target/profile.prof. Откроется браузер с time-attribution. Смотрим, что занимает больше всего времени. Обычно паттерн: 80% времени в _render — значит, тяжёлые макросы или глобальные хуки. 40% в _load_yaml — значит, гигантские schema.yml. Дальше action item понятный. (3) Проверить anti-patterns в dbt_project.yml. Конкретно: есть ли +post-hook на верхнем уровне (применяется ко всем моделям, в parse рендерится 800 раз). Есть ли +pre-hook с тяжёлым макросом. Есть ли var, который меняется на каждом CI run (тогда partial parse не работает). Это типичные источники slow parse. После этих трёх действий обычно понятен root cause. Решение: рефакторинг хуков, разбивка schema.yml, или fix к partial parsing setup. Реальный target — менее 5s warm parse на 800 моделей. 45s = 9x slower than possible, есть что улучшать. "Это normal" — это пораженческий ответ; всегда возможно мерить и улучшать.

Резюме

  • 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.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. Что инвалидирует partial_parse.msgpack и заставляет dbt сделать full re-parse?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5