Overleaf-Benchmark.pdf
摘要
自行託管的 Overleaf 部署通常以一條經驗法則來估算規模:每五到十位並行使用者配置一個 CPU 核心與 1 GB 記憶體。我們證明這條法則不只是不夠精確,而是在結構上就是錯的:它假設容量由單一資源維度決定,但實際上由兩道彼此獨立的牆所決定;而且有兩個軟體參數——兩者都不是硬體——對結果的影響可高達四倍。 我們在 QEMU/KVM 客體中,以 21 種 CPU/記憶體設定,量測一個啟用沙箱編譯(TeX Live 2025)的標準 Ayakaleaf Pro v6.2.2 部署,主機核心時脈鎖定在 3.0 GHz。工作負載是一份真實的 63 頁 XeLaTeX 學位論文,由多達數百個不同的使用者帳號同時編譯。我們發現,在客體記憶體低於 32 GiB 時,核心數量幾乎無關緊要——在 16 GiB 下,4、8 與 16 vCPU 客體的實測容量差距不到 8%——容量反而是由一道超線性的記憶體牆所決定,其成因是 TeX Live 樹的共用頁面快取。 為了探究這些規律在規模放大一個數量級後是否仍然成立,我們在一台 64 核心、995 GiB 的單一伺服器上重複了這項掃描。它以 100% 成功率承受了 1024 個同時進行的冷編譯——是其執行緒數量的八倍——而我們始終沒有觸及它的上限。真正有用的數字不是那個上限,而是其下方的拐點:尾端延遲在 之前每倍增一次成長 20–40%,到 時則成長 190%。因此,若將容量報告為「不會失敗的最大並行數」,會將實際可用的運作點高估四倍。在那台機器上,記憶體從來不是瓶頸資源;限制來自 CPU,以及容器常駐程式接納新沙箱的速率——無論請求了多少編譯,該速率都會在約 200 附近飽和。 我們進一步找出了兩個在容量規劃中看不見的實作層面效應。第一,CLSI 強制實施一個寫死的上限:最多 65 個同時編譯,且未透過任何環境變數公開;超過此上限時,使用者會立即收到 HTTP 503,而不是被排入佇列。第二,Docker runner 中的每容器記憶體限制自 2018 年引入以來就一直無效,無論是數值大小還是設定位置都有問題,因此一次記憶體不足事件會讓整台主機當機,而不只是單一編譯失敗。解除並行上限並將預設編譯逾時從 180 秒提高到 300 秒後,8 vCPU/48 GiB 客體的實測容量從 64 個並行編譯提升到 268 個——在零硬體成本下提升 4.2 倍。 最後,我們證明在此系統中,並行帶來的只有分時共享,而工作負載僅受時脈限制。擬合出的退化定律 得到 ,接近完美的等比例減速;而在機器完整的 1.0–5.5 GHz 範圍內進行時脈掃描,三十筆量測結果都落在 上,其中 ,殘差離散度為 5.1%。5.5 倍的時脈帶來 5.5 倍的加速,且沒有報酬遞減——這正是時脈與核心帶來不同效益的意義所在:時脈讓每位使用者的編譯更快,核心只是讓更多使用者能夠進來。1. 簡介
Overleaf 是主流的協作式 LaTeX 編輯器,其本機部署版本被大學與研究團隊廣泛採用,因為他們無法將未發表的稿件傳送到第三方雲端。這類部署的規模估算是一個反覆出現的實務問題:在固定的硬體預算下,實際上能有多少人同時按下「重新編譯」? 官方指引是一條線性法則——大約每五到十位並行使用者配置一個核心與 1 GB 記憶體——這預設了容量會在兩種資源上平滑且共同地擴展。我們的量測結果從三個方面與此相矛盾。1.1 容量由兩道獨立的牆決定,而非一道
一個設定之所以失敗,要嘛是因為記憶體耗盡——此時 Overleaf 堆疊本身會當掉並回傳 HTTP 502;要嘛是因為編譯超過伺服器端逾時——此時 CLSI 回報timedout,而好幾 GB 的記憶體卻閒置未用。這兩種狀態的擴展行為完全不同,補救方法也不同。為受記憶體限制的設定增加核心不只是沒有效率,有時甚至適得其反:我們量測到一些設定在增加核心數後容量反而_降低_,因為更多核心讓並行編譯同步推進,使它們的記憶體需求峰值同時出現,而不是彼此交錯。
1.2 軟體參數比硬體更具支配性
編譯逾時是 MongoDB 中的一個每位使用者欄位,其預設值 180 秒會默默地限制受 CPU 限制的設定。在硬體不變的情況下,將它提高到 300 秒可使實測容量提升高達 4.2 倍。此外,CLSI 會依一個寫死的常數拒絕超過 65 個同時編譯。任何沒有同時考量這兩點的容量研究——以及任何部署——量測的其實是軟體,而不是機器。1.3 並行是分時共享,而非平行處理
由於 LaTeX 編譯是單執行緒的,在 個核心上服務 個同時使用者並不會讓系統更快完成;只會讓每位使用者等待更久,且等比例增加。因此,「支援多少並行使用者」這個問題在確定使用者願意等待多久之前,本身就是不適定的。我們明確呈現這種相依性並加以量化。1.4 貢獻
- 一個涵蓋 21 種 CPU/記憶體設定的容量矩陣,在時脈鎖定、經重複驗證的條件下量測,並依據每個設定的失敗特徵辨識出其瓶頸限制。
- 兩個擬合模型:一個將超線性記憶體牆與 CPU 上限區分開來的容量模型,以及一個確立純分時共享行為的延遲模型。
- 辨識並以實驗確認了已部署系統中的兩個實作問題,包括一個自 2018 年起就失效的容器記憶體限制。
- 量化編譯逾時與容量之間的取捨,我們主張任何並行數字都必須同時說明這一點。
2. 背景
2.1 編譯路徑
一個 Overleaf 編譯請求的路徑為web clsi 編譯容器。在沙箱編譯部署中(SIBLING_CONTAINERS_ENABLED=true),CLSI 不會在行程內執行 latexmk;它會透過以 bind mount 掛載的 socket 請求主機的 Docker 常駐程式,從 TeX Live 映像檔啟動一個全新的容器,並將專案目錄以 bind mount 掛載到 /compile。因此,一次編譯就是一個執行單一 latexmk 行程的短命容器。
這帶來三個結果,而這三者都影響了本文的量測。第一,工作單位是單執行緒行程:XeLaTeX 不會平行化。第二,每次編譯的資源隔離程度取決於 Docker runner 所請求的內容——我們在 §6.2 中證明它實際上什麼都沒有請求。第三,工作集主要不是由文件決定,而是由 TeX Live 樹決定——這是一個約 32 GiB 的唯讀資料集,每個並行編譯都會從中讀取,因此透過主機頁面快取共用。這種共用正是我們觀察到的超線性記憶體擴展的根源。
2.2 啟用沙箱編譯
Community Edition 的 Overleaf 會在應用程式容器本身內執行latexmk。Ayakaleaf Pro 與 Overleaf Server Pro 一樣,可以改為在_同層_容器中執行每次編譯——也就是由應用程式在_主機的_ Docker 常駐程式上啟動的容器,而非巢狀於應用程式容器內。兩個 Toolkit 設定可以開啟此功能:
SANDBOXED_COMPILES=true、SANDBOXED_COMPILES_SIBLING_CONTAINERS=true 與 SANDBOXED_COMPILES_HOST_DIR,最後一個是編譯目錄在_主機上_的路徑。這個路徑很重要:由於啟動編譯容器的是主機的常駐程式,提供給它的 bind mount 必須能在主機的命名空間中解析,而不是在應用程式容器的命名空間中。Server Pro 的 config/env.sh 還會在此模式下強制設定 TEXLIVE_IMAGE_USER=www-data,讓編譯容器寫入的檔案擁有者保持一致。
驗證方式很直接:在編譯期間,主機上會出現一個名為 project-{projectId}-{userId}-{hash} 的容器,從 TeX Live 映像檔執行 latexmk,並以 0 結束。這就是本文自始至終量測其數量的單位,而我們在 §6.2 中回報了它完全沒有資源限制的情況。
同層容器讓量測變得乾淨——每次編譯都是一個可觀察、獨立排程的作業系統實體——但這也代表在編譯之間仲裁 CPU 與記憶體的是客體核心,而不是 Overleaf。因此,本文中的每條擴展定律都是 Linux 排程器套用於 個單執行緒行程時的特性,這也是它們如此規律的原因。

clsi 拉取。兩者都不是主導因素——專案的編譯成本由 32 GiB 的 TeX Live 樹決定,每個並行編譯都會透過共用頁面快取讀取它。

git-bridge。我們所量測的 Toolkit 預設配置 (b) 的倍數為一,因此一個為叢集中單一成員設計的常數,就成了整個安裝的上限。

clsi-cache 中的分片選擇。專案透過 進行對應,也就是雜湊空間被切成與分片數量相同的等分扇區。這是_取模_雜湊,而非以環為基礎的一致性雜湊:將叢集從三個分片擴增到四個會重新劃分整個空間,幾乎重新對應每一個專案 (a, b)。這正是為什麼實作需要一個明確的線上重新分片漸進機制,在一段時間內將線性增加比例的專案從 currentShards 移到 desiredShards,而不是一致性雜湊環所提供的 移動量。當某個分片的斷路器跳脫時,鹽值 會遞增,並將該分片從候選清單中移除,因此查詢會繼續探測下去而不是失敗 (c)。
2.3 兩種失敗模式
我們量測的每個設定都恰好以兩種方式之一失敗,而且這種區別可以直接從回應狀態看出,而非推論得來:- 記憶體耗盡——Overleaf 堆疊本身失去回應,請求回傳 HTTP 502。在失敗等級時,客體可用記憶體通常低於 500 MiB。
- 編譯逾時——CLSI 在每位使用者的逾時時間到達時終止編譯,並回報狀態
timedout。在失敗等級時,可用記憶體通常還有好幾 GB。
3. 方法
3.1 測試平台與時脈控制
所有客體都在一台搭載 62 GiB RAM 與 NVMe 儲存裝置的 Intel Core i9-14900K 主機上,以 QEMU/KVM 執行。客體為 Ubuntu 24.04,搭配 Docker 29.7 與 Overleaf Toolkit,部署啟用沙箱編譯並使用texlive-full:2025.1 的 Ayakaleaf Pro v6.2.2。
除非控制時脈,否則一般消費級桌上型 CPU 並不適合用來代表伺服器。KVM 沒有提供設定虛擬時脈的機制:vCPU 是一個主機執行緒,以主機核心當下的頻率執行。因此我們直接限制主機:停用 turbo,將每個核心的 scaling_max_freq 固定在 3.0 GHz,並使用 taskset 將客體的 vCPU 綁定到實體 P 核心。這個區別在混合核心 CPU 上很重要:此型號 E 核心的基礎時脈為 2.4 GHz,停用 turbo 後_無法_達到 3.0 GHz,因此若執行過程跑到 E 核心上,就會默默地量測到一台較慢的機器。在滿載下,我們驗證全部十六個綁定的執行緒都精確維持在 3000 MHz。一個守護腳本會在每次基準測試前斷言此不變條件,否則拒絕啟動;在研究期間它攔截到了一次調速器被默默重設的情況。
3.2 第二個測試平台:一台大型執行器
QEMU 矩陣一次隔離一個變數,但上限只有十六個綁定的執行緒。為了探究相同的規律在高一個數量級時是否仍然成立,我們在一台大型伺服器上重複了並行掃描:一顆 AMD EPYC 7773X(Milan-X,64 核心/128 執行緒,768 MiB L3)搭配 995 GiB RAM,執行相同的 Ayakaleaf Pro v6.2.2 映像檔與相同的texlive-full:2025.1。與 QEMU 客體不同,這台機器沒有鎖定時脈:它是正式環境等級的伺服器,我們也就以這種狀態量測它。
有兩項操作上的預防措施是必要的,值得說明,因為少了它們,實驗量測的將是測試工具而不是伺服器。第一,每個容器都被限制在一個設有 MemoryMax=940 GiB 的 systemd slice 中,讓失控的掃描耗盡的是 cgroup 而不是主機。第二,沙箱編譯由主機常駐程式建立,每個都會寫入自己的寫入時複製層——即使 20.6 GiB 的基礎映像檔是共用的,每個容器仍量測到 116 MiB——因此我們將 Docker 資料根目錄移到一個專用的 NVMe 裝置上。在 的掃描會寫入約 119 GiB 的暫存層,標準的根檔案系統容納不下。
3.3 工作負載
文件是一份真實的 63 頁碩士論文(SJTU 範本),透過latexmk 以 XeLaTeX 編譯,包含 TikZ 圖形、biblatex 書目處理以及內嵌的 PDF 資產——也就是說,這是一個真實而非合成的負載。在無負載的客體上,單次編譯在所有設定中耗時 8.6–9.8 秒,我們將其作為無干擾基準 。
3.4 負載產生
我們建立了 512 個真實的使用者帳號,並讓每個帳號擁有自己的專案副本,讓並行編譯的競爭情況與獨立使用者完全相同,而不是共用一個專案鎖。請求從主機發送到客體的轉送連接埠,因此負載產生不會消耗客體的 CPU。 並行是_同時_的,而非錯開的。每個工作階段都會先建立好——登入、CSRF 權杖、編譯器選擇——然後每個執行緒才會休眠,直到一個只計算一次並共用的共同實際時間點,再發出它的POST /project/:id/compile。這個區別並非吹毛求疵。錯開的漸進負載量測的是穩定佇列下的吞吐量;同時的突發負載量測的則是一整間教室的學生在同一個截止時間公告後同時按下同一個按鈕時會發生什麼——這才是維運人員真正擔心的情況。兩者的差異不只是一個常數倍數,因為後者填滿編譯佇列的速度比常駐程式消化它的速度還快。
在能夠如實地送出這種突發負載之前,必須先排除四個實務障礙。每一個都值得記錄,因為每一個都會默默地讓實驗退化為對測試工具而非伺服器的量測。
3.4.1 兩個速率限制器,而非一個
Overleaf 依來源位址限制登入次數——每分鐘 20 次嘗試——而我們所有的流量都來自同一台主機。為每個模擬使用者指派不同的X-Forwarded-For 位址可以移除這個限制,但會立刻遇到第二個更粗略的限制:每個子網路每分鐘約 200 次的配額。因此,將使用者分散在一個連續區塊中會在第 201 個帳號時失敗。我們改為從使用者索引推導合成位址,讓連續的使用者落在不同的 /24 中:
這讓兩個限制器在全部 1024 個使用者下都保持寬鬆。
3.4.2 注入的標頭預設會被捨棄
光是設定標頭還不夠。Express 只會對它被告知要信任的對端採用X-Forwarded-For,而 Overleaf 的 trustedProxyIps 預設為 loopback。由於負載產生器是透過容器的橋接網路而非回送介面連到應用程式,標頭會被解析後隨即丟棄,所有模擬使用者又會合併回同一個位址。症狀是剛好在第二十次登入時出現一波 HTTP 429,很容易被誤判為伺服器過載。必須明確地將閘道網路加入信任鏈;在 §4.3 的叢集部署中,還必須加入 pod 與 service 的 CIDR。
3.4.3 負載平衡器會覆寫它應該保留的標頭
當執行個體位於代理之後時,慣用的option forwardfor 會將真實的用戶端位址_附加_到鏈中,這對正式環境是正確的行為,但在這裡恰恰是錯的:合成位址會被負載產生器自己的位址取代。此指令必須限定為 option forwardfor if-none,讓代理只在用戶端未提供值時才加入。
3.4.4 用戶端的檔案描述元會比伺服器的容量更早耗盡
在 時,產生器同時持有超過一千個 socket,而預設的 1024 個描述元軟性限制會在工作階段建立期間就被觸及,而不是在量測期間。這種失敗很安靜:有三個工作階段無法建立,執行結果回報 1021 而非 1024,而一個透過 shell 取得容器數量的取樣執行緒會因EMFILE 而終止,默默地截斷遙測資料。必須提高產生器的軟性限制——我們主機上的硬性限制原本就是 1048576——然後重新執行。我們在 §4.3 中回報了兩次執行的結果:修正後的那次完成了 1024 個中的 1024 個,中位數與被截斷那次的差距在 1.2 秒以內,這也是我們將第一次視為可用但不具權威性的原因。
3.5 量測規程
有幾項方法上的選擇被證明是可重現性所必需的。3.5.1 暖機
在剛開機的客體上,頁面快取是空的,最初的編譯量測到的是冷啟動 I/O,而不是穩態容量:同樣的 2 vCPU/2 GiB 設定,冷狀態為 36.5 秒、暖狀態為 9.8 秒,相差 3.7 倍。因此每個設定在開機後都會執行兩次被捨棄的單次編譯暖機。3.5.2 通過標準
一個並行等級只有在_每一個_編譯都成功、且該等級在重複執行後依然成立時才算通過。這比成功率門檻更嚴格,而且這很重要:在 4 vCPU/16 GiB 下,32 這個等級曾以 80.2 秒的中位數通過一次,但重複執行時 32 個編譯全部逾時,因此我們回報 31。3.5.3 搜尋
各等級的定位方式是:從模型預測的初始值開始進行指數式區間搜尋,接著進行精確的整數二分搜尋。由於標準是全有或全無,一個等級在第一次失敗時就已確定,因此一旦有一個失敗,我們就放棄其餘進行中的請求——但在小等級時除外,因為被放棄的編譯會嚴重拖住小型客體,使它永遠無法恢復。3.5.4 等級之間的隔離
在下一個等級開始之前,會先清空編譯容器,並輪詢 web 應用程式直到它再次回應。若沒有這一步,緊接在當機之後的等級會記錄到一個虛假的零工作階段失敗。3.5.5 主機環境整潔
主機上不相關的虛擬機器都已關閉:當有 24 GiB 主機記憶體被其他用途佔用時,同樣的客體設定在相同並行數下回報的平均負載為 11.7,而非 3.2。主機記憶體壓力會傳導到客體中,使量測失效。4. 結果
4.1 容量矩陣
表 1 與圖 4 列出了每個設定的實測上限。橫向閱讀一列就是第一個意外。在 4 GiB 時,2、4 與 8 vCPU 客體都恰好達到 9——核心數變成四倍完全沒有任何改變。在 16 GiB 時,它們分別達到 54、45 與 57:從 4 核心增加到 16 核心只多了 6%,而 8 核心客體實際上還比 4 核心_更差_(§5.2)。只有在 48 GiB 時,核心數才明顯區分出各設定:143、268 與 331。
表 1. 能成功完成的最大同時編譯數,在 300 秒編譯逾時且解除 CLSI 並行上限的條件下量測。粗體表示受 CPU 限制的設定(編譯逾時,但記憶體仍有餘裕);其餘為受記憶體限制(堆疊當掉並回傳 HTTP 502)。2 GiB 這一列已套用 §5.2 中討論的修正。
4.2 並行是分時共享
圖 5 在固定的 8 vCPU/16 GiB 客體上掃描了每個並行等級。兩種狀態被一個恰好位於每核心一個編譯處的明顯拐點分隔開來。在拐點以下,平均編譯時間持平——從 時的 8.7 秒變為 時的 9.1 秒,只變化了 5%。在拐點以上,時間嚴格按 的比例成長:在 時我們量測到 18.5 秒與 27.1 秒,即 的比例,對照理想值 。

4.3 垂直擴展至 1024 個並行編譯
表 2 與圖 7 回報了在大型執行器上的掃描結果。每個等級都是_冷_編譯:在每個等級之前,我們透過DELETE /project/:id/output 清除每個參與專案的編譯目錄與 CLSI 快取,因此沒有任何等級能受益於前一個等級完成的工作。這台機器的單次編譯基準為 28.8 秒,這是冷狀態的數字,不應與先前使用的 8.6–9.8 秒穩態基準比較;QEMU 客體的冷狀態基準為 28.3 秒,因此就這個工作負載而言,兩台機器的每執行緒效能差距在 2% 以內。
表 2. 在一台 EPYC 7773X(64 核心/128 執行緒,995 GiB)上的並行掃描。所有等級皆為冷狀態;基準為 28.8 秒。容器峰值是同時存活的沙箱數量的最大值。

4.3.1 這台機器從未失敗
每個等級都以 100% 完成,包括 ——執行緒數量的八倍。我們沒有找到這台機器的容量上限;在它耗盡餘裕之前,我們先耗盡了耐心。這是本研究中第一個瓶頸限制不是記憶體的設定:在 時,編譯 cgroup 的峰值為 184 GiB,是其 940 GiB 上限的五分之一,而 CPU 則處於 100% 使用率,平均負載為 166。4.3.2 退化是次線性的,因為接納速率受到限制
單純的分時共享預測 的執行緒會帶來 的延遲。實測的成本相對於單次編譯為 ,但相對於 只有 ——而施加的負載增加了八倍。原因可從圖 7(b) 與表 2 的最後一欄看出:雖然 1024 個請求是同時發出的,但實際存活的沙箱數量從未超過 205。常駐程式建立容器的速度跟不上用戶端的請求速度,因此請求在接納階段排隊,而不是在 CPU 內部爭用。正是排隊在這裡挽救了尾端延遲,而且這純屬意外。4.3.3 拐點在 512,而不是在失敗點
在 與 之間,負載倍增使 延遲上升了 ;而先前每次倍增的成本都在 到 之間。若將容量定義為「不會失敗的最大 」,就會回報 1024,但這對維運人員毫無用處:在那個點上,尾端等待時間將近八分鐘。4.4 編譯時間與時脈成反比
由於工作負載受 CPU 限制,其成本應該按 擴展。我們直接驗證這一點:在其他條件不變的客體上,以十個階段掃描主機時脈,涵蓋機器的完整範圍 1.0–5.5 GHz(圖 8)。單次編譯時間從 26.5 秒變為 4.8 秒:5.5 倍的時脈帶來 5.5 倍的加速,在整個範圍內都沒有報酬遞減。乘積 在全部十個時脈下的差異都在 2% 以內。 以核心配額正規化後,全部三十筆量測——十個時脈下的三個並行等級——都收斂到單一常數上: 在時脈本身變化達 5.5 倍的範圍內,殘差離散度為 5.1%。沒有任何曲率本身就是結果:如果工作負載受記憶體頻寬或 I/O 限制,那麼在高時脈下,當 CPU 超越其他資源時, 就會趨於平坦。
5. 分析
5.1 兩道牆,分別擬合
每個設定都依其失敗特徵分類(§2.3),然後只用實際碰到記憶體牆與 CPU 上限的設定分別擬合: 其中 的單位為 GiB, 的單位為 vCPU。 記憶體牆的指數始終是超線性的,:每多一個並行編譯的邊際記憶體成本,會隨著總記憶體增加而_下降_,從 3 GiB 客體上每次編譯約 312 MiB,降到 32 GiB 客體上約 194 MiB。其機制就是 §2.1 中所述的 TeX Live 樹共用頁面快取:並行編譯會讀取重疊的字型與巨集檔案,因此較大的快取可以分攤給更多編譯。這就是為什麼單純的「每五位使用者 1 GB」法則會低估大型機器、高估小型機器。
5.2 更多核心反而讓情況更糟的地方
式 (2) 是兩項中的最小值,因此對 是單調的,但量測結果並非如此。我們觀察到兩次增加核心反而_降低_容量的反轉:在 16 GiB(54 對 45)與 32 GiB(145 對 135)。兩者都發生在受記憶體限制的狀態下,機制也相同:核心較多時,並行編譯同步推進,並在同一時刻達到常駐記憶體的峰值;而核心較少時,排程器會讓它們交錯執行,峰值也隨之錯開。在記憶體餘裕本就勉強的客體上,正是這種錯開讓它得以存活。以平均資源使用量建立的容量模型無法表達這一點;這是峰值_同時出現_的特性。 第三個看似反轉的情況出現在 2 GiB,我們現在不予採計。搜尋記錄到 2 vCPU 時容量為 2,但 4 與 8 vCPU 時為 1,看起來像是同樣的效應。重新檢視原始掃描資料後,發現事情更單純:在 2 GiB 時, 這個等級在三種核心數下第一次嘗試都成功了,但在其中兩種的確認執行中失敗。這個等級並不是容量,而是擲硬幣,2 vCPU 的那一筆只是剛好擲中的那一次。因此我們在三種核心數下都回報可重現的值 1,並且不從差異中得出任何結論。我們在此記錄這項修正,而不是默默地改寫表格,因為被捨棄的那個讀數,正是那種原本能夠支持某個有趣論點的資料。6. 實作上的發現
6.1 寫死的並行上限
在夠大的客體上,無論請求的並行數為何,容量都恰好停在 65 個同時編譯:在 時,我們量測到 個成功與 個立即回傳的unavailable 回應,同時容器數固定在 65、還有好幾 GB 的記憶體未使用,而編譯時間中位數穩定在 77 秒——遠低於任何逾時。
原因是 CLSI 中的一個常數:
success=65, unavailable=15 的 16 vCPU/32 GiB 客體,改為回報 success=80。
6.2 失效的容器記憶體限制
檢查一個運作中的編譯容器,會發現完全沒有任何資源隔離:HostConfig 內,因此會被捨棄——觀察到的 Memory=0 證實了這一點。這兩個錯誤在引入此檔案的提交(9a519f0d3d,2018 年 3 月)中就已存在,並且歷經從 CoffeeScript 的轉換、整個儲存庫的重新格式化,以及從 CJS 到 ESM 的遷移後依然存在,因為這些變更都沒有重新檢視語意。值得注意的是,同一個提交中的 MAX_OUTPUT = 1024 * 1024 // 1MB 是正確的,這表示這是一時疏忽,而非理解錯誤。
其後果可以在我們的低記憶體量測中看到。由於編譯沒有上限,記憶體耗盡並不會表現為 Docker 終止某個違規的容器;而是讓整個客體當機。在 2 vCPU/2 GiB 設定上,我們觀察到監控用的 SSH 工作階段被阻塞了 300 秒、兩個核心上的平均負載達到 68,最後客體自行重新開機。一個正常運作的每容器限制會讓退化過程平和得多:過大的編譯會失敗,而服務會存活下來。
唯一真正生效的限制是 RLIMIT_CPU,設定為 秒。它限制的是 CPU 時間,而不是實際經過時間,而單次編譯只消耗約 9 秒的 CPU,因此在任何並行數下都不會觸發;它防範的是像失控巨集這類病態輸入。不過,它是一個很有用的判斷依據:觀察到 Soft:305 就證實了 300 秒的逾時設定確實已傳遞到容器中。
6.3 編譯逾時是最具支配性的可調參數
每位使用者的欄位features.compileTimeout 預設為 180 秒。對任何受 CPU 限制的設定而言,這並不是安全餘裕,而是一個容量設定,因為一台仍在正確計算的機器會被判定為失敗。將它提高到 300 秒——只需一次 MongoDB 更新——就能讓實測容量改變高達 4.2 倍(表 3)。上限是 600 秒,由 RequestParser.MAX_TIMEOUT 強制實施,超過此值會被默默截斷。
表 3. 編譯逾時對實測容量的影響。
最後兩列是結果中違反直覺的那一半,也是我們在單一逾時下重新量測所有設定的原因。對於受_記憶體_限制的設定,較長的逾時反而會_降低_容量,因為每個編譯會更長時間佔用其常駐記憶體集,導致更多編譯重疊。因此,一個容量數字若沒有說明它是在什麼逾時下量測的,就毫無意義,而且兩者不能混在同一張表中。
7. 相關研究
7.1 廠商指引
Overleaf 自己的硬體文件陳述了我們在此量化的定性事實:LaTeX 是單執行緒的,因此單核心效能決定編譯時間,而且「只有在您嘗試編譯的文件數量多於可用 CPU 核心數時,更多核心才會有幫助」[1]。接著它給出了線性規模估算法則——以 2 核心/3 GiB 為基礎,再加上每五到十位並行使用者一個核心與 1 GB——這正是本研究的動機。我們的貢獻是將這些陳述轉化為量測得出的定律(式 (1) 與 (2)),並指出線性法則在哪裡失效:它沒有考量讓記憶體牆呈超線性的共用頁面快取,也沒有考量主導結果的兩個軟體參數。7.2 建置與 CI 容量研究
在 LaTeX 領域之外,並行下的建置系統量測已相當成熟。LightSys 指出,在 Docker 容器內進行編譯的傳統 CI 系統,其 I/O 會隨 pull request 到達率上升而退化,瓶頸大約出現在十一個並行請求時 [17];TAOS-CI 則觀察到編譯在 CI 的實際經過時間中佔主導地位,在大型專案中佔整個管線時間的 60–67% [18]。我們的系統在一個方面有所不同,而這一點結果是決定性的:LaTeX 編譯是互動式的。一個 CI 作業花兩倍時間只是不方便;但一次編譯花兩倍時間,會被正在預覽窗格前等待的使用者直接感受到,這就是為什麼我們不把逾時視為失敗門檻,而是視為容量參數。7.3 容器額外負擔
近期的研究將 Docker 容器啟動延遲依儲存層級加以分解 [19],並描述了容器在邊緣環境的效能特性 [20]。在我們的情境中,每次編譯的容器啟動成本被攤提了:相對於 9 秒的編譯,它是一個很小的常數,並被我們擬合的無干擾時間 吸收。真正重要的容器特性是資源限制的_缺席_(§6.2),它會將單次編譯的記憶體超用轉變為整台主機的失敗。7.4 LaTeX 作為不受信任的輸入
沙箱編譯之所以存在,是因為 TeX 是一種程式語言,而文件是不受信任的輸入 [21, 22]。正是這個設計選擇讓本研究成為可能——每次編譯都是一個具有可觀察資源行為的隔離容器——也正是它讓缺少記憶體限制這件事變得嚴重,因為部署它的維運人員都假設有隔離存在。7.5 以編譯器作為研究對象
TeX 本身作為一種語言已有完善的文件 [16],但它作為_建置目標_的行為直到最近才受到關注。Tan 與 Rigger [8] 以不同的引擎與發行版本編譯大量 arXiv 原始碼,發現引擎的選擇不可互相替代:只有極少比例的文件在 XeTeX 與 pdfTeX 下會產生位元組完全相同的輸出。這個結果與我們的方法直接相關。容量是文件_與_引擎共同的特性,因此沒有同時固定兩者的基準測試是無法重現的;所以我們自始至終固定一份文件、一個引擎與一個發行版(texlive-full:2025.1),並在每張圖的說明中註明引擎。這也限制了我們數字的通用性,值得明白說出:它們描述的是 XeLaTeX 在這份文件上的表現,而不是抽象的 TeX。
關於 LaTeX 建置_系統_的研究大多由實務工作者推動。LaTeX3 專案的 l3build [13] 將迴歸測試與封裝標準化,而獨立的基準測試則比較各種包裝工具——一項針對 26 個建置系統的調查發現,預先編譯的導言區比單純執行快約 20%,比 latexmk 快約 40% [14]。這些最佳化的是_單次_編譯。它們與我們量測的內容正交,且可以互相組合:導言區快取可以縮短 ,而本文中的每個容量數字都會隨 縮放。
7.6 編輯器中的並行控制,而非編譯器中
Overleaf 的協作部分建立在一系列成熟的研究之上。操作轉換(operational transformation)源自 Ellis 與 Gibbs [9],並由 Jupiter 系統 [10] 使其適用於高延遲用戶端,其設計在document-updater 中清晰可見:一個為操作排序的伺服器,以及一個供用戶端同步的每文件緩衝區。無衝突複寫資料型別 [11] 則在沒有中央排序器的情況下解決了相同的問題。這個區別正是 §4.3 的拓撲之所以可行的原因:由於待處理更新緩衝區位於共用的 Redis 中,而不是某個執行個體的記憶體中,因此路由到任何複本的編譯都能看到最新的按鍵輸入,而編譯親和性可以為了快取區域性來選擇,而不是為了正確性。
7.7 容量模型
Amdahl 定律 [24] 界定了平行化所能帶來的加速上限,而 Little 定律 [23] 將佔用量與到達率及服務時間連結起來;兩者在前文都有使用。Gunther 的通用可擴展性定律 [12] 以一個代表一致性延遲的逆行項擴充了前者,預測吞吐量會先達到峰值然後下降。我們注意到,我們的系統在 之前_並未_出現這種逆行狀態:吞吐量飽和、延遲成長,但沒有任何崩潰。原因是結構性的而非僥倖——編譯之間沒有需要保持一致的共用狀態,因此該定律加入的那一項接近零,而 §4.3 的接納平台在競爭變得重要之前就已將其封頂。8. 給維運人員的建議
1
購買硬體之前,先修正兩個軟體參數
兩者都是免費的,而且兩者的價值都高於我們量測過的任何單一硬體升級。將
features.compileTimeout 提高到您的使用者實際能容忍的值——CLSI 接受的最大值為 600 秒——而如果您預期會超過 65 個同時編譯,請在衍生映像檔中提高 compileConcurrencyLimit,或進行水平擴展。兩者都不做,就等於花錢買了軟體拒絕使用的核心。2
依據拐點而非上限來估算單一機器的規模
大型執行器的掃描(§4.3)區分了兩個經常被混為一談的數字。上限——仍能回傳每一份 PDF 的最大並行數——在 64 核心伺服器上至少是 1024,而我們從未達到。拐點——尾端延遲不再緩慢成長、開始倍增的點——位於 512,而在其下方最後一個舒適的運作點是 256。在 與 之間, 等待時間從兩分鐘增加到將近六分鐘;在 512 與 1024 之間則達到八分鐘。依上限來估算規模的維運人員,交付的會是一個技術上可行、卻沒有人想用的系統。因此,對這台機器與這份文件而言,建議的運作點是 256 個並行編譯,這是實體核心數的 、執行緒數的 ,並能讓 維持在約 120 秒。我們建議將
compileConcurrencyLimit 設為該值,而不是讓它維持在高值:一次接納 1024 個編譯會讓每個人都等八分鐘,而接納 256 個並讓其餘的排隊,則能讓大多數使用者在兩分鐘內得到服務。排隊只會讓晚到者的體驗變差;爭用則會讓所有人的體驗變差。3
將這些數字視為最壞情況
表 2 中的每個等級都是同時觸發的冷編譯。這兩個條件在正式環境中都不成立:同一份文件的暖編譯需要 8.6 秒,冷編譯則需要 28.3 秒,相差 倍;而真實使用者也不會在同一秒按下按鈕。因此,一個每兩分鐘重新編譯一次、具有典型快取命中率的穩態使用者群體,所能支撐的撰寫者數量會遠多於單看並行數字所暗示的——在 256 的運作點下,大約可達上千名甚至更多的活躍作者。並行數字是瞬間突發量的上限,而不是席位數。
4
先決定延遲預算,再據此讀出規模
式 (1) 可以直接反推。對於在時脈 、 個核心上的目標等待時間 ,可容納的並行數為 ,對這份文件而言 。在 3 GHz 的 8 個核心上,60 秒的預算得到 ;120 秒的預算則使其加倍。將預算與容量一併公布,是誠實陳述兩者的唯一方式。
5
先買記憶體,再買核心,並檢查您碰到的是哪道牆
在 32 GiB 以下,我們量測到增加核心幾乎沒有任何好處。診斷方法很簡單:如果失敗以 HTTP 502 呈現且客體記憶體不足,就增加記憶體;如果失敗以
timedout 呈現且記憶體仍有餘裕,就增加核心或提高逾時。維運人員可以從我們用來分類設定的同一個失敗特徵中讀出這一點。6
為體驗選擇時脈,為人數選擇核心
由於 在沒有曲率的情況下成立(在 1.0–5.5 GHz 範圍內差異 2%),更快的時脈會讓每位使用者的每次編譯都變快。更多核心不會讓任何單次編譯變快;只會接納更多並行編譯。抱怨「編譯很慢」的部署應該購買時脈;抱怨「截止時間時編譯失敗」的部署則應該購買記憶體與核心。
7
超過上限時,應水平擴展而非垂直擴展
超過 65 個並行編譯後,受支援的途徑是水平擴展(圖 2c 與圖 10,詳見 §9):多個應用程式執行個體位於具備 cookie 工作階段親和性的負載平衡器之後,共用集中式的 MongoDB、Redis 與相容 S3 的儲存空間,並讓
git-bridge 維持單一執行個體。這會將每個執行個體的上限乘以執行個體數量,這正是 SaaS 部署達到其自身容量的方式。8
不要依賴每次編譯的隔離
在 Docker runner 的記憶體限制被修正之前(§6.2),單一份病態文件就可能耗盡整台主機,而不是只有它自己被終止。需要此保證的維運人員應該自行實施,而不是等待修正。我們在大型主機上使用的機制是一個帶有硬性上限的 systemd slice,然後讓 Docker 常駐程式指向它,使其建立的每個容器都計入其中:這裡有一個細節,若是忽略就會耗掉一整個下午。名為
docker-capped.slice 的 slice 並不是位於 docker.slice 旁邊;而是位於它的_內部_,因為連字號是階層分隔符號,而不是名稱的一部分。看似沒有作用的上限,通常是被套用在與容器實際所在位置相差一層的地方。請在執行後從 memory.max_usage_in_bytes 讀回峰值來驗證,而不是相信設定檔——在我們的主機上,即使在 1024 個同時編譯時,編譯 cgroup 也從未超過其上限的五分之一,這本身就證明了瓶頸限制是常駐程式而不是記憶體。
git-bridge,它將儲存庫保存在本機磁碟上且沒有複寫途徑,必須以單一執行個體的形式在某個指定複本旁執行。
9. 參考多機部署
以上所有內容量測的都是單一機器。本節以足以實際建置的細節說明分散式形式,並且——由於維運人員真正面對的問題不是_如何做_而是_是否該做_——首先說明在什麼時間點它才值得這些麻煩。9.1 何時值得採用分散式形式
單一機器在所有重要方面的維運成本都更低:一個故障域、沒有需要保持一致的共用狀態、沒有可能出錯的路由。我們的資料為何時該離開單機給出了三個門檻。9.1.1 低於 65 個並行編譯時,不要這麼做
每個執行個體的上限是軟體常數,而不是硬體常數(§6.1)。在施加的負載接近它之前,第二台機器只會增加失敗模式,而沒有任何收穫。64 核心主機只有在提高compileConcurrencyLimit 之後,才能以完全成功的狀態服務 256 個並行編譯;尚未變更這個值的維運人員並沒有受限於硬體,也不應該去採購硬體。
9.1.2 介於 65 到約 500 之間時,先垂直擴展
垂直擴展在我們的整個範圍內都保持線性,從未進入逆行狀態。一台大型主機以 100% 成功率達到 1024 個同時冷編譯(§4.3);延遲的拐點出現在 512,而不是更早。在這個區間內,一台較大的機器嚴格來說比好幾台較小的機器更簡單,而且根據 §4.4,一台更快的機器會改善每位使用者的體驗,而不只是接納更多使用者。9.1.3 為了可用性而採用分散式,而不是為了吞吐量
在低於上限時執行多個應用程式複本的真正理由是:一台機器就是一個電源供應器、一個核心、一個升級時段。這是一個正當的理由,也是我們會給出的理由;它只是不是一個容量上的論點,而將兩者混為一談,會導致維運人員在需要記憶體時去購買複本。9.2 各層級及其規模估算
圖 10 顯示了拓撲。它有四個層級,各自依據不同的量來擴展——這正是將它們分開的全部意義所在。9.2.1 邊緣
一個負載平衡器,或為了可用性而使用兩個。它負責終止 TLS,不做任何昂貴的工作;它隨連線數而非編譯數擴展,對於本文研究的負載而言,一個小型執行個體就足夠了。重要的是它的設定,而不是它的大小(§9.3)。9.2.2 應用程式複本
這些複本承擔編譯負載,也是唯一隨並行數擴展的層級。依據 §8 的規則估算每個複本的規模——先記憶體再核心,然後是時脈——再將複本數量設為峰值並行數除以每個複本的上限。複本不保存任何持久資料:它們的本機磁碟存放編譯暫存檔與輸出快取,兩者都可以重建。這讓它們可以安全地自由新增與移除,而這一點值得驗證而非假設,因為單一個設定錯誤的filestore 路徑就會默默地將這個層級轉變為有狀態的層級。
9.2.3 狀態
Redis、MongoDB 與一個相容 S3 的物件儲存,各自位於獨立的主機上。Redis 是承重的那一個,也是最不明顯的:它保存工作階段儲存與即時文件緩衝區,這讓路由到任何複本的編譯都能看到在另一個複本上輸入的按鍵。將 Redis 視為快取並依淘汰機制來估算規模的維運人員,會產生以過時文件進行的編譯,而這極難診斷,因為沒有任何東西失敗——只是輸出是錯的。MongoDB 隨專案數量而非編譯速率擴展。物件儲存在單一複本時是選用的,超過之後則是必要的。9.2.4 單一執行個體元件
git-bridge 將儲存庫保存在本機磁碟上、維護一個本機索引,而且沒有複寫途徑。它必須恰好以一個執行個體執行,固定在某個指定複本旁,它也是讓整個部署無法完全無狀態的元件。請據此規劃它的主機:它的磁碟才是需要備份的那一個。
表 4. 參考層級。只有應用程式層級隨並行數擴展;其規模估算是 §8 的主題。
9.3 路由是容易出錯的部分
三類請求必須抵達三個不同的地方,而預設的單一規則設定最多只能滿足其中兩類。/project/ 下的編譯流量應該依專案識別碼以一致性雜湊分配,讓一個專案的編譯快取留在同一個複本上。我們使用 HAProxy 的 balance hash path,field(3,/) 搭配 hash-type consistent 與 hash-balance-factor 150。這個選擇在水平擴展時很重要:使用 cookie 親和性時,現有的工作階段會無限期固定在原本的複本上,而新加入的複本只會接收新使用者,因此維運人員剛花錢買的機器完全無法分擔當初促使購買它的負載。在我們的設定中,一致性雜湊在水平擴展時重新分配了 35% 的專案,而 cookie 則是 0%。
工作階段流量則不同。當 WebSocket 升級失敗、socket.io 退回使用 XHR 輪詢時,同一工作階段的連續輪詢必須抵達同一個複本,而路徑中沒有可供雜湊的專案識別碼。這類流量需要一個採用 cookie 親和性的獨立後端。我們設計了這個分流,但沒有實際部署;我們將其標示為一個缺口,而不是宣稱已經完成。
最後,/git/ 必須抵達 git-bridge 所在的那個複本。它被路由到該複本,而不是直接路由到 git-bridge,因為 bridge 會透過應用程式的 OAuth 端點驗證其回呼,並透過它解析 blob URL;繞過該複本只會破壞身分驗證,而不會改善任何事情。
9.4 縮減規模需要排空緩衝
移除一個複本與新增一個複本並不對稱:進行中的編譯會遺失,而使用者會看到一個不是他們造成的失敗。可行的順序是先停止新的流量、等待,然後才終止。我們以一個 pre-stop hook 實作此流程,在負載平衡器將後端標記為排空中的同時,讓 pod 保留一段可設定的時間——短到可以在幾分鐘內測試完畢,而在正式環境中則長到足以讓工作階段自然結束,也就是數小時而非數秒。這個時間間隔就是決定彈性擴展是無感還是令人抓狂的調整鈕。 還有一項限制是我們透過量測而非設計發現的:以 CPU 為依據的自動擴展對這個工作負載無效。應用程式 pod 自身的使用率只有 22 m core,而節點總計為 3997 m core,因為編譯工作發生在 pod 不會計入的同層容器中。用來擴展此層級的任何訊號都必須計算執行中的編譯容器,而不是 pod 的 CPU。10. 超越 Overleaf 的意涵
§4.2 或 §4.3 中沒有任何內容是 Overleaf 程式碼所特有的。量測得出的定律源自任何託管式 LaTeX 服務都具備的三個特性:工作單位是單執行緒行程、它被隔離在容器中,以及它的工作集是一個頁面快取必須容納的大型唯讀樹。有三個結果可以直接套用到任何打造此類服務的人身上。10.1 先配置記憶體,再配置核心
矩陣最有力的結果是否定性的:在 16 GiB 以下,核心數幾乎無關緊要,只有在 48 GiB 時,4、8 與 16 vCPU 的設定才有所區分(143、268、331)。將傳統法則解讀為「每五位使用者增加一個核心」的維運人員,買錯了資源。其機制是發行版樹的共用頁面快取,而這是 TeX Live 大小的特性,而不是任何特定前端的特性。10.2 接納速率是一種資源,而它通常被遺忘
在 時,即使所有請求同時抵達,我們的伺服器也從未同時持有超過 205 個存活的沙箱(圖 7b)。限制因素是容器建立,而不是編譯——這與將容器啟動成本歸因於執行階段額外負擔而非映像檔大小的量測研究一致 [19, 20]。只估算 CPU 與記憶體規模的服務,會發現其突發行為受到一個從未量測過的量所支配。其實務形式就是 §8 的建議:刻意限制接納數量,因為您自己選擇的佇列,勝過您事後才發現的佇列。10.3 存活時間超過其編譯的沙箱會使模型失效
此處的每個容量數字都假設容器被建立、完成一次編譯後就結束——生命週期為數十秒,且只有在執行時工作週期才接近一。近期有兩種設計模式打破了這個假設,而且是以相同的方式打破的。 第一種是每位使用者的持久沙箱。為每位使用者配置固定的私人環境,會將統計多工的資源池轉變為一組保留資源:一個透過分時共享能以 64 個核心服務 256 個並行編譯的服務,如果每位使用者都被分配四個專用核心,就只能服務 16 位使用者,在相同硬體下少了一個數量級。我們的資料量化了這個選擇的代價,而不是反對它——保留資源換來的是可預測性,而在我們建議的運作點上,換算比例大約是 。 第二種、也是較新的一種,是與編譯器共用沙箱的 AI 代理。在代理輔助的寫作平台中,執行 XeLaTeX 的同一個容器可能也承載著一個長時間執行的程式設計代理,因此它是持續被佔用,而不是突發性地使用。實務工作者回報的正是模型對此類部署所預測的症狀——在不多的使用者數量下仍持續遲緩 [15]。這種交互作用值得精確說明,因為它不只是「更多負載」而已。我們的三項發現會相互疊加。佔用不再是突發性的,因此 §4.2 的分時共享定律會同時套用到整個使用者群體,而不只是當下正在編譯的那一部分。頁面快取——正是它帶來了 §4.1 的超線性記憶體報酬——現在要與代理自身的工作集共用,對 TeX 而言不再保持溫熱。而 §6.2 中缺少的容器記憶體限制會變得危險得多,因為一個永不結束的容器永遠不會歸還它的記憶體。 我們沒有量測此類平台,也不對任何特定產品做出任何主張。我們能說的是我們的數字對設計的意涵:一個為每位使用者提供長時間存活的多核心沙箱的架構,應該以保留系統的方式來估算規模,而不是依據此處回報的並行數字,而它能預期的容量更接近其核心數除以每位使用者的核心數,而不是表 1 中的任何數字。11. 效度威脅
11.1 單一文件
所有量測都使用同一份 63 頁的 XeLaTeX 文件。其他文件的絕對容量會有所不同;但擴展定律作為比例,則不應該不同。常駐記憶體集明顯較大的文件會移動記憶體牆的位置,但不會改變其超線性的特性。11.2 虛擬化主機
客體在一台實體機器上以 KVM 執行,因此絕對數字包含虛擬化的額外負擔,而且各客體共用主機的頁面快取與 NVMe 裝置。我們在觀察到主機記憶體壓力會讓相同並行數下的客體內平均負載膨脹超過 之後,關閉了不相關的客體,以減輕最大的干擾因素。11.3 同時抵達
每個編譯都在同一瞬間發出,這是最壞的情況。真實使用者是以隨機過程抵達的,因此依我們的數字估算規模的部署會有餘裕而非不足——但繳交截止時間前的峰值,比起卜瓦松模型,更接近我們的模型。11.4 邊緣設定
在 2 GiB 時,系統已接近崩潰,同一設定的重複執行結果可能相差一個編譯。我們回報保守的值,且不從該狀態下 的差異得出結論。12. 可取得性
受測系統、部署工具,以及其所衍生的上游專案都是公開的:- Ayakaleaf Pro — https://github.com/ayaka-notes/ayakaleaf-pro
- 部署工具組 — https://github.com/ayaka-notes/toolkit
- 文件 — https://ayakaleaf-pro.ayaka.space
- 上游 Overleaf — https://github.com/overleaf/overleaf
- TeX Live 編譯映像檔 —
ghcr.io/ayaka-notes/texlive-full:2025.1
9a519f0d3d、5d472e9b38)都可以在 Overleaf 的歷史記錄中找到。

