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))?;
|