> ## Documentation Index
> Fetch the complete documentation index at: https://ayakaleaf-pro.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Overleaf 基准测试：深入研究 Overleaf（SaaS 与自托管）中的并发 LaTeX 编译

<Card title="Overleaf-Benchmark.pdf" icon="file-arrow-down" href="/images/blog/overleaf-benchmark.pdf" horizontal />

## 摘要

自托管 Overleaf 部署的容量规划通常依据一条经验法则：每五到十个并发用户配备一个 CPU 核心和一 GB 内存。我们证明这条法则不仅不精确，而且在结构上就是错误的：它假设容量只受单一资源维度支配，而实际上存在两道相互独立的"墙"；此外，两个软件参数（而非任何硬件）对结果的影响可达四倍之多。

我们在 QEMU/KVM 虚拟机中，以宿主机核心锁频 3.0 GHz 的条件，对启用沙盒编译（TeX Live 2025）的原版 Ayakaleaf Pro v6.2.2 部署在 21 种 CPU/内存配置下进行了测量。工作负载是一篇真实的 63 页 XeLaTeX 学位论文，由多达数百个不同的用户账户同时编译。我们发现，当虚拟机内存低于 32 GiB 时，核心数几乎无关紧要——在 16 GiB 下，4、8 和 16 vCPU 虚拟机的实测容量差异不到 8%——容量实际上由一道超线性的内存墙决定，其根源在于 TeX Live 目录树上的共享页缓存。

为了检验这些规律在规模提升一个数量级后是否依然成立，我们在一台 64 核、995 GiB 内存的服务器上重复了测试。它能以 100% 的成功率承受 1024 个同时进行的冷编译——是其线程数的八倍——而我们始终未触及其上限。真正有用的数字不是这个上限，而是其下方的拐点：在 $N=256$ 之前，并发每翻一倍，尾延迟增长 20–40%；到 $N=512$ 时则增长 190%。因此，如果把容量报告为"不出现失败的最大并发数"，将会把实际可用的工作点高估四倍。在这台机器上，内存从来不是瓶颈资源；限制来自 CPU，以及容器守护进程接纳新沙盒的速率——无论请求多少编译，该速率都会在 200 左右饱和。

我们还发现了两个在容量规划中不可见的实现层面效应。第一，CLSI 强制执行一个硬编码的 65 个并发编译上限，且未通过任何环境变量暴露；超出该上限时，用户会立即收到 HTTP 503，而不是进入队列等待。第二，Docker runner 中针对单个容器的内存限制自 2018 年引入以来一直无效，无论在数值上还是在设置位置上都是如此，因此一次内存耗尽事件会拖垮整个宿主机，而不仅仅是单次编译。解除并发上限并将默认编译超时从 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 编辑器，其本地部署版本被大量无法将未发表手稿发送到第三方云端的大学和研究团队广泛采用。如何为这类部署确定规模是一个反复出现的实际问题：在固定的硬件预算下，究竟有多少人可以同时按下"重新编译"？

官方指导是一条线性法则——大约每五到十个并发用户配备一个核心和一 GB 内存——它假定容量在两种资源上平滑且联合地扩展。我们的测量结果在三个方面与之相悖。

### 1.1 容量由两道相互独立的墙决定，而非一道

一个配置失败的原因有两种：要么内存耗尽，此时 Overleaf 服务栈本身崩溃并返回 HTTP 502；要么编译超出服务端超时，此时 CLSI 报告 `timedout`，而数 GB 内存却闲置未用。这两种状态具有完全不同的扩展行为和不同的补救措施。为内存受限的配置增加核心不仅效率低下，有时甚至适得其反：我们测得一些配置中，增加核心数反而 *降低* 了容量，因为更多核心使并发编译同步推进，导致它们的内存峰值需求同时出现，而不是交错分布。

### 1.2 软件参数的影响超过硬件

编译超时是 MongoDB 中的一个按用户设置的字段，其默认值 180 秒会悄无声息地限制 CPU 受限配置的容量。在硬件不变的情况下，将其提高到 300 秒可使实测容量提升至多 4.2 倍。另外，CLSI 通过一个硬编码常量拒绝超过 65 个的并发编译。任何不考虑这两者的容量研究——以及任何部署——测量的都是软件，而不是机器。

### 1.3 并发是分时共享，而非并行

由于 LaTeX 编译是单线程的，在 $C$ 个核心上为 $N$ 个同时在线的用户提供服务并不会让系统更快完成；它只会让每个用户成比例地等待更久。因此，在确定用户愿意等待多久之前，"支持多少并发用户"这个问题本身是不适定的。我们明确了这种依赖关系并对其进行了量化。

### 1.4 贡献

* 一个涵盖 21 种 CPU/内存配置的容量矩阵，在锁频且经重复验证的条件下测得，并根据每个配置的失败特征确定其约束因素。
* 两个拟合模型：一个将超线性内存墙与 CPU 上限分离的容量模型，以及一个确立纯分时共享行为的延迟模型。
* 识别并通过实验证实了已部署系统中的两个实现问题，其中包括一个自 2018 年以来一直无效的容器内存限制。
* 量化了编译超时与容量之间的权衡，我们认为任何并发数据都必须同时注明这一点。

## 2. 背景

### 2.1 编译路径

一个 Overleaf 编译请求的路径为 `web` $\rightarrow$ `clsi` $\rightarrow$ 编译容器。在沙盒编译部署（`SIBLING_CONTAINERS_ENABLED=true`）中，CLSI 不会在进程内运行 `latexmk`；它会通过绑定挂载的套接字请求宿主机的 Docker 守护进程，从 TeX Live 镜像启动一个新容器，并将项目目录绑定挂载到 `/compile`。因此，一次编译就是一个运行单个 `latexmk` 进程的短生命周期容器。

由此产生三个推论，它们都影响了本文中的测量结果。第一，工作单元是一个单线程进程：XeLaTeX 不会并行化。第二，每次编译的资源隔离完全取决于 Docker runner 的请求——我们在 §6.2 中表明，它实际上什么也没有请求。第三，工作集的主体不是文档本身，而是 TeX Live 目录树——一个约 32 GiB 的只读语料库，每个并发编译都会从中读取，因而通过宿主机页缓存共享它。这种共享正是我们观察到的超线性内存扩展的根源。

### 2.2 启用沙盒编译

社区版 Overleaf 在应用容器内部运行 `latexmk`。而 Ayakaleaf Pro 与 Overleaf Server Pro 一样，可以将每次编译放在一个 *兄弟* 容器中运行——即由应用在 *宿主机* 的 Docker 守护进程上启动的容器，而不是嵌套在应用容器内部。两个 Toolkit 设置可以开启此功能：

```ini theme={null}
# 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
```

Toolkit 将宿主机的 Docker 套接字绑定挂载到应用容器中，并将这些设置转换为 CLSI 读取的环境变量：`SANDBOXED_COMPILES=true`、`SANDBOXED_COMPILES_SIBLING_CONTAINERS=true` 和 `SANDBOXED_COMPILES_HOST_DIR`，最后一个是编译目录在 *宿主机* 上的路径。这个路径很重要：由于启动编译容器的是宿主机的守护进程，传给它的绑定挂载路径必须能在宿主机的命名空间中解析，而不是在应用容器的命名空间中。Server Pro 的 `config/env.sh` 还会在此模式下强制设置 `TEXLIVE_IMAGE_USER=www-data`，以确保编译容器写入的文件所有者保持一致。

验证方式很直接：在编译过程中，宿主机上会出现一个名为 `project-{projectId}-{userId}-{hash}` 的容器，它使用 TeX Live 镜像运行 `latexmk`，并以状态码 0 退出。这正是我们在全文中测量其数量的单元，我们也在 §6.2 中报告了它完全没有资源限制的问题。

<strong>为什么这对本研究很重要。</strong>

兄弟容器使测量变得干净——每次编译都是一个可观测、独立调度的操作系统实体——但这也意味着在各次编译之间仲裁 CPU 和内存的是虚拟机内核，而不是 Overleaf。因此，本文中的每条扩展规律都是 Linux 调度器作用于 $N$ 个单线程进程时的性质，这也是它们如此规律的原因。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_flow.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=c6b96766866668c223bfa4ae1a4d9d1b" alt="" width="1353" height="359" data-path="images/blog/fig_flow.png" />
</Frame>

<strong>图 1.</strong> 一个编译请求在社区版微服务中的完整追踪。各步骤之间的分工对容量很重要：文档文本被复制到请求体中，而二进制资源则通过引用传递并由 `clsi` 拉取。两者都不是主导因素——项目的编译开销由 32 GiB 的 TeX Live 目录树决定，每个并发编译都通过共享页缓存读取它。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_arch.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=117eb544d44a6d2bdd9dc88945d9543f" alt="" width="1731" height="559" data-path="images/blog/fig_arch.png" />
</Frame>

<strong>图 2.</strong> 三种部署拓扑，以及每种拓扑中单实例编译上限所处的位置；堆叠的面板表示副本。65 个编译的常量只保护 *一个* CLSI，因此 SaaS 集群将其乘以实例数和可用区数（a），而 Server Pro 和 Ayakaleaf Pro 支持的水平扩展则将其乘以实例数（c）——代价是需要集中式的 MongoDB、Redis 和 S3 兼容存储，一个具备基于 Cookie 的会话亲和性的负载均衡器（编译输出写入实例本地磁盘，因此一次编译及其后续的 PDF 下载必须落在同一实例上），以及一个单例 `git-bridge`。Toolkit 默认配置（b）——也就是我们测量的对象——的乘数为一，因此一个为集群中单个成员设定的常量变成了整个安装的上限。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_ring.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=65b022a41f38ac91f273953b1e5f7003" alt="" width="971" height="279" data-path="images/blog/fig_ring.png" />
</Frame>

<strong>图 3.</strong> `clsi-cache` 中的分片选择。项目通过 $\operatorname{crc32}(\text{projectId}\text{-}i)\bmod|\text{shards}|$ 进行映射，即哈希空间被切分为与分片数量相同的等大扇区。这是 *取模* 哈希，而不是基于环的一致性哈希：将集群从三个分片扩展到四个会重新划分整个空间，几乎每个项目都会被重新映射（a、b）。这正是该实现需要一个显式的在线重分片过渡期的原因——在一个时间窗口内，将线性增长比例的项目从 `currentShards` 迁移到 `desiredShards`，而不是像一致性哈希环那样只迁移 $K/n$。当某个分片的熔断器被触发时，盐值 $i$ 会递增，并将该分片从候选列表中移除，因此查找会继续探测下一个分片而不是失败（c）。

### 2.3 两种失败模式

我们测量的每个配置都恰好以两种方式之一失败，而且这种区别可以直接从响应状态中看出，而非推断得出：

* **内存耗尽**——Overleaf 服务栈本身失去响应，请求返回 **HTTP 502**。在失败的并发级别下，虚拟机可用内存通常低于 500 MiB。
* **编译超时**——CLSI 在按用户设置的超时时间到达时终止编译，并报告状态 `timedout`。在失败的并发级别下，可用内存往往还有数 GB。

我们根据这一特征而不是基于资源比例的启发式方法对每个配置进行分类，这使得"我们撞上了哪道墙"这个问题可以直接从数据中得到回答。

## 3. 方法

### 3.1 测试平台与时钟控制

所有虚拟机都运行在一台 Intel Core i9-14900K 宿主机上的 QEMU/KVM 中，宿主机配有 62 GiB 内存和 NVMe 存储。虚拟机系统为 Ubuntu 24.04，配备 Docker 29.7，并通过 Overleaf Toolkit 部署启用沙盒编译的 Ayakaleaf Pro v6.2.2，使用 `texlive-full:2025.1`。

除非控制其时钟，否则普通桌面 CPU 很难作为服务器的替代。KVM 没有提供设置虚拟时钟的机制：一个 vCPU 就是一个宿主机线程，以宿主机核心当时的频率运行。因此我们直接约束宿主机：禁用睿频，将每个核心的 `scaling_max_freq` 固定为 3.0 GHz，并使用 `taskset` 将虚拟机的 vCPU 绑定到物理 P 核上。在混合核心 CPU 上，这一区别很重要：该型号的 E 核基础频率为 2.4 GHz，禁用睿频后 *无法* 达到 3.0 GHz，因此一旦测试跑到 E 核上，就会悄无声息地测量一台更慢的机器。在满载下，我们验证了全部十六个绑定线程都精确运行在 3000 MHz。一个守护脚本会在每次基准测试前检查这一不变量，否则拒绝启动；在研究期间，它捕获到了一次调频策略被悄悄重置的情况。

### 3.2 第二个测试平台：一台大型运行机

QEMU 矩阵每次只隔离一个变量，但它最多只能有十六个绑定线程。为了检验同样的规律在高一个数量级时是否依然成立，我们在一台大型服务器上重复了并发扫描：一颗 AMD EPYC 7773X（Milan-X，64 核 / 128 线程，768 MiB L3 缓存），配备 995 GiB 内存，运行相同的 Ayakaleaf Pro v6.2.2 镜像和相同的 `texlive-full:2025.1`。与 QEMU 虚拟机不同，这台机器没有锁频：它是一台生产级服务器，我们也按生产级服务器来测量它。

有两项运维预防措施是必要的，值得说明，因为没有它们，实验测量的将是测试工具而不是服务器。第一，每个容器都被限制在一个 `MemoryMax=940 GiB` 的 `systemd` slice 中，这样失控的扫描只会耗尽一个 cgroup，而不是整个宿主机。第二，沙盒编译由宿主机守护进程创建，每个都会写脏自己的写时复制层——即使 20.6 GiB 的基础镜像是共享的，每个容器实测仍占用 116 MiB——因此我们将 Docker 数据根目录迁移到了一块专用 NVMe 设备上。在 $N=1024$ 时，一次扫描会写入约 119 GiB 的临时层，这超出了普通根文件系统的容量。

### 3.3 工作负载

文档是一篇真实的 63 页硕士学位论文（SJTU 模板），通过 `latexmk` 使用 XeLaTeX 编译，包含 TikZ 图形、`biblatex` 参考文献处理和嵌入的 PDF 资源——也就是说，这是真实的而非合成的负载。在空载虚拟机上，单次编译在所有配置下耗时 8.6–9.8 秒，我们将其作为无负载基线 $T_1$。

### 3.4 负载生成

我们创建了 512 个真实用户账户，并为每个账户提供一份独立的项目副本，这样并发编译之间的竞争就与独立用户完全一致，而不是共享同一个项目锁。请求从宿主机发往虚拟机的转发端口，因此负载生成不会消耗虚拟机的 CPU。

并发是 *同时* 发生的，而不是错开的。每个会话都会先建立好——登录、获取 CSRF 令牌、选择编译器——然后每个线程休眠至一个共同的挂钟时刻（只计算一次并共享），再发出其 `POST /project/:id/compile` 请求。这一区别并非吹毛求疵。错开的渐进加压测量的是稳定队列下的吞吐量；而同时爆发测量的是一整个阶梯教室的学生在同一条截止通知之后按下同一个按钮时会发生什么，这才是运维人员真正担心的情况。两者的差异不止是一个常数倍，因为后者填充编译队列的速度比守护进程消化它的速度更快。

在能够如实地发出这种爆发请求之前，必须先清除四个实际障碍。每一个都值得记录，因为每一个都会悄无声息地把实验退化为对测试工具而非服务器的测量。

#### 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` 默认值为 `loopback`。由于负载生成器是通过容器网桥而不是回环接口访问应用的，请求头会被解析然后丢弃，所有模拟用户又会坍缩回同一个地址。其症状是恰好在第二十次登录时出现一波 `HTTP 429`，很容易被误读为服务器过载。必须将网关网络显式加入信任链；在 §4.3 的集群部署中，还必须加入 Pod 和 Service 的 CIDR。

#### 3.4.3 负载均衡器会覆盖它本应保留的请求头

当实例位于代理之后时，常规的 `option forwardfor` 会把真实客户端地址 *追加* 到链中，这在生产环境中是正确的行为，但在这里恰恰是错误的：合成地址会被负载生成器自己的地址取代。该指令必须限定为 `option forwardfor if-none`，这样代理只会在客户端未提供该值时才添加。

#### 3.4.4 客户端先耗尽文件描述符，服务器才耗尽容量

在 $N=1024$ 时，生成器同时持有一千多个套接字，默认的 1024 个描述符软限制在会话建立阶段就会被耗尽，而不是在测量阶段。这种失败是无声的：三个会话未能建立，运行结果报告 1021 而不是 1024，同时一个通过调用外部命令统计容器数量的采样线程因 `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。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig1_capacity.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=a7bf6e97613c9565b4031986e98dffb2" alt="" width="2713" height="1116" data-path="images/blog/fig1_capacity.png" />
</Frame>

<strong>图 4.</strong> 整个配置矩阵的实测容量。（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** |

<strong>表 1.</strong> 能够成功完成的最大同时编译数，在 300 秒编译超时且解除 CLSI 并发上限的条件下测得。**粗体** 表示 CPU 受限的配置（编译超时而内存仍有富余）；其余为内存受限（服务栈崩溃并返回 HTTP 502）。2 GiB 这一行包含 §5.2 中讨论的修正。

### 4.2 并发即分时共享

图 5 在固定的 8 vCPU / 16 GiB 虚拟机上扫描了每个并发级别。两种状态被一个恰好位于每核一个编译处的尖锐拐点分隔开。在拐点以下，平均编译时间保持平稳——从 $N=1$ 时的 8.7 秒变为 $N=C=8$ 时的 9.1 秒，变化仅 5%。在拐点以上，时间严格按 $N/C$ 成比例增长：在 $N=16,24$ 时，我们测得 18.5 秒和 27.1 秒，即比例为 $1:2.13:3.12$，而理想值为 $1:2:3$。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig4_scaling.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=edcc49a4e403ecead7b9dd21f795bdb1" alt="" width="2633" height="1085" data-path="images/blog/fig4_scaling.png" />
</Frame>

<strong>图 5.</strong> 固定硬件下编译延迟与并发数的关系。拐点位于 $N=C$；超过该点后，实测减速与 $N/C$ 的偏差在 5–7% 以内。全部十五个级别都完全成功。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig2_latency.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=01880bd8722d1ad2789649c049c0ecc2" alt="" width="2693" height="1960" data-path="images/blog/fig2_latency.png" />
</Frame>

<strong>图 6.</strong> 多种配置下编译延迟与并发数的关系。每个面板固定硬件并扫描施加的负载；竖线标记 $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 报告了在大型运行机上的扫描结果。每个级别都是 *冷* 编译：在每个级别开始之前，我们通过 `DELETE /project/:id/output` 清除所有参与项目的编译目录和 CLSI 缓存，因此没有任何级别能受益于前一级别已完成的工作。这台机器上的单编译基线为 28.8 秒，这是冷启动数值，不应与前文使用的 8.6–9.8 秒稳态基线比较；QEMU 虚拟机上的冷启动基线为 28.3 秒，因此对于这一工作负载，两台机器的单线程性能相差不到百分之二。

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

<strong>表 2.</strong> 在一台 EPYC 7773X（64 核 / 128 线程，995 GiB）上的并发扫描。所有级别均为冷编译；基线为 28.8 秒。容器峰值是同时存活的沙盒的最大数量。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig6_large_runner.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=baf71caed9904a87456b4df16bb7a708" alt="" width="2677" height="1040" data-path="images/blog/fig6_large_runner.png" />
</Frame>

<strong>图 7.</strong> 单台大型运行机上的纵向扩展。（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$，但相对于 $N=128$ 仅为 $3.8\times$——而施加的负载增加了八倍。原因可以从图 7(b) 和表 2 的最后一列看出：尽管 1024 个请求是同时发出的，实际存活的沙盒数量从未超过 205。守护进程创建容器的速度跟不上客户端的请求速度，因此请求在准入阶段排队，而不是在 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.5 倍的范围内，残差离散度为 5.1%。没有任何曲率这一点本身就是结论：如果工作负载受内存带宽或 I/O 限制，那么随着 CPU 超过其他资源，$T$ 在高时钟频率下会趋于平缓。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig5_frequency.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=64447722825ad0cc0277e4734c7625e9" alt="" width="2633" height="1085" data-path="images/blog/fig5_frequency.png" />
</Frame>

<strong>图 8.</strong> 时钟扫描。（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 虚拟机上每次编译约 312 MiB 降至 32 GiB 虚拟机上的约 194 MiB。其机制正是 §2.1 中描述的 TeX Live 目录树上的共享页缓存：并发编译会读取相互重叠的字体和宏文件，因此更大的缓存被更多编译分摊。这就是为什么朴素的"每五个用户一 GB"法则会低估大型机器、高估小型机器。

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig3_3d.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=ba01a4cb43c6a05b1dca0742971bb539" alt="" width="2868" height="1298" data-path="images/blog/fig3_3d.png" />
</Frame>

<strong>图 9.</strong> 将相同数据表示为 $(C,R)$ 平面上的两个曲面。（a）容量：拟合曲面是一道山脊而不是一个平面——它随内存急剧攀升，而沿核心轴几乎平坦，直到内存不再构成约束为止，这就是为什么只有 48 GiB 这一行中核心数才能区分各配置。（b）每个配置的延迟与并发数的关系，虚线为拟合的 $T=12.6\,(N/C)^{0.91}$，180 秒超时绘制为一个平面。一个配置在其实线曲线穿过该平面的地方失败，这直观地展示了超时设置如何直接决定所报告的容量。

### 5.2 增加核心反而更糟的情况

公式 (2) 是两项的最小值，因此对 $C$ 单调，但测量结果并非如此。我们观察到两次增加核心反而 *降低* 容量的反转：在 16 GiB 时（54 对 45）和 32 GiB 时（145 对 135）。两者都出现在内存受限的状态下，机制也相同：核心更多时，并发编译同步推进，并在同一时刻达到常驻内存峰值；而核心较少时，调度器会让它们交错执行，峰值也随之错开。在内存余量本已捉襟见肘的虚拟机上，正是这种错开让它得以存活。基于平均资源使用量构建的容量模型无法表达这一点；它是峰值 *重合* 的性质。

第三个表面上的反转出现在 2 GiB，我们现在不予采信。搜索记录显示 2 vCPU 时容量为 2，而 4 和 8 vCPU 时为 1，看起来像是同一效应。重新检查原始扫描数据后发现情况更简单：在 2 GiB 下，$N=2$ 这一级别在三种核心数下首次尝试都成功了，随后在其中两种的确认运行中失败。这个级别不是容量，而是抛硬币，2 vCPU 那一项只是碰巧落对了的那一次。因此，我们在三种核心数下都报告可复现的值 1，并且不从这一差异中得出任何结论。我们在此记录这一修正，而不是悄悄修改表格，因为被舍弃的读数恰恰是那种本可以支撑一个有趣论断的数据。

## 6. 实现层面的发现

### 6.1 硬编码的并发上限

在足够大的虚拟机上，无论请求的并发数是多少，容量都恰好停在 65 个同时编译：在 $N=66,80,96,128$ 时，我们测得 $65$ 次成功和 $1,15,31,63$ 次立即返回的 `unavailable` 响应，容器数量固定在 65，数 GB 内存未被使用，编译时间中位数稳定在 77 秒——远低于任何超时。

原因是 CLSI 中的一个常量：

```javascript theme={null}
// services/clsi/config/settings.defaults.cjs:110
compileConcurrencyLimit: isSpotInstance ? 32 : 64,

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

比较是非严格的，因此实际上限为 $64+1=65$，与测量结果完全一致。多出的请求会收到 **HTTP 503**——它们被 *拒绝* 而不是排队，因此在用户看来，编译按钮直接失败了。与同一文件中的所有其他可调参数不同，这个参数不读取任何环境变量；它于 2024 年 8 月在上游引入，只能通过修改镜像来更改。提高该上限后，同一台 16 vCPU / 32 GiB 虚拟机在 $N=80$ 时的结果从 `success=65, unavailable=15` 变为 `success=80`。

### 6.2 无效的容器内存限制

检查一个运行中的编译容器，会发现它完全没有任何资源隔离：

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

没有任何 CPU 配额是有意为之，这也解释了为什么 §4.2 中的分时共享指数如此干净：没有任何因素扭曲编译之间的竞争。然而，没有 *内存* 限制并非有意为之。Docker runner 确实请求了一个：

```javascript theme={null}
// services/clsi/app/js/DockerRunner.mjs:262
Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
```

这里错了两次。该值为 $1024^4=1 tebiB$，而注释的本意是 $1024^3$；并且该字段被放在了创建选项的顶层，而不是 Docker API 所期望的 `HostConfig` 内部，因此被丢弃——观察到的 `Memory=0` 证实了这一点。这两个错误都存在于引入该文件的提交（`9a519f0d3d`，2018 年 3 月）中，并在从 CoffeeScript 的转换、全仓库的格式化以及 CJS 到 ESM 的迁移中保留了下来，而这些改动都没有重新审视语义。值得注意的是，同一提交中的 `MAX_OUTPUT = 1024 * 1024 // 1MB` 是正确的，说明这只是笔误，而不是理解错误。

其后果在我们的低内存测量中清晰可见。由于编译不受限制，内存耗尽并不会表现为 Docker 终止某个违规容器；它会拖垮整个虚拟机。在 2 vCPU / 2 GiB 配置上，我们观察到监控用的 SSH 会话被阻塞了 300 秒，两个核心上的负载平均值达到 68，最终虚拟机自行重启。一个正常工作的单容器限制会让退化温和得多：过大的编译会失败，而服务会存活下来。

唯一真正生效的限制是 `RLIMIT_CPU`，设置为 $\text{timeout}+5$ 秒。它限制的是 *CPU* 时间，而不是挂钟时间，而单次编译只消耗约 9 秒 CPU，因此在任何并发下都不会触发；它防范的是诸如失控宏之类的病态输入。不过，它是一个有用的判断依据：观察到 `Soft:305` 就可以确认 300 秒的超时设置确实已经传递到了容器中。

### 6.3 编译超时是最主要的可调参数

按用户设置的字段 `features.compileTimeout` 默认值为 180 秒。对于任何 CPU 受限的配置而言，这不是安全余量，而是一个容量设置，因为一台仍在正确计算的机器会被判定为失败。将其提高到 300 秒——只需一次 MongoDB 更新——就能使实测容量变化多达 4.2 倍（表 3）。上限为 600 秒，由 `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$ | 内存 |

<strong>表 3.</strong> 编译超时对实测容量的影响。

最后两行是结果中违反直觉的另一半，也是我们在同一超时下重新测量所有配置的原因。对于 *内存* 受限的配置，更长的超时反而 *降低* 容量，因为每次编译占用其常驻内存的时间更长，相互重叠的编译也更多。因此，不注明测量时所用超时的容量数据毫无意义，而且两者不能在同一张表中混用。

## 7. 相关工作

### 7.1 厂商指导

Overleaf 自己的硬件文档陈述了我们在此量化的定性事实：LaTeX 是单线程的，因此单核性能决定编译时间，并且"只有当你尝试编译的文档数量超过空闲 CPU 核心数时，更多核心才会有帮助" \[1]。随后它给出了线性的规模法则——2 核/3 GiB 的基础配置，外加每五到十个并发用户一个核心和一 GB 内存——这正是本研究的出发点。我们的贡献是将这些陈述转化为实测的规律（公式 (1) 和 (2)），并指出线性法则在哪里失效：它没有考虑使内存墙呈超线性的共享页缓存，也没有考虑主导结果的两个软件参数。

### 7.2 构建与 CI 容量研究

在 LaTeX 场景之外，对并发下构建系统的测量已相当成熟。LightSys 报告称，在 Docker 容器内编译的传统 CI 系统会随着 Pull Request 到达率的上升而出现 I/O 性能退化，瓶颈出现在约十一个并发请求时 \[17]；TAOS-CI 观察到编译主导了 CI 的挂钟时间，在大型项目上占整个流水线时长的 60–67% \[18]。我们的系统在一个方面有所不同，而这一点被证明是决定性的：LaTeX 编译是交互式的。一个耗时翻倍的 CI 任务只是带来不便；而一个耗时翻倍的编译会被正在盯着预览窗格等待的用户直接感知，这就是为什么我们将超时视为容量参数而不是失败阈值。

### 7.3 容器开销

近期的研究将 Docker 容器启动延迟按存储层级进行了分解 \[19]，并刻画了边缘环境中的容器性能 \[20]。在我们的场景中，每次编译的容器启动开销被摊销了：相对于 9 秒的编译，它是一个很小的常数，并被我们拟合的无负载时间 $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` 快约 40% \[14]。这些工作优化的是 *单次* 编译。它们与我们测量的内容正交，并且可以叠加：导言区缓存会缩短 $T_1$，而本文中的每个容量数据都随 $T_1$ 缩放。

### 7.6 并发控制位于编辑器中，而非编译器中

Overleaf 的协作部分建立在一系列成熟的研究工作之上。操作变换（Operational Transformation）源于 Ellis 和 Gibbs \[9]，并由 Jupiter 系统 \[10] 使其在高延迟客户端上变得实用，其设计在 `document-updater` 中清晰可见：一个对操作进行排序的服务器，以及一个供客户端同步的按文档缓冲区。无冲突复制数据类型（CRDT）\[11] 则在没有中央排序器的情况下解决了同样的问题。这一区别正是 §4.3 中拓扑能够成立的原因：由于待处理更新缓冲区位于共享的 Redis 中，而不是某个实例的内存中，路由到任何副本的编译都能看到最新的按键输入，因此编译亲和性可以出于缓存局部性而非正确性来选择。

### 7.7 容量模型

Amdahl 定律 \[24] 界定了并行化带来的加速上限，Little 定律 \[23] 将占用量与到达率和服务时间联系起来；上文都使用了这两者。Gunther 的通用可扩展性定律 \[12] 在前者基础上增加了一个表示一致性延迟的逆行项，预测吞吐量会先达到峰值然后下降。我们注意到，在 $N=1024$ 之前，我们的系统 *并未* 表现出这种逆行状态：吞吐量饱和、延迟增长，但没有发生崩溃。原因是结构性的而非侥幸——编译之间不共享需要保持一致的状态，因此该定律增加的那一项接近于零，而 §4.3 中的准入平台期在争用产生影响之前就将其限制住了。

## 8. 给运维人员的建议

<Steps>
  <Step title="在购买硬件之前，先调整两个软件参数">
    两者都是免费的，而且都比我们测量的任何单项硬件升级更有价值。将 `features.compileTimeout` 提高到你的用户实际能够容忍的值——CLSI 接受的最大值为 600 秒——如果你预计会超过 65 个同时编译，请在派生镜像中提高 `compileConcurrencyLimit`，或者进行横向扩展。两者都不做，就意味着为软件拒绝使用的核心付费。
  </Step>

  <Step title="按拐点而不是上限来确定单台机器的规模">
    大型运行机的扫描（§4.3）区分了两个经常被混为一谈的数字。*上限*——仍能返回所有 PDF 的最大并发数——在 64 核服务器上至少为 1024，而我们从未达到它。*拐点*——超过该点后尾延迟不再平缓增长而开始翻倍——位于 512，而在其之下最后一个舒适的工作点是 256。在 $N=256$ 到 $N=512$ 之间，$p_{95}$ 等待时间从两分钟增加到将近六分钟；在 512 到 1024 之间则达到八分钟。按上限确定规模的运维人员交付的是一个技术上可用、却没人愿意使用的系统。

    因此，对于这台机器和这份文档，推荐的工作点是 **256 个并发编译**，即物理核心数的 $4\times$、线程数的 $2\times$，此时 $p_{95}$ 保持在 120 秒左右。我们建议将 `compileConcurrencyLimit` 设置为该值，而不是让它保持很高：一次接纳 1024 个编译会让每个人都等待八分钟，而接纳 256 个并让其余的排队，则能让大多数用户在两分钟内得到结果。排队只会影响后到的人；争用则会影响所有人。
  </Step>

  <Step title="将这些数字视为最坏情况">
    表 2 中的每个级别都是同时触发的冷编译。在生产环境中，这两个条件都不成立：同一文档的热编译耗时 8.6 秒，而冷编译为 28.3 秒，相差 $3.3$ 倍；而且真实用户不会在同一秒按下按钮。因此，一个每两分钟重新编译一次、缓存命中率处于典型水平的稳态用户群，能够支撑的写作者数量会远多于单看并发数所暗示的——在 256 的工作点下，大约可支持一千名甚至更多的活跃作者。并发数据是瞬时爆发的上界，而不是席位数。
  </Step>

  <Step title="先确定延迟预算，再据此得出规模">
    公式 (1) 可以直接反解。对于在 $C$ 个核心、时钟频率 $f$ 下的目标等待时间 $T$，可容纳的并发数为 $N \le C\,fT/k$，对于这份文档 $k\approx28 GHz·s$。在 3 GHz 的 8 核机器上，60 秒的预算给出 $N\le51$；120 秒的预算则使其翻倍。将预算与容量一同公布，是诚实表述两者的唯一方式。
  </Step>

  <Step title="先买内存，再买核心，并检查你碰到的是哪道墙">
    在 32 GiB 以下，我们测得增加核心几乎没有任何收益。诊断方法很简单：如果失败表现为 HTTP 502 且虚拟机内存不足，就增加内存；如果失败表现为 `timedout` 而内存仍有富余，就增加核心或提高超时。运维人员可以从我们用于对配置进行分类的同一失败特征中读出这一点。
  </Step>

  <Step title="想改善体验就选时钟频率，想支撑更多用户就选核心">
    由于 $T\propto 1/f$ 在没有任何曲率的情况下成立（在 1.0–5.5 GHz 范围内偏差为 2%），更快的时钟会让每个用户的每次编译都更快。更多的核心不会让任何单次编译变快；它们只能接纳更多并发编译。抱怨"编译太慢"的部署应该购买更高的时钟频率；抱怨"截止日期前编译失败"的部署应该购买内存和核心。
  </Step>

  <Step title="超过上限后，选择横向扩展而不是纵向扩展">
    超过 65 个并发编译后，受支持的方式是横向扩展（图 2c 和图 10，详见 §9）：在具有基于 Cookie 会话亲和性的负载均衡器后部署多个应用实例，共享集中式的 MongoDB、Redis 和 S3 兼容存储，`git-bridge` 保持单例。这会将单实例上限乘以实例数，这正是 SaaS 部署达到其自身容量的方式。
  </Step>

  <Step title="不要依赖单次编译的隔离">
    在 Docker runner 的内存限制被修正之前（§6.2），单个病态文档可能会耗尽整个宿主机，而不是被单独终止。需要这一保证的运维人员应该自己施加限制，而不是等待修复。我们在大型宿主机上使用的机制是一个带有硬上限的 systemd slice，然后让 Docker 守护进程指向它，使其创建的每个容器都计入其中：

    ```ini theme={null}
    [Slice]
    MemoryMax=940G
    ```

    其中有一个细节，如果忽略会耗掉你一个下午。名为 `docker-capped.slice` 的 slice 并不位于 `docker.slice` 旁边；它位于其 *内部*，因为连字符是层级分隔符，而不是名称的一部分。一个看似没有效果的上限，通常是被施加在了与容器实际所在位置相差一层的地方。请在运行后从 `memory.max_usage_in_bytes` 读取峰值来验证，而不是信任配置文件——在我们的宿主机上，即使在 1024 个同时编译时，编译 cgroup 也从未超过其上限的五分之一，这本身就证明了约束因素是守护进程而不是内存。
  </Step>
</Steps>

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/arch_ha.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=fe6e39991948cf607ea85bfedf8daac8" alt="" width="1309" height="1159" data-path="images/blog/arch_ha.png" />
</Frame>

<strong>图 10.</strong> 横向扩展部署的参考拓扑，依据我们验证过的配置绘制。应用副本可以相互替换，且不保存任何持久数据，因此可以自由地添加和移除。有三个组件则不然：Redis，其文档缓冲区使得路由到任何副本的编译都能看到最新的按键输入；对象存储，在多于一个副本时从可选变为必需；以及 `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` 将仓库保存在本地磁盘上，维护一个本地索引，并且没有复制途径。它必须恰好运行一个实例，固定在某个指定副本旁边，它也是使该部署并非完全无状态的组件。请据此规划其所在主机：需要备份的正是它的磁盘。

| **层级** | **数量** | **扩展依据** |
| - | - | - |
| 负载均衡器 | 1–2 | 并发连接数 |
| 应用 | $\lceil N/C\rceil$ | 峰值并发编译数 |
| Redis | 1（+副本） | 活跃编辑会话数 |
| MongoDB | 1（+副本） | 已存储的项目数 |
| 对象存储 | 1 个集群 | 项目总字节数 |
| `git-bridge` | 恰好 1 个 | 磁盘上的仓库数 |

<strong>表 4.</strong> 参考层级。只有应用层随并发数扩展；其规模确定是 §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 钩子，在负载均衡器将后端标记为排空状态时，让 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 比编译存活更久的沙盒会使模型失效

这里的每个容量数据都假设容器被创建、执行一次编译然后退出——生命周期为数十秒，只有在运行期间占空比才接近一。最近出现的两种设计模式打破了这一假设，而且打破的方式相同。

第一种是按用户分配的持久沙盒。为每个用户分配一个固定的私有环境，会把一个统计复用的资源池变成一组预留：一个通过分时共享可以用 64 个核心服务 256 个并发编译的服务，如果给每个用户分配四个专用核心，就只能服务 16 个用户，在同样的硬件下少了一个数量级。我们的数据量化了这一选择的代价，而不是反对它——预留换来的是可预测性，在我们推荐的工作点上，其兑换比例大约是 $16\times$。

第二种，也是更新的一种，是与编译器共享沙盒的 AI 代理。在代理辅助的写作平台中，运行 XeLaTeX 的同一个容器可能还托管着一个长时间运行的编程代理，因此它被持续占用，而不是间歇性地被占用。实践者报告的正是模型对这类部署所预测的症状——在用户数不多的情况下持续卡顿 \[15]。这种相互作用值得精确表述，因为它并不只是"更多负载"那么简单。我们的三个发现会相互叠加。占用不再是突发性的，因此 §4.2 的分时共享规律会同时作用于全部用户，而不仅仅是当前正在编译的那部分。页缓存——正是它带来了 §4.1 中内存的超线性回报——现在要与代理自身的工作集共享，对 TeX 而言不再保持热状态。而 §6.2 中缺失的容器内存限制会变得危险得多，因为一个永不退出的容器永远不会归还其内存。

我们没有测量过这样的平台，也不对任何具体产品做出论断。我们能说的是我们的数据对设计意味着什么：为每个用户提供长生命周期多核沙盒的架构，应该按预留系统来确定规模，而不是依据本文报告的并发数据，其可预期的容量更接近于核心数除以每用户核心数，而不是表 1 中的任何数值。

## 11. 有效性威胁

### 11.1 单一文档

所有测量都使用同一份 63 页的 XeLaTeX 文档。对于其他文档，绝对容量会有所不同；但作为比例的扩展规律不应改变。常驻内存显著更大的文档会移动内存墙的位置，但不会改变其超线性特征。

### 11.2 虚拟化宿主机

虚拟机在同一台物理机上的 KVM 中运行，因此绝对数值包含虚拟化开销，而且各虚拟机共享宿主机的页缓存和 NVMe 设备。在观察到宿主机内存压力会在相同并发下使虚拟机内的负载平均值膨胀超过 $3\times$ 之后，我们关闭了无关的虚拟机，从而缓解了最大的混杂因素。

### 11.3 同时到达

每个编译都在同一瞬间发出，这是最坏情况。真实用户的到达是一个随机过程，因此按我们的数据确定规模的部署会有余量而不是不足——但提交截止日期临近时的峰值更接近我们的模型，而不是泊松模型。

### 11.4 边缘配置

在 2 GiB 时，系统已非常接近崩溃，同一配置的多次运行结果可能相差一个编译。我们报告保守值，并且不从该状态下 $\pm 1$ 的差异中得出结论。

## 12. 可获取性

被测系统、部署工具以及其所衍生自的上游项目都是公开的：

* Ayakaleaf Pro — [https://github.com/ayaka-notes/ayakaleaf-pro](https://github.com/ayaka-notes/ayakaleaf-pro)
* 部署工具包 — [https://github.com/ayaka-notes/toolkit](https://github.com/ayaka-notes/toolkit)
* 文档 — [https://ayakaleaf-pro.ayaka.space](/zh-CN)
* 上游 Overleaf — [https://github.com/overleaf/overleaf](https://github.com/overleaf/overleaf)
* TeX Live 编译镜像 — `ghcr.io/ayaka-notes/texlive-full:2025.1`

我们引用的每个源码位置都以相对于仓库的路径加行号的形式给出，对应 Ayakaleaf Pro v6.2.2，而我们标注了日期的两个上游提交（`9a519f0d3d`、`5d472e9b38`）都可以在 Overleaf 的历史记录中找到。

## 13. 贡献说明

Musicminion 设计了本研究，提供并运维了测试平台，指导了研究方向，并核实了本文报告的每一项测量。Claude Opus 5（Anthropic）构建并运行了基准测试工具，实现了部署自动化，进行了源码考古，制作了图表，并起草了手稿。两位作者都审阅了最终文本。对于被报告为受污染的运行——§4.3 中 1021 个会话的扫描和 §4.1 中异常的 $N=256$ 级别——缺陷是在审阅过程中发现的，相关运行在发表前已重新进行，而不是被悄悄丢弃。

读者应当注意，ACM、IEEE 和 ICMJE 的作者署名政策目前将作者身份保留给能够对作品承担责任的一方，因此会要求将第二作者的贡献作为披露信息而非署名来记录。我们在此明确说明分工，以便在任一惯例下记录都是准确的。

## 14. 结论

自托管 Overleaf 的容量规划并不是扩展某一种资源的问题。有三项发现应当改变容量规划的方式。

第一，当虚拟机内存低于 32 GiB 时，核心数几乎无关紧要：在 16 GiB 下，4、8 和 16 vCPU 虚拟机的容量差异不到 8%。内存通过 TeX Live 目录树上的共享页缓存决定了上限；只有在内存充裕之后，核心才开始发挥作用。

第二，两个软件参数的影响超过了硬件。解除 CLSI 硬编码的 65 个编译上限并提高默认的 180 秒编译超时，使一台 8 vCPU / 48 GiB 虚拟机的并发编译数从 64 提升到 268——在不增加任何硬件的情况下提升了 4.2 倍。这两者都无法从配置文档中发现；其中一个甚至根本无法配置。

第三，"这台机器支持多少并发用户"这个问题本身的定义并不充分。该系统中的并发是纯粹的分时共享，容量取决于超时允许的范围。诚实的回答应当同时说明两者：*如果用户愿意等待* $T$ *秒，这台机器可以服务* $N$ *个同时编译*，其中 $N$ 和 $T$ 由公式 (1) 联系起来。

我们还报告了一个潜在缺陷：Docker runner 中针对单个容器的内存限制自 2018 年以来一直无效，无论在数值上还是在设置位置上都是如此。其实际影响是，小型部署上的内存耗尽会拖垮整个服务，而不仅仅是导致问题的那一次编译。

## 参考文献

\[1] Overleaf. *Hardware requirements*，本地部署文档。[https://docs.overleaf.com/on-premises/getting-started/requirements/hardware-requirements](https://docs.overleaf.com/on-premises/getting-started/requirements/hardware-requirements)

\[2] Overleaf. *Horizontal scaling*，本地部署文档。[https://docs.overleaf.com/on-premises/maintenance/horizontal-scaling](https://docs.overleaf.com/on-premises/maintenance/horizontal-scaling)

\[3] Overleaf. *Microservices*，本地部署文档。[https://docs.overleaf.com/on-premises/getting-started/microservices](https://docs.overleaf.com/on-premises/getting-started/microservices)

\[4] Overleaf. *Source repository*。[https://github.com/overleaf/overleaf](https://github.com/overleaf/overleaf)

\[5] Ayaka-notes. *Ayakaleaf Pro*。[https://github.com/ayaka-notes/ayakaleaf-pro](https://github.com/ayaka-notes/ayakaleaf-pro)

\[6] Ayaka-notes. *Overleaf Toolkit*。[https://github.com/ayaka-notes/toolkit](https://github.com/ayaka-notes/toolkit)

\[7] D. Karger, E. Lehman, T. Leighton, R. Panigrahy, M. Levine and D. Lewin. *Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web*. STOC, 1997.

\[8] J. Tan and M. Rigger. *Inconsistencies in TeX-Produced Documents*. In *Proc. 33rd ACM SIGSOFT International Symposium on Software Testing and Analysis (ISSTA)*, Vienna, 2024. doi: [https://doi.org/10.1145/3650212.3680370](https://doi.org/10.1145/3650212.3680370)

\[9] C. A. Ellis and S. J. Gibbs. *Concurrency Control in Groupware Systems*. In *Proc. ACM SIGMOD*, pp. 399–407, 1989.

\[10] D. A. Nichols, P. Curtis, M. Dixon and J. Lamping. *High-Latency, Low-Bandwidth Windowing in the Jupiter Collaboration System*. In *Proc. ACM UIST*, pp. 111–120, 1995.

\[11] M. Shapiro, N. Preguiça, C. Baquero and M. Zawirski. *Conflict-Free Replicated Data Types*. In *Proc. SSS*, pp. 386–400, 2011.

\[12] N. J. Gunther. *Guerrilla Capacity Planning: A Tactical Approach to Planning for Highly Scalable Applications and Services*. Springer, 2007.

\[13] The LaTeX3 Project. *l3build — A Testing and Building System for (La)TeX*. CTAN.

\[14] M. Isaksson. *Which LaTeX Build System Is Fastest? A Benchmark*. [https://blog.martisak.se/latex-build-systems-comparison/](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: Lightweight and Efficient CI System for Improving Integration Speed of Software*. arXiv:2101.07961 \[cs.SE], 2021. Preprint.

\[18] G. Lim, M. Ham, J. Moon, W. Song, S. Woo and S. Oh. *TAOS-CI: Lightweight & Modular Continuous Integration System for Edge Computing*. arXiv:2101.08889 \[cs.SE], 2021. Preprint.

\[19] S. Khan. *Decomposing Docker Container Startup Performance: A Three-Tier Measurement Study on Heterogeneous Infrastructure*. arXiv:2602.15214, 2026. Preprint.

\[20] R. Gupta and K. Nahrstedt. *Performance Characterization of Containers in Edge Computing*. arXiv:2505.02082, 2025. Preprint.

\[21] S. Checkoway, H. Shacham and E. Rescorla. *Are Text-Only Data Formats Safe? Or, Use This LaTeX Class File to Pwn Your Computer*. In *Proc. USENIX Workshop on Large-Scale Exploits and Emergent Threats (LEET)*, 2010.

\[22] G. Lacombe, K. Masalygina, A. Tahiri, C. Adam and C. Lauradoux. *Can You Accept LaTeX Files from Strangers? Ten Years Later*. arXiv:2102.00856 \[cs.CR], 2021. Preprint.

\[23] J. D. C. Little. *A Proof for the Queuing Formula* $L=\lambda W$. Operations Research, 9(3):383–387, 1961.

\[24] G. M. Amdahl. *Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities*. AFIPS, 1967.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.