sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit c3887dedb76f84b5a7356e05654c5eaee22fdffc
parent 68d97a07d0c94cbd76f6994e881f6a77c6a0f9aa
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:06:10 +0900

같은 머신, 같은 코퍼스로 잰 벤치마크 페이지를 단다

Hugo, Zola, Jekyll, Astro와 시나리오 4종(minimal / blog / heavy·반복 /
heavy·고유)에서 대조했다. 1,000페이지, 마크다운 본문은 도구 간 바이트
동일, 콜드 3회 중앙값, taskset 4코어. heavy를 반복/고유로 가른 이유를
페이지가 직접 말한다 — 코드 반복도가 순위를 가르는 변수인데 대부분의
벤치마크는 자기 코퍼스의 반복도를 공개하지 않는다.

중앙값: minimal 18ms(2위 Hugo 93ms), blog 25ms(112ms), heavy·반복
187ms(3,559ms), heavy·고유 1,298ms(5,912ms). 다섯 도구 모두 빌드 타임
하이라이팅을 켰고, sqzass는 다섯 중 가장 세밀한 마크업(span 3,205 vs
Hugo 600)과 검색 색인·sitemap·llms.txt까지 만들면서 낸 수치다.

재현 스크립트(gen.py, bench.py)와 원본 데이터는 bench/에 있다. 밀리초는
머신을 타지만, 정의와 하네스가 공개면 비율은 재현된다. 페이지의 모든
수치·막대 폭은 results JSON과 기계 대조로 확인했다.

막대는 SVG rect의 표현 속성으로 그린다 — 인라인 style은 이 사이트가
지켜 온 strict style-src CSP 가능성을 깨기 때문이다. 도구 식별은 행
라벨이, 수치는 옆의 텍스트가 운반하므로 색이 정보를 단독으로 나르지
않는다.

Diffstat:
Abench/README.md | 25+++++++++++++++++++++++++
Abench/bench.py | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench/gen.py | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench/results-2026-07-29.json | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/benchmark/_index.ko.md | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/benchmark/_index.md | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/static/css/main.css | 30++++++++++++++++++++++++++++++
7 files changed, 678 insertion(+), 0 deletion(-)

diff --git a/bench/README.md b/bench/README.md
@@ -0,0 +1,25 @@
+# 벤치마크 재현
+
+<https://sqzass.sqzer.com/benchmark/> 의 수치를 만든 도구다. 수치는 머신마다
+다르니 **비율을 보되, 반드시 직접 돌려서** 확인할 것.
+
+```bash
+cargo build --release                 # sqzass (기본 = onig 엔진)
+python3 bench/gen.py                  # 도구 5종 × 시나리오 4종 코퍼스 생성
+python3 bench/bench.py sqzass         # 도구별 실행 (hugo/zola/jekyll/astro)
+```
+
+- 대조군 설치는 각자: hugo, zola, jekyll(gem), astro(`bench/astro-base`에
+  `package.json`을 만들고 `npm install astro`).
+- `gen.py`는 자기 디렉터리 아래에 코퍼스를 만든다. 본문 마크다운은 도구 간
+  바이트 동일, front matter 형식만 다르다.
+- `bench.py`는 매 회 출력물과 도구별 캐시(.jekyll-cache, astro cacheDir·dist,
+  hugo resources·lock)를 지운 콜드 빌드다. `taskset -c 0-3`, 3회, 중앙값,
+  GNU time으로 peak RSS.
+- 결과는 `results/<tool>-<scenario>.json`. 공개 수치의 원본은
+  `results-2026-07-29.json`.
+
+시나리오 정의는 `gen.py`가 곧 명세다 — 특히 heavy가 **repeat**(페이지당 동일
+코드 블록 5개)와 **unique**(전부 다른 블록)로 나뉘는 이유는 코드 반복도가
+순위를 가르는 변수라서다. 하나의 heavy 수치만 공개하는 벤치마크는 그 변수를
+숨긴 것이다.
diff --git a/bench/bench.py b/bench/bench.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""벤치마크 하네스. 사용: bench.py <tool> [scenario...]
+콜드 규율: 매 회 출력 디렉터리와 도구별 캐시를 지운다. taskset -c 0-3, 3회, 중앙값.
+결과는 results/<tool>-<scenario>.json 으로 append-safe하게 저장."""
+import json, os, re, shutil, statistics, subprocess, sys, time
+
+S = os.path.dirname(os.path.abspath(__file__))
+RUNS = 3
+GEM_BIN = subprocess.run(["ruby", "-e", "puts Gem.user_dir"], capture_output=True, text=True).stdout.strip() + "/bin"
+
+TOOLS = {
+    "sqzass": {
+        "cmd": lambda d: [os.environ.get("SQZASS", "target/release/sqzass"),
+                          "build", "-i", d, "-o", f"{d}/out"],
+        "clean": lambda d: [f"{d}/out"],
+        "cwd": None,
+    },
+    "hugo": {
+        "cmd": lambda d: ["hugo", "--quiet", "--source", d, "--destination", f"{d}/out"],
+        "clean": lambda d: [f"{d}/out", f"{d}/resources", f"{d}/.hugo_build.lock"],
+        "cwd": None,
+    },
+    "zola": {
+        "cmd": lambda d: ["zola", "--root", d, "build", "--output-dir", f"{d}/out", "--force"],
+        "clean": lambda d: [f"{d}/out"],
+        "cwd": None,
+    },
+    "jekyll": {
+        "cmd": lambda d: [f"{GEM_BIN}/jekyll", "build", "-s", d, "-d", f"{d}/out", "--disable-disk-cache"],
+        "clean": lambda d: [f"{d}/out", f"{d}/.jekyll-cache"],
+        "cwd": None,
+    },
+    "astro": {
+        "cmd": lambda d: ["./node_modules/.bin/astro", "build"],
+        "clean": lambda d: [f"{d}/dist", f"{d}/.astro-cache"],
+        "cwd": lambda d: d,
+    },
+}
+
+def clean(paths):
+    for p in paths:
+        if os.path.isdir(p):
+            shutil.rmtree(p, ignore_errors=True)
+        elif os.path.exists(p):
+            os.remove(p)
+
+def run_one(tool, scenario):
+    t = TOOLS[tool]
+    d = f"{S}/{tool}/{scenario}"
+    times, rss = [], []
+    for i in range(RUNS):
+        clean(t["clean"](d))
+        cmd = ["taskset", "-c", "0-3", "/usr/bin/time", "-v"] + t["cmd"](d)
+        cwd = t["cwd"](d) if t["cwd"] else None
+        t0 = time.monotonic()
+        r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
+        wall = time.monotonic() - t0
+        if r.returncode != 0:
+            print(f"FAIL {tool}/{scenario}: {r.stderr[-500:]}", file=sys.stderr)
+            sys.exit(1)
+        m = re.search(r"Maximum resident set size \(kbytes\): (\d+)", r.stderr)
+        times.append(wall * 1000)
+        rss.append(int(m.group(1)) if m else 0)
+        time.sleep(2)
+    result = {
+        "tool": tool, "scenario": scenario,
+        "median_ms": round(statistics.median(times)),
+        "runs_ms": [round(x) for x in times],
+        "peak_rss_mb": round(max(rss) / 1024),
+    }
+    os.makedirs(f"{S}/results", exist_ok=True)
+    with open(f"{S}/results/{tool}-{scenario}.json", "w") as f:
+        json.dump(result, f)
+    print(f"{tool:7} {scenario:8} 중앙값 {result['median_ms']:>7} ms  "
+          f"(runs: {', '.join(str(x) for x in result['runs_ms'])})  peakRSS {result['peak_rss_mb']} MB")
+
+tool = sys.argv[1]
+scenarios = sys.argv[2:] or ["minimal", "blog", "heavyr", "heavyu"]
+for sc in scenarios:
+    run_one(tool, sc)
diff --git a/bench/gen.py b/bench/gen.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""ssg 벤치마크 코퍼스 생성기. 본문 마크다운은 도구 간 바이트 동일, front matter 형식만 다르다."""
+import os, shutil, subprocess, sys
+
+S = os.path.dirname(os.path.abspath(__file__))
+N = 1000
+PARA = "The quick brown fox jumps over the lazy dog while the build system watches. " * 4
+
+def code_block(page, blk, unique):
+    tag = f"page{page}_blk{blk}_" if unique else ""
+    lines = "\n".join(
+        f"fn {tag}handler_{j}(req: Request) -> Response {{ Response::ok(req.body_{tag}{j}()) }}"
+        for j in range(20)
+    )
+    return f"```rust\n{lines}\n```"
+
+def body(scenario, i):
+    if scenario == "minimal":
+        return f"# Post {i}\n\n{PARA}\n"
+    if scenario == "blog":
+        nxt = f"/posts/post-{(i + 1) % N}/"
+        return (f"# Post {i}\n\n" + "\n\n".join([PARA] * 6)
+                + f"\n\n- one\n- two\n- three\n\n> quote block\n\n[next]({nxt})\n")
+    unique = scenario == "heavyu"
+    parts = [f"# Post {i}"]
+    for b in range(5):
+        parts.append(PARA)
+        parts.append(code_block(i, b, unique))
+    return "\n\n".join(parts) + "\n"
+
+def w(path, content):
+    os.makedirs(os.path.dirname(path), exist_ok=True)
+    with open(path, "w") as f:
+        f.write(content)
+
+SCENARIOS = ["minimal", "blog", "heavyr", "heavyu"]
+SQZASS = os.environ.get("SQZASS", "target/release/sqzass")
+
+for sc in SCENARIOS:
+    # --- sqzass: init 스캐폴드 + TOML +++ front matter
+    d = f"{S}/sqzass/{sc}"
+    if not os.path.exists(d):
+        subprocess.run([SQZASS, "init", d], check=True, capture_output=True)
+    w(f"{d}/content/posts/_index.md", '+++\ntitle = "posts"\n+++\n')
+    for i in range(N):
+        w(f"{d}/content/posts/post-{i}.md", f'+++\ntitle = "Post {i}"\n+++\n\n{body(sc, i)}')
+
+    # --- hugo: TOML +++ front matter (동일 형식)
+    d = f"{S}/hugo/{sc}"
+    w(f"{d}/hugo.toml", 'baseURL = "https://example.com/"\ntitle = "bench"\n')
+    w(f"{d}/layouts/_default/single.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{{ .Title }}</title></head>"
+      "<body><main>{{ .Content }}</main></body></html>\n")
+    w(f"{d}/layouts/_default/list.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{{ .Title }}</title></head>"
+      "<body><main>{{ range .Pages }}<a href=\"{{ .RelPermalink }}\">{{ .Title }}</a> {{ end }}</main></body></html>\n")
+    for i in range(N):
+        w(f"{d}/content/posts/post-{i}.md", f'+++\ntitle = "Post {i}"\n+++\n\n{body(sc, i)}')
+
+    # --- zola: TOML +++ front matter, highlight_code 명시적 활성화(기본 꺼짐)
+    d = f"{S}/zola/{sc}"
+    w(f"{d}/config.toml",
+      'base_url = "https://example.com"\ntitle = "bench"\ncompile_sass = false\n'
+      'build_search_index = false\n\n[markdown.highlighting]\ntheme = "github-dark"\n')
+    w(f"{d}/templates/index.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>bench</title></head><body><main>index</main></body></html>\n")
+    w(f"{d}/templates/page.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{{ page.title }}</title></head>"
+      "<body><main>{{ page.content | safe }}</main></body></html>\n")
+    w(f"{d}/templates/section.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{{ section.title }}</title></head>"
+      "<body><main>{% for p in section.pages %}<a href=\"{{ p.permalink }}\">{{ p.title }}</a> {% endfor %}</main></body></html>\n")
+    w(f"{d}/content/posts/_index.md", '+++\ntitle = "posts"\n+++\n')
+    for i in range(N):
+        w(f"{d}/content/posts/post-{i}.md", f'+++\ntitle = "Post {i}"\n+++\n\n{body(sc, i)}')
+
+    # --- jekyll: YAML --- front matter
+    d = f"{S}/jekyll/{sc}"
+    w(f"{d}/_config.yml",
+      'title: bench\ndefaults:\n  - scope: { path: "" }\n    values: { layout: default }\n')
+    w(f"{d}/_layouts/default.html",
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{{ page.title }}</title></head>"
+      "<body><main>{{ content }}</main></body></html>\n")
+    for i in range(N):
+        w(f"{d}/posts/post-{i}.md", f'---\ntitle: "Post {i}"\n---\n\n{body(sc, i)}')
+
+    # --- astro: YAML front matter + layout 지정, node_modules는 공용 심링크
+    d = f"{S}/astro/{sc}"
+    os.makedirs(d, exist_ok=True)
+    shutil.copy(f"{S}/astro-base/package.json", f"{d}/package.json")
+    w(f"{d}/astro.config.mjs", "export default { cacheDir: './.astro-cache' };\n")
+    if not os.path.islink(f"{d}/node_modules"):
+        os.symlink(f"{S}/astro-base/node_modules", f"{d}/node_modules")
+    w(f"{d}/src/layouts/Base.astro",
+      "---\nconst { frontmatter } = Astro.props;\n---\n"
+      "<!doctype html><html><head><meta charset=\"utf-8\"><title>{frontmatter.title}</title></head>"
+      "<body><main><slot /></main></body></html>\n")
+    for i in range(N):
+        w(f"{d}/src/pages/posts/post-{i}.md",
+          f'---\nlayout: ../../layouts/Base.astro\ntitle: "Post {i}"\n---\n\n{body(sc, i)}')
+
+print("corpora ready:", ", ".join(SCENARIOS))
diff --git a/bench/results-2026-07-29.json b/bench/results-2026-07-29.json
@@ -0,0 +1,233 @@
+{
+ "date": "2026-07-29",
+ "machine": "4 cores (taskset -c 0-3), AC, performance governor",
+ "versions": {
+  "sqzass": "0.1.0 (bf1a9dc)",
+  "hugo": "0.164.0+extended",
+  "zola": "0.22.1",
+  "jekyll": "4.4.1 (ruby 3.4.10)",
+  "astro": "5.18.2 (node 26.5.0)"
+ },
+ "results": [
+  {
+   "tool": "astro",
+   "scenario": "blog",
+   "median_ms": 6699,
+   "runs_ms": [
+    6699,
+    7079,
+    6666
+   ],
+   "peak_rss_mb": 604
+  },
+  {
+   "tool": "astro",
+   "scenario": "heavyr",
+   "median_ms": 22090,
+   "runs_ms": [
+    23517,
+    22090,
+    21101
+   ],
+   "peak_rss_mb": 2578
+  },
+  {
+   "tool": "astro",
+   "scenario": "heavyu",
+   "median_ms": 23958,
+   "runs_ms": [
+    23211,
+    25187,
+    23958
+   ],
+   "peak_rss_mb": 2607
+  },
+  {
+   "tool": "astro",
+   "scenario": "minimal",
+   "median_ms": 4672,
+   "runs_ms": [
+    3899,
+    4672,
+    4894
+   ],
+   "peak_rss_mb": 555
+  },
+  {
+   "tool": "hugo",
+   "scenario": "blog",
+   "median_ms": 112,
+   "runs_ms": [
+    114,
+    112,
+    111
+   ],
+   "peak_rss_mb": 140
+  },
+  {
+   "tool": "hugo",
+   "scenario": "heavyr",
+   "median_ms": 3559,
+   "runs_ms": [
+    3559,
+    3553,
+    3604
+   ],
+   "peak_rss_mb": 411
+  },
+  {
+   "tool": "hugo",
+   "scenario": "heavyu",
+   "median_ms": 5912,
+   "runs_ms": [
+    5792,
+    6061,
+    5912
+   ],
+   "peak_rss_mb": 421
+  },
+  {
+   "tool": "hugo",
+   "scenario": "minimal",
+   "median_ms": 93,
+   "runs_ms": [
+    94,
+    93,
+    92
+   ],
+   "peak_rss_mb": 123
+  },
+  {
+   "tool": "jekyll",
+   "scenario": "blog",
+   "median_ms": 752,
+   "runs_ms": [
+    769,
+    752,
+    746
+   ],
+   "peak_rss_mb": 62
+  },
+  {
+   "tool": "jekyll",
+   "scenario": "heavyr",
+   "median_ms": 10850,
+   "runs_ms": [
+    9754,
+    10850,
+    11406
+   ],
+   "peak_rss_mb": 174
+  },
+  {
+   "tool": "jekyll",
+   "scenario": "heavyu",
+   "median_ms": 11456,
+   "runs_ms": [
+    11456,
+    11668,
+    10797
+   ],
+   "peak_rss_mb": 182
+  },
+  {
+   "tool": "jekyll",
+   "scenario": "minimal",
+   "median_ms": 442,
+   "runs_ms": [
+    446,
+    442,
+    441
+   ],
+   "peak_rss_mb": 56
+  },
+  {
+   "tool": "sqzass",
+   "scenario": "blog",
+   "median_ms": 25,
+   "runs_ms": [
+    25,
+    25,
+    24
+   ],
+   "peak_rss_mb": 24
+  },
+  {
+   "tool": "sqzass",
+   "scenario": "heavyr",
+   "median_ms": 187,
+   "runs_ms": [
+    191,
+    186,
+    187
+   ],
+   "peak_rss_mb": 229
+  },
+  {
+   "tool": "sqzass",
+   "scenario": "heavyu",
+   "median_ms": 1298,
+   "runs_ms": [
+    1298,
+    1308,
+    1291
+   ],
+   "peak_rss_mb": 439
+  },
+  {
+   "tool": "sqzass",
+   "scenario": "minimal",
+   "median_ms": 18,
+   "runs_ms": [
+    17,
+    18,
+    18
+   ],
+   "peak_rss_mb": 15
+  },
+  {
+   "tool": "zola",
+   "scenario": "blog",
+   "median_ms": 172,
+   "runs_ms": [
+    175,
+    169,
+    172
+   ],
+   "peak_rss_mb": 142
+  },
+  {
+   "tool": "zola",
+   "scenario": "heavyr",
+   "median_ms": 5084,
+   "runs_ms": [
+    5084,
+    5055,
+    5398
+   ],
+   "peak_rss_mb": 269
+  },
+  {
+   "tool": "zola",
+   "scenario": "heavyu",
+   "median_ms": 6789,
+   "runs_ms": [
+    6969,
+    6752,
+    6789
+   ],
+   "peak_rss_mb": 279
+  },
+  {
+   "tool": "zola",
+   "scenario": "minimal",
+   "median_ms": 141,
+   "runs_ms": [
+    141,
+    142,
+    140
+   ],
+   "peak_rss_mb": 136
+  }
+ ]
+}
\ No newline at end of file
diff --git a/docs/content/benchmark/_index.ko.md b/docs/content/benchmark/_index.ko.md
@@ -0,0 +1,104 @@
++++
+title = "벤치마크"
+description = "생성기 다섯, 머신 하나, 코퍼스 하나 — 그리고 정의는 전부 공개"
+weight = 60
+toc = true
++++
+
+같은 1,000페이지 사이트를 같은 머신에서 다섯 생성기로 콜드 빌드했습니다.
+짧을수록 좋습니다. sqzass 수치에는 기본으로 내보내는 모든 것 — 검색 색인,
+sitemap, robots.txt, llms.txt — 이 포함돼 있고, 다른 도구는 이걸 만들지
+않습니다.
+
+수치는 특정 머신 한 대에서 잰 콜드 3회의 중앙값입니다. 밀리초보다 비율이
+더 멀리 갑니다. 생성기와 하네스가
+[`bench/`](https://github.com/sqzer-x/sqzass/tree/main/bench)에 있으니 직접
+돌려 보세요.
+
+## minimal — 제목 하나와 문단 하나
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.6%" height="100%" rx="3"></rect></svg><span class="b-val">18 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="2%" height="100%" rx="3"></rect></svg><span class="b-val">93 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="3%" height="100%" rx="3"></rect></svg><span class="b-val">141 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="9.5%" height="100%" rx="3"></rect></svg><span class="b-val">442 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">4,672 ms</span></span>
+</div>
+
+## blog — 문단 여섯, 목록, 인용, 링크
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.6%" height="100%" rx="3"></rect></svg><span class="b-val">25 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="1.7%" height="100%" rx="3"></rect></svg><span class="b-val">112 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="2.6%" height="100%" rx="3"></rect></svg><span class="b-val">172 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="11.2%" height="100%" rx="3"></rect></svg><span class="b-val">752 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">6,699 ms</span></span>
+</div>
+
+## heavy·반복 — 페이지마다 동일한 20줄 Rust 블록 5개
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.85%" height="100%" rx="3"></rect></svg><span class="b-val">187 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="16.1%" height="100%" rx="3"></rect></svg><span class="b-val">3,559 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="23%" height="100%" rx="3"></rect></svg><span class="b-val">5,084 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="49.1%" height="100%" rx="3"></rect></svg><span class="b-val">10,850 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">22,090 ms</span></span>
+</div>
+
+## heavy·고유 — 페이지마다 전부 다른 20줄 Rust 블록 5개
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="5.4%" height="100%" rx="3"></rect></svg><span class="b-val">1,298 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="24.7%" height="100%" rx="3"></rect></svg><span class="b-val">5,912 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="28.3%" height="100%" rx="3"></rect></svg><span class="b-val">6,789 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="47.8%" height="100%" rx="3"></rect></svg><span class="b-val">11,456 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">23,958 ms</span></span>
+</div>
+
+## 전체 수치
+
+벽시계 시간(콜드 3회 중앙값)과 peak RSS입니다.
+
+| | minimal | blog | heavy·반복 | heavy·고유 |
+|---|---|---|---|---|
+| **sqzass 0.1.0** | **18 ms** · 15 MB | **25 ms** · 24 MB | **187 ms** · 229 MB | **1,298 ms** · 439 MB |
+| Hugo 0.164.0 | 93 ms · 123 MB | 112 ms · 140 MB | 3,559 ms · 411 MB | 5,912 ms · 421 MB |
+| Zola 0.22.1 | 141 ms · 136 MB | 172 ms · 142 MB | 5,084 ms · 269 MB | 6,789 ms · 279 MB |
+| Jekyll 4.4.1 | 442 ms · 56 MB | 752 ms · 62 MB | 10,850 ms · 174 MB | 11,456 ms · 182 MB |
+| Astro 5.18.2 | 4,672 ms · 555 MB | 6,699 ms · 604 MB | 22,090 ms · 2,578 MB | 23,958 ms · 2,607 MB |
+
+## heavy가 둘인 이유
+
+코드 반복도가 순위를 가르는데, 대부분의 벤치마크는 자기 코퍼스의 반복도를
+말하지 않기 때문입니다. sqzass는 서로 다른 블록만 빌드에서 한 번씩
+하이라이트하므로, 같은 블록을 반복하는 생성 코퍼스와 전부 다른 블록인
+코퍼스는 서로 다른 측정입니다 — 187 ms와 1,298 ms의 간격이 바로 그
+변수입니다. 반복도를 밝히지 않은 단일 "heavy" 수치는 결과를 만든 변수를
+숨긴 것입니다.
+
+## 방법
+
+- **콜드.** 매 회 출력 디렉터리와 도구별 캐시 — `.jekyll-cache`, Astro의
+  `cacheDir`와 `dist`, Hugo의 `resources`와 빌드 락 — 를 지웠습니다. 벽시계
+  시간과 peak RSS는 GNU time으로 쟀습니다.
+- **코퍼스.** 1,000페이지 + 섹션 인덱스. 마크다운 *본문*은 도구 간 바이트
+  동일이고 front matter 문법만 다릅니다. 문단은 76자 문장 하나를 네 번
+  반복한 고정 텍스트입니다.
+- **하이라이팅은 다섯 도구 모두 빌드 타임에**, 각자 싣는 그대로 켰습니다:
+  syntect+Oniguruma(sqzass), Chroma(Hugo), Giallo(Zola), Rouge(Jekyll),
+  Shiki(Astro). 마크업 세분도는 다릅니다 — 같은 heavy 페이지의 span 수:
+  sqzass 3,205 · Jekyll 1,800 · Zola·Astro 1,700 · Hugo 600. sqzass는 다섯 중
+  가장 세밀한 마크업을 내면서 위의 수치를 냅니다.
+- **템플릿.** 다섯 도구 모두 테마 없는 동일한 최소 레이아웃 — 렌더된 본문을
+  감싸는 HTML 셸 — 을 씁니다.
+- **Astro의 시간에는 Node 기동(~1초)이 포함**됩니다. Astro는 마크다운→HTML
+  이상의 일을 하는 컴포넌트 프레임워크이기도 합니다. 그게 정직한 단서이고,
+  이 페이지의 대상은 순수 마크다운 워크로드입니다.
+
+## 이 페이지가 주장하지 않는 것
+
+당신의 머신은 다른 밀리초를 냅니다. 증분 빌드와 개발 서버는 재지 않았습니다.
+기능 범위도 서로 다릅니다 — 여기서 잰 건 하나입니다: 마크다운 디렉터리를
+HTML 디렉터리로, 콜드로, 각 도구의 기본값으로 바꾸는 일. 위의 정의와
+[`bench/`](https://github.com/sqzer-x/sqzass/tree/main/bench)의 스크립트가
+방법론의 전부입니다. 수치가 이상해 보이면 직접 돌려 보세요.
diff --git a/docs/content/benchmark/_index.md b/docs/content/benchmark/_index.md
@@ -0,0 +1,104 @@
++++
+title = "Benchmark"
+description = "Five generators, one machine, one corpus — and the definitions published"
+weight = 60
+toc = true
++++
+
+The same 1,000-page site, built cold by five generators on the same machine.
+Lower is better. The sqzass numbers include everything it emits by default —
+search index, sitemap, robots.txt, llms.txt — which none of the others build.
+
+Numbers are medians of three cold runs on one specific machine. Ratios travel
+better than milliseconds; the generator and harness are in
+[`bench/`](https://github.com/sqzer-x/sqzass/tree/main/bench), so run them on
+yours.
+
+## minimal — a heading and a paragraph
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.6%" height="100%" rx="3"></rect></svg><span class="b-val">18 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="2%" height="100%" rx="3"></rect></svg><span class="b-val">93 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="3%" height="100%" rx="3"></rect></svg><span class="b-val">141 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="9.5%" height="100%" rx="3"></rect></svg><span class="b-val">442 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">4,672 ms</span></span>
+</div>
+
+## blog — six paragraphs, a list, a quote, a link
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.6%" height="100%" rx="3"></rect></svg><span class="b-val">25 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="1.7%" height="100%" rx="3"></rect></svg><span class="b-val">112 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="2.6%" height="100%" rx="3"></rect></svg><span class="b-val">172 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="11.2%" height="100%" rx="3"></rect></svg><span class="b-val">752 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">6,699 ms</span></span>
+</div>
+
+## heavy, repeated code — five identical 20-line Rust blocks per page
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="0.85%" height="100%" rx="3"></rect></svg><span class="b-val">187 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="16.1%" height="100%" rx="3"></rect></svg><span class="b-val">3,559 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="23%" height="100%" rx="3"></rect></svg><span class="b-val">5,084 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="49.1%" height="100%" rx="3"></rect></svg><span class="b-val">10,850 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">22,090 ms</span></span>
+</div>
+
+## heavy, unique code — five distinct 20-line Rust blocks per page
+
+<div class="bench">
+<span class="b-name">sqzass</span><span class="b-row"><svg><rect class="b-us" width="5.4%" height="100%" rx="3"></rect></svg><span class="b-val">1,298 ms</span></span>
+<span class="b-name">Hugo</span><span class="b-row"><svg><rect width="24.7%" height="100%" rx="3"></rect></svg><span class="b-val">5,912 ms</span></span>
+<span class="b-name">Zola</span><span class="b-row"><svg><rect width="28.3%" height="100%" rx="3"></rect></svg><span class="b-val">6,789 ms</span></span>
+<span class="b-name">Jekyll</span><span class="b-row"><svg><rect width="47.8%" height="100%" rx="3"></rect></svg><span class="b-val">11,456 ms</span></span>
+<span class="b-name">Astro</span><span class="b-row"><svg><rect width="100%" height="100%" rx="3"></rect></svg><span class="b-val">23,958 ms</span></span>
+</div>
+
+## Every number
+
+Wall clock, median of three cold runs, and peak RSS.
+
+| | minimal | blog | heavy · repeat | heavy · unique |
+|---|---|---|---|---|
+| **sqzass 0.1.0** | **18 ms** · 15 MB | **25 ms** · 24 MB | **187 ms** · 229 MB | **1,298 ms** · 439 MB |
+| Hugo 0.164.0 | 93 ms · 123 MB | 112 ms · 140 MB | 3,559 ms · 411 MB | 5,912 ms · 421 MB |
+| Zola 0.22.1 | 141 ms · 136 MB | 172 ms · 142 MB | 5,084 ms · 269 MB | 6,789 ms · 279 MB |
+| Jekyll 4.4.1 | 442 ms · 56 MB | 752 ms · 62 MB | 10,850 ms · 174 MB | 11,456 ms · 182 MB |
+| Astro 5.18.2 | 4,672 ms · 555 MB | 6,699 ms · 604 MB | 22,090 ms · 2,578 MB | 23,958 ms · 2,607 MB |
+
+## Why heavy comes twice
+
+Because code repetition decides rankings, and most benchmarks do not say what
+theirs is. sqzass highlights each distinct block once per build, so a corpus
+whose generator repeats the same block is a different measurement from one
+where every block is unique — the gap between 187 ms and 1,298 ms *is* that
+variable. A single "heavy" number that does not state its repetition has
+hidden the thing that produced it.
+
+## Method
+
+- **Cold.** Before every run the output directory and each tool's caches are
+  removed — `.jekyll-cache`, Astro's `cacheDir` and `dist`, Hugo's `resources`
+  and build lock. Wall clock and peak RSS come from GNU time.
+- **Corpus.** 1,000 pages plus a section index. The markdown *bodies* are
+  byte-identical across tools; only the front matter syntax differs.
+  A paragraph is one fixed 76-character sentence repeated four times.
+- **Highlighting is on everywhere**, at build time, as each tool ships it:
+  syntect + Oniguruma (sqzass), Chroma (Hugo), Giallo (Zola), Rouge (Jekyll),
+  Shiki (Astro). Markup granularity differs — spans on the same heavy page:
+  sqzass 3,205 · Jekyll 1,800 · Zola and Astro 1,700 · Hugo 600. sqzass emits
+  the most detailed markup of the five *and* the numbers above.
+- **Templates.** Each tool gets the same minimal no-theme layout: an HTML
+  shell around the rendered content.
+- **Astro's time includes Node startup** (~1 s). It is also doing more than
+  markdown-to-HTML — it is a component framework. That is the honest caveat;
+  the corpus is plain markdown, which is the workload this page is about.
+
+## What this page does not claim
+
+Your machine will produce different milliseconds. Incremental builds and dev
+servers are not measured. Feature scopes differ — this measures one thing:
+turning a directory of markdown into a directory of HTML, cold, with the
+defaults each tool ships. The definitions above and the scripts in
+[`bench/`](https://github.com/sqzer-x/sqzass/tree/main/bench) are the whole
+methodology; if a number looks wrong, run it.
diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -598,6 +598,36 @@ h4:hover > .anchor, h5:hover > .anchor, h6:hover > .anchor,
   border-radius: var(--radius) var(--radius) 0 0;
 }
 
+/* ---------- benchmark bars ---------- */
+
+/* 도구 식별은 행 라벨이, 수치는 막대 옆 텍스트가 운반한다. 색은 우리(악센트)와
+   비교군(중립) 둘뿐이라 색이 단독으로 정보를 나르지 않는다. 막대 폭이 style
+   속성이 아니라 SVG rect의 표현 속성인 이유: 인라인 style은 이 사이트가
+   지켜 온 strict style-src CSP 가능성을 깨기 때문이다. */
+.bench {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: 0.45rem 0.8rem;
+  align-items: center;
+  margin: 0.8rem 0 1.8rem;
+}
+.bench .b-name {
+  font-family: var(--mono);
+  font-size: 0.8rem;
+  color: var(--ink);
+  text-align: right;
+}
+.bench .b-row { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
+.bench svg { flex: 1; min-width: 0; height: 0.75rem; display: block; }
+.bench rect { fill: var(--ink-3); opacity: 0.55; }
+.bench rect.b-us { fill: var(--accent); opacity: 1; }
+.bench .b-val {
+  font-family: var(--mono);
+  font-size: 0.78rem;
+  color: var(--ink-2);
+  white-space: nowrap;
+}
+
 /* ---------- tables ---------- */
 
 /* `display: block`이었다. 넓은 표를 가로로 굴리려던 것인데, 그러면 표가 내부