sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit b13943df93b47729b60db081e55ce674b50e18e8
parent cc7fc4ea0123bd404740c01a1dcfa2596e213c40
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:20:41 +0900

페이지 렌더를 병렬로 돌린다 — 결정성은 병합이 지킨다

코드가 많은 코퍼스의 빌드 시간은 사실상 전부 syntect 하이라이팅이다
(1000페이지 heavy 실측: --profile render 랩 10.0s / 전체 11.6s).
페이지 렌더는 서로 완전히 독립이라 이 비용은 코어 수로 나눌 수 있다.

병렬을 막던 논거는 둘 다 소멸했다. "출력 URL 충돌"은 렌더와 병합을
분리하면 생기지 않는다 — 각 결과를 페이지 인덱스 자리에 꽂고 입력
순서대로 합치므로, 출력 바이트도 실패 시 어느 에러가 보이는지도 스레드
수와 무관하다. "이 크기에서 이득 없음"은 위 실측이 반증한다.

분배는 원자 카운터 훔치기다 — 의존성 없이 스무 줄이고, 코드 블록이
많은 페이지와 없는 페이지의 비용 차가 커서 미리 나누는 청크 분배는
가장 느린 청크를 기다리게 된다. 하이라이터 인스턴스는 페이지마다 새로
만든다: pending(펜스 옵션 핸드오프)과 errors가 페이지별 상태라,
공유하면 병렬에서 서로의 옵션과 에러가 섞인다. 8스레드×50회 동시 렌더
테스트가 이를 고정한다.

측정(1000페이지, 4코어, 3회 중앙값): heavy 11.6s → 4.0s, blog 64ms →
44ms. 변경 전 직렬 바이너리와 docs 58p·heavy 1002p 산출물 diff -r
바이트 동일, taskset 1워커 == 4워커 == 8워커 동일과 4워커 반복 빌드
동일까지 확인했다.

Diffstat:
Msrc/highlight.rs | 5+++--
Msrc/lib.rs | 49+++++++++++++++++++++++++++++++++++++++++++++----
Msrc/markdown.rs | 39++++++++++++++++++++++++++++++++++++---
3 files changed, 84 insertion(+), 9 deletion(-)

diff --git a/src/highlight.rs b/src/highlight.rs
@@ -38,8 +38,9 @@ static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
 pub struct Highlighter {
     syntaxes: &'static SyntaxSet,
     /// 파싱된 펜스 옵션을 `write_code_tag` → `write_highlighted` 사이에 건네는
-    /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고 렌더는
-    /// 단일 스레드다. 병렬 렌더를 도입하면 이 상태는 렌더 단위별로 분리해야 한다.
+    /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고, 한
+    /// 페이지의 렌더는 단일 스레드다. 페이지들은 병렬로 렌더되므로 인스턴스는
+    /// **호출(페이지)마다 새로 만든다** — markdown.rs의 render_in이 그렇게 한다.
     pending: Mutex<FenceOpts>,
     /// 잘못된 펜스 옵션. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다 — 모르는 키를
     /// 조용히 무시하면 `hl_line=` 오타가 강조 없는 채로 배포된다.
diff --git a/src/lib.rs b/src/lib.rs
@@ -240,8 +240,13 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     // 언어 코드 → 그 언어의 검색 색인 행들.
     let mut index: BTreeMap<String, Vec<search::Entry>> = BTreeMap::new();
 
-    for page in &pages {
-        let (html, entry) = render_page(
+    // 페이지 렌더는 서로 완전히 독립이라 병렬로 돌린다. 결정성은 스케줄이 아니라
+    // 병합이 정한다: 각 결과를 페이지 인덱스 자리에 꽂고 입력 순서대로 합치므로,
+    // 출력 바이트도 — 실패할 때 어느 에러가 보이는지도 — 스레드 수와 무관하다.
+    // 분배는 원자 카운터 훔치기다. 코드 블록이 많은 페이지와 없는 페이지의 렌더
+    // 비용 차가 커서, 미리 나누는 청크 분배는 가장 느린 청크를 기다리게 된다.
+    let render_one = |page: &Page| {
+        render_page(
             page,
             &site,
             &cfg,
@@ -256,7 +261,40 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
                 .contains_key(&page.language)
                 .then(|| format!("{}/{}", cfg.base_path(), feed::path(&page.language)))
                 .as_deref(),
-        )?;
+        )
+    };
+    let workers = std::thread::available_parallelism()
+        .map(|n| n.get())
+        .unwrap_or(1)
+        .min(pages.len())
+        .max(1);
+    let mut results: Vec<Option<Result<RenderedPage>>> =
+        std::iter::repeat_with(|| None).take(pages.len()).collect();
+    let next = std::sync::atomic::AtomicUsize::new(0);
+    let done = std::thread::scope(|scope| {
+        let handles: Vec<_> = (0..workers)
+            .map(|_| {
+                scope.spawn(|| {
+                    let mut local = Vec::new();
+                    loop {
+                        let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+                        let Some(page) = pages.get(i) else { break };
+                        local.push((i, render_one(page)));
+                    }
+                    local
+                })
+            })
+            .collect();
+        handles
+            .into_iter()
+            .flat_map(|h| h.join().expect("렌더 스레드가 패닉했다"))
+            .collect::<Vec<_>>()
+    });
+    for (i, r) in done {
+        results[i] = Some(r);
+    }
+    for (page, result) in pages.iter().zip(results) {
+        let (html, entry) = result.expect("모든 페이지가 렌더된다")?;
         out.files.insert(
             page.out_path.to_string_lossy().replace('\\', "/"),
             html.into_bytes(),
@@ -423,6 +461,9 @@ fn check_output_collisions(out: &BuildOutput) -> Result<()> {
 /// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
 /// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다. 색인을 끈 사이트에는
 /// 행을 만들지 않는다 — 페이지당 본문 사본 하나가 통째로 사라진다.
+/// 페이지 하나의 렌더 결과: HTML과, 검색이 켜져 있을 때의 색인 행.
+type RenderedPage = (String, Option<search::Entry>);
+
 // 렌더 루프 한 곳에서만 부르는 내부 함수라 인자를 묶는 구조체가 이름값을 못 한다.
 #[allow(clippy::too_many_arguments)]
 fn render_page(
@@ -434,7 +475,7 @@ fn render_page(
     sections: minijinja::Value,
     highlight_css: Option<&str>,
     feed_url: Option<&str>,
-) -> Result<(String, Option<search::Entry>)> {
+) -> Result<RenderedPage> {
     let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
 
     let site_ctx = SiteCtx {
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -126,6 +126,14 @@ impl Renderer {
             options.extension.image_url_rewriter = Some(r.clone());
         }
 
+        // 하이라이터도 호출마다 새로 만든다. pending(펜스 옵션 핸드오프)과
+        // errors가 페이지별 상태라, 인스턴스를 페이지끼리 공유하면 병렬 렌더에서
+        // 서로의 옵션과 에러가 섞인다. SyntaxSet은 정적 공유라 비용은 Mutex 두 개다.
+        let highlighter = self
+            .highlighter
+            .as_ref()
+            .map(|_| crate::highlight::Highlighter::new());
+
         // 한 번만 파싱해서 HTML과 평문을 같은 트리에서 뽑는다. 링크 재작성기는
         // 렌더 단계에서 도므로 평문 수집이 그 결과에 영향을 받지 않는다.
         let arena = Arena::new();
@@ -135,7 +143,7 @@ impl Renderer {
         let html = {
             let mut plugins = Plugins::default();
             plugins.render.heading_adapter = Some(&headings);
-            if let Some(h) = &self.highlighter {
+            if let Some(h) = &highlighter {
                 plugins.render.codefence_syntax_highlighter = Some(h);
             }
             let mut out = String::new();
@@ -148,8 +156,7 @@ impl Renderer {
             .as_ref()
             .map(|r| r.take_unresolved())
             .unwrap_or_default();
-        let bad_fences = self
-            .highlighter
+        let bad_fences = highlighter
             .as_ref()
             .map(|h| h.take_errors())
             .unwrap_or_default();
@@ -469,6 +476,32 @@ mod tests {
         assert!(r.text.contains("Some bold and a link."), "실제: {}", r.text);
     }
 
+    #[test]
+    fn concurrent_renders_do_not_share_state() {
+        // 하이라이터의 pending/errors가 호출 간에 공유되면 병렬 렌더에서 다른
+        // 페이지의 펜스 옵션과 에러가 서로 섞인다. 단일 스레드 결과를 기준으로
+        // 여러 스레드가 반복 렌더해도 같은 결과인지 본다.
+        let r = Renderer::new(&MarkdownConfig::default())
+            .with_highlighter(crate::highlight::Highlighter::new());
+        let a = "```rust hl_lines=1 name=a.rs\nlet a = 1;\nlet b = 2;\n```";
+        let b = "```rust linenos=oops\nlet c = 3;\n```";
+        let expect_a = r.render(a).html;
+        std::thread::scope(|s| {
+            for _ in 0..8 {
+                s.spawn(|| {
+                    for _ in 0..50 {
+                        let ra = r.render(a);
+                        assert_eq!(ra.html, expect_a);
+                        assert!(ra.bad_fences.is_empty(), "실제: {:?}", ra.bad_fences);
+                        let rb = r.render(b);
+                        assert_eq!(rb.bad_fences.len(), 1, "실제: {:?}", rb.bad_fences);
+                        assert!(!rb.html.contains("data-name"), "옵션이 샜다: {}", rb.html);
+                    }
+                });
+            }
+        });
+    }
+
     #[test]
     fn anchors_can_be_disabled_but_ids_remain() {
         let cfg = MarkdownConfig {