> 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-cn/2026/overleaf-benchmark.md).

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

{% file src="/files/92475459f81c0b9074257725f56879c221c79864" %}

## 摘要

自托管的 Overleaf 部署通常按一条经验法则来估算规模：每 5 到 10 个并发用户配 1 个 CPU 核心和 1 GiB 内存。我们表明，这条规则不仅不精确，而且在结构上就是错误的，因为它假定容量由单一资源维度决定，而事实上有两个相互独立的瓶颈；同时，还有两个软件参数——都不是硬件参数——以最高达 4 倍的因素主导了结果。

我们在 21 种 CPU/内存配置下，于 QEMU/KVM 虚拟机中测量一套标准的 Ayakaleaf Pro v6.2.2 部署，使用沙箱化编译（TeX Live 2025），且宿主核心被锁定在 3.0 GHz。负载是一个真实的 63 页 XeLaTeX 学位论文，由多达数百个不同用户账号同时编译。我们发现，在来宾内存低于 32 GiB 时，核心数几乎无关紧要——在 16 GiB 时，4、8 和 16 vCPU 来宾的实测容量相差不到 8%——容量反而受一个由 TeX Live 树上的共享页缓存导致的超线性内存墙支配。

为检验这些规律是否能在数量级变化后仍然成立，我们在一台单独的 64 核、995 GiB 服务器上重复了该扫描。它以 100% 成功率支撑住了 1024 个同时进行的冷编译——是其线程数的 8 倍——而我们始终没有触及它的上限。真正有用的数字不是那个上限，而是其下方的拐点：尾延迟每翻倍增加 20–40%，直到 $$N=256$$，随后在 $$N=512$$。因此，将容量报告为“不会失败的最大并发数”会把可用运行点高估 4 倍。在那台机器上，内存从来不是约束资源；限制因素是 CPU，以及容器守护进程接纳新沙箱的速率，而后者无论请求多少编译，都会在接近 200 时饱和。

我们进一步识别出两个在容量规划中不可见的实现层效应。首先，CLSI 强制执行一个硬编码的 65 个同时编译上限，这一限制不通过任何环境变量暴露；超过之后，用户会立刻收到 HTTP 503，而不是进入队列。其次，自 2018 年引入以来，Docker runner 中按容器设置的内存限制在数量级和位置上都没有发挥作用，因此一旦发生内存耗尽，倒下的是整个宿主机，而不是单个编译。将并发上限提高，并把默认编译超时时间从 180 s 提升到 300 s，会使一台 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 范围进行时钟扫描，会把 30 次测量折叠到 $$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 s，它会悄无声息地限制 CPU 受限的配置。把它提高到 300 s，会在硬件不变的情况下把实测容量最多提升 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` 进程内运行；它会请求通过绑定挂载的 socket 可达的宿主 Docker 守护进程，从一个 TeX Live 镜像启动一个新的容器，并将项目目录绑定挂载到 `/compile`。因此一次编译就是一个短生命周期容器运行一个 `latexmk` 进程。

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

### 2.2 启用沙箱编译

社区版 Overleaf 在 `latexmk` 应用容器内部运行。Ayakaleaf Pro 与 Overleaf Server Pro 类似，也可以把每次编译改为在一个 *同级* 容器中运行——即由应用在 *宿主机的* Docker 守护进程上启动的容器，而不是嵌套在应用容器内部。以下两个工具包设置会启用此功能：

```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 绑定挂载到应用容器中，并将其转换为 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}` 的容器正在 `latexmk` 从 TeX Live 镜像运行，并以 0 退出。本文测量的就是这个单位的数量，以及我们在 §6.2 中报告的它完全没有资源限制这一事实。

**这为何对研究很重要。**

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

<figure><img src="/files/5b1d305c0a4ca520df0ff34dc6bbe9df5cbc0b8a" alt=""><figcaption></figcaption></figure>

**图 1。** 一次编译请求，沿社区版微服务链路追踪。步骤中的拆分对容量很重要：文档正文被复制到请求体中，而二进制资源则通过引用传递并由 `clsi`拉取。二者都不是主导因素——一个项目的编译成本由每个并发编译都通过共享页缓存读取的 32 GiB TeX Live 树决定。

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

**图 2。** 三种部署拓扑以及每种情况下单实例编译上限落点；堆叠面板表示复制。65 编译常量约束 *一个* CLSI，因此 SaaS 集群会按实例和可用区放大它（a），而 Server Pro 和 Ayakaleaf Pro 支持的横向扩展会按实例放大它（c）——代价是需要中央 MongoDB、Redis 和 S3 兼容存储、带 cookie 会话亲和性的负载均衡器（编译输出写入实例本地磁盘，因此一次编译及其随后 PDF 下载必须落在同一实例上），以及一个单例 `git-bridge`。工具包默认设置（b），也就是我们实际测量的模式，乘数为 1，因此为集群中一个成员设定的常量会成为整个安装的上限。

<figure><img src="/files/4394480de8a561f9536665250cf06f7ab6a26305" alt=""><figcaption></figcaption></figure>

**图 3。** 在 `clsi-cache`中的分片选择。一个项目由 $$\operatorname{crc32}(\text{projectId}\text{-}i)\bmod|\text{shards}|$$映射，即把哈希空间切成与分片数量相同的等大扇区。这是 *取模* 哈希，而不是基于环的一致性哈希：把集群从 3 个分片扩展到 4 个会重新划分整个空间，并实质上重映射每个项目（a,b）。这正是实现需要显式在线重新分片坡道的原因，它会在一个时间窗口内把线性增长比例的项目从 `currentShards` 移到 `desiredShards` ，而不是一致性哈希环会提供的 $$K/n$$ 迁移。当某个分片的断路器被触发时，盐值 $$i$$ 会递增，并将该分片从候选列表中移除，因此查找会继续探测而不是失败（c）。

### 2.3 两种失败模式

我们测量的每一种配置都恰好以两种方式中的一种失败，而且这一差异可直接从响应状态中看出，而不是推断出来：

* **内存耗尽** —— Overleaf 栈本身变得无响应，请求返回 **HTTP 502**。失败层级下可用的来宾内存通常低于 500 MiB。
* **编译超时** —— CLSI 在按用户设置的超时时间终止编译，并报告状态 `超时`。失败层级下可用内存往往还有数 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 是宿主线程，其运行频率取决于宿主核心的运行频率。因此我们直接约束宿主，关闭 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 第二个测试平台：一台大运行器

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

需要两项操作性预防措施，而且值得说明，因为没有它们，实验测到的是测试框架而不是服务器。首先，每个容器都被限制在一个 `systemd` 切片中，并设置 `MemoryMax=940 GiB`，这样失控的扫描会耗尽 cgroup，而不是宿主机。其次，沙箱编译由宿主守护进程创建，每个容器都会污染自己的写时复制层——尽管共享 20.6 GiB 的基础镜像，每个容器实测仍为 116 MiB——因此我们把 Docker 数据根迁移到一块专用的 NVMe 设备上。在 $$N=1024$$ 写入大约 119 GiB 的临时层，这放不进标准根文件系统。

### 3.3 工作负载

该文档是一篇真实的 63 页硕士论文（SJTU 模板），通过 XeLaTeX 和 `latexmk`编译，包含 TikZ 图形、 `biblatex` 文献处理以及嵌入式 PDF 资源——也就是说，它是一个真实而非合成的负载。在一个未加载的来宾上，单次编译在所有配置下耗时 8.6–9.8 s，我们把它用作空闲基线 $$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` 对等方认可 `trustedProxyIps` 默认为 `loopback`。因为负载生成器是通过容器桥接而不是通过 loopback 接口接入应用，所以该头部会被解析然后丢弃，所有模拟用户又会塌缩回同一个地址。症状是在恰好第 20 次登录时出现一波 `HTTP 429` ，这很容易被误读为服务器过载。必须显式把网关网络加入信任链；在 §4.3 的集群部署中，还必须把 pod 和 service 的 CIDR 一并加入。

#### 3.4.3 负载均衡器会改写它被要求保留的头部

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

#### 3.4.4 客户端在服务器耗尽容量之前就先耗尽了文件描述符

在 $$N=1024$$ 时，生成器持有的并发 socket 超过一千个，而默认的 1024 个描述符软限制在会话建立阶段就被触及，而不是在测量阶段。失败是静默的：有三个会话未能建立，运行结果报告为 1021 而不是 1024，而一个为统计容器数量而 shell 出去的采样线程则因 `EMFILE` 而死掉，并悄悄截断遥测数据。必须在生成器上提高软限制——我们宿主上的硬限制本来就已经是 1048576——然后重新运行。我们在 §4.3 中报告了两次运行：更正后的那次完成了 1024/1024，且其中位数与被截断那次相差 1.2 s 以内，这也是我们把第一次视为可用但不具权威性的原因。

### 3.5 测量协议

为保证可复现性，必须做出若干方法选择。

#### 3.5.1 预热

在刚启动的来宾上，页缓存是空的，前几次编译测到的是冷启动 I/O，而不是稳态容量：同样的 2 vCPU / 2 GiB 配置，冷态为 36.5 s，热态为 9.8 s，差了 3.7 倍。因此每个配置在启动后都要先进行两次被丢弃的单次编译预热。

#### 3.5.2 通过标准

只有当 *每一个* 编译都成功并且该级别在重复测试中依然存活时，并发级别才算通过。这个标准比成功率阈值更严格，而且很重要：在 4 vCPU / 16 GiB 时，32 级在 80.2 s 的中位数下曾通过一次，但在重复时全部 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——把核心数翻四倍完全没有改变。 *更差* 于 4 核配置（§5.2）。只有在 48 GiB 时，核心数才会明显地区分这些配置：143、268 和 331。

<figure><img src="/files/08b6db63f8e59d1aff9835cf1218e3cf9a877385" 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 s 的条件下，成功完成的最大同时编译数。 **加粗** 表示 CPU 受限配置（编译在内存还有富余时超时）；其余为内存受限（栈因 HTTP 502 崩溃）。2 GiB 那一行包含 §5.2 中讨论的修正。

### 4.2 并发是时间共享

图 5 在一台固定的 8 vCPU / 16 GiB 来宾上扫描每一个并发级别。两种状态由恰好每核一个编译处的尖锐拐点分隔。在拐点以下，平均编译时间基本保持不变——它从 $$N=1$$ 8.7 s $$N=C=8$$变到 9.1 s， $$N/C$$；在 $$N=16,24$$ 我们测得 18.5 s 和 27.1 s，也就是 $$1:2.13:3.12$$ 相对于一个理想 $$1:2:3$$.

<figure><img src="/files/452af4ef05f30a341af38653c46e7347ecec4a69" alt=""><figcaption></figcaption></figure>

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

<figure><img src="/files/3995ced9a7d3c2ccc26177d9bbbe7dce02229097" 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 s，而不是 8.7 s。

### 4.3 垂直扩展到 1024 个并发编译

表 2 和图 7 报告了在大型运行器上的扫描结果。每个级别都是一次 *冷* 编译：在每个级别之前，我们通过 `DELETE /project/:id/output`清空编译目录和所有参与项目的 CLSI 缓存，因此没有任何级别能从下一级别做过的工作中受益。这台机器上的单次编译基线为 28.8 s，这是冷态数值，不应与前面使用的 8.6–9.8 s 稳态基线直接比较；QEMU 来宾上的冷态基线为 28.3 s，因此按每线程计算，两台机器在这一负载下相差不到 2%。

| $$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 |

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

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

**图 7。** 单台大型运行器上的垂直扩展。(a) 延迟与所施加并发的关系；阴影区域标示拐点之后的区间。(b) 实际存活的沙箱数量从不跟随请求数量——它在接近 200 时饱和——而编译 cgroup 从未使用超过其上限的五分之一。

#### 4.3.1 机器从不失败

每个级别都以 100% 完成，包括 $$N=1024$$ —— 是线程数的 8 倍。我们没有找到这台机器的容量上限；在它耗尽余量之前，我们先耗尽了耐心。这是本研究中第一个约束因素不是内存的配置：在 $$N=1024$$ 时，编译 cgroup 峰值为 184 GiB，仅占其 940 GiB 上限的五分之一，而 CPU 则以 166 的负载平均值保持 100% 利用率。

#### 4.3.2 退化是次线性的，因为接纳受速率限制

朴素的时间共享预测 $$8\times$$ 线程会带来 $$8\times$$ 的延迟成本。实测成本是 $$9.7\times$$ 相对于单次编译而言 $$3.8\times$$ ，但相对于 $$N=128$$ ——对于施加负载增加 8 倍而言。其原因可见于图 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，并对操作者毫无用处：此时尾部等待几乎长达 8 分钟。

### 4.4 编译时间与时钟成反比

由于工作负载受 CPU 限制，其成本应按 $$1/f$$。我们通过在不改变其他条件的来宾上，把宿主时钟在机器的完整范围 1.0–5.5 GHz 内分十步扫过来直接检验这一点（图 8）。单次编译时间从 26.5 s 变为 4.8 s：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 限制， $$T$$ 会在高时钟下变平，因为 CPU 已经超过了其他资源。

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

**图 8。** 时钟扫描。(a) $$T=k/f$$ 与拟合的双曲线。 $$\max(1,N/C)$$ （b）将

式 (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 树上的共享页面缓存：并发编译会读取重叠的字体和宏文件，因此更大的缓存会由更多编译共同分摊。这就是为什么朴素的“每五名用户一吉字节”规则会低估大型机器并高估小型机器。

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

**图 9。** 同一数据以两个曲面呈现在 $$(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$$ 立即 `不可用` 响应，容器数量固定为 65，仍有数 GiB 内存未使用，编译中位时间稳定在 77 秒——远低于任何超时阈值。

原因是 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$$，与测量结果完全一致。超额请求会收到 **HTTP 503** ——它们会被 *拒绝*，而非排队，因此从用户角度看，编译按钮就是直接失败。与同一文件中的其他所有可调项不同，这一项不读取环境变量；它于 2024 年 8 月在上游引入，只能通过修改镜像来改变。提高该限制后，此前报告 `success=65, unavailable=15` 在 $$N=80$$ 的同一 16 vCPU / 32 GiB 虚拟机，改为报告 `success=80`.

### 6.2 未生效的容器内存限制

检查一个正在运行的编译容器表明完全没有资源隔离：

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

没有任何 CPU 配额是有意设计的，并解释了为何 §4.2 的时间共享指数如此纯粹：没有任何东西扭曲编译之间的竞争。然而，没有 *内存* 限制并非有意为之。Docker 运行器确实请求了一个：

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

这里错了两次。该值是 $$1024^4=1 tebiB$$ ，而注释意图是 $$1024^3$$；并且该字段被放在创建选项的顶层，而非 `HostConfig`内部；Docker API 期望它位于后者，因此其被丢弃——观察到的 `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 秒 | 300 秒 |             比率 | 约束条件          |
| ---------------- | ----: | ----: | -------------: | ------------- |
| 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。** 编译超时对测得容量的影响。

最后两行是该结果反直觉的一半，也是我们在统一超时条件下重新测量每种配置的原因。对于 *内存*受限配置，更长的超时会 *降低* 容量，因为每次编译会更久地占有其驻留集，更多编译会发生重叠。因此，若不说明容量数值是在何种超时下测得，它便毫无意义；二者不能混杂在同一张表中。

## 7. 相关工作

### 7.1 厂商指导

Overleaf 自身的硬件文档陈述了我们在此量化的定性事实：LaTeX 是单线程的，因此单核性能决定编译时间，并且“只有当你尝试编译的文档数多于空闲 CPU 核心数时，更多核心才会有所帮助”\[1]。接着它给出了激发本研究的线性规模规则——2 核/3 GiB 基础配置，外加每五至十名并发用户一个核心和一吉字节。我们的贡献是将这些说法转化为测得的规律（公式 (1) 和 (2)），并展示线性规则失效之处：它没有描述使内存墙呈超线性的共享页面缓存，也没有涉及主导结果的两个软件参数。

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

在 LaTeX 场景之外，并发条件下构建系统的测量已相当成熟。LightSys 报告称，在 Docker 容器中编译的常规 CI 系统会随着拉取请求到达率上升而出现 I/O 性能下降，并在约 11 个并发请求处出现瓶颈 \[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` \[14] 约提升 40%。这些工作优化的是 *单次* 编译。它们与我们测量的内容正交且可组合：导言区缓存缩短 $$T\_1$$，而本文的每个容量数字都会随 $$T\_1$$.

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

Overleaf 的协作部分建立在一条成熟研究脉络之上。操作变换起源于 Ellis 和 Gibbs \[9]，并由 Jupiter 系统 \[10] 为高延迟客户端实现实用化；其设计可在 `document-updater`中辨认出来：一个对操作排序的服务器，以及客户端据以同步的每文档缓冲区。无冲突复制数据类型 \[11] 在不使用中心排序器的情况下解决同一问题。这一区别使 §4.3 的拓扑得以运作：由于待更新缓冲区位于共享 Redis 中而非某实例的内存中，路由到任一副本的编译均可看到最新按键，且编译亲和性可依据缓存局部性而非正确性选择。

### 7.7 容量模型

阿姆达尔定律 \[24] 限制并行化带来的加速，而利特尔定律 \[23] 将占用量与到达率和服务时间关联；二者均在上文使用。Gunther 的通用可扩展性定律 \[12] 为前者扩展了描述一致性延迟的逆行项，预测吞吐量会达到峰值后下降。我们注意到，我们的系统 *没有* 在达到 $$N=1024$$之前表现出该逆行区间：吞吐量饱和且延迟增长，但没有任何东西崩溃。原因是结构性的而非幸运——编译之间没有需要保持一致的状态，因此该定律增加的项接近零，且 §4.3 的准入平台在竞争变得重要前便限制了它。

## 8. 面向运维人员的建议

{% stepper %}
{% step %}

## 购买硬件前先修复两个软件参数

两者都是免费的，且都比我们测得的任何单项硬件升级更有价值。将 `features.compileTimeout` 提高到用户实际能够容忍的值——CLSI 接受的最大值是 600 秒——并且，如果预计会超过 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 秒。我们建议将 `compileConcurrencyLimit` 设为该值，而不是保持很高：一次准入 1024 个编译会让所有人等待八分钟；一次准入 256 个并将其余排队，则多数用户在两分钟内得到服务。排队使较晚到达者退化；竞争使所有人退化。
{% endstep %}

{% step %}

## 将这些视作最坏情况的数字

表 2 中的每个级别都是同时发起的冷编译。生产环境中这两个条件均不成立：同一文档的热编译耗时 8.6 秒，而冷编译为 28.3 秒，系数为 $$3.3$$，而实际用户不会在同一秒按下按钮。因此，以典型缓存命中率每两分钟重新编译一次的稳态用户群，所能支撑的作者数会显著高于单独并发度数字所暗示的数量——在 256 运行点约为一千名或更多活跃作者。并发数字是瞬时突发的界限，而非席位数。
{% endstep %}

{% step %}

## 先确定延迟预算，再读取规模

公式 (1) 可直接反演。对于目标等待时间 $$T$$ 、时钟频率 $$f$$ 、以及 $$C$$ 个核心，可容纳的并发度为 $$N \le C,fT/k$$ 并且 $$k\approx28 GHz·s$$ ，适用于此文档。3 GHz 的 8 核机器若预算为 60 秒，则得到 $$N\le51$$；120 秒预算则使其翻倍。将预算与容量一并公布，是诚实陈述二者的唯一方式。
{% endstep %}

{% step %}

## 先买内存，再买核心，并确认自己处于哪堵墙前

在 32 GiB 以下，我们测得增加核心几乎没有益处。诊断成本很低：若失败表现为 HTTP 502 且虚拟机内存不足，则增加内存；若表现为 `超时` 且内存有余，则增加核心或提高超时。运维人员可从我们用于分类配置的同一失败特征中读出这一点。
{% endstep %}

{% step %}

## 追求体验选时钟，追求用户规模选核心

由于 $$T\propto 1/f$$ 没有曲率地成立（在 1.0–5.5 GHz 范围内为 2%），更快的时钟会让每位用户的每次编译都更快。更多核心不会让任何单次编译更快；它们只会准入更多并发编译。抱怨“编译很慢”的部署应购买更高时钟；抱怨“编译在截止时间失败”的部署应购买内存和核心。
{% endstep %}

{% step %}

## 超过上限后应横向扩展而非纵向扩展

超过 65 个并发编译后，受支持的路径是横向扩展（图 2c 和图 10，详见 §9）：多个应用实例位于负载均衡器之后，使用 Cookie 会话亲和性，共享中央 MongoDB、Redis 和兼容 S3 的存储，且 `git-bridge` 保持为单例。这会按实例数倍增单实例上限，这正是 SaaS 部署达到自身容量的方式。
{% endstep %}

{% step %}

## 不要依赖每次编译的隔离

在 Docker 运行器的内存限制得到修正前（§6.2），单个病态文档可能耗尽主机资源，而不会只被单独杀死。需要该保证的运维人员应自行施加，而非等待。我们在大型主机上使用的机制是一个带硬上限的 systemd slice，随后将 Docker 守护进程指向它，使其创建的每个容器都在其中被计入：

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

此处有一个细节，若忽略它会耗掉一下午。名为 `docker-capped.slice` 的 slice 并不位于 `docker.slice`旁边；它位于其 *内部* ，因为连字符是层级分隔符而非名称的一部分。一个看起来毫无效果的上限，通常是施加在容器实际所在位置之外的一层。运行后应读取 `memory.max_usage_in_bytes` 中的峰值进行验证，而非相信配置文件——在我们的主机上，即使同时运行 1024 个编译，编译 cgroup 也从未超过其上限的五分之一；这本身证明了守护进程而非内存才是约束条件。
{% endstep %}
{% endstepper %}

<figure><img src="/files/821ca03f18b6803492a7e0465c9f4f1f922ca37f" alt=""><figcaption></figcaption></figure>

**图 10。** 根据我们验证的配置绘制的横向扩展部署参考拓扑。应用副本可互换且不持有任何持久数据，因此可自由添加和移除。有三个组件并非如此：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 的规则为每个副本定规模——内存优先于核心，然后是时钟——再将副本数设为峰值并发度除以每副本上限。副本不持有持久数据：其本地磁盘承载编译临时数据和输出缓存，二者均可重建。这使其可安全地自由添加和移除；应验证而非假定这一点，因为一个配置错误的 `文件存储` 路径会悄然将该层级变为有状态层。

#### 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 个               | 磁盘上的仓库     |

**表 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` ，因为桥接器会通过应用的 OAuth 端点验证其回调，并通过应用解析 blob URL；绕过副本会破坏认证，而不会带来任何改进。

### 9.4 缩容需要排空缓冲期

移除副本与添加副本并不对称：进行中的编译会丢失，用户会看到并非其导致的失败。可行的顺序是先停止新流量、等待、然后才终止。我们将其实现为一个 pre-stop 钩子：当均衡器将后端标记为排空时，该钩子将 Pod 保持一个可配置的时间间隔——短到可在数分钟内测试，而在生产中长到足以让会话自然结束，即数小时而非数秒。该时间间隔是决定弹性伸缩是无感还是令人恼火的调节旋钮。

我们通过测量而非设计发现的另一个约束是：基于 CPU 的自动扩缩对此工作负载无效。应用 Pod 自身利用率为 22 m 核，而节点总量为 3997 m 核，因为编译工作发生在 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/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；我们标注日期的两个上游提交（`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 倍。这两者均无法从配置文档中发现；其中一个根本不可配置。

第三，关于“这台机器支持多少并发用户”这个问题，本身表述不够明确。这个系统中的并发完全是时间分片，容量取决于超时允许多少。诚实的回答形式应当同时说明： *这台机器可服务* $$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 和 D. Lewin。 *一致性哈希与随机树：用于缓解万维网上热点的分布式缓存协议*。STOC，1997。

\[8] J. Tan 和 M. Rigger。 *TeX 生成文档中的不一致性*。发表于 *第 33 届 ACM SIGSOFT 软件测试与分析国际研讨会（ISSTA）论文集*，维也纳，2024。doi： <https://doi.org/10.1145/3650212.3680370>

\[9] C. A. Ellis 和 S. J. Gibbs。 *群件系统中的并发控制*。发表于 *ACM SIGMOD 论文集*，第 399–407 页，1989。

\[10] D. A. Nichols, P. Curtis, M. Dixon 和 J. Lamping。 *Jupiter 协作系统中的高延迟、低带宽窗口处理*。发表于 *ACM UIST 论文集*，第 111–120 页，1995。

\[11] M. Shapiro, N. Preguiça, C. Baquero 和 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。 *TeX 书*。Addison-Wesley，1984。

\[17] G. Lim, M. Ham, J. Moon 和 W. Song。 *LightSys：用于提升软件集成速度的轻量高效 CI 系统*。arXiv:2101.07961 \[cs.SE]，2021。预印本。

\[18] G. Lim, M. Ham, J. Moon, W. Song, S. Woo 和 S. Oh。 *TAOS-CI：面向边缘计算的轻量级与模块化持续集成系统*。arXiv:2101.08889 \[cs.SE]，2021。预印本。

\[19] S. Khan。 *Docker 容器启动性能分解：异构基础设施上的三层测量研究*。arXiv:2602.15214，2026。预印本。

\[20] R. Gupta 和 K. Nahrstedt。 *边缘计算中容器的性能特征分析*。arXiv:2505.02082，2025。预印本。

\[21] S. Checkoway, H. Shacham 和 E. Rescorla。 *纯文本数据格式安全吗？或者，用这个 LaTeX 类文件来攻陷你的电脑*。发表于 *USENIX 大规模利用与新兴威胁研讨会（LEET）论文集*, 2010.

\[22] G. Lacombe, K. Masalygina, A. Tahiri, C. Adam 和 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-cn/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.
