dbt-jinja 基准测试指南:用 Criterion 追踪 minijinja 引擎性能并横向对比主流模板引擎
【免费下载链接】dbtdbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.项目地址: https://gitcode.com/GitHub_Trending/db/dbt
导读
本文聚焦于 dbt 开源仓库中 dbt-jinja 项目自带的基准测试套件(位于 crates/dbt-jinja/benchmarks),它既承担着对 minijinja 模板引擎自身的随时间演进追踪(parse / compile / render 三个关键阶段),也负责与handlebars、liquid、tera、askama、rinja等主流 Rust 模板引擎的横向对比。读完本文,你将掌握如何用一条cargo bench命令复现这套基准、理解每个基准函数在测什么、看懂比较结果数据,并能从源码层面理解为什么 minijinja 在编译与渲染阶段表现出不同特性。
基准测试套件概览
依据 benchmarks/README.md,这是一套「刚刚起步(beginning)」但结构清晰的基准测试套件,目前包含两类目标:
- 引擎自身基准(engine benchmarks):追踪 minijinja 在解析、编译、渲染等环节的性能随时间的变化,用于防止回归;
- 对比基准(comparison benchmarks):在相同模板语义下,与
handlebars、liquid、tera(以及编译期模板askama、rinja)做横向对比。
整套基准基于 criterion.rs(Criterion 基准测试框架)实现,它内置统计检验、回归检测和 HTML 报告生成能力,非常适合「追踪随时间变化」这一目标。
运行方式
在crates/dbt-jinja/benchmarks目录下(或仓库根目录下使用-p benchmarks指定包)执行:
$ cargo benchCriterion 会依次运行全部[[bench]]目标,并在完成后输出每个基准的置信区间估计(time: [lower mean upper])。
基准测试的工程配置
从 Cargo.toml 可以完整还原这套基准的构建方式:
[package] name = "benchmarks" version = "0.1.0" edition = "2018" [features] speedups = ["minijinja/speedups"] [dependencies] askama = "0.12.1" handlebars = "5.1.2" liquid = "0.26.1" minijinja = { path = "../minijinja", default-features = false, features = [ "unstable_machinery", "multi_template", "builtins", "serde", ] } rinja = "0.3.4" serde = { workspace = true } tera = "1.17.1" [dev-dependencies] criterion = { version = "0.5.1", features = ["html_reports"] } [[bench]] name = "templates" harness = false [[bench]] name = "comparison" harness = false几个值得注意的配置点:
minijinja以路径依赖引入(path = "../minijinja"),即基准直接对本仓库内的 minijinja 源码进行评测,而不是 crates.io 上的发布版本,这正是「追踪引擎自身随时间变化」的前提;- 通过 feature 组合
unstable_machinery、multi_template、builtins、serde显式开启解析器机械结构(machinery)、多模板、内置函数与 Serde 支持;default-features = false则关闭默认特性,保持测试环境的可控性; - 单独的
speedupsfeature 可一键开启minijinja/speedups(minijinja 的可选速度优化特性),方便对比开启与否的性能差异; - 两个
[[bench]]目标都声明harness = false,即不使用 Rust 标准测试 harness,改由 Criterion 接管基准调度; criterion开启了html_reportsfeature,运行结束后会在target/criterion/report生成可视化 HTML 报告;askama、rinja分别通过 askama.toml 与 rinja.toml 中的dirs = ["inputs"]配置模板搜索目录。
引擎自身基准:parse / compile / render 三阶段追踪
引擎自身基准定义在 benches/templates.rs 中,它使用criterion_main!注册单个criterion_benchmark分组,覆盖 minijinja 的三个核心阶段:
1. parse:纯语法解析
fn do_parse() { parse( black_box(include_str!("../inputs/all_elements.html")), "all_elements.html", Default::default(), Default::default(), ) .unwrap(); }这里直接调用minijinja::machinery::parse,对模板源码做词法与语法层面的解析(尚未编译成可执行指令),基准名为parse。black_box用于阻止编译器将常量输入优化掉,确保真实测量解析开销。
2. compile:解析并编译
fn do_parse_and_compile() { let mut env = Environment::new(); env.add_template( "all_elements.html", include_str!("../inputs/all_elements.html"), ) .unwrap(); }compile基准将解析与编译合并为一次Environment::add_template调用,反映模板「注册进环境」这一实际使用场景的开销。
3. render:完整渲染
fn do_render(env: &Environment) { let tmpl = env.get_template("all_elements.html").unwrap(); tmpl.render(context! { DEBUG => false, site => context! { nav => vec![ context!{url => "/", is_active => true, title => "Index"}, context!{url => "/doc", is_active => false, title => "Docs"}, context!{url => "/help", is_active => false, title => "Help"}, ] }, items => (0..200).skip(3).collect::<Vec<_>>(), }) .unwrap(); }渲染基准通过create_real_env()构建了一个更接近生产环境的实例——额外注册了footer.html子模板、自定义过滤器asset_url与自定义函数current_year:
fn create_real_env() -> Environment<'static> { let mut env = Environment::new(); env.add_template("footer.html", include_str!("../inputs/footer.html")) .unwrap(); env.add_template( "all_elements.html", include_str!("../inputs/all_elements.html"), ) .unwrap(); env.add_filter("asset_url", |_: &State, value: String| Ok(value)); env.add_function("current_year", |_: &State| Ok(2022)); env }渲染上下文包含一个 197 项的列表((0..200).skip(3)),确保基准覆盖真实的循环与过滤链工作量。
测试模板:all_elements.html
inputs/all_elements.html 是这个基准的「全家桶」模板,几乎覆盖了 minijinja 的核心语法元素,这正是命名为 all_elements 的原因:
<!doctype html> <meta charset="utf-8"> <title>{% block title %}{% endblock %} | Hello</title> <link rel="stylesheet" href="{{ 'styles/index.css'|asset_url }}"> {# this is a comment #} <body> {% block html_body %} <header> <h1>Hello</h1> <nav> <ul class="nav"> {% for item in site.nav %} <li><a href="{{ item.url }}"{% if item.is_active %} class="active"{% endif %}>{{ item.title|upper }}</a> {% endfor %} </ul> </nav> </header> <main> {% block body %} <ul> {% for item in items %} <li>{{ loop.index }}: {{ item|upper }}</li> {% endfor %} </ul> {% endblock %} </main> <footer> {% block footer %} {% with copyright=current_year() %} {% include "footer.html" %} {% endwith %} {% endblock %} </footer> {% if DEBUG %} <pre class="debug">{{ debug() }}</pre> {% endif %} {% endblock html_body %} </body>它一次性覆盖了:块继承({% block %})、注释({# ... #})、for循环与loop.index、if条件、过滤器(|upper、|asset_url)、with作用域、include子模板引入、函数调用(current_year()、debug())以及site.nav/item.url这类嵌套属性访问。模板中还包含footer.html的 include(定义于 inputs/footer.html),使渲染链路更完整。
跨引擎对比基准:相同语义、不同语法
对比基准定义在 benches/comparison.rs 中,注册了cmp_compile与cmp_render两个分组。
统一的基准上下文
为了让对比公平,各引擎共享同一份序列化上下文。Context派生Serialize,内部包含导航列表(site.nav)、版权年份与标题,并通过宏为Context(askama 用)与RinjaContext(rinja 用)生成相同的Default实现:
#[derive(Serialize, Debug, askama::Template)] #[template(path = "comparison/askama.html")] struct Context { items: Vec<String>, site: Site, title: &'static str, }items为 6 个字符串条目,site.nav为 4 个导航项,其中一个标记为激活态。各引擎的输入模板分布在 inputs/comparison 目录下,包括minijinja.html、tera.html、liquid.html、handlebars.html、askama.html及其各自的*_footer.html子模板。这些模板在结构上保持同构(导航循环、条目循环、页脚 include),仅在语法上遵循各引擎方言——例如 liquid 使用{{ item.title|upcase }}与{% for %}/{% include %}语法(liquid.html),handlebars 使用{{#each items}}与{{> footer.html}}语法(handlebars.html),而 tera 与 minijinja 同为 Jinja 系语法,模板几乎一致(tera.html 与 minijinja.html)。
cmp_compile:编译性能对比
该分组在b.iter内反复向各引擎注册同一对模板(主模板 + footer 子模板):
- minijinja:每次迭代执行两次
env.add_template; - tera:每次迭代执行两次
tera.add_raw_template; - liquid:每次迭代用
ParserBuilder::with_stdlib()解析两次模板; - handlebars:每次迭代执行两次
hbs.register_template_string。
由于 askama/rinja 属于编译期模板(模板在 Rust 编译阶段完成编译),它们不参与编译基准——这一点 README 中也有专门说明。
cmp_render:渲染性能对比
该分组在循环外完成模板注册,b.iter内仅执行渲染,并针对各引擎特点做了等价适配:
- minijinja:
env.get_template("template.html").unwrap().render(Context::default()); - tera:先
tera::Context::from_serialize(Context::default())再tera.render(...); - liquid:使用
EagerCompiler<InMemorySource>预编译 partials,liquid::to_object转换上下文后渲染; - handlebars:通过
handlebars_helper!注册upper辅助函数后渲染; - rinja / askama:直接调用
Template::render(&context)——由于模板是编译期生成的 Rust 代码,渲染路径没有字符串解析与运行时编译开销。
模板语义的对齐细节
对比模板刻意保留了各引擎的惯用写法而非机械翻译:minijinja 与 tera 的模板文件在页脚处都通过{% include "footer.html" %}引入子模板,handlebars 用 partial({{> footer.html}}),liquid 则通过EagerCompiler预编译 partial。这种「语义等价、语法惯用」的设计,使得对比结果反映的是各引擎在真实使用方式下的表现,而非被强行统一语法后的失真数据。
对比结果与解读
README 记录了在MacBook Pro 16"(2021 款)上的一次实测结果(单位为微秒,Criterion 输出置信区间[lower mean upper]):
cmp_compile/handlebars time: [47.043 µs 47.163 µs 47.295 µs] cmp_compile/liquid time: [28.669 µs 28.776 µs 28.919 µs] cmp_compile/minijinja time: [4.6921 µs 4.7003 µs 4.7092 µs] cmp_compile/tera time: [35.145 µs 35.227 µs 35.315 µs] cmp_render/askama time: [1.5744 µs 1.5793 µs 1.5846 µs] cmp_render/handlebars time: [6.3056 µs 6.3221 µs 6.3399 µs] cmp_render/liquid time: [11.364 µs 11.394 µs 11.426 µs] cmp_render/minijinja time: [5.0475 µs 5.0600 µs 5.0739 µs] cmp_render/tera time: [7.1101 µs 7.1285 µs 7.1482 µs]如何阅读这组数据
- 编译阶段(cmp_compile):minijinja 约 4.70 µs,明显快于 tera(约 35.2 µs)、liquid(约 28.8 µs)与 handlebars(约 47.2 µs),差距在一个数量级左右。这与 minijinja 把解析与编译分离、且解析器轻量的实现思路一致;
- 渲染阶段(cmp_render):askama 约 1.58 µs 领先,符合其「编译期模板 + 静态类型」的定位;minijinja 约 5.06 µs,介于 handlebars(约 6.32 µs)与 tera(约 7.13 µs)之间,明显快于 liquid(约 11.4 µs)。
两个必须注意的边界
- Askama 没有编译基准。README 明确指出:Askama(以及同类的 rinja)在 Rust 构建阶段就完成了模板编译,并采用静态类型检查,因此它没有
cmp_compile数据。换言之,cmp_render中 askama 的领先是以「编译期成本转移到构建过程」为代价的,不能简单解读为「渲染一定更快」; - 数据是特定环境快照。以上数字来自 2021 款 MacBook Pro 16",是某一时刻的样本,不是可移植的性能结论。要复现或验证,请在本仓库中自行运行
cargo bench,并结合 Criterion 的target/criterion/reportHTML 报告查看置信区间与回归趋势。
在 dbt 项目中的定位
从仓库结构看,minijinja 是被 dbt 深度依赖的 Jinja 模板引擎实现(minijinja 为该仓库内维护的模板引擎,dbt-jinja家族还包含 dbt-jinja-ctx、dbt-jinja-filters、dbt-jinja-utils、dbt-jinja-vars 等配套 crate)。在这条技术栈上,模板引擎的解析、编译、渲染性能直接影响到大规模 dbt 项目的模型编译与渲染耗时,因此:
- 引擎自身基准(parse / compile / render)承担回归守护职责,任何一次针对解析器或渲染器的改动,都可以通过
cargo bench观察耗时是否出现统计显著的回退; - 跨引擎对比基准则作为参考锚点,帮助团队在引入新特性(如
speedupsfeature)时量化收益。
总结
dbt-jinja 的 benchmark 套件虽然自称「刚起步」,但已经覆盖了模板引擎最核心的性能维度:
| 基准分组 | 所在文件 | 测量内容 |
|---|---|---|
| parse / compile / render | benches/templates.rs | minijinja 引擎自身三阶段耗时 |
| cmp_compile | benches/comparison.rs | minijinja vs tera / liquid / handlebars 的模板编译耗时 |
| cmp_render | benches/comparison.rs | 上述引擎 + askama / rinja 的渲染耗时 |
复现与扩展这套基准非常简单:直接运行cargo bench,即可获得含置信区间的统计结果与 HTML 报告。结合 Cargo.toml 中预留的speedupsfeature,你还可以一键对比 minijinja 在开启速度优化前后的差异——这为后续的性能调优工作提供了一个开箱即用的测量起点。
【免费下载链接】dbtdbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.项目地址: https://gitcode.com/GitHub_Trending/db/dbt
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考