sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 4ca60e81ae117864951a3b30d927b63e90a6ed48
parent 4733be73c5d01220f0ba0e99786975ea74493904
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:52:02 +0900

렌더 루프의 O(n²)를 제거하고 serve 고정비를 캐시한다

5000페이지 실측(ssg-benchmark 방법론 재현, 콜드 3회 중앙값, taskset 4코어)에서
빌드가 hwaro보다 7.7배 느렸다. 원인은 렌더 자체가 아니라 페이지마다 반복되던
O(n) 작업들이다 — 100→1000→5000 차분비가 28로 순수 이차식 예상치(≈24)에 붙어
있었다.

- 링크 표(LinkIndex)와 URL 집합(KnownUrls)을 Arc로 공유한다. Resolver는
페이지마다 새로 만들어야 하지만(unresolved가 페이지별 상태다) 표는 사이트
전체에서 같은 하나다. 값으로 들고 있던 시절에는 페이지마다 전 URL 집합이
통째로 복사됐다.
- 사이드바 컨텍스트(section_ctx)를 언어당 한 번만 만들어 minijinja Value로
사전 직렬화한다. minijinja는 Value를 직렬화 경계 너머로 그대로 통과시키므로
페이지마다 드는 비용이 참조 카운트 복사로 준다. 404 렌더도 같은 캐시를 쓴다.
- neighbours_of의 섹션 내 위치 탐색을 URL 문자열 선형 탐색에서 Site::build 때
한 번 만드는 표 조회로 바꾼다. 평평한 대형 섹션에서 O(k²)였다.
- SyntaxSet(two-face 덤프 역직렬화, 수십 ms급)과 ThemeSet을 OnceLock으로
프로세스당 1회만 로드한다. serve가 저장마다 빌드를 새로 돌므로 이 둘이
저장→반영 지연에 매번 얹혔다. 콘텐츠와 무관해 결정성에는 영향이 없다.

측정:
minimal 5000p: 7.58s → 0.193s (39배; hwaro 0.985s 대비 5.1배 빠름)
code 5000p: 21.7s → 9.63s (남은 격차는 syntect 하이라이팅 자체)
차분비 28 → 4.4 = 선형 예상치. 실제 docs 사이트(58페이지)는 전후 동일 ~0.2s.

수정 후 빌드는 수정 전 커밋 산출물(docs/public)과 바이트 동일 — 동작 보존이
산출물로 증명된다. 같은 입력 2회 빌드 동일성도 확인했다.

Diffstat:
Msrc/highlight.rs | 19+++++++++++++++----
Msrc/lib.rs | 36++++++++++++++++++++++++++++++++----
Msrc/links.rs | 30+++++++++++++++++-------------
Msrc/markdown.rs | 21++++++++++++---------
Msrc/render.rs | 10++++++++--
Msrc/site.rs | 30++++++++++++++++++++++++------
6 files changed, 108 insertion(+), 38 deletion(-)

diff --git a/src/highlight.rs b/src/highlight.rs
@@ -12,6 +12,7 @@ use comrak::adapters::SyntaxHighlighterAdapter;
 use std::borrow::Cow;
 use std::collections::HashMap;
 use std::fmt;
+use std::sync::OnceLock;
 use syntect::highlighting::ThemeSet;
 use syntect::html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style};
 use syntect::parsing::SyntaxSet;
@@ -25,8 +26,15 @@ const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed {
     prefix: CLASS_PREFIX,
 };
 
+/// 문법 덤프는 프로세스당 한 번만 역직렬화한다.
+///
+/// two-face의 bincode 덤프 역직렬화는 수십 ms급 고정비다. 단일 `build`에서는 어차피
+/// 한 번이지만, `serve`는 저장할 때마다 빌드를 새로 돌므로 이게 저장→반영 지연에
+/// 매번 얹혔다. 문법 집합은 콘텐츠와 무관하니 재사용해도 결정성에 영향이 없다.
+static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
+
 pub struct Highlighter {
-    syntaxes: SyntaxSet,
+    syntaxes: &'static SyntaxSet,
 }
 
 impl Highlighter {
@@ -34,7 +42,7 @@ impl Highlighter {
         // `extra_newlines` 여야 한다. ClassedHTMLGenerator는 줄 끝의 개행을 요구하고,
         // no-newlines 덤프와 섞으면 조용히 잘못된 결과가 나온다.
         Self {
-            syntaxes: two_face::syntax::extra_newlines(),
+            syntaxes: SYNTAXES.get_or_init(two_face::syntax::extra_newlines),
         }
     }
 }
@@ -62,7 +70,7 @@ impl SyntaxHighlighterAdapter for Highlighter {
             .unwrap_or_else(|| self.syntaxes.find_syntax_plain_text());
 
         let mut generator =
-            ClassedHTMLGenerator::new_with_class_style(syntax, &self.syntaxes, CLASS_STYLE);
+            ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
         for line in LinesWithEndings::from(code) {
             // 강조에 실패해도 문서 빌드를 통째로 죽이지 않는다. 이 줄만 포기하고
             // 이스케이프된 원문을 내보낸다.
@@ -131,7 +139,10 @@ fn escape_attr(s: &str) -> String {
 /// 수동 토글용 `[data-theme="dark"]`에 한 번. 네이티브 CSS 중첩을 써서 생성된
 /// 규칙을 문자열로 파싱하지 않고 그대로 감싼다.
 pub fn stylesheet(cfg: &HighlightConfig) -> Result<String> {
-    let themes = ThemeSet::load_defaults();
+    // 테마 집합도 문법 덤프와 같은 이유로 프로세스당 한 번만 로드한다 —
+    // serve의 리빌드마다 이 함수가 다시 불린다.
+    static THEMES: OnceLock<ThemeSet> = OnceLock::new();
+    let themes = THEMES.get_or_init(ThemeSet::load_defaults);
 
     let get = |name: &str| -> Result<String> {
         let theme = themes.themes.get(name).with_context(|| {
diff --git a/src/lib.rs b/src/lib.rs
@@ -180,6 +180,21 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         .with_assets(assets.manifest.clone())
         .with_i18n(i18n::load(root).map_err(Kind::Template.tag())?);
 
+    // 사이드바 컨텍스트는 언어당 한 번만 만들어 미리 minijinja 값으로 직렬화한다.
+    // 페이지마다 전 섹션·전 페이지를 재조립해 다시 직렬화하면 페이지당 O(n),
+    // 빌드 전체 O(n²)다 — 5000페이지 실측에서 링크 표 복사와 함께 지배 항이었다.
+    // `Value` 복사는 참조 카운트라 페이지마다 나눠 주는 비용은 포인터 복사다.
+    let sections_by_lang: BTreeMap<&str, minijinja::Value> = pages
+        .iter()
+        .map(|p| p.language.as_str())
+        .collect::<std::collections::BTreeSet<_>>()
+        .into_iter()
+        .map(|lang| {
+            let ctx = site::section_ctx(site.sections(lang), site.pages);
+            (lang, minijinja::Value::from_serialize(&ctx))
+        })
+        .collect();
+
     let mut out = BuildOutput {
         pages: pages.len(),
         ..BuildOutput::default()
@@ -196,6 +211,10 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             &cfg,
             &templates,
             &md,
+            sections_by_lang
+                .get(page.language.as_str())
+                .cloned()
+                .expect("언어별 섹션 컨텍스트는 모든 페이지 언어에 대해 만들었다"),
             highlight_css_url.as_deref(),
             feeds
                 .contains_key(&page.language)
@@ -238,8 +257,14 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         let html = render_standalone(
             emit::NOT_FOUND_PATH,
             &cfg,
-            &site,
             &templates,
+            // 기본 언어에 페이지가 하나도 없으면 섹션도 없다 — 빈 목록이 옳다.
+            sections_by_lang
+                .get(cfg.default_language.as_str())
+                .cloned()
+                .unwrap_or_else(
+                    || minijinja::Value::from_serialize(Vec::<site::SectionCtx>::new()),
+                ),
             highlight_css_url.as_deref(),
         )
         .map_err(Kind::Template.tag())?;
@@ -352,12 +377,15 @@ fn check_output_collisions(out: &BuildOutput) -> Result<()> {
 
 /// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
 /// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다.
+// 렌더 루프 한 곳에서만 부르는 내부 함수라 인자를 묶는 구조체가 이름값을 못 한다.
+#[allow(clippy::too_many_arguments)]
 fn render_page(
     page: &Page,
     site: &Site,
     cfg: &Config,
     templates: &Templates,
     md: &markdown::Renderer,
+    sections: minijinja::Value,
     highlight_css: Option<&str>,
     feed_url: Option<&str>,
 ) -> Result<(String, search::Entry)> {
@@ -369,7 +397,7 @@ fn render_page(
         origin: cfg.origin().to_string(),
         base_path: cfg.base_path().to_string(),
         language: page.language.clone(),
-        sections: site::section_ctx(site.sections(&page.language), site.pages),
+        sections,
         feed: feed_url.map(str::to_string),
         highlight_css: highlight_css.map(str::to_string),
     };
@@ -448,8 +476,8 @@ fn render_page(
 fn render_standalone(
     name: &str,
     cfg: &Config,
-    site: &Site,
     templates: &Templates,
+    sections: minijinja::Value,
     highlight_css: Option<&str>,
 ) -> Result<String> {
     let language = cfg.default_language.clone();
@@ -461,7 +489,7 @@ fn render_standalone(
         origin: cfg.origin().to_string(),
         base_path: cfg.base_path().to_string(),
         language: language.clone(),
-        sections: site::section_ctx(site.sections(&language), site.pages),
+        sections,
         feed: None,
         highlight_css: highlight_css.map(str::to_string),
     };
diff --git a/src/links.rs b/src/links.rs
@@ -9,7 +9,7 @@
 
 use comrak::options::URLRewriter;
 use std::collections::BTreeMap;
-use std::sync::Mutex;
+use std::sync::{Arc, Mutex};
 
 pub const PREFIX: &str = "@/";
 
@@ -23,24 +23,28 @@ pub struct KnownUrls {
     pub urls: std::collections::BTreeSet<String>,
 }
 
+/// 표 두 개는 `Arc`다. 리졸버 자체는 페이지마다 새로 만들어야 하지만(`unresolved`가
+/// 페이지별 상태다), 표는 사이트 전체에서 같은 하나다. 값으로 들고 있던 시절에는
+/// 페이지마다 전 URL 집합이 통째로 복사돼 빌드 전체가 O(n²)였다 — 5000페이지
+/// 실측에서 hwaro보다 7배 느린 주범이 이 복사였다.
 pub struct Resolver {
-    index: LinkIndex,
+    index: Arc<LinkIndex>,
     language: String,
     default_language: String,
     /// 해석하지 못한 링크. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다.
     unresolved: Mutex<Vec<String>>,
     /// 이 사이트가 내보내는 URL 전체. 비어 있으면 절대 경로 검사를 건너뛴다.
-    known: KnownUrls,
+    known: Arc<KnownUrls>,
 }
 
 impl Resolver {
-    pub fn new(index: LinkIndex, language: &str, default_language: &str) -> Self {
+    pub fn new(index: Arc<LinkIndex>, language: &str, default_language: &str) -> Self {
         Self {
             index,
             language: language.to_string(),
             default_language: default_language.to_string(),
             unresolved: Mutex::new(Vec::new()),
-            known: KnownUrls::default(),
+            known: Arc::default(),
         }
     }
 
@@ -49,7 +53,7 @@ impl Resolver {
     /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이 된다. 사람이 가장
     /// 자연스럽게 쓰는 건 `[설치](/start/install/)`인데, 그건 지금까지 아무도
     /// 검사하지 않았고 프로덕션에서 404가 됐다.
-    pub fn with_known_urls(mut self, known: KnownUrls) -> Self {
+    pub fn with_known_urls(mut self, known: Arc<KnownUrls>) -> Self {
         self.known = known;
         self
     }
@@ -168,14 +172,14 @@ fn translation_key(path: &str) -> String {
 mod language_suffix_tests {
     use super::*;
 
-    fn index(pairs: &[(&str, &str)]) -> LinkIndex {
+    fn index(pairs: &[(&str, &str)]) -> Arc<LinkIndex> {
         let mut idx = LinkIndex::new();
         for (key, url) in pairs {
             idx.entry((*key).to_string())
                 .or_default()
                 .insert("en".to_string(), (*url).to_string());
         }
-        idx
+        Arc::new(idx)
     }
 
     /// 콘텐츠 쪽은 **선언된** 언어 코드만 떼고, 링크 쪽은 목록을 갖고 있지 않다.
@@ -217,12 +221,12 @@ mod absolute_link_tests {
     use std::collections::BTreeSet;
 
     fn resolver(urls: &[&str]) -> Resolver {
-        Resolver::new(LinkIndex::new(), "en", "en").with_known_urls(KnownUrls {
+        Resolver::new(Arc::default(), "en", "en").with_known_urls(Arc::new(KnownUrls {
             urls: urls
                 .iter()
                 .map(|s| (*s).to_string())
                 .collect::<BTreeSet<_>>(),
-        })
+        }))
     }
 
     /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이다. 사람이 가장
@@ -257,7 +261,7 @@ mod absolute_link_tests {
     /// 반쯤 아는 상태로 판정하면 멀쩡한 링크를 죽었다고 말하게 된다.
     #[test]
     fn no_known_urls_means_no_checking() {
-        let r = Resolver::new(LinkIndex::new(), "en", "en");
+        let r = Resolver::new(Arc::default(), "en", "en");
         r.to_html("/anything/");
         assert!(r.take_unresolved().is_empty());
     }
@@ -267,7 +271,7 @@ mod absolute_link_tests {
 mod tests {
     use super::*;
 
-    fn index() -> LinkIndex {
+    fn index() -> Arc<LinkIndex> {
         let mut i = LinkIndex::new();
         i.insert(
             "start/installation".into(),
@@ -281,7 +285,7 @@ mod tests {
             "start/_index".into(),
             [("en".to_string(), "/start/".to_string())].into(),
         );
-        i
+        Arc::new(i)
     }
 
     #[test]
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -11,7 +11,7 @@ use comrak::options::Plugins;
 use comrak::{Anchorizer, Arena, Options, format_html_with_plugins, parse_document};
 use serde::Serialize;
 use std::fmt;
-use std::sync::Mutex;
+use std::sync::{Arc, Mutex};
 
 /// 목차 항목. 템플릿에는 중첩된 형태로 나간다.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -36,9 +36,11 @@ pub struct Rendered {
 pub struct Renderer {
     cfg: MarkdownConfig,
     highlighter: Option<crate::highlight::Highlighter>,
-    links: Option<crate::links::LinkIndex>,
+    // 링크 표와 URL 집합은 `Arc`로 들고 페이지마다 포인터만 나눠 준다. 값으로 들면
+    // `render_in`이 페이지마다 O(n) 복사를 하게 되어 빌드 전체가 O(n²)가 된다.
+    links: Option<Arc<crate::links::LinkIndex>>,
     default_language: String,
-    known: crate::links::KnownUrls,
+    known: Arc<crate::links::KnownUrls>,
 }
 
 impl Renderer {
@@ -48,14 +50,14 @@ impl Renderer {
             highlighter: None,
             links: None,
             default_language: "en".into(),
-            known: crate::links::KnownUrls::default(),
+            known: Arc::default(),
         }
     }
 
     /// 루트 절대 경로 링크(`/start/install/`)와 이미지도 존재를 검사하게 한다.
     /// 비워 두면 검사하지 않는다.
     pub fn with_known_urls(mut self, known: crate::links::KnownUrls) -> Self {
-        self.known = known;
+        self.known = Arc::new(known);
         self
     }
 
@@ -67,7 +69,7 @@ impl Renderer {
 
     /// `@/path.md` 내부 링크 해석을 켠다.
     pub fn with_links(mut self, index: crate::links::LinkIndex, default_language: &str) -> Self {
-        self.links = Some(index);
+        self.links = Some(Arc::new(index));
         self.default_language = default_language.to_string();
         self
     }
@@ -105,10 +107,11 @@ impl Renderer {
         let headings = HeadingCollector::new(self.cfg.heading_anchors);
         let mut options = self.options();
 
+        // 리졸버는 페이지마다 새로(unresolved가 페이지별 상태), 표는 Arc 포인터 복사만.
         let resolver = self.links.as_ref().map(|index| {
-            std::sync::Arc::new(
-                crate::links::Resolver::new(index.clone(), language, &self.default_language)
-                    .with_known_urls(self.known.clone()),
+            Arc::new(
+                crate::links::Resolver::new(Arc::clone(index), language, &self.default_language)
+                    .with_known_urls(Arc::clone(&self.known)),
             )
         });
         if let Some(r) = &resolver {
diff --git a/src/render.rs b/src/render.rs
@@ -209,8 +209,14 @@ pub struct SiteCtx {
     pub base_path: String,
     /// 지금 렌더 중인 페이지의 언어.
     pub language: String,
-    /// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
-    pub sections: Vec<crate::site::SectionCtx>,
+    /// 이 언어의 최상위 섹션들, 언어당 한 번만 직렬화해 둔 minijinja 값.
+    /// 사이드바는 여기서 만든다.
+    ///
+    /// `Vec<SectionCtx>`가 아닌 이유: 값 그대로 두면 minijinja가 **페이지마다**
+    /// 전 섹션·전 페이지 트리를 다시 직렬화한다 — 페이지당 O(n), 빌드 전체 O(n²).
+    /// minijinja는 `Value`를 직렬화 경계 너머로 그대로 통과시키므로, 미리 만든
+    /// 값을 참조 카운트 복사로 나눠 주면 그 비용이 사라진다.
+    pub sections: minijinja::Value,
     /// 이 언어의 Atom 피드 URL. 날짜 있는 페이지가 하나도 없으면 `None`이고,
     /// 그때 템플릿은 자동 발견 링크를 넣지 않으면 된다.
     pub feed: Option<String>,
diff --git a/src/site.rs b/src/site.rs
@@ -16,6 +16,12 @@ pub struct Site<'a> {
     trees: BTreeMap<String, Vec<Section>>,
     /// translation_key → (언어 코드 → 페이지 인덱스)
     translations: BTreeMap<String, BTreeMap<String, usize>>,
+    /// 페이지 URL → 소속 섹션 안에서의 순번 (정렬 후).
+    ///
+    /// `neighbours_of`가 페이지마다 섹션 목록을 URL 비교로 훑으면 평평한 대형
+    /// 섹션에서 O(k²)가 된다 — 한 번 만들어 두면 조회다. 키가 URL인 근거:
+    /// URL 충돌은 discover 단계의 하드 에러라 이 시점에 URL은 전역 유일하다.
+    pos_in_section: BTreeMap<String, usize>,
 }
 
 /// 내부 트리 노드. 페이지는 인덱스로 참조해 소유권 문제를 피한다.
@@ -51,16 +57,22 @@ impl<'a> Site<'a> {
             by_lang.entry(p.language.clone()).or_default().push(i);
         }
 
-        let trees = by_lang
+        let trees: BTreeMap<String, Vec<Section>> = by_lang
             .into_iter()
             .map(|(lang, idxs)| (lang, build_tree(pages, &idxs, cfg)))
             .collect();
 
+        let mut pos_in_section = BTreeMap::new();
+        for sections in trees.values() {
+            index_positions(sections, pages, &mut pos_in_section);
+        }
+
         Self {
             cfg,
             pages,
             trees,
             translations,
+            pos_in_section,
         }
     }
 
@@ -178,11 +190,7 @@ impl<'a> Site<'a> {
         let Some(section) = self.section_of(page) else {
             return (None, None);
         };
-        let at = section
-            .pages
-            .iter()
-            .position(|&i| self.pages[i].url == page.url);
-        let Some(at) = at else {
+        let Some(&at) = self.pos_in_section.get(&page.url) else {
             return (None, None);
         };
 
@@ -344,6 +352,16 @@ fn ensure_section<'s>(root: &'s mut Section, dirs: &[String]) -> &'s mut Section
     cur
 }
 
+/// 정렬이 끝난 트리에서 페이지 URL → 섹션 내 순번을 한 번에 적는다.
+fn index_positions(sections: &[Section], pages: &[Page], out: &mut BTreeMap<String, usize>) {
+    for s in sections {
+        for (pos, &i) in s.pages.iter().enumerate() {
+            out.insert(pages[i].url.clone(), pos);
+        }
+        index_positions(&s.subsections, pages, out);
+    }
+}
+
 fn find_section<'s>(sections: &'s [Section], dirs: &[String]) -> Option<&'s Section> {
     let (first, rest) = dirs.split_first()?;
     let node = sections.iter().find(|s| s.path.last() == Some(first))?;