> For the complete documentation index, see [llms.txt](https://ayakaleaf-pro.ayaka.space/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://ayakaleaf-pro.ayaka.space/blog/zh-tw/2026/overleaf-benchmark.md).

# Overleaf 並行 LaTeX 編譯的深入研究（SaaS 與自架設）

{% file src="/files/4715e018f5a3075609450f3e5fa105b468fdaa6f" %}

## 摘要

自架式 Overleaf 部署通常用一條經驗法則來定規模：每 5 到 10 位同時使用者，配置 1 個 CPU 核心與 1 GiB 記憶體。我們 ցույց明這條法則不僅不精確，而且在結構上就是錯的，因為它假設只有單一資源維度在主導容量，但事實上有兩道彼此獨立的牆；此外，還有兩個軟體參數——兩者都不是硬體——會讓結果產生高達 4 倍的差異。

我們在 QEMU/KVM 客體中，對一套原生 Ayakaleaf Pro v6.2.2 部署（沙盒化編譯、TeX Live 2025）進行量測，共涵蓋 21 種 CPU／記憶體組態，而主機核心的時脈鎖定在 3.0 GHz。工作負載是一篇真實的 63 頁 XeLaTeX 碩士論文，可由多達數百個不同使用者帳號同時編譯。我們發現，在客體記憶體低於 32 GiB 時，核心數幾乎無關緊要——在 16 GiB 時，4、8 與 16 vCPU 客體的量測容量差異不到 8%——而容量反而受一條來自 TeX Live 樹共享頁面快取的超線性記憶體牆所支配。

為了檢驗這些定律是否在一個數量級的規模變化下仍然成立，我們在一台 64 核心、995 GiB 伺服器上重複這項掃描。它以 100% 成功率支撐 1024 個同時的冷編譯——是其執行緒數的八倍——而我們從未碰到其上限。真正有用的數字不是那個上限，而是其下方的轉折點：尾端延遲每次倍增上升 20–40%，直到 $$N=256$$，接著在 $$N=512$$之後上升 190%。因此，將容量報告為「不會失敗的最大並發數」會把可用操作點高估四倍。在那台機器上，記憶體從來不是綁定資源；限制因素是 CPU，以及容器 daemon 接納新沙盒的速率，而後者不管要求多少編譯，飽和點都接近 200。

此外，我們還辨識出兩個在容量規劃中看不見的實作層級效應。第一，CLSI 以硬編碼常數強制 65 個同時編譯的上限，且沒有任何環境變數可以調出；超過後使用者會立刻收到 HTTP 503，而不是被排隊。第二，自 2018 年引入以來，Docker runner 中每個容器的記憶體限制在數量與放置位置上都未發揮作用，因此一旦發生記憶體不足事件，會把整個主機拖垮，而不是單一編譯。把並發上限提高，並將預設編譯逾時從 180 秒拉長到 300 秒，會讓一個 8 vCPU／48 GiB 客體的量測容量從 64 增加到 268 個並發編譯——在零硬體成本下提升 4.2 倍。

最後，我們證明這個系統中的並發只買得到時間共享，而工作負載僅受時脈所限制。一條擬合出的退化律 $$T(N)=T\_1\max(1,N/C)^{b}$$ 得出 $$b=0.914$$，幾乎是完美的成比例減速，而對該機器完整 1.0–5.5 GHz 範圍進行時脈掃描，會將三十個測量值收斂到 $$T=(k/f)\max(1,N/C)$$ 且 $$k=27.9 GHz·s$$ 殘餘散布為 5.1%。5.5× 的時脈換來 5.5× 的加速，沒有任何遞減報酬；這就是時脈與核心買到的是不同東西的意義：時脈讓每位使用者的編譯都更快，核心只會讓更多使用者能進來。

## 1. 引言

Overleaf 是主導性的協作式 LaTeX 編輯器，其本地部署版本被無法將未發表稿件送往第三方雲端的大學與研究團隊廣泛使用。為這類部署決定適當規模是一個反覆出現的實務問題：在固定硬體預算下，究竟有多少人能真的同時按下「重新編譯」？

官方建議是一條線性規則——大致上每 5 到 10 位同時使用者配置 1 個核心與 1 GiB 記憶體——其前提是假設容量會在兩種資源上平滑且共同地擴展。我們的量測從三個方面推翻了這個假設。

### 1.1 容量受兩道獨立的牆支配，而不是一道

一個配置會失敗，要嘛是因為記憶體耗盡，這時 Overleaf 堆疊本身會失去回應並回傳 HTTP 502；要嘛是因為編譯超過伺服器端逾時，這時 CLSI 會回報 `逾時` ，而數 GB 的記憶體卻閒置未用。這兩種狀態的擴展行為與修正方式完全不同。對一個受記憶體限制的配置加核心，不只是低效率，有時甚至適得其反：我們量到的某些配置中，增加核心數會 *降低* 容量，因為更多核心會讓並發編譯同步前進，導致它們的峰值記憶體需求同時出現，而不是交錯發生。

### 1.2 軟體參數主導硬體

編譯逾時是 MongoDB 中每位使用者的一個欄位，其預設值 180 秒會在無聲無息中封頂受 CPU 限制的配置。把它提高到 300 秒，會在硬體不變的情況下讓量測容量最高增加 4.2 倍。另外，CLSI 以一個硬編碼常數拒絕超過 65 個同時編譯。任何沒有把這兩者都納入的容量研究——以及任何部署——測到的都只是軟體，不是機器。

### 1.3 並發只是時間共享，不是平行運算

由於 LaTeX 編譯是單執行緒的，提供 $$N$$ 同時使用者在 $$C$$ 核心上並不會讓系統更快完成；它只會讓每位使用者按比例等得更久。因此，「支援多少並發使用者」這個問題在先固定使用者願意等待多久之前其實是不成立的。我們把這種依賴關係明確化並加以量化。

### 1.4 貢獻

* 在 21 種 CPU／記憶體配置上、於鎖定時脈且重複驗證的條件下量測出的容量矩陣，並根據每個配置的失敗特徵辨識其綁定限制。
* 兩個擬合模型：一個把超線性記憶體牆與 CPU 上限分開的容量模型，以及一個證明純時間共享行為的延遲模型。
* 對部署系統中兩個實作問題的辨識與實驗確認，包括一個自 2018 年以來就未運作的容器記憶體限制。
* 對編譯逾時／容量取捨的量化，我們主張它必須與任何並發數字一併陳述。

## 2. 背景

### 2.1 編譯路徑

一個 Overleaf 編譯請求會經由 `web` $$\rightarrow$$ `CLSI` $$\rightarrow$$ 一個編譯容器。在沙盒化編譯部署（`SIBLING_CONTAINERS_ENABLED=true`）中，CLSI 不會在 `latexmk` 進程內執行；它會要求可透過 bind-mounted socket 存取的主機 Docker daemon，從 TeX Live 映像啟動一個新的容器，並把專案目錄以 bind mount 方式掛載在 `/compile`。因此，一次編譯就是一個短命容器在執行一個 `latexmk` 程序。

這帶來三個後果，而且這三者都形塑了本文的量測。第一，工作的單位是一個單執行緒程序：XeLaTeX 不會平行化。第二，每次編譯的資源隔離程度就是 Docker runner 要求的程度——我們在 §6.2 證明它實際上幾乎什麼都沒要求。第三，工作集合的主體不是文件本身，而是 TeX Live 樹，一個約 32 GiB 的唯讀語料庫；每個並發編譯都會從中讀取，因此透過主機頁面快取共享。這種共享正是我們觀察到超線性記憶體擴展的根源。

### 2.2 啟用沙盒化編譯

社群版 Overleaf 會在 `latexmk` 應用程式容器內部執行。Ayakaleaf Pro 和 Overleaf Server Pro 一樣，也可以改為讓每次編譯都在一個 *同層* 容器中執行——也就是由應用程式在 *主機的* Docker daemon 上啟動，而不是嵌套在應用程式容器裡。啟用這個模式需要兩個工具組設定：

```ini
# config/overleaf.rc
SERVER_PRO=true
SIBLING_CONTAINERS_ENABLED=true
DOCKER_SOCKET_PATH=/var/run/docker.sock

# config/variables.env
TEX_LIVE_DOCKER_IMAGE=ghcr.io/ayaka-notes/texlive-full:2025.1
ALL_TEX_LIVE_DOCKER_IMAGES=ghcr.io/ayaka-notes/texlive-full:2025.1
```

工具組會把主機的 Docker socket bind mount 到應用程式容器中，並把這些設定轉換成 CLSI 讀取的環境變數： `SANDBOXED_COMPILES=true`, `SANDBOXED_COMPILES_SIBLING_CONTAINERS=true`，以及 `SANDBOXED_COMPILES_HOST_DIR`，其中最後一個是 *主機* 上編譯目錄的路徑。那個路徑很重要：因為啟動編譯容器的 daemon 是主機的，所以它收到的 bind mount 必須能在主機的命名空間中解析，而不是在應用程式容器的命名空間中。Server Pro 的 `config/env.sh` 另外會強制設定 `TEXLIVE_IMAGE_USER=www-data` ，以便編譯容器寫出的檔案能一致地歸同一個擁有者。

驗證很直接：在編譯期間，主機會顯示一個名為 `project-{projectId}-{userId}-{hash}` 的容器正在 `latexmk` 執行

**這對本研究為何重要。**

同層容器讓量測變得乾淨——每次編譯都是一個可觀察、獨立排程的 OS 實體——但這也意味著是客體核心，而不是 Overleaf，在決定各次編譯之間的 CPU 與記憶體分配。因此，本文中的每一條擴展定律，都是 Linux 排程器作用於 $$N$$ 單執行緒程序時的性質，也正因如此它才如此規律。

<figure><img src="/files/283bccdb2f353fb15aeb7e76005e2d94605a25b4" alt=""><figcaption></figcaption></figure>

**圖 1。** 一個編譯請求，沿著社群版微服務追蹤。步驟間的分工對容量很重要：文件文字會複製到請求本文，而二進位資產則以參照方式傳遞，並由 `CLSI`。兩者都不占主導——專案的編譯成本由 32 GiB 的 TeX Live 樹決定，而每個並發編譯都透過共享頁面快取讀取它。

<figure><img src="/files/734a261eb14efc37ee284c3f8d229bd547fd1e79" alt=""><figcaption></figcaption></figure>

**圖 2。** 三種部署拓樸，以及各自每個實例的編譯上限落點；堆疊面板表示複本。65 編譯常數約束單一 *一個* CLSI，因此 SaaS 集群會按實例與可用區將其乘上去（a），而 Server Pro 與 Ayakaleaf Pro 所支援的水平擴展會按實例將其乘上去（c）——代價是集中式 MongoDB、Redis 與相容 S3 的儲存、帶有 cookie 會話親和性的負載平衡器（編譯輸出寫入實例本地磁碟，因此一次編譯及其後續的 PDF 下載必須落在同一個實例上），以及單例的 `git-bridge`。工具組預設（b）也就是我們量測的模式，其乘數為一，因此原本為集群中某一成員大小設計的常數，會變成整個安裝的上限。

<figure><img src="/files/4addc0e74f1027ec5495de923120d80bd49618fc" alt=""><figcaption></figcaption></figure>

**圖 3。** 在 `clsi-cache`中的分片選擇。專案會由 $$\operatorname{crc32}(\text{projectId}\text{-}i)\bmod|\text{shards}|$$對映，也就是說，雜湊空間會被切成與分片數相同的等大扇區。這是 *模數* 雜湊，而不是基於環的一致性雜湊：把集群從三個分片擴成四個，會重新分割整個空間，並幾乎重映射每個專案（a, b）。這正是實作需要明確線上重新分片過渡的原因，也就是在一段時間窗內，把線性增加的一部分專案從 `currentShards` 移至 `desiredShards` ，而不是一致性雜湊環會提供的 $$K/n$$ 遷移方式。當某個分片的 circuit breaker 被觸發時，salt $$i$$ 會遞增，且該分片會從候選清單中移除，因此查詢會繼續探測而不是失敗（c）。

### 2.3 兩種失敗模式

我們量測的每個配置都只會以兩種方式中的一種失敗，而這個區別是直接反映在回應狀態碼上，而不是推斷得出的：

* **記憶體耗盡** —— Overleaf 堆疊本身變得無回應，而請求回傳 **HTTP 502**。失敗層級的可用客體記憶體通常低於 500 MiB。
* **編譯逾時** —— CLSI 在每位使用者的逾時點終止編譯，並回報狀態 `逾時`。失敗層級的可用記憶體通常有數 GB。

我們依這個特徵來分類每個配置，而不是依資源比例的啟發式規則，這讓「我們撞上的是哪一道牆」這個問題可以直接從資料本身回答。

## 3. 方法

### 3.1 測試平台與時脈控制

所有客體都在單一 Intel Core i9-14900K 主機上透過 QEMU/KVM 執行，主機配有 62 GiB RAM 與 NVMe 儲存。客體為 Ubuntu 24.04，搭配 Docker 29.7 與 Overleaf Toolkit，部署 Ayakaleaf Pro v6.2.2，沙盒化編譯所對應的映像為 `texlive-full:2025.1`.

除非時脈受到控制，否則消費級桌上型 CPU 對伺服器而言是個很差的替身。KVM 沒有機制可以設定虛擬時鐘：vCPU 就是一條主機執行緒，並以主機核心當下的頻率運作。因此，我們直接約束主機，關閉 turbo，並把每個核心的 `scaling_max_freq` 鎖定為 3.0 GHz，然後用 `taskset`把客體的 vCPU 綁定到實體 P-core。這個區別在混合核心 CPU 上很重要：這顆處理器的 E-core 基礎時脈是 2.4 GHz，而且在關閉 turbo 後 *無法* 達到 3.0 GHz，所以若有執行緒不小心跑到它們上面，量到的其實就是一台較慢的機器。在全載下，我們可在所有 16 條被綁定的執行緒上精確驗證 3000 MHz。守護腳本會在每次基準測試前斷言這項不變量，若不符合就拒絕啟動；在本研究中，它抓到過一次 governor 被無聲重設。

### 3.2 第二個測試平台：一台大型 runner

QEMU 矩陣一次只隔離一個變數，但它最多只能到 16 條被綁定執行緒。為了檢驗同樣的規律在高一個數量級時是否仍成立，我們把並發掃描重複跑在一台大型伺服器上：一台 AMD EPYC 7773X（Milan-X，64 核心／128 執行緒，768 MiB L3），配有 995 GiB RAM，執行相同的 Ayakaleaf Pro v6.2.2 映像並對同樣的 `texlive-full:2025.1`。與 QEMU 客體不同，這台機器的時脈並未鎖定：它是一台生產級伺服器，而我們將其視為一個整體來測量。

需要兩項操作上的注意事項，而且值得說明，因為沒有它們，實驗測到的會是測試架構而不是伺服器。首先，每個容器都被限制在一個 `systemd` 切片，且 `MemoryMax=940 GiB`，使得失控的掃描只會耗盡 cgroup，而不會拖垮主機。第二，沙盒化編譯由主機 daemon 建立，而每個編譯都會弄髒自己的 copy-on-write 層——即使 20.6 GiB 的基底映像是共享的，實測每個容器仍有 116 MiB——因此 Docker data root 被移到專用 NVMe 裝置。一次在 $$N=1024$$ 會寫出約 119 GiB 的暫存層，這不可能放進原生 root 檔案系統。

### 3.3 工作負載

這份文件是真實的 63 頁碩士論文（SJTU 模板），透過 `latexmk`編譯為 XeLaTeX， `biblatex` 參考文獻處理，以及嵌入式 PDF 資產——也就是說，是一個真實而非合成的負載。在未負載的客體上，一次單獨編譯在所有配置下需 8.6–9.8 秒，我們將其用作無負載基線 $$T\_1$$.

### 3.4 負載產生

我們建立 512 個真實使用者帳號，並讓每個帳號各自擁有一份專案副本，使並發編譯像獨立使用者一樣彼此競爭，而不是共享專案鎖。請求從主機送到客體轉發的埠，因此負載產生不會消耗客體 CPU。

並發是 *同時*，不是錯開的。 `POST /project/:id/compile`。這種區別並非吹毛求疵。錯開式遞增量測的是穩定佇列下的吞吐量；同時突發量測的是在一群學生於相同截止公告後按下同一個按鈕時會發生什麼，而這正是實際營運者真正擔心的情況。兩者差異不只是常數倍，因為第二種情況讓編譯佇列填入的速度比 daemon 清空它的速度更快。

在那波突發真正可靠地送出之前，必須先排除四個實務障礙。每一個都值得記錄，因為它們都會在無聲無息間把實驗變成對測試架構的量測，而不是對伺服器的量測。

#### 3.4.1 兩個限速器，不是一個

Overleaf 會對來源位址的登入進行節流——每分鐘 20 次嘗試——而我們的所有流量都來自單一主機。為每個模擬使用者指定不同的 `X-Forwarded-For` 位址可以移除這個限制，但會立刻撞上第二個、更粗粒度的限制：每個子網段每分鐘大約 200 的額度。因此，把使用者分散到一個連續區塊會在第 201 個帳號失敗。我們改為依使用者索引推導合成位址，讓連續使用者落在不同的 `/24`子網段，

$$
\texttt{203.};\big\lfloor i/250 \big\rfloor \bmod 100 + 1\texttt{.};
i \bmod 250 + 1\texttt{.}; i \bmod 200 + 10 ,
$$

如此一來，兩個限速器在 1024 的完整人數下都仍有餘裕。

#### 3.4.2 預設會丟棄注入的標頭

只設定標頭還不夠。Express 只會對 `X-Forwarded-For` 它被告知要信任的同伴才會認可，而 Overleaf 的 `trustedProxyIps` 預設為 `迴環`。由於負載產生器是透過容器的 bridge 介面而不是 loopback 介面連到應用程式，因此標頭會被解析後再丟棄，每個模擬使用者都會縮回到同一個位址。其症狀是在恰好第 20 次登入時出現一波 `HTTP 429` ，這很容易被誤讀成伺服器過載。必須明確把 gateway 網路納入信任鏈；在 §4.3 的叢集部署中，pod 與 service CIDR 也必須一併加入。

#### 3.4.3 負載平衡器會覆寫它被要求保留的標頭

當實例位於代理伺服器後方時，傳統的 `option forwardfor` *會附加* 真實客戶端位址到鏈上；這對生產環境是正確行為，但在此卻完全錯誤：合成位址被負載產生器自己的位址取代。該指令必須寫成 `option forwardfor if-none`，因此代理伺服器只會在客戶端未提供值時才新增一個值。

#### 3.4.4 客戶端先用完檔案描述元，而伺服器還沒用完容量

在 $$N=1024$$ 負載產生器會持有超過一千個同時 socket，而預設 1024 個描述元的軟上限會在 session 設定期間就被觸及，而不是在量測期間。這個失敗是安靜的：有三個 session 沒有建立成功，執行結果回報 1021 而不是 1024，而一個會 shell 出去計算容器數量的取樣執行緒則死於 `EMFILE` ，並無聲無息地把遙測資料截斷。必須在負載產生器上提高 soft limit——我們主機上的 hard limit 早已是 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。

<figure><img src="/files/0ab6fb4433394149ec21e3fce6cc288b21fb7d93" alt=""><figcaption></figcaption></figure>

**圖 4。** 配置矩陣中的量測容量。（a）每個配置都以長條呈現，依記憶體分組並以核心數著色；實心長條代表受記憶體限制（客體因記憶體耗盡而死亡），斜線長條代表受 CPU 限制（編譯在仍有記憶體餘裕時逾時）。在同一組內由左至右閱讀，可以看出在 16 GiB 以下核心數幾乎買不到什麼；跨組閱讀則可看出記憶體的超線性報酬。（b）同一批點與擬合模型相比 $$N\_{\max}=\min(0.69R^{1.60},,26.4C)$$；虛線是記憶體牆，而點狀水平線則是各核心數對應的 CPU 上限。一個配置會被它先碰到的那一道牆所限制。

沿著一欄往下讀是第二個驚喜：在核心數固定時，容量會隨記憶體超線性成長，大致如 $$R^{1.6}$$，原因見 §5.1 對頁面快取所述。

|    記憶體 | 2 vCPU |  4 vCPU |  8 vCPU | 16 vCPU |
| -----: | -----: | ------: | ------: | ------: |
|  2 GiB |      1 |       1 |       1 |       — |
|  3 GiB |      4 |       5 |       6 |       — |
|  4 GiB |      9 |       9 |       9 |       — |
|  8 GiB |     21 |      22 |      26 |       — |
| 16 GiB |      — |      54 |  **45** |      57 |
| 32 GiB |      — | **145** |     135 |     141 |
| 48 GiB |      — | **143** | **268** | **331** |

**表 1。** 在解除 CLSI 並發上限後，以 300 秒編譯逾時量測到、可成功完成的最大同時編譯數。 **粗體** 標示受 CPU 限制的配置（編譯在仍有記憶體餘裕時逾時）；其餘則受記憶體限制（堆疊以 HTTP 502 死亡）。2 GiB 這一列包含 §5.2 討論的修正。

### 4.2 並發只是時間共享

圖 5 在固定的 8 vCPU／16 GiB 客體上掃描每一個並發層級。兩種狀態被一個恰好位於每核心一個編譯的尖銳轉折點分開。在其下方，平均編譯時間是平坦的——它從 $$N=1$$ 到 9.1 秒於 $$N=C=8$$，變化 5%。在其上方，時間會嚴格按 $$N/C$$成正比增加：在 $$N=16,24$$ 我們測得 18.5 秒和 27.1 秒，也就是比值為 $$1:2.13:3.12$$ 相對於理想的 $$1:2:3$$.

<figure><img src="/files/2230891a369663b980f821cbaedaa4dfb37a9c4c" alt=""><figcaption></figcaption></figure>

**圖 5。** 固定硬體下，編譯延遲對並發度的關係。轉折點在 $$N=C$$；超過它之後，測得的減速趨勢跟隨 $$N/C$$ ，誤差僅 5–7%。全部十五個層級都完整成功。

<figure><img src="/files/6e8949d6f24f6286dc081f111b6bcfe8a0a055c3" alt=""><figcaption></figcaption></figure>

**圖 6。** 若干配置下，編譯延遲對並發度的關係。每個面板都固定硬體並掃描所提供的負載；垂直線標示 $$N=C$$。曲線在其左側平坦，並在 $$N/C$$ 右側呈線性，這是時間共享而非競爭的特徵：工作並沒有變得更昂貴，只是輪到它時再等待。

擬合 $$T(N)=T\_1\max(1,N/C)^{b}$$ 對本研究中所有成功的測量結果進行後，得到 $$b=0.914$$ ($$R^2\_{\log}=0.904$$, $$n=81$$）。指數與 1 幾乎無異，這是定量上的說明：一次編譯是單執行緒、CPU 綁定的工作單位，而並發除了分攤核心之外，既無助也無害。對容量規劃而言，實際推論相當令人不安：一個配置可以容納任意多使用者而不 *失敗* ，同時讓每一位都按比例等待更久。在 $$N=56$$ 在這台客體上所有編譯仍然成功，但每位使用者等待 64.8 秒，而不是 8.7 秒。

### 4.3 擴充到 1024 個並發編譯的垂直擴展

表 2 與圖 7 報告了在大型 runner 上的掃描結果。每個層級都是一個 *冷* 編譯：在每個層級之前，我們會透過 `DELETE /project/:id/output`清空編譯目錄與所有參與專案的 CLSI 快取，因此任何層級都不會受下一層級的工作所益。這台機器上的單次編譯基線為 28.8 秒，那是冷態數字，不應與前面使用的 8.6–9.8 秒穩態基線相比；QEMU 客體上的冷態基線為 28.3 秒，因此就每執行緒而言，兩台機器在這個工作負載下彼此差異不到 2%。

| $$N$$ |        成功 | $$p\_{50}$$ | $$p\_{95}$$ | $$p\_{50}/T\_1$$ | 峰值容器數 |
| ----: | --------: | ----------: | ----------: | ---------------: | ----: |
|    64 |     64/64 |      55.0 秒 |      55.1 秒 |             1.9× |     — |
|   128 |   128/128 |      73.6 秒 |      74.4 秒 |             2.6× |     — |
|   192 |   192/192 |      80.2 秒 |      87.3 秒 |             2.8× |     — |
|   256 |   256/256 |      69.8 秒 |     121.2 秒 |             2.4× |   151 |
|   512 |   512/512 |     179.2 秒 |     344.6 秒 |             6.2× |   205 |
|  1024 | 1024/1024 |     280.2 秒 |     468.9 秒 |             9.7× |   179 |

**表 2。** 在一台 EPYC 7773X（64 核心／128 執行緒，995 GiB）上的並發掃描。所有層級皆為冷編譯；基線為 28.8 秒。峰值容器數是同時存活的沙盒數最大值。

<figure><img src="/files/4f8801b22e87ab32c2f3c445b8598ddea81a915d" alt=""><figcaption></figcaption></figure>

**圖 7。** 單一大型 runner 上的垂直擴展。（a）延遲對所提供並發度的關係；陰影區域標示轉折點之後的狀態。（b）實際存活的沙盒數從不跟著要求數量走——它飽和在約 200 左右——而編譯 cgroup 從未用到其上限的五分之一以上。

#### 4.3.1 這台機器從不失敗

每個層級都以 100% 完成，包括 $$N=1024$$ ——是執行緒數的八倍。我們沒有找到這台機器的容量上限；在它耗盡餘量之前，我們先耗盡了耐心。這是本研究中第一個綁定限制不是記憶體的配置：在 $$N=1024$$ 編譯 cgroup 的峰值達到 184 GiB，僅為其 940 GiB 上限的五分之一，而 CPU 則以 100% 利用率運作，平均負載為 166。

#### 4.3.2 降級是次線性的，因為接納被限速

天真的時間共享預測 $$8\times$$ 執行緒數所付出的 $$8\times$$ 延遲。實測代價是 $$9.7\times$$ 相對於單次編譯，但僅有 $$3.8\times$$ 相對於 $$N=128$$ ——對於八倍增加的所提供負載而言。其原因可從圖 7(b) 與表 2 的最後一欄看出：雖然 1024 個請求是同時送出的，但實際存活的沙盒數從未超過 205。daemon 不能像客戶端要求的那樣快地建立容器，所以請求是在接納階段排隊，而不是在 CPU 內部競爭。正是這種排隊在這裡救了尾端，而且它是意外發生的。

#### 4.3.3 轉折點在 512，而不是失敗點

介於 $$N=256$$ 與 $$N=512$$ 之間， $$p\_{95}$$ 延遲上升 $$2.9\times$$ 每次負載倍增；先前每一次倍增的代價介於 $$1.2\times$$ 與 $$1.4\times$$。若將容量表述為「最大的 $$N$$ 不會失敗」，則會報出 1024，對操作員毫無用處：到那時尾端等待時間已接近八分鐘。

### 4.4 編譯時間與時脈成反比

由於工作負載受 CPU 限制，其成本應按 $$1/f$$。我們直接透過在機器的整個範圍內掃描主機時脈來測試這一點：在其他條件不變的客體上，以十個步階跑完 1.0–5.5 GHz（圖 8）。單次編譯時間從 26.5 秒降到 4.8 秒：5.5× 的時脈在整個範圍內都換來 5.5× 的加速，沒有任何遞減報酬。乘積 $$T!\cdot!f$$ 在全部十個時脈下都能維持在 2% 以內不變。

按核心份額正規化後，三十個測量值——十個時脈下的三個並發層級——全部坍縮到同一個常數：

$$
T(N,f) ;=; \frac{k}{f},\max!\left(1,\frac{N}{C}\right),
\qquad k = 27.9\ \mathrm{GHz\cdot s}
$$

其殘餘散布為 5.1%，而時脈本身的變化幅度達 5.5×。沒有任何曲率本身就是結果：如果工作負載是受記憶體頻寬或 I/O 所限制， $$T$$ 那麼在高時脈下會趨於平坦，因為 CPU 會先於其他資源。

<figure><img src="/files/3cfaf2623382f37b7a0ffe32bce185377ed7305c" alt=""><figcaption></figcaption></figure>

**圖 8。** 時脈掃描。（a） $$T=k/f$$ 與擬合的雙曲線。（b）在除以 $$\max(1,N/C)$$ 後，所有點都坍縮到同一常數，驗證式（1）。

式（1）有一個直接的採購後果，既容易表述，也容易弄錯： *時脈改善每位個別使用者的體驗，核心數只會讓更多人得以進入*。時脈高 20% 的機器會讓所有人的編譯都快 20%，而且沒有遞減報酬；核心加倍則根本不會讓任何人的編譯變快。

## 5. 分析

### 5.1 兩道牆，分別擬合

每個配置都依其失敗特徵（§2.3）分類，然後只在實際撞上它們的配置上擬合記憶體牆與 CPU 上限：

$$
N\_{\max} = \min\left(A R^{p},; k\_c C\right)
$$

且 $$R$$ 以 GiB 表示，並 $$C$$ 以 vCPU 表示。

記憶體牆指數始終呈超線性， $$p>1$$：增加一個額外並行編譯所帶來的邊際記憶體成本 *會下降* ，隨著總記憶體增加而下降，從 3 GiB 的 guest 上每次編譯約 312 MiB，降到 32 GiB 的 guest 上約 194 MiB。其機制是 §2.1 所述、跨越 TeX Live 樹的共享頁面快取：並行編譯會讀取重疊的字型與巨集檔案，因此更大的快取會在更多編譯之間攤提。這就是為什麼天真的『每五位使用者一 GB』規則會低估大型機器、而高估小型機器。

<figure><img src="/files/dde72f885f5b359b2e3fd5e987d369c160fe70ac" alt=""><figcaption></figcaption></figure>

**圖 9。** 相同資料，以兩個曲面呈現於 $$(C,R)$$ 平面上。(a) 容量：擬合曲面是一道山脊，而不是一個平面——它隨記憶體急劇上升，並沿核心軸幾乎保持平坦，直到記憶體不再成為約束為止，這就是為什麼 48 GiB 那一列是唯一能由核心數區分各配置的一列。(b) 每一種配置的延遲對並行度關係，擬合的 $$T=12.6,(N/C)^{0.91}$$ 以虛線表示，180 s 超時則畫成一個平面。當某配置的實線曲線穿過該平面時，它便失敗，這使人能清楚看見超時設定如何直接決定所報告的容量。

### 5.2 更多核心反而讓事情變糟之處

方程式（2）是兩項中的最小值，因此對於 $$C$$，但量測結果卻不是。我們觀察到兩次反轉，增加核心數 *降低了* 容量：在 16 GiB（54 對 45）與 32 GiB（145 對 135）時皆如此。這兩次都發生在記憶體受限的區域，而機制在兩者中相同：核心越多，並行編譯就越同步前進，並在同一時刻達到各自的峰值駐留大小；相反地，核心較少時，排程器會交錯安排它們，峰值因此錯開。對於記憶體餘裕已經岌岌可危的 guest 而言，錯開峰值正是讓它存活下來的關鍵。建立在平均資源使用上的容量模型無法表達這一點；這是 *同時* 發生峰值的巧合。

第三個看似的反轉出現在 2 GiB，我們現在不再把它算進去。搜尋紀錄顯示，在 2 vCPU 時容量為 2，但在 4 與 8 vCPU 時為 1，乍看之下像是同樣的效應。重新檢視原始掃描後，情況更單純：在 2 GiB 時，該層級 $$N=2$$ 在三種核心數下都於第一次嘗試成功，之後又在三者中的兩者上確認執行失敗。該層級不是容量，而是擲硬幣，而 2 vCPU 那筆記錄只是剛好落在正面。我們因此在三種核心數下都報告可重現的值 1，並不從這個差異得出結論。我們在此記錄修正，而不是悄悄重述表格，因為被捨棄的讀法原本會支持一個有意思的主張。

## 6. 實作發現

### 6.1 硬編碼的並行度上限

在足夠大的 guest 上，無論要求的並行度為何，容量都恰好停在 65 個同時編譯：在 $$N=66,80,96,128$$ 我們量得 $$65$$ 次成功與 $$1,15,31,63$$ 立即 `不可用` 回應，而容器數量鎖定在 65，仍有好幾 GiB 記憶體未使用，中位編譯時間則穩定在 77 s——遠低於任何超時設定。

其原因是一個 CLSI 中的常數：

```javascript
// services/clsi/config/settings.defaults.cjs:110
compileConcurrencyLimit: isSpotInstance ? 32 : 64,

// services/clsi/app/js/LockManager.js
if (LOCKS.size <= Settings.compileConcurrencyLimit) return   // <= 允許 64+1
throw new Errors.TooManyCompileRequestsError(...)
```

這個比較不是嚴格比較，所以實際上界為 $$64+1=65$$65 **HTTP 503** ——它們是 *被拒絕*，而不是排隊，因此從使用者端看來，編譯按鈕就是失效。不同於同一檔案中的其他所有可調整項，這一項不讀取任何環境變數；它於 2024 年 8 月在上游引入，只能透過修改映像檔來變更。把限制提高後，同一台 16 vCPU / 32 GiB guest 原本報告 `成功=65，不可用=15` 在 $$N=80$$ 則改為報告 `成功=80`.

### 6.2 無法運作的容器記憶體限制

檢視一個正在運作的編譯容器會發現完全沒有任何資源隔離：

```
Memory=0  NanoCpus=0  CpuShares=0  CpuQuota=0  CpusetCpus=[]
Ulimits=[{Name:cpu Soft:305 Hard:310}]
```

沒有任何 CPU 配額是刻意設計的，這也解釋了為什麼 §4.2 的分時指數如此乾淨：沒有任何東西扭曲編譯之間的競爭。然而，沒有 *記憶體* 記憶體限制則不是刻意為之。Docker runner 確實會要求一個：

```javascript
// services/clsi/app/js/DockerRunner.mjs:262
Memory: 1024 * 1024 * 1024 * 1024, // 1 GB
```

這裡有兩處錯誤。其值是 $$1024^4=1 tebiB$$ ，而註解原意是 $$1024^3$$；而且這個欄位被放在 create options 的頂層，而不是放在 `HostConfig`，也就是 Docker API 預期的位置，所以它被丟棄了——這也由觀察到的 `Memory=0` 所證實。這兩個錯誤都存在於引入該檔案的提交（`9a519f0d3d`，2018 年 3 月）中，並且在從 CoffeeScript 轉換、全倉庫重新格式化，以及從 CJS 遷移到 ESM 的過程中倖存下來，而這些過程都沒有回頭檢查語意。值得注意的是 `MAX_OUTPUT = 1024 * 1024 // 1MB` 在同一個提交中是正確的，這表示這是手滑，而不是理解錯誤。

其後果在我們的低記憶體量測中很明顯。由於編譯沒有上限，記憶體耗盡時不會表現為 Docker 只終止那個出事的容器；它會把整台 guest 直接拖垮。在 2 vCPU / 2 GiB 的配置上，我們觀察到監控用 SSH 工作階段被阻塞了 300 s、兩核心上的 load average 高達 68，最後 guest 自行重新啟動。如果每個容器都有正常運作的限制，故障會優雅得多：過大的編譯會失敗，而服務會存活下來。

唯一真正生效的限制是 `RLIMIT_CPU`，設為 $$\text{timeout}+5$$ 秒。它限制的是 *CPU* 時間，而不是牆鐘時間，而且單一編譯只會消耗約 9 s 的 CPU，所以在任何並行度下都不會觸及它；它防範的是像失控巨集之類的病態輸入。不過，它是一個有用的探針：觀察 `Soft:305` 可確認 300 s 的超時設定確實已傳遞到容器中。

### 6.3 編譯超時是主導性的可調參數

每位使用者的欄位 `features.compileTimeout` 預設為 180 s。對於任何 CPU 綁定的配置來說，這不是安全餘裕，而是容量設定，因為仍在正確計算的機器會被判定失敗。將其提高到 300 s——只需一次 MongoDB 更新——會讓量測到的容量最多增加 4.2 倍（表 3）。上限是 600 s，由 `RequestParser.MAX_TIMEOUT`強制，超過此值會被悄悄截斷。

| 配置               | 180 s | 300 s |             比值 | 約束條件          |
| ---------------- | ----: | ----: | -------------: | ------------- |
| 8 vCPU / 48 GiB  |    64 |   268 | $$4.19\times$$ | CPU，28 GiB 可用 |
| 4 vCPU / 32 GiB  |    47 |   145 | $$3.09\times$$ | CPU，30 GiB 可用 |
| 4 vCPU / 48 GiB  |    63 |   143 | $$2.27\times$$ | CPU           |
| 4 vCPU / 16 GiB  |    31 |    54 | $$1.74\times$$ | CPU           |
| 2 vCPU / 8 GiB   |    15 |    21 | $$1.40\times$$ | CPU           |
| 8 vCPU / 32 GiB  |   127 |   135 | $$1.06\times$$ | 下一步撞上記憶體牆     |
| 8 vCPU / 16 GiB  |    56 |    45 | $$0.80\times$$ | 記憶體           |
| 16 vCPU / 32 GiB |   159 |   141 | $$0.89\times$$ | 記憶體           |

**表 3。** 編譯超時對量測容量的影響。

最後兩列是結果中反直覺的一半，也是我們為什麼要在單一超時下重新量測每個配置的原因。對於 *記憶體*受 CPU 限制的配置，更長的超時會 *降低* 提高容量，因為每個編譯會更久地持有其駐留集合，更多編譯因此重疊。因此，若不說明量測時所用的超時，容量數字就毫無意義，而且兩者不能混在同一張表裡。

## 7. 相關工作

### 7.1 廠商指引

Overleaf 自身的硬體文件陳述了我們在此量化的質性事實：LaTeX 是單執行緒，因此單核心效能決定編譯時間，而且『更多核心只有在你嘗試編譯的文件數超過可用 CPU 核心數時才會有幫助』\[1]。接著它給出線性配置規則——2 核心 / 3 GiB 的基準，再加上每五到十位並行使用者一個核心和一個 gigabyte——這也促成了本研究。我們的貢獻是把這些陳述轉化為經量測的定律（方程式（1）與（2）），並找出線性規則失效之處：它沒有包含讓記憶體牆呈超線性的共享頁面快取項，也沒有包含主導結果的兩個軟體參數。

### 7.2 建置與 CI 容量研究

在 LaTeX 場景之外，對並行度下建置系統的量測早已相當成熟。LightSys 報告指出，在 Docker 容器內編譯的傳統 CI 系統會隨著 pull request 到達率上升而在 I/O 上退化，瓶頸約在 11 個並行請求時出現\[17]；TAOS-CI 觀察到編譯主導了 CI 的牆鐘時間，在大型專案上占整體管線持續時間的 60–67%\[18]。我們的系統在一個最終變得決定性的方面有所不同：LaTeX 編譯是互動式的。花兩倍時間完成的 CI 工作只是麻煩；花兩倍時間完成的編譯則會直接被等在預覽窗格前的使用者感受到，因此我們把超時視為容量參數，而不是失敗門檻。

### 7.3 容器開銷

近期工作分解了跨儲存層的 Docker 容器啟動延遲\[19]，並刻畫了邊緣端的容器效能\[20]。在我們的情境中，每次編譯的容器啟動是可攤提的：相對於 9 s 的編譯時間，它只是個很小的常數，而自由飛行時間 $$T\_1$$ 則由我們擬合的模型吸收掉。真正重要的容器屬性是 *缺乏* 資源限制（§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` \[14] 提升 40%。這些是在最佳化 *單次* 編譯。它們與我們所量測的東西互不相干，且可組合：前言快取可縮短 $$T\_1$$，而本文中的每個容量數字都會隨著 $$T\_1$$.

### 7.6 編輯器中的並行控制，而非編譯器

Overleaf 的協作半邊建立在一條已相當成熟的研究脈絡上。操作轉換源自 Ellis 與 Gibbs \[9]，並由 Jupiter 系統 \[10] 讓高延遲用戶端可實際使用；在 `document-updater`：一個伺服器負責排序操作，而每份文件有一個客戶端與之同步的緩衝區。無衝突複製資料型別 \[11] 以無中心排序器的方式解決同樣的問題。這種區分正是 §4.3 的拓樸之所以能運作的原因：因為待更新緩衝區位於共享 Redis 而不在某個執行個體的記憶體中，所以路由到任何複本的編譯都能看到最新的按鍵輸入，而編譯親和性可以依快取區域性而非正確性來選擇。

### 7.7 容量模型

Amdahl 定律 \[24] 為平行化帶來的加速比設下上限，而 Little 定律 \[23] 則把占用量與到達率及服務時間聯繫起來；上文兩者都已用到。Gunther 的通用可擴展性定律 \[12] 在前者之外加入一個用於一致性延遲的逆行項，預測吞吐量會先達峰值然後下降。我們注意到，我們的系統 *沒有* 在直到 $$N=1024$$：吞吐量會飽和且延遲會增長，但不會有任何崩潰。原因是結構性的，而非僥倖——編譯之間沒有共享狀態需要保持一致，因此該定律加入的項接近於零，而 §4.3 的接納平台又在它變得重要之前就把競爭上限住了。

## 8. 營運者建議

{% stepper %}
{% step %}

## 先修正兩個軟體參數，再買硬體

兩者都免費，而就我們量測到的任何單一硬體升級而言，兩者都更有價值。將 `features.compileTimeout` 提高到使用者實際能接受的值——CLSI 接受的最大值是 600 s——並且，如果你預期會超過 65 個同時編譯，就要不是提升 `compileConcurrencyLimit` 在衍生映像中將其提高，或進行橫向擴展。兩者都不做，就等於為軟體拒絕使用的核心付錢。
{% endstep %}

{% step %}

## 以膝點而非上限來為一台機器定規模

大型執行器的掃描（§4.3）將兩個經常被混為一談的數字區分開來。 *上限* ——仍能回傳所有 PDF 的最大並行度——在一台 64 核伺服器上至少是 1024，而我們從未碰到它。 *膝點* ——也就是尾端延遲開始不再緩慢上升、而是開始翻倍的那個點——是在 512，而其下方最後一個舒適的運行點是 256。介於 $$N=256$$ 與 $$N=512$$ 之間， $$p\_{95}$$ 之間，等待時間會從兩分鐘變成接近六分鐘；介於 512 與 1024 之間，它會達到八分鐘。把規模定在上限的營運者，會上線一個技術上可運作、但沒人想使用的系統。

因此對於這台機器與這份文件，建議的運行點是 **256 個同時編譯**，也就是 $$4\times$$ 物理核心數，且 $$2\times$$ 執行緒數，並且維持 $$p\_{95}$$ 接近 120 s。 `compileConcurrencyLimit` 我們建議把
{% endstep %}

{% step %}

## 把這些視為最壞情況的數字

表 2 中的每一個層級都是同時啟動的冷編譯。生產環境中這兩個條件都不成立：同一份文件的暖編譯需時 8.6 s，而冷編譯為 28.3 s，差了 $$3.3$$約 3.3 倍，而真實使用者也不會在同一秒按下按鈕。因此，一個每兩分鐘重新編譯一次、且快取命中率典型的穩態族群，所能支撐的寫入者數量會遠比單看並行度數字所暗示的更多——在 256 的運行點上，大約可達一千名或更多活躍作者。並行度數字上限的是瞬時突發，而不是座位數。
{% endstep %}

{% step %}

## 先決定延遲預算，再反推規模

方程式（1）可直接反解。對於一個目標等待 $$T$$ 在時脈 $$f$$ 上 $$C$$ 核心上可容納的並行度為 $$N \le C,fT/k$$ 且 $$k\approx28 GHz·s$$ 在這份文件中，8 核、3 GHz 的 60 s 預算可得 $$N\le51$$；120 s 的預算則使它加倍。將預算與容量一併發布，是陳述任一者的唯一誠實方式。
{% endstep %}

{% step %}

## 先買記憶體，再買核心，並檢查你碰上的是哪一面牆

低於 32 GiB 時，我們幾乎看不到額外核心帶來的好處。診斷很便宜：如果失敗表現為在 guest 記憶體不足時出現 HTTP 502，就加記憶體；如果失敗表現為 `逾時` 在記憶體還有餘裕時出現
{% endstep %}

{% step %}

## 則加核心或提高超時。營運者可以從我們用來分類配置的同一失敗特徵中讀出這一點。

因為 $$T\propto 1/f$$ 在沒有曲率的情況下仍成立（在 1.0–5.5 GHz 間差異 2%），更快的時脈會讓每次編譯對每位使用者都更快。更多核心不會讓單一編譯更快；它們只會允許更多並行編譯。抱怨是『編譯很慢』的部署應該買時脈；抱怨是『編譯在期限前失敗』的部署則應該買記憶體與核心。
{% endstep %}

{% step %}

## 在超過上限後，應橫向擴展而非縱向擴充

超過 65 個同時編譯後，支援的路徑是橫向擴展（圖 2c 與 10，§9 中有更詳細說明）：多個應用程式執行個體置於負載平衡器之後，使用 cookie session affinity，共享中央 MongoDB、Redis 與相容 S3 的儲存，而 `git-bridge` 則保持為單例。這會把每個執行個體的上限乘上執行個體數量，這也正是 SaaS 部署達到其自身容量的方式。
{% endstep %}

{% step %}

## 不要依賴每次編譯的隔離

在 Docker runner 的記憶體限制修正之前（§6.2），單一有問題的文件就可能耗盡主機，而不是只被單獨殺掉。需要這項保證的營運者應該自行施加，而不是等待它自動出現。我們在大型主機上使用的機制是一個帶有硬上限的 systemd slice，然後把 Docker daemon 指向它，讓它建立的每個容器都被計入其中：

```ini
[Slice]
MemoryMax=940G
```

如果漏掉，這裡有一個細節會讓你白白花掉一個下午。名為 `docker-capped.slice` 的 slice 並不是放在 `docker.slice`旁邊；它是放在 *裡面* 的，因為連字號是階層分隔符，而不是名稱的一部分。看起來沒有作用的上限，通常是套在距離容器實際所在層級還差一層的地方。請在一趟執行後從 `memory.max_usage_in_bytes` 回讀峰值，而不是相信組態檔——在我們的主機上，即使有 1024 個同時編譯，compile cgroup 也從未超過其上限的五分之一，而這本身就證明了綁定約束是 daemon，而不是記憶體。
{% endstep %}
{% endstepper %}

<figure><img src="/files/7090bbf7563bb526aa381f4ba8fc4a5eebcf6b38" alt=""><figcaption></figcaption></figure>

**圖 10。** 我們驗證過的設定所繪出的橫向擴展部署參考拓樸。應用程式複本彼此可互換，且不攜帶任何持久狀態，因此可自由增減。共有三個元件不是這樣：Redis，其文件緩衝區讓路由到任何複本的編譯都能看到最新的按鍵輸入；物件儲存，當複本超過一個時它便從可選變成必需；以及 `git-bridge`，它把儲存庫保存在本地磁碟上，沒有複寫路徑，且必須作為單例與一個指定的複本並行執行。

## 9. 一個參考性的多機部署

以上內容都在量測一台機器。本節會把分散式形式說明到足以建置的程度，而且——因為營運者真正面對的問題不是 *如何* 而是 *是否* ——所以先說明它到了哪個點才值得如此大費周章。

### 9.1 何時值得採用分散式形式

一台機器在所有重要面向上都更便宜：一個故障域、沒有需要保持一致的共享狀態、也沒有容易出錯的路由。我們的資料為何時該離開它設下了三道門檻。

#### 9.1.1 低於 65 個同時編譯時，不要

每個執行個體的上限是軟體常數，不是硬體常數（§6.1）。在提供的負載接近它之前，第二台機器只會增加故障模式，卻得不到任何好處。那台 64 核主機只有在 `compileConcurrencyLimit` 被提升後，才以 100% 成功率服務 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 的規則來為每個複本定規模——先記憶體、再核心、然後時脈——接著把複本數設為以每個複本的上限來涵蓋峰值並行度。複本不保有任何持久狀態：它們的本地磁碟承載編譯暫存與輸出快取，兩者都可重建。這就是它們可以安全地自由增減的原因，而這點值得驗證，而不是假設，因為單一設定錯誤的 `檔案儲存` 路徑就會悄悄把該層級轉變成有狀態的。

#### 9.2.3 狀態

Redis、MongoDB 與相容 S3 的物件儲存，分別位於不同主機上。Redis 是最承重也最不顯眼的一個：它保存 session store 與即時文件緩衝區，這使得路由到任何複本的編譯都能看到在另一個複本上輸入的按鍵。如果營運者把 Redis 當作快取並按清除來配置，就會產生過時文件的編譯，而且非常難以診斷，因為沒有任何東西失敗——輸出只是錯了。MongoDB 依專案數而不是編譯率擴展。物件儲存在只有一個複本時是可選的，超過一個複本時則必需。

#### 9.2.4 單例

`git-bridge` 把儲存庫保存在本地磁碟上，維護本地索引，而且沒有複寫路徑。它必須剛好以一個執行個體運作，固定在一個指定複本旁邊，而且它是讓部署不是完全無狀態的元件。請相應地規劃其主機：它的磁碟是需要備份的那一個。

| **層級**       | **數量**               | **隨著...而擴展** |
| ------------ | -------------------- | ------------ |
| 負載平衡器        | 1–2                  | 同時連線數        |
| 應用程式         | $$\lceil N/C\rceil$$ | 峰值同時編譯數      |
| Redis        | 1（+複本）               | 活躍編輯工作階段     |
| MongoDB      | 1（+複本）               | 已儲存專案        |
| 物件儲存         | 1 個叢集                | 專案總位元組數      |
| `git-bridge` | 恰好 1 個               | 磁碟上的儲存庫      |

**表 4。** 參考層級。只有應用程式層級會隨並行度擴展；為它定規模是 §8 的主題。

### 9.3 路由是最容易出錯的部分

三類請求必須到達三個不同的地方，而預設的單一規則設定最多只能滿足其中兩類。

來自 `/project/` 下的編譯流量，應該根據專案識別碼以一致性雜湊方式分發，讓某個專案的編譯快取停留在同一個複本上。我們使用 HAProxy 的 `balance hash path,field(3,/)` 且 `hash-type consistent` 與 `hash-balance-factor 150`。這個選擇在橫向擴展時很重要：採用 cookie affinity 時，既有工作階段會無限期地固定在原本的複本上，而新加入的複本只會接收新使用者，因此營運者剛付錢買來的機器吸收不到任何當初促使購買的負載。以我們的設定來看，一致性雜湊在橫向擴展時重新分配了 35% 的專案，而 cookie 則是 0%。

工作階段流量則不同。當 WebSocket 升級失敗而且 `socket.io` 退回到 XHR 輪詢時，同一個工作階段的連續輪詢必須到達同一個複本，而路徑中沒有可供雜湊的專案識別碼。這類流量需要一個具 cookie affinity 的獨立後端。我們設計了這種切分，但沒有部署；我們把它標示為缺口，而不是聲稱已完成。

最後， `/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 接納率是一種資源，而且通常被忘記

在 $$N=1024$$ 在每個請求同時到達的情況下，我們的伺服器從未容納超過 205 個活動沙箱（圖 7b）。限制因素是容器建立，而不是編譯——這與將容器啟動成本歸因於執行時開銷而非映像大小的量測研究一致\[19, 20]。只為 CPU 與記憶體做規模設定的服務，會發現自己的突發行為由一個它從未量測過的量決定。本節的實用形式就是 §8 的建議：刻意限制接納，因為你自己選擇的佇列，比你事後才發現的佇列好。

### 10.3 一個壽命長於其編譯的沙箱會使模型失效

這裡的每一個容量數字都假設容器被建立、做一次編譯，然後退出——其壽命是數十秒，而且只有在運作時其 duty cycle 才接近 1。有兩種近來的設計模式打破了這個假設，而它們以相同的方式打破它。

第一種是每位使用者的持久沙箱。為每位使用者配置一個固定的私有環境，會把一個經統計多工的資源池轉變成一組保留：一個靠時間共享可從 64 核伺服器提供 256 個同時編譯的服務，如果每位使用者都被分配四個專用核心，那麼就只能服務 16 位使用者，對同樣硬體而言少了整整一個數量級。我們的資料是在量化這個選擇的成本，而不是反對它——保留換來的是可預測性，而匯率大約是 $$16\times$$ 在我們建議的運行點。

第二種，也是較新的，是與編譯器共享沙箱的 AI agent。在 agent 輔助的撰寫平台中，執行 XeLaTeX 的同一個容器也可能長時間運行一個 coding agent，因此它會持續被占用，而不是以突發方式占用。實務界報告的症狀正是這類部署所預測到的——在溫和的使用者數量下仍持續遲緩\[15]。這種互動值得精確說明，因為它不只是『更多負載』而已。三項結果會疊加。占用不再是突發式的，因此 §4.2 的分時定律會一次套用到整個族群，而不是只套用到目前正在編譯的那一部分。頁面快取，這正是 §4.1 中超線性記憶體回報的來源，現在要與 agent 自己的工作集共享，對 TeX 而言不再是暖的。再者，§6.2 中缺失的容器記憶體限制會變得危險得多，因為一個永不退出的容器也永遠不會歸還其記憶體。

我們沒有量測這樣的平台，也不對任何特定產品作出主張。我們能說的是，我們的數字對設計意味著什麼：一個給每位使用者長壽命多核心沙箱的架構，應該按保留系統來配置，而不是依照此處報告的並行度數字；它可期望的容量更接近其核心數除以每位使用者核心數，而不是表 1 中的任何數字。

## 11. 有效性威脅

### 11.1 單一文件

所有量測都使用同一份 63 頁的 XeLaTeX 文件。其他文件的絕對容量會不同；但作為比值的擴展定律應該不會。若文件的駐留集合大得多，會移動記憶體牆，但不會改變其超線性特徵。

### 11.2 虛擬化主機

guest 在一台實體機器上透過 KVM 執行，因此絕對數值包含虛擬化開銷，而 guest 共享主機的頁面快取與 NVMe 裝置。我們在觀察到主機記憶體壓力會使 guest 內的 load average 在相同並行度下膨脹超過 $$3\times$$ 。

### 11.3 同時到達

每次編譯都在同一瞬間發出，這是最壞情況。真實使用者以隨機過程到達，因此依我們數字配置的部署會有餘裕，而不是不足——但提交截止時刻結束時的尖峰，仍比 Poisson 模型更接近我們的模型。

### 11.4 邊緣配置

在 2 GiB 時，系統已接近崩潰，因此同一配置的重複執行可能相差一個編譯。我們報告保守值，而不從 $$\pm 1$$ 在該區域中的差異

## 12. 可取得性

被測系統、部署工具，以及其所源自的上游專案，皆為公開：

* Ayakaleaf Pro — <https://github.com/ayaka-notes/ayakaleaf-pro>
* 部署工具包 — <https://github.com/ayaka-notes/toolkit>
* 文件 — [https://ayakaleaf-pro.ayaka.space](https://ayakaleaf-pro.ayaka.space/)
* 上游 Overleaf — <https://github.com/overleaf/overleaf>
* TeX Live 編譯映像 — `ghcr.io/ayaka-notes/texlive-full:2025.1`

我們引用的每個原始碼位置，都以相對於 Ayakaleaf Pro v6.2.2 的 repository-relative 路徑與行號給出，而我們所標註日期的兩個上游提交（`9a519f0d3d`, `5d472e9b38`）可在 Overleaf 歷史中找到。

## 13. 貢獻

Musicminion 設計了本研究，提供並運作測試平台，主導研究方向，並驗證此處報告的每一項量測。Claude Opus 5（Anthropic）建立並運作基準測試架構，自動化部署，進行原始碼考古，製作圖表，並撰寫手稿。兩位作者都審閱了最終文字。若某次執行被報告為受污染——§4.3 的 1021 個工作階段掃描，以及 §4.1 的異常 $$N=256$$ 層級——該缺陷是在審閱期間發現的，且在發表前已重跑，而不是被悄悄刪除。

讀者應注意，ACM、IEEE 與 ICMJE 的作者規範目前保留作者資格給能對作品負責的一方，並會要求第二作者的貢獻以揭露事項而非署名列示。我們在此明確說明分工，以便在任一慣例下記錄都準確。

## 14. 結論

自架 Overleaf 的容量規劃不是單一資源的擴充問題。有三項發現應該改變做法。

第一，低於 32 GiB 的 guest 記憶體時，核心數幾乎無關緊要：在 16 GiB 時，4、8 與 16 vCPU guest 的容量差異不到 8%。透過 TeX Live 樹上的共享頁面快取，記憶體才是限制；只有當記憶體充裕時，核心才開始變得重要。

第二，兩個軟體參數的影響大於硬體。解除 CLSI 硬編碼的 65 編譯上限並提高預設 180 s 的編譯超時，讓一台 8 vCPU / 48 GiB guest 的同時編譯數從 64 提升到 268——不增加任何硬體就達 4.2 倍。這兩者都無法從組態文件中得知；而且其中一個根本無法設定。

第三，問題「這台機器支援多少並發使用者」的規格不夠明確。此系統中的並發純粹是時間共享，而容量則取決於逾時設定所允許的範圍。誠實的回答會把兩者都說明： *這台機器可提供* $$N$$ *若使用者願意等待，可同時進行編譯* $$T$$ *秒*，並且有 $$N$$ 與 $$T$$ 其關係由式（1）表示。

我們也報告一個潛在缺陷：Docker 執行器中的每個容器記憶體限制自 2018 年起在數值與配置位置上都一直無效。其實際影響是，在小型部署中發生記憶體耗盡時，會使整個服務當機，而不是讓造成問題的那一次編譯失敗。

## 參考文獻

\[1] Overleaf. *硬體需求*，內部部署文件。 <https://docs.overleaf.com/on-premises/getting-started/requirements/hardware-requirements>

\[2] Overleaf. *水平擴展*，內部部署文件。 <https://docs.overleaf.com/on-premises/maintenance/horizontal-scaling>

\[3] Overleaf. *微服務*，內部部署文件。 <https://docs.overleaf.com/on-premises/getting-started/microservices>

\[4] Overleaf. *原始碼存放庫*. <https://github.com/overleaf/overleaf>

\[5] Ayaka-notes. *Ayakaleaf Pro*. <https://github.com/ayaka-notes/ayakaleaf-pro>

\[6] Ayaka-notes. *Overleaf 工具包*. <https://github.com/ayaka-notes/toolkit>

\[7] D. Karger, E. Lehman, T. Leighton, R. Panigrahy, M. Levine and D. Lewin. *一致性雜湊與隨機樹：用於緩解全球資訊網熱點的分散式快取協定*. STOC, 1997.

\[8] J. Tan and M. Rigger. *TeX 產生文件中的不一致性*. 在 *第 33 屆 ACM SIGSOFT 軟體測試與分析國際研討會（ISSTA）論文集*，維也納，2024 年。doi： <https://doi.org/10.1145/3650212.3680370>

\[9] C. A. Ellis and S. J. Gibbs. *群組軟體系統中的並發控制*. 在 *ACM SIGMOD 論文集*，第 399–407 頁，1989 年。

\[10] D. A. Nichols, P. Curtis, M. Dixon and J. Lamping. *Jupiter 協作系統中的高延遲、低頻寬視窗化*. 在 *ACM UIST 論文集*，第 111–120 頁，1995 年。

\[11] M. Shapiro, N. Preguiça, C. Baquero and M. Zawirski. *無衝突複製資料型別*. 在 *SSS 論文集*，第 386–400 頁，2011 年。

\[12] N. J. Gunther. *游擊式容量規劃：為高度可擴展應用與服務進行規劃的戰術方法*。Springer，2007 年。

\[13] LaTeX3 計畫。 *l3build — 用於（La）TeX 的測試與建置系統*。CTAN。

\[14] M. Isaksson. *哪個 LaTeX 建置系統最快？一項基準測試*. <https://blog.martisak.se/latex-build-systems-comparison/>

\[15] 實務人員回報，在與 LaTeX 編譯器共置於每位使用者沙盒中的持久性程式碼代理之代理輔助撰寫平台上，存在持續性的延遲。我們將其引用為實際運作經驗的報告，而非受控測量；我們並未對此類平台進行基準測試。

\[16] D. E. Knuth. *The TeXbook*。Addison-Wesley，1984 年。

\[17] G. Lim, M. Ham, J. Moon and W. Song. *LightSys：用於提升軟體整合速度的輕量且高效 CI 系統*。arXiv:2101.07961 \[cs.SE]，2021 年。預印本。

\[18] G. Lim, M. Ham, J. Moon, W. Song, S. Woo and S. Oh. *TAOS-CI：用於邊緣運算的輕量級與模組化持續整合系統*。arXiv:2101.08889 \[cs.SE]，2021 年。預印本。

\[19] S. Khan. *分解 Docker 容器啟動效能：異質基礎架構上的三層級量測研究*。arXiv:2602.15214，2026 年。預印本。

\[20] R. Gupta and K. Nahrstedt. *邊緣運算中容器的效能特性分析*。arXiv:2505.02082，2025 年。預印本。

\[21] S. Checkoway, H. Shacham and E. Rescorla. *純文字資料格式安全嗎？或者，使用這個 LaTeX 類別檔來攻陷你的電腦*. 在 *USENIX 大規模利用與新興威脅工作坊（LEET）論文集*, 2010.

\[22] G. Lacombe, K. Masalygina, A. Tahiri, C. Adam and C. Lauradoux. *你能接受陌生人傳來的 LaTeX 檔案嗎？十年之後*。arXiv:2102.00856 \[cs.CR]，2021 年。預印本。

\[23] J. D. C. Little. *排隊公式的一個證明* $$L=\lambda W$$。Operations Research，9(3)：383–387，1961 年。

\[24] G. M. Amdahl. *實現大規模運算能力之單處理器方法的有效性*。AFIPS，1967 年。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://ayakaleaf-pro.ayaka.space/blog/zh-tw/2026/overleaf-benchmark.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
