Skip to content

Commit 728b0d7

Browse files
authored
Benchmark against Jinja (#16)
* Jinja2 benchmark * Updated benchmark and README with results.
1 parent fc1eca7 commit 728b0d7

4 files changed

Lines changed: 137 additions & 31 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
![Tests](https://raw.githubusercontent.com/sanic-org/html5tagger/main/docs/img/tests-badge.svg)
55
![Coverage](https://raw.githubusercontent.com/sanic-org/html5tagger/main/docs/img/coverage-badge.svg)
66

7-
If you're looking for a more efficient and streamlined way to generate HTML5, look no further than html5tagger! This is a super fast HTML generation module that can run faster than Jinja2. But the main difference is you'll be writing HTML tags with Python syntax, from your code. No special templating language control structures and no typing in HTML.
7+
If you're looking for a more efficient and streamlined way to generate HTML5, look no further than html5tagger! This is a super fast HTML generation module that can run faster than Jinja. But the main difference is you'll be writing HTML tags with Python syntax, from your code. No special templating language control structures and no typing in HTML.
88

99
Use [UV](https://docs.astral.sh/uv/getting-started/installation/) to add it to your project dependencies:
1010

@@ -229,6 +229,16 @@ Any preformatted HTML may be wrapped in `html5tagger.HTML(string_of_html)` to av
229229

230230
⚠️ Do not use `HTML()` for text, in particular not on messages sent by users, that may contain HTML that you didn't intend to execute as HTML.
231231

232+
## Performance
233+
234+
We benchmark rendering of a medium-sized product listing page in various modes, with Jinja for reference. The rendering time goes up to 1.5ms with full document regeneration each time, and drops to **0.2ms with templating**, although depending on how dynamic the document is this difference may vary.
235+
236+
This suggests that even generation from scratch likely runs faster than an SQL query (10ms) and, with templating, faster than FastAPI itself (1ms, empty handler).
237+
238+
Our template implementation benchmarks 1.2x faster than Jinja, or 4.6x faster if Jinja needs to load the template from file (cached). The Jinja document is larger due to whitespace needed for formatting and more verbose escaping rules, meaning it transfers to the client more slowly as well. We tried without whitespace for comparison, and the rendering time was not measurably different, but editing the template becomes hard.
239+
240+
The benchmark script is included in the source repository. All the values quoted are single CPU. High performance Python web frameworks like Sanic can reach 10 000+ req/s with html5tagger included, using multiple workers.
241+
232242
## Further development
233243

234244
There have been no changes to the tagging API since 2018 when this module was brought to production use, and thus the interface is considered stable with only small incremental changes like the `script` and `style` special methods being added.

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ authors = [
1212
{ name = "Sanic Community", email = "tronic@noreply.users.github.com" },
1313
]
1414
requires-python = ">=3.10"
15-
keywords = ["HTML", "HTML5", "templating", "Jinja2"]
15+
keywords = ["HTML", "HTML5", "templating", "Jinja"]
1616
classifiers = [
1717
"Development Status :: 5 - Production/Stable",
1818
"Intended Audience :: Developers",
@@ -35,14 +35,15 @@ Repository = "https://github.com/sanic-org/html5tagger"
3535
Issues = "https://github.com/sanic-org/html5tagger/issues"
3636

3737
[dependency-groups]
38-
dev = ["nox"]
38+
dev = [
39+
"nox",
40+
]
3941
test = [
4042
"pytest>=7.0.0",
4143
"pytest-cov>=4.0.0",
4244
"ruff>=0.14.9",
4345
"ty>=0.0.7",
4446
]
45-
4647
[tool.hatch.version]
4748
source = "vcs"
4849

scripts/benchmark.html.jinja

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<meta charset="utf-8">
4+
<title>Shop</title>
5+
<link href="style.css" rel="stylesheet">
6+
<script src="app.js" defer></script>
7+
8+
<header class="site-header">
9+
<div class="container">
10+
<a class="logo" href="/">Shop</a>
11+
<nav class="main-nav">
12+
<a href="/">Home</a>
13+
<a href="/products">Products</a>
14+
<a href="/about">About</a>
15+
<a href="/contact">Contact</a>
16+
</nav>
17+
</div>
18+
</header>
19+
<main class="main">
20+
<div class="container">
21+
<aside class="sidebar">
22+
<h2>Categories</h2>
23+
<ul>
24+
<li>Electronics</li>
25+
<li>Clothing</li>
26+
<li>Home & Garden</li>
27+
<li>Sports</li>
28+
<li>Books</li>
29+
</ul>
30+
</aside>
31+
<section class="content">
32+
<h1>Products</h1>
33+
<div class="product-grid">
34+
{% for p in products %}
35+
<article class="product-card" data-sku="{{ p.SKU }}">
36+
<div class="product-image">
37+
<span class="placeholder">{{ p.Initial }}</span>
38+
</div>
39+
<div class="product-body">
40+
<h3 class="product-name">{{ p.Name }}</h3>
41+
<p class="product-desc">{{ p.Desc }}</p>
42+
<div class="product-meta">
43+
<span class="product-price">{{ p.Price }}</span>
44+
<span class="product-stock">{{ p.Stock }}</span>
45+
</div>
46+
<a class="product-detail" href="/product/view">Details</a>
47+
</div>
48+
</article>
49+
{% endfor %}
50+
</div>
51+
</section>
52+
</div>
53+
</main>
54+
<footer class="site-footer">
55+
<div class="container">
56+
<p>&copy; 2026 Shop. All rights reserved.</p>
57+
</div>
58+
</footer>

scripts/benchmark.py

100644100755
Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
"""Benchmark: stateless Template callable vs. building items from scratch.
2-
3-
Run with:
4-
5-
uv run python benchmark.py
6-
7-
or, after installing the package in editable form:
8-
9-
python benchmark.py
10-
"""
11-
1+
#!/bin/env -S uv run
2+
# /// script
3+
# requires-python = ">=3.10"
4+
# dependencies = [ "html5tagger", "jinja2" ]
5+
# tool.uv.sources.html5tagger = { path = "../", editable = true }
6+
# ///
127
from __future__ import annotations
138

149
import timeit
10+
from pathlib import Path
11+
12+
try:
13+
import jinja2
14+
except ImportError:
15+
jinja2 = None
1516

1617
from html5tagger import Document, E, Template
1718

@@ -79,6 +80,17 @@
7980
)
8081
)
8182

83+
# Equivalent Jinja template for comparison (autoescape enabled).
84+
# Loaded from a file and pretty-formatted, as in normal Jinja use.
85+
if jinja2 is not None:
86+
_jinja_env = jinja2.Environment(
87+
loader=jinja2.FileSystemLoader(Path(__file__).parent),
88+
autoescape=True,
89+
)
90+
JinjaPage = _jinja_env.get_template("benchmark.html.jinja")
91+
else:
92+
JinjaPage = None
93+
8294

8395
def make_products(count: int = 100) -> list[dict[str, str]]:
8496
categories = ("Electronics", "Clothing", "Home", "Sports", "Books")
@@ -100,6 +112,23 @@ def render_with_template(products: list[dict[str, str]]) -> str:
100112
return Page(Items=[Item(**p) for p in products])
101113

102114

115+
def render_with_jinja(products: list[dict[str, str]]) -> str:
116+
"""Render the same page using a pre-loaded Jinja template (autoescape enabled)."""
117+
assert JinjaPage is not None, "Package jinja2 is not installed`"
118+
return JinjaPage.render(products=products)
119+
120+
121+
def render_with_jinja_runtime(products: list[dict[str, str]]) -> str:
122+
"""Render the same page by loading the Jinja template at runtime each call."""
123+
assert jinja2 is not None, "Package jinja2 is not installed`"
124+
env = jinja2.Environment(
125+
loader=jinja2.FileSystemLoader(Path(__file__).parent),
126+
autoescape=True,
127+
)
128+
template = env.get_template("benchmark.html.jinja")
129+
return template.render(products=products)
130+
131+
103132
def render_from_scratch(products: list[dict[str, str]]) -> str:
104133
doc = Document(
105134
"Shop",
@@ -198,27 +227,35 @@ def main() -> None:
198227
html_selectors = render_with_selectors(products)
199228
assert html_template == html_scratch == html_selectors, "Outputs differ!"
200229

201-
print(f"Generated HTML length: {len(html_template)} bytes")
202-
print(f"Number of products: {len(products)}")
203-
print()
204-
230+
html_jinja = render_with_jinja(products) if jinja2 is not None else None
231+
jinja_len = f" (Jinja {len(html_jinja)} bytes)" if html_jinja is not None else ""
232+
print(f"Generated HTML length: {len(html_template)} bytes{jinja_len}")
233+
print(f"Product items on page: {len(products)}\n")
205234
number = 1000
235+
t_full = timeit.timeit(lambda: render_from_scratch(products), number=number)
236+
t_full_selectors = timeit.timeit(lambda: render_with_selectors(products), number=number)
206237
t_template = timeit.timeit(lambda: render_with_template(products), number=number)
207-
t_scratch = timeit.timeit(lambda: render_from_scratch(products), number=number)
208-
t_selectors = timeit.timeit(lambda: render_with_selectors(products), number=number)
238+
239+
def row(label: str, t: float) -> str:
240+
return f" {label:<29} {t * 1000 / number:8.3f} ms ({t * 1_000_000 / number / len(products):2.0f} µs/item)"
209241

210242
print(f"Single page render time (averaged over {number} renders):")
211-
print(
212-
f" Template callable: {t_template * 1000 / number:8.3f} ms ({t_template * 1_000_000 / number / len(products):.2f} µs/item)"
213-
)
214-
print(
215-
f" Build from scratch: {t_scratch * 1000 / number:8.3f} ms ({t_scratch * 1_000_000 / number / len(products):.2f} µs/item)"
216-
)
217-
print(
218-
f" With CSS selectors: {t_selectors * 1000 / number:8.3f} ms ({t_selectors * 1_000_000 / number / len(products):.2f} µs/item)"
219-
)
243+
print(row("Full generation:", t_full))
244+
print(row("Full generation w/ selectors:", t_full_selectors))
245+
print(row("Template:", t_template))
246+
247+
t_jinja_file = t_jinja_preloaded = 0
248+
j2 = ""
249+
if jinja2 is not None:
250+
print("\nJinja for comparison:")
251+
t_jinja_file = timeit.timeit(lambda: render_with_jinja_runtime(products), number=number)
252+
print(row("Template file:", t_jinja_file))
253+
t_jinja_preloaded = timeit.timeit(lambda: render_with_jinja(products), number=number)
254+
print(row("Template preloaded:", t_jinja_preloaded))
255+
j2 = f", and {(t_jinja_preloaded / t_template):.1f}x faster than Jinja (preloaded; {(t_jinja_file / t_template):.1f}x file)"
256+
220257
print()
221-
print(f"Template is {t_scratch / t_template:.1f}x faster than building from scratch")
258+
print(f"Templating is {(t_full / t_template):.1f}x faster than full document generation{j2}")
222259

223260

224261
if __name__ == "__main__":

0 commit comments

Comments
 (0)