[{"content":"CUDA kernel launch 通常是异步的：CPU 把 kernel、内存拷贝等操作提交到 stream 后便继续执行。由此产生两个常见问题：如何准确测量 GPU 工作耗时，以及如何让不同 stream 在不阻塞 CPU 的情况下建立依赖？\nCUDA Event 就是解决这类问题的基础设施。它可以理解为插入 GPU stream 时间线中的一个标记：当 event 之前的工作全部完成，event 才进入完成状态。借助这个状态，我们可以做 GPU 计时、CPU 等待、跨 stream 同步和异步资源回收。\n1. Event 不是 CPU 事件，而是 GPU 时间线标记 一个 CUDA stream 是按序执行的 GPU 工作队列：\nCPU 提交： Kernel A → memcpy → Event E → Kernel B 异步提交 GPU 执行： Kernel A ── memcpy ── E 完成 ── Kernel B 调用 cudaEventRecord(event, stream) 时，CPU 通常只是向 stream 提交一个 event 记录操作，并不会等待 GPU 执行到该位置。只有当这个 stream 中排在 event 前面的操作完成后，event 才会被标记为完成。\n这带来三个重要性质：\nevent 描述的是 stream 的执行进度，而不是某个 kernel 本身； record 是异步提交，event 不会在函数返回时自动完成； 同一 stream 内有序，不同 stream 之间默认没有这种顺序保证。 最基础的生命周期如下：\ncudaEvent_t event; cudaEventCreate(\u0026amp;event); cudaEventRecord(event, stream); cudaEventSynchronize(event); cudaEventDestroy(event); 其中 cudaEventSynchronize 表示 CPU 等待 event 完成。若不想阻塞 CPU，可以使用 cudaEventQuery 查询状态。\n2. Event 为什么能准确测量 GPU 时间 下面这段 CPU 计时代码很容易测错：\nauto begin = std::chrono::steady_clock::now(); vectorAdd\u0026lt;\u0026lt;\u0026lt;grid, block\u0026gt;\u0026gt;\u0026gt;(a, b, c, n); auto end = std::chrono::steady_clock::now(); kernel launch 是异步的，end 很可能在 GPU 真正执行 kernel 之前就被记录。因此测到的主要是 CPU 发射 kernel 的时间，而不是 GPU 执行时间。\nCUDA Event 的计时发生在 GPU 时间线上：\nstream: start event ── kernel ── stop event │\u0026lt;------ GPU elapsed time ------\u0026gt;│ 当 stop event 完成后，cudaEventElapsedTime 可以计算两个 event 之间经过的 GPU 时间。它返回毫秒值。\n3. Demo 一：用 Event 测量 kernel 执行时间 下面是一个完整、可编译的向量加法程序。它同时演示预热、event 计时和结果校验。\n// event_timing.cu #include \u0026lt;cuda_runtime.h\u0026gt; #include \u0026lt;cmath\u0026gt; #include \u0026lt;cstdlib\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;vector\u0026gt; #define CUDA_CHECK(call) \\ do { \\ cudaError_t error = (call); \\ if (error != cudaSuccess) { \\ std::cerr \u0026lt;\u0026lt; \u0026#34;CUDA error: \u0026#34; \u0026lt;\u0026lt; cudaGetErrorString(error) \\ \u0026lt;\u0026lt; \u0026#34; at \u0026#34; \u0026lt;\u0026lt; __FILE__ \u0026lt;\u0026lt; \u0026#39;:\u0026#39; \u0026lt;\u0026lt; __LINE__ \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; \\ std::exit(EXIT_FAILURE); \\ } \\ } while (0) __global__ void vectorAdd(const float* a, const float* b, float* c, int n) { int index = blockIdx.x * blockDim.x + threadIdx.x; if (index \u0026lt; n) { c[index] = a[index] + b[index]; } } int main() { constexpr int n = 1 \u0026lt;\u0026lt; 24; const std::size_t bytes = n * sizeof(float); std::vector\u0026lt;float\u0026gt; hostA(n, 1.0f); std::vector\u0026lt;float\u0026gt; hostB(n, 2.0f); std::vector\u0026lt;float\u0026gt; hostC(n); float* deviceA = nullptr; float* deviceB = nullptr; float* deviceC = nullptr; CUDA_CHECK(cudaMalloc(\u0026amp;deviceA, bytes)); CUDA_CHECK(cudaMalloc(\u0026amp;deviceB, bytes)); CUDA_CHECK(cudaMalloc(\u0026amp;deviceC, bytes)); CUDA_CHECK(cudaMemcpy(deviceA, hostA.data(), bytes, cudaMemcpyHostToDevice)); CUDA_CHECK(cudaMemcpy(deviceB, hostB.data(), bytes, cudaMemcpyHostToDevice)); constexpr int threads = 256; const int blocks = (n + threads - 1) / threads; // 预热：避免把上下文初始化、模块加载等一次性成本混入测量。 vectorAdd\u0026lt;\u0026lt;\u0026lt;blocks, threads\u0026gt;\u0026gt;\u0026gt;(deviceA, deviceB, deviceC, n); CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize()); cudaEvent_t start; cudaEvent_t stop; CUDA_CHECK(cudaEventCreate(\u0026amp;start)); CUDA_CHECK(cudaEventCreate(\u0026amp;stop)); CUDA_CHECK(cudaEventRecord(start)); vectorAdd\u0026lt;\u0026lt;\u0026lt;blocks, threads\u0026gt;\u0026gt;\u0026gt;(deviceA, deviceB, deviceC, n); CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaEventRecord(stop)); // 只等待 stop；同一 stream 中 stop 之前的 kernel 也必然已经完成。 CUDA_CHECK(cudaEventSynchronize(stop)); float elapsedMs = 0.0f; CUDA_CHECK(cudaEventElapsedTime(\u0026amp;elapsedMs, start, stop)); std::cout \u0026lt;\u0026lt; \u0026#34;kernel time: \u0026#34; \u0026lt;\u0026lt; elapsedMs \u0026lt;\u0026lt; \u0026#34; ms\\n\u0026#34;; CUDA_CHECK(cudaMemcpy(hostC.data(), deviceC, bytes, cudaMemcpyDeviceToHost)); if (std::fabs(hostC[n - 1] - 3.0f) \u0026gt; 1e-6f) { std::cerr \u0026lt;\u0026lt; \u0026#34;verification failed\\n\u0026#34;; return EXIT_FAILURE; } CUDA_CHECK(cudaEventDestroy(start)); CUDA_CHECK(cudaEventDestroy(stop)); CUDA_CHECK(cudaFree(deviceA)); CUDA_CHECK(cudaFree(deviceB)); CUDA_CHECK(cudaFree(deviceC)); } 编译运行：\nnvcc -O3 event_timing.cu -o event_timing ./event_timing 一次测量容易受到 GPU 升频、其他进程和缓存状态影响。正式 benchmark 应预热后循环多次，报告中位数或分位数，而不是只报告一次结果。\nEvent 计时也不是端到端耗时：上面的范围没有包含 host 端数据准备，也没有包含 event 范围之外的内存拷贝。如果要衡量用户请求延迟，仍应在 CPU 侧测量完整路径，并在结束前进行必要同步。\n4. 两种等待：CPU 等待与 GPU 等待 CUDA Event 最容易混淆的地方，是 cudaEventSynchronize 和 cudaStreamWaitEvent 看起来都在“等待”，但等待者完全不同。\ncudaEventSynchronize(event)：CPU 线程阻塞，直到 event 完成。 cudaEventQuery(event)：CPU 非阻塞查询 event 是否完成。 cudaStreamWaitEvent(stream, event)：GPU stream 在设备端等待 event，不阻塞 CPU。 cudaStreamSynchronize(stream)：CPU 等待指定 stream 已提交的工作完成。 cudaDeviceSynchronize()：CPU 等待整个设备此前提交的工作完成。 高性能流水线通常更偏爱 cudaStreamWaitEvent。它不会把控制权拉回 CPU，也不会粗暴地同步整个设备。\n5. Demo 二：跨 Stream 构造 copy-compute 依赖 假设 copy stream 负责把输入传到 GPU，compute stream 负责执行 kernel。kernel 必须等输入拷贝完成，但 CPU 没有必要停下来等待。\n// event_pipeline.cu #include \u0026lt;cuda_runtime.h\u0026gt; #include \u0026lt;cstdlib\u0026gt; #include \u0026lt;iostream\u0026gt; #define CUDA_CHECK(call) \\ do { \\ cudaError_t error = (call); \\ if (error != cudaSuccess) { \\ std::cerr \u0026lt;\u0026lt; \u0026#34;CUDA error: \u0026#34; \u0026lt;\u0026lt; cudaGetErrorString(error) \\ \u0026lt;\u0026lt; \u0026#34; at \u0026#34; \u0026lt;\u0026lt; __FILE__ \u0026lt;\u0026lt; \u0026#39;:\u0026#39; \u0026lt;\u0026lt; __LINE__ \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; \\ std::exit(EXIT_FAILURE); \\ } \\ } while (0) __global__ void scale(float* data, int n) { int index = blockIdx.x * blockDim.x + threadIdx.x; if (index \u0026lt; n) { data[index] *= 2.0f; } } int main() { constexpr int n = 1 \u0026lt;\u0026lt; 20; const std::size_t bytes = n * sizeof(float); // 异步 H2D/D2H 要想真正与其他工作重叠，host 内存应为 pinned memory。 float* hostData = nullptr; float* deviceData = nullptr; CUDA_CHECK(cudaMallocHost(\u0026amp;hostData, bytes)); CUDA_CHECK(cudaMalloc(\u0026amp;deviceData, bytes)); for (int index = 0; index \u0026lt; n; ++index) { hostData[index] = 1.0f; } cudaStream_t copyStream; cudaStream_t computeStream; cudaEvent_t copyDone; cudaEvent_t computeDone; CUDA_CHECK(cudaStreamCreateWithFlags(\u0026amp;copyStream, cudaStreamNonBlocking)); CUDA_CHECK(cudaStreamCreateWithFlags(\u0026amp;computeStream, cudaStreamNonBlocking)); CUDA_CHECK(cudaEventCreateWithFlags(\u0026amp;copyDone, cudaEventDisableTiming)); CUDA_CHECK(cudaEventCreateWithFlags(\u0026amp;computeDone, cudaEventDisableTiming)); CUDA_CHECK(cudaMemcpyAsync( deviceData, hostData, bytes, cudaMemcpyHostToDevice, copyStream)); CUDA_CHECK(cudaEventRecord(copyDone, copyStream)); // GPU 侧依赖：不阻塞 CPU，也不等待 copyStream 中 copyDone 之后的操作。 CUDA_CHECK(cudaStreamWaitEvent(computeStream, copyDone)); scale\u0026lt;\u0026lt;\u0026lt;(n + 255) / 256, 256, 0, computeStream\u0026gt;\u0026gt;\u0026gt;(deviceData, n); CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaEventRecord(computeDone, computeStream)); // 回传也通过 event 建立精确依赖。 CUDA_CHECK(cudaStreamWaitEvent(copyStream, computeDone)); CUDA_CHECK(cudaMemcpyAsync( hostData, deviceData, bytes, cudaMemcpyDeviceToHost, copyStream)); // 最终结果要被 CPU 使用，此处才需要 CPU 等待。 CUDA_CHECK(cudaStreamSynchronize(copyStream)); std::cout \u0026lt;\u0026lt; \u0026#34;result: \u0026#34; \u0026lt;\u0026lt; hostData[n - 1] \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; CUDA_CHECK(cudaEventDestroy(copyDone)); CUDA_CHECK(cudaEventDestroy(computeDone)); CUDA_CHECK(cudaStreamDestroy(copyStream)); CUDA_CHECK(cudaStreamDestroy(computeStream)); CUDA_CHECK(cudaFree(deviceData)); CUDA_CHECK(cudaFreeHost(hostData)); } 对应的时间线如下：\ncopyStream: H2D copy ── copyDone ───────── wait computeDone ── D2H copy │ ▲ ▼ │ computeStream: wait copyDone ── scale ── computeDone CPU: 提交上述操作后继续运行，最终消费结果前才同步 这里的关键不是多开两条 stream，而是准确表达数据依赖。没有 copyDone，compute stream 可能在输入尚未准备好时读取数据；如果改成 cudaDeviceSynchronize，结果虽然正确，却会阻塞 CPU，并破坏无关工作的并行机会。\n6. Event 的实现原理：记录完成点，传播依赖 从编程模型看，event 可以拆成三个阶段。\n6.1 Record：向 stream 插入记录命令 cudaEventRecord(event, producerStream); 运行时把记录操作排在 producer stream 当前已提交工作的后面。它代表的不是“record 调用发生的 CPU 时刻”，而是 GPU 执行到这个队列位置的时刻。\n6.2 Complete：GPU 到达标记位置 当 producer stream 中排在 event 之前的工作完成后，设备更新 event 的完成状态。若 event 启用了 timing，运行时还会保留计时所需的信息。\n6.3 Observe 或 Wait：消费完成状态 消费方有两类：\nCPU 通过 query 或 synchronize 观察状态； 另一个 GPU stream 通过 cudaStreamWaitEvent 把后续命令挂在该状态之后。 因此 event 的本质不是“执行一次回调”，而是一个可被 CPU 和其他 stream 观察的 GPU 完成点。它类似异步系统中的 future/fence，但语义严格绑定 CUDA 的设备执行时间线。\n7. 常见使用场景 7.1 GPU 微基准与算子性能分析 用 start/stop event 包围 kernel、多个 kernel 或内存拷贝，可以测量设备侧执行时间。典型场景包括 GEMM、attention、通信算子和自定义 CUDA kernel benchmark。\n为了让结果可信，应做到：\n测量前预热； start 和 stop 放在目标工作实际所在的 stream； 循环多次，避免一次性噪声； 明确是否包含数据传输； 不要在每个 kernel 后做全局同步。 7.2 计算与数据传输流水线 双缓冲系统常把数据分块，在不同 stream 上重叠 H2D、计算和 D2H：\n时间 ──────────────────────────────────────────────\u0026gt; copy: H2D(0) H2D(1) H2D(2) compute: K(0) K(1) K(2) return: D2H(0) D2H(1) D2H(2) 每个 buffer 槽位配套 event，可以表达“数据已到达”“计算已结束”和“槽位可以复用”。\n7.3 异步内存池与资源回收 kernel 发射后，CPU 不能立即把它使用的 buffer 交给另一个请求。资源池可以在最后一次使用之后 record event：\nkernel\u0026lt;\u0026lt;\u0026lt;grid, block, 0, stream\u0026gt;\u0026gt;\u0026gt;(buffer); cudaEventRecord(bufferReadyToRecycle, stream); 后台回收器通过 cudaEventQuery 检查完成状态，完成后再把 buffer 放回 free list。这样既避免 use-after-free，也避免为了回收内存调用全局同步。\n深度学习框架的 caching allocator、推理引擎的 workspace 池和 KV Cache 管理，都可能使用类似机制。\n7.4 多 Stream 算子依赖 一个任务可能由通信、计算和后处理组成：\ncommunication stream: all-reduce ── event │ compute stream: wait event ── next layer Event 可以只同步真正有依赖的边，而不冻结整个设备。这是通信计算重叠、流水线并行和多 stage 推理调度的重要基础。\n7.5 CPU 异步任务完成通知 CPU 可以周期性调用 cudaEventQuery，在 GPU 工作未完成时处理网络、调度或其他请求：\ncudaError_t status = cudaEventQuery(done); if (status == cudaSuccess) { consumeResult(); } else if (status != cudaErrorNotReady) { CUDA_CHECK(status); } 这比立即 cudaEventSynchronize 更适合事件循环，但要避免无休止忙轮询；实际系统通常会结合任务队列、退避或专门的完成线程。\n8. Event 创建选项 默认创建的 event 支持计时：\ncudaEventCreate(\u0026amp;event); 如果只用于同步，通常应关闭 timing：\ncudaEventCreateWithFlags(\u0026amp;event, cudaEventDisableTiming); 这清楚表达了用途，也可降低不必要的计时开销。\n如果 CPU 会调用 cudaEventSynchronize 且等待时间较长，可以使用：\ncudaEventCreateWithFlags( \u0026amp;event, cudaEventBlockingSync | cudaEventDisableTiming); cudaEventBlockingSync 影响 host 等待方式，不会把异步 record 变成同步提交。具体等待策略和成本还会受到操作系统与 CUDA 运行时实现影响。\n9. 容易踩的坑 9.1 Record 后立即认为 event 完成 cudaEventRecord(event, stream); useResultOnCpu(); // 错误：GPU 可能还没执行到 event Record 只提交标记。CPU 使用 GPU 结果前仍需要 synchronize，或者通过 query 确认完成。\n9.2 每一步都调用 cudaDeviceSynchronize 全局同步适合调试和程序最终收尾，但在热路径中频繁使用会让本可重叠的 stream 串行化。优先用 event 表达局部依赖。\n9.3 用 Event 计时却测错 Stream 若 kernel 在 workerStream，event 却记录在另一个无依赖 stream，时间范围就无法包围目标工作：\nkernel\u0026lt;\u0026lt;\u0026lt;grid, block, 0, workerStream\u0026gt;\u0026gt;\u0026gt;(); cudaEventRecord(stop, anotherStream); // 没有依赖，测量语义错误 最简单可靠的方式，是把 start、目标工作和 stop 放到同一 stream。多 stream benchmark 则应先构造汇合依赖，再记录结束 event。\n9.4 复用 Event 时误解其含义 同一个 event 可以多次 record，后一次记录会更新它代表的完成点。资源池复用 event 时，必须确保旧一轮依赖已经不再需要它，否则可能把消费者绑定到错误的代次。\n9.5 只关闭计时，却仍调用 elapsedTime 用 cudaEventDisableTiming 创建的 event 适合同步，不适合传给 cudaEventElapsedTime。计时 event 和同步 event 最好在命名与封装上明确区分。\n9.6 忽略异步错误 kernel launch 错误可通过 cudaGetLastError 检查；执行期错误常在后续同步 API 中暴露。因此示例中的同步调用也必须检查返回值，而不是默认 event 一定成功完成。\n10. 如何选择同步方式 可以按下面的决策顺序考虑：\n是否需要 CPU 立刻使用结果？ ├─ 是：等待精确完成点 │ ├─ 已有 event → cudaEventSynchronize │ └─ 只关心单条 stream → cudaStreamSynchronize └─ 否：是否只是另一条 GPU stream 依赖它？ ├─ 是 → cudaEventRecord + cudaStreamWaitEvent └─ 否 → 保持异步，必要时 cudaEventQuery cudaDeviceSynchronize 不是不能用，而是同步范围最大。初始化、调试、测试程序退出前或确实需要设备全局完成时，它非常方便；在追求并行度的核心路径上，则应尽量用 event 和 stream 构造精确依赖。\n总结 CUDA Event 是插入 stream 时间线的 GPU 完成标记。它主要解决四类问题：\n用 GPU 时间线准确测量设备工作耗时； 让 CPU 查询或等待一段 GPU 工作； 在不同 stream 之间建立非阻塞依赖； 判断异步资源何时可以安全回收和复用。 真正掌握 Event 的关键，是区分“CPU 等待 GPU”和“GPU stream 等待另一个 GPU stream”。前者会影响 host 控制流，后者只在设备时间线上增加一条依赖边。高性能 CUDA 程序通常不是消灭同步，而是把粗粒度全局同步改写成尽可能精确的 event 依赖。\n","permalink":"https://yangyang233333.github.io/posts/cuda-event-principles-demo/","summary":"\u003cp\u003eCUDA kernel launch 通常是异步的：CPU 把 kernel、内存拷贝等操作提交到 stream 后便继续执行。由此产生两个常见问题：如何准确测量 GPU 工作耗时，以及如何让不同 stream 在不阻塞 CPU 的情况下建立依赖？\u003c/p\u003e\n\u003cp\u003eCUDA Event 就是解决这类问题的基础设施。它可以理解为插入 GPU stream 时间线中的一个标记：当 event 之前的工作全部完成，event 才进入完成状态。借助这个状态，我们可以做 GPU 计时、CPU 等待、跨 stream 同步和异步资源回收。\u003c/p\u003e\n\u003ch2 id=\"1-event-不是-cpu-事件而是-gpu-时间线标记\"\u003e1. Event 不是 CPU 事件，而是 GPU 时间线标记\u003c/h2\u003e\n\u003cp\u003e一个 CUDA stream 是按序执行的 GPU 工作队列：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eCPU 提交： Kernel A → memcpy → Event E → Kernel B\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                         异步提交\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 执行： Kernel A ── memcpy ── E 完成 ── Kernel B\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e调用 \u003ccode\u003ecudaEventRecord(event, stream)\u003c/code\u003e 时，CPU 通常只是向 \u003ccode\u003estream\u003c/code\u003e 提交一个 event 记录操作，并不会等待 GPU 执行到该位置。只有当这个 stream 中排在 event 前面的操作完成后，event 才会被标记为完成。\u003c/p\u003e","title":"CUDA Event：GPU 时间线上的事件、同步原理与实战"},{"content":"大模型自回归推理看起来是纯 GPU 工作：每一步做一次模型前向，采样一个 token，再把 token 喂给下一步。然而在高性能推理系统里，GPU 计算只是流水线的一部分。采样结果回传、EOS 判断、请求回收、KV Cache 管理、下一批组装和元数据准备，都可能在两次前向之间制造空洞。\nSGLang 的 Overlap Scheduling 解决的正是这个问题。它不是简单地“多开一条 CUDA stream”，而是同时完成两件事：\n让当前步产生的 token 留在 GPU，直接作为下一步输入，解除下一次 forward 对 CPU 读值的依赖； 用 scheduler stream 和 engine stream 构造软件流水线，让 GPU 计算当前批次时，调度器处理上一批次的结果。 本文以 Mini-SGLang 的 python/minisgl/scheduler/scheduler.py 为线索，解释 overlap_loop、_forward 和 _process_last_data 背后的依赖重排。\n先看结论：优化的不是“提交速度”，而是依赖关系 CUDA kernel 的提交本来就是异步的。CPU 把工作放进 stream 后，不必等待 GPU 完成。因此一个自然的问题是：只用一条 stream，只要 CPU 提交得足够快，不也能让 GPU 连续工作吗？\n如果系统只有计算任务，这个判断基本成立。但自回归推理存在一个控制依赖：调度器通常要知道采样出的 token，才能判断请求是否命中 EOS、是否应该释放资源，以及下一轮有哪些请求仍应留在 batch 中。\n串行执行会形成如下链条：\nGPU: forward(B1) ── idle ── forward(B2) ── idle ── forward(B3) CPU: 处理 B1 处理 B2 读 token 读 token 判 EOS 判 EOS 组 B2 组 B3 GPU 完成 B1 后，CPU 才读取结果并决定 B2。此时 GPU 没有可执行的新工作，只能等待。这不是 CPU 调用 CUDA API 太慢，而是 B2 在逻辑上依赖 B1 的结果。\nOverlap Scheduling 的关键，是继续追问：下一步模型计算真的需要 CPU 理解 token 的值吗？\n答案是否定的。模型只需要 token ID 对应的 GPU 数据；只有终止判断和文本输出才需要 CPU 把它解释成整数。只要把这两类依赖拆开，流水线就能继续向前。\n两种依赖：计算依赖与控制依赖 一个新生成的 token 有两种用途。\n用途 消费方 是否需要 CPU 知道具体值 作为下一步模型输入 GPU 不需要 EOS 判断、请求回收、detokenize CPU / 前端 需要 传统直觉容易把它们绑在一起：GPU 采样 token，复制回 CPU，CPU 读取整数，再把它作为下一步输入传回 GPU。这样会产生 D2H、.item() 和下一轮 H2D 的同步链。\nMini-SGLang 则让 token 沿两条路径流动：\n┌── GPU → GPU：写入 token_pool，供下一步 forward 使用 采样 token（GPU）───┤ └── GPU → CPU：异步回传，稍后用于判停和输出 GPU 计算路径不再等待 CPU 控制路径。CPU 仍然会读取 token，但读取被推迟到下一轮，并尽量与当前批次的 GPU 计算重叠。\n双 Stream 的职责划分 调度器初始化时创建自己的 CUDA stream，并保留 engine 已有的计算 stream：\nself.device = self.engine.device self.stream = torch.cuda.Stream(device=self.device) self.engine_stream_ctx = torch.cuda.stream(self.engine.stream) torch.cuda.set_stream(self.stream) 两条 stream 的职责并不对称。\nStream 主要职责 工作特征 self.stream batch 组装、mapping/position、元数据准备、拷贝和结果收尾 小而碎，夹杂 CPU 控制逻辑 self.engine.stream 模型 forward、attention、采样 计算密集，持续时间较长 engine_stream_ctx 不是第三条 stream，只是一个上下文管理器。进入 with self.engine_stream_ctx 后，相关 CUDA 操作会被提交到 self.engine.stream。\n为什么不把所有 GPU 操作都放进 engine stream？因为同一 stream 内严格有序。如果元数据准备所需的小 kernel 或拷贝被排在一个大 forward 后面，它们即使资源需求很低，也必须等前面的任务结束。独立的 scheduler stream 让这些轻量操作可以更早执行，或者与不冲突的计算重叠。\n但多 stream 并不自动保证正确。不同 stream 没有隐含顺序；如果 forward 读取的 metadata 尚未准备完成，就可能读到未就绪的数据。SGLang 因而只在真实数据依赖处建立同步：\nwith self.engine_stream_ctx: self.engine.stream.wait_stream(self.stream) ongoing_data = (forward_input, self._forward(forward_input)) wait_stream 的含义是：engine stream 在继续执行前，等待 scheduler stream 此前提交的工作完成。它把同步限制在 metadata → forward 这条边上，而不是粗暴地同步整个设备。\n_forward：让 token 留在显存闭环 Overlap Scheduling 能成立的支点位于 _forward：\nbatch.input_ids = self.token_pool[input_mapping] forward_output = self.engine.forward_batch(batch, ...) self.token_pool[output_mapping] = forward_output.next_tokens_gpu 这三步构成了 token 的 GPU 闭环：\n根据 input_mapping 从常驻显存的 token_pool gather 当前输入； engine 执行模型前向与采样，得到 next_tokens_gpu； 根据 output_mapping 把采样结果写回 token_pool 对应位置。 调度器在安排下一轮时只需要维护“哪个请求对应 token_pool 的哪个位置”。它不需要先把 token 读回 CPU，再重新上传。换句话说，CPU 负责索引和生命周期，GPU buffer 保存真正参与下一步计算的数据。\n这里容易出现一个误解：CPU 不读取 token，如何知道下一批由哪些请求组成？\n答案是允许短暂的滞后一轮。当前批次开始计算时，上一批次的 CPU 后处理才完成。已经结束的请求可能在一个受控窗口内进入下一轮，因此实现还需要 finished_reqs 一类机制避免重复释放。这是一种有意设计的流水线状态，而不是忽略终止条件。\noverlap_loop：把一轮拆成四个阶段 核心循环可以概括为：\ndef overlap_loop(self, last_data): for msg in self.receive_msg(blocking=...): self._process_one_msg(msg) forward_input = self._schedule_next_batch() ongoing_data = None if forward_input is not None: with self.engine_stream_ctx: self.engine.stream.wait_stream(self.stream) ongoing_data = (forward_input, self._forward(forward_input)) self._process_last_data(last_data) return ongoing_data 1. 接收新请求 调度器先读取前端消息，把新请求加入 pending 或 decode 管理结构。是否阻塞取决于当前是否还有可推进的工作。\n2. 组装下一批并准备元数据 _schedule_next_batch() 选择请求、分配资源并准备 attention metadata、position 和 mapping。这些工作发生在 scheduler stream 的语义下。\n这里的“下一批”并不要求上一批的 token 已经被 CPU 完整消费，因为其模型输入已经由 token_pool 在 GPU 上保存。\n3. 在 engine stream 发起当前批次 调度器切到 engine stream，先等待本轮 metadata 就绪，再提交 _forward。CPU 提交完成后即可继续执行，不必等待模型前向真正结束。\n返回的 ongoing_data 保存本轮的输入和异步输出对象，在下一次循环中变成 last_data。\n4. 处理上一批结果 当前批次已经在 engine stream 上运行，此时 scheduler 开始处理 last_data。于是形成关键重叠：\nengine.stream: forward(Bn) ║ 并行 scheduler / CPU: process(Bn-1) 注意，这里的“scheduler stream 处理结果”并不意味着所有逻辑都在 GPU 上。它包含 CPU 控制逻辑，也包含 D2H 拷贝和少量 GPU 操作。优化目标是让整段后处理处在当前 forward 的时间窗口内。\n_process_last_data：同步仍然存在，只是被移到了流水线里 Overlap Scheduling 没有消灭同步。CPU 最终仍要得到 token 的具体值：\ncopy_done.synchronize() req.append_host(next_token) next_token = int(next_token.item()) finished = not req.can_decode if not req.sampling_params.ignore_eos: finished |= next_token == self.eos_token_id copy_done.synchronize() 等待上一批 token 的异步 D2H 拷贝完成，.item() 将张量值转换成 CPU 整数。随后调度器才能执行：\n追加请求的 host 端 token； 判断 EOS、长度上限和其他停止条件； 从 decode manager 移除已完成请求； 释放 request table、KV Cache 页等资源； 为未结束的 prefill 请求更新或缓存前缀； 把 DetokenizeMsg 发给前端。 区别在于，CPU 等待的是 Bn-1 的结果，而 engine stream 已经在计算 Bn。只要当前 forward 足够长，上一批结果的同步和控制开销就能隐藏在它后面。\nChunked Prefill 请求通常不在每个 chunk 后采样，因此收尾逻辑会跳过尚未完成的 chunk；finished_reqs 等状态则用于防止流水线滞后一轮带来的重复回收。\n展开三轮：流水线如何填满 把循环展开后，执行关系更直观：\n时间 t1 t2 t3 engine.stream forward(B1) forward(B2) forward(B3) │ │ │ scheduler/CPU 无上一批 处理 B1 处理 B2 判 EOS 判 EOS 组装 B1 回收资源 回收资源 组装 B2 组装 B3 第一轮只有 forward，没有可处理的上一批结果，这是流水线的填充阶段。从第二轮开始，GPU 计算 Bn，CPU 处理 Bn-1。停止时同样会有一次排空阶段。\n理想情况下，每轮耗时近似从串行的：\nT_serial ≈ T_schedule + T_forward + T_postprocess 变成稳定流水线中的：\nT_overlap ≈ max(T_forward, T_schedule + T_postprocess) + T_sync 其中 T_sync 表示无法隐藏的真实依赖和流水线边界成本。这个公式不是精确性能模型，但能说明收益上限：只有可并行部分才能被隐藏。\n为什么模型越小，收益往往越明显 当模型很大时，单步 forward 很长，CPU 后处理占总时延的比例较小。即使完全隐藏后处理，端到端提升也有限。\n小模型或高性能 GPU 上的 decode 则相反：单步 kernel 很快，Python 调度、token 回读、请求状态更新和 metadata 准备开始占据显著比例。此时每一步留下几十或几百微秒空洞，累积到长序列后会明显降低 GPU 利用率，Overlap Scheduling 的价值更高。\n收益还受以下因素影响：\nbatch 大小和请求到达模式； prefill 与 decode 的混合比例； CPU 单核性能及 Python 开销； D2H/H2D 拷贝是否真正异步； metadata kernel 能否与模型 kernel 并发； GPU 是否已有足够高的计算或带宽占用； 终止请求带来的滞后一轮额外计算与资源占用。 因此“双 stream”不是恒定倍数的加速开关，而是一种减少 bubble 的调度方法。\nnormal_loop 为什么仍然必要 Mini-SGLang 仍保留由 ENV.DISABLE_OVERLAP_SCHEDULING 控制的串行循环。normal_loop 通常按“调度 → forward → 处理结果”的顺序执行，便于建立性能基线和排查并发问题。\n它尤其适合诊断以下错误：\n跨 stream 缺少依赖，导致 metadata 未就绪； buffer 生命周期过短，异步 kernel 仍在访问已复用内存； D2H copy 的 event 或 synchronize 使用不正确； 请求滞后一轮后重复结束、重复释放； 某个看似异步的 .item() 或内存操作触发全局同步。 如果关闭 overlap 后错误消失，问题通常不在模型数学计算，而在 stream 顺序、事件同步或对象生命周期。\n常见误解 多一条 stream 就一定能并行 不一定。GPU 是否真正并发取决于 kernel 的资源占用、硬件 copy engine、依赖关系和提交时机。若 forward 已占满 SM 或内存带宽，scheduler stream 的小 kernel 可能仍只能穿插执行。但即使物理并发有限，拆分 stream 仍能避免无关任务被同一队列的人为顺序阻塞。\nwait_stream 会把优化抵消 不会。问题不在于“是否同步”，而在于同步粒度。metadata 是 forward 的真实前置依赖，必须等待；上一批 token 的 CPU 处理不是当前 forward 的前置依赖，因此不应阻塞 engine stream。精确同步保留正确性，也保留其余并发空间。\ntoken 留在 GPU 就不需要回传 仍然需要。EOS 判断、停止条件、日志和 detokenize 都需要 host 端 token。优化只是把回传从“下一步计算的必经路径”移到旁路，并让它异步、延迟地完成。\nOverlap Scheduling 等于 CUDA Graph 两者解决的问题不同。CUDA Graph 主要减少 CPU launch 开销和动态提交成本；Overlap Scheduling 重排的是跨步控制依赖，让上一批后处理与当前批计算并行。它们可以同时存在并互补。\n设计这类流水线时应检查什么 从 Mini-SGLang 的实现可以提炼出一套通用检查清单：\n区分数据的计算消费者和控制消费者，不要让 CPU 控制路径阻塞 GPU 数据路径； 为长期跨步使用的数据建立稳定的 GPU buffer，而不是依赖临时 tensor； 用 event 或 stream wait 表达最小必要依赖，避免设备级全局同步； 明确每个异步对象的生命周期，确保 buffer 在消费者完成前不会被复用； 处理流水线填充、排空和滞后一轮带来的重复状态问题； 保留串行模式作为正确性基线，并用 profiler 验证是否真的消除了 bubble。 真正值得借鉴的不是“两条 stream”这个数字，而是依赖图的重构方法：先找出阻塞下一步的边，再判断它是真实计算依赖，还是实现方式制造的控制依赖。\n总结 SGLang 的双 Stream 重叠调度可以浓缩为一句话：让 token 沿 GPU 数据路径立即进入下一步，把 CPU 必须完成的判停、回收和输出延迟到旁路处理。\ntoken_pool 切断了下一次 forward 对 CPU 读值的依赖；scheduler stream 与 engine stream 将 metadata/后处理和模型计算分离；wait_stream 只保护真实的数据依赖；last_data 则把循环变成“计算当前批、收尾上一批”的稳态流水线。\n最终被优化掉的不是某个算子，而是 GPU 两次 forward 之间原本无事可做的时间。这也是推理系统优化中最重要的视角之一：当 kernel 已经足够快，下一步往往不是继续打磨算子，而是重新安排数据与控制依赖，让硬件始终有工作可做。\n","permalink":"https://yangyang233333.github.io/posts/sglang-overlap-scheduling/","summary":"\u003cp\u003e大模型自回归推理看起来是纯 GPU 工作：每一步做一次模型前向，采样一个 token，再把 token 喂给下一步。然而在高性能推理系统里，GPU 计算只是流水线的一部分。采样结果回传、EOS 判断、请求回收、KV Cache 管理、下一批组装和元数据准备，都可能在两次前向之间制造空洞。\u003c/p\u003e\n\u003cp\u003eSGLang 的 Overlap Scheduling 解决的正是这个问题。它不是简单地“多开一条 CUDA stream”，而是同时完成两件事：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e让当前步产生的 token 留在 GPU，直接作为下一步输入，解除下一次 forward 对 CPU 读值的依赖；\u003c/li\u003e\n\u003cli\u003e用 scheduler stream 和 engine stream 构造软件流水线，让 GPU 计算当前批次时，调度器处理上一批次的结果。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e本文以 Mini-SGLang 的 \u003ccode\u003epython/minisgl/scheduler/scheduler.py\u003c/code\u003e 为线索，解释 \u003ccode\u003eoverlap_loop\u003c/code\u003e、\u003ccode\u003e_forward\u003c/code\u003e 和 \u003ccode\u003e_process_last_data\u003c/code\u003e 背后的依赖重排。\u003c/p\u003e\n\u003ch2 id=\"先看结论优化的不是提交速度而是依赖关系\"\u003e先看结论：优化的不是“提交速度”，而是依赖关系\u003c/h2\u003e\n\u003cp\u003eCUDA kernel 的提交本来就是异步的。CPU 把工作放进 stream 后，不必等待 GPU 完成。因此一个自然的问题是：只用一条 stream，只要 CPU 提交得足够快，不也能让 GPU 连续工作吗？\u003c/p\u003e\n\u003cp\u003e如果系统只有计算任务，这个判断基本成立。但自回归推理存在一个控制依赖：调度器通常要知道采样出的 token，才能判断请求是否命中 EOS、是否应该释放资源，以及下一轮有哪些请求仍应留在 batch 中。\u003c/p\u003e\n\u003cp\u003e串行执行会形成如下链条：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU:  forward(B1) ── idle ── forward(B2) ── idle ── forward(B3)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eCPU:               处理 B1                 处理 B2\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                   读 token                读 token\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                   判 EOS                  判 EOS\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                   组 B2                   组 B3\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eGPU 完成 \u003ccode\u003eB1\u003c/code\u003e 后，CPU 才读取结果并决定 \u003ccode\u003eB2\u003c/code\u003e。此时 GPU 没有可执行的新工作，只能等待。这不是 CPU 调用 CUDA API 太慢，而是 \u003ccode\u003eB2\u003c/code\u003e 在逻辑上依赖 \u003ccode\u003eB1\u003c/code\u003e 的结果。\u003c/p\u003e","title":"SGLang 双 Stream 重叠调度：如何把 CPU 后处理藏到 GPU 计算背后"},{"content":" 本文翻译并整理自论文 SAC: Disaggregated KV Cache System for Sparse Attention LLMs with CXL（arXiv:2606.19746v1，2026 年 6 月 18 日）。原论文采用 CC BY 4.0 许可。为适应博客阅读，本文省略参考文献列表中的完整出版信息，但保留正文结构、关键论证、实验结果与附录要点。\n随着大模型进入长上下文与稀疏注意力时代，推理系统的主要瓶颈正从算力转向内存容量。传统 RDMA 解耦 KV Cache 系统会在解码前把完整前缀 KV Cache 搬回本地；但稀疏注意力每一步只访问少量 top-k 条目，这种“全量搬运、少量使用”的方式会同时浪费网络带宽和本地内存。\nSAC 的核心思路是：使用 CXL 的低延迟、缓存行粒度 load/store 语义，把 KV Cache 留在解耦内存池中，只在计算时按需读取被选中的 top-k 条目。论文在 DeepSeek-V3.2 与 SGLang 上的实验显示，相比 RDMA 基线，SAC 可实现最高约 2.1 倍吞吐量、9.7 倍更低的 TTFT，以及 1.8 倍更低的 TBT。\n摘要 大模型向长上下文推理扩展，使服务系统的主要瓶颈从计算能力转向内存容量。面向稠密注意力模型的传统方案通常采用基于 RDMA 的解耦内存池，在解码开始前，以粗粒度方式将整个前缀 KV Cache 从远端存储取回本地内存。\n然而，这种方法并不适合新兴的稀疏注意力模型。解码过程中虽然只有很少一部分 KV 条目真正活跃，系统仍然要把完整 KV Cache 拉回本地，从而造成严重的传输瓶颈和本地内存浪费。\n为解决这一问题，论文提出 SAC，这是首个面向稀疏注意力模型优化的高效解耦 KV Cache 系统。SAC 利用 Compute Express Link（CXL）的低延迟和缓存行粒度 load/store 语义，在推理过程中仅按需读取所需的 top-k KV 条目。\n基于 SGLang 和 DeepSeek-V3.2 的评测表明，与 RDMA 基线相比，SAC 能获得更高吞吐量、更低 TTFT 和更低 TBT，说明 CXL 解耦内存更适合作为新一代稀疏注意力模型的基础设施。\n1. 引言 大模型参数规模不断增长，长上下文推理需求也持续增加，系统瓶颈随之由计算转向内存。如何管理前缀 KV Cache，已经成为大模型服务中的关键问题。\n单机 GPU HBM 与主机 DRAM 很难满足 TB 级 KV Cache 容量需求，因此，基于 RDMA 的解耦 KV Cache 系统逐渐成为扩展内存容量、实现跨节点共享的常见方案。此类系统通常通过 RDMA 网卡将整个前缀 KV Cache 预取到本地内存，以便后续解码阶段低延迟访问。\n对稠密注意力模型而言，全量预取是合理的，因为每个解码步骤都需要访问所有历史 token 的 KV。但对于 DeepSeek-V3.2、GLM-5.1、DeepSeek-V4 等稀疏注意力模型，这种方式存在根本性低效：每一步只使用少量 KV 条目，却要传输并驻留完整上下文的 KV Cache。\n论文将问题归纳为两点。\n1.1 传输瓶颈 稀疏注意力显著降低了计算复杂度，因此服务吞吐量往往不再受算力限制，而是受可达到的 batch size 限制。长上下文请求的 KV Cache 可达数十 GB；在高并发下，RDMA 搬运和内存布局重排都会形成巨大压力，引起排队延迟，恶化首 Token 延迟（TTFT）与总体吞吐量。\n1.2 本地内存浪费 稀疏注意力每层只使用 top-k KV 条目。论文观察到，在长上下文请求的整个解码过程中，实际访问的前缀 KV Cache 比例极低，但系统仍需把全部数据取回本地。\n为了避免本地内存容量限制 batch size，系统需要配置 TB 级内存来支撑高并发，导致基础设施成本高、内存利用率低。\n一种直观方案是按需传输 top-k KV，但 RDMA 很难胜任：\ntop-k 索引由当前 query 在运行时逐层动态决定，对读取延迟极其敏感； 被选中的 KV 条目是离散的小数据段，需要大量独立 RDMA 请求或复杂 gather/scatter，软件栈开销很高。 CXL 为此提供了新的可能。它建立在 PCIe 物理层之上，协议栈更精简，访问延迟显著低于 RDMA；同时支持缓存行粒度 load/store，无需消息协议开销，天然适合读取细粒度、稀疏分布的数据。\nSAC 据此做出三项主要贡献：\n揭示 RDMA 全量预取在稀疏注意力模型中的传输和容量瓶颈； 设计基于 CXL 的解耦 KV Cache 系统，实现 top-k KV 的低延迟按需读取； 在 DeepSeek-V3.2 与 SGLang 上进行端到端验证，证明其性能接近本地 DRAM，并显著优于 RDMA。 2. 背景 2.1 大模型中的稀疏注意力 传统稠密注意力在生成每个 token 时，都要让 query 与全部历史 key/value 交互，计算复杂度和 KV Cache 访问量都会随上下文长度线性增长。\n稀疏注意力通过只选择少量重要 KV 条目来降低开销。以 DeepSeek Sparse Attention 为例，其流程包含两个部分：\nLightning Indexer：根据当前 query 对历史 token 打分，选出 top-k 位置； 稀疏注意力计算：仅加载这些位置对应的 KV，并完成注意力运算。 这种模型把昂贵的全量 KV 扫描变成了细粒度、数据依赖的随机访问，也改变了最适合它的存储系统形态。\n2.2 大模型服务中的解耦内存 解耦内存把计算资源与内存资源分离，使多个计算节点能够共享更大的远端内存池。现有大模型服务系统大多通过 RDMA 连接远端 KV Cache 存储。\nRDMA 擅长传输大块连续数据，但通常依赖显式消息和 DMA 操作。为了避免解码期间频繁访问远端，系统会先把完整前缀 KV Cache 搬到本地，再开始计算。这一设计隐含了“后续会使用大部分数据”的假设，而稀疏注意力恰好破坏了这个假设。\n2.3 Compute Express Link（CXL） CXL 是建立在 PCIe 之上的开放互连标准，支持处理器、加速器和内存设备之间的一致性与内存语义访问。对本论文最重要的是 CXL.mem：CPU 可以像访问本地内存一样，通过普通 load/store 访问 CXL 扩展内存。\n与 RDMA 相比，CXL 具有三项适合稀疏 KV Cache 的特征：\n访问延迟更低； 最小访问粒度可达到缓存行级别； 无需为每次细粒度读取构造网络消息和请求队列。 3. 动机分析 3.1 RDMA 解耦 KV Cache 的瓶颈 论文首先测量了前缀 KV Cache 的 RDMA 预取延迟。随着上下文长度增长，KV Cache 体积迅速增大，传输时间也近似线性增长。高并发进一步使 RDMA 带宽饱和，请求不得不排队，导致 TTFT 显著升高。\n另一方面，DeepSeek-V3.2 的稀疏注意力只选择 top-k 条目。虽然完整前缀 KV Cache 占用很大，但实际被访问的数据只是其中很小一部分。将完整 KV Cache 放入本地 DRAM，等于为了少量有效数据长期保留大量冷数据。\n因此，RDMA 系统的问题不只是“链路还不够快”，而是传输粒度与模型访问模式不匹配。\n3.2 稀疏 KV Cache 的访问延迟 按需读取的关键要求是：远端访问必须足够快，不能阻塞每层解码。论文比较了 RDMA 与 CXL 对稀疏 KV 条目的读取延迟。\nRDMA 需要提交工作请求、处理队列、完成通知，并可能执行 gather/scatter。数据越零散，请求管理成本越突出。CXL 则允许 CPU 对映射后的内存地址直接读写，将细粒度访问交给硬件缓存与内存协议完成。\n实验表明，对于稀疏、小粒度 KV 获取，CXL 的延迟明显低于 RDMA。这说明“KV Cache 留在远端、只按需读取 top-k”不仅节省容量，而且在延迟上可行。\n4. SAC 系统设计 4.1 工作流程 SAC 将完整前缀 KV Cache 保存在解耦 CXL 内存池中，计算实例不再预取完整数据。一个请求的流程可以概括为：\n第一轮：前缀填充 请求进入计算实例 ↓ GPU 完成 prefill 并生成 KV Cache ↓ KV Cache 写入共享 CXL 内存池 第二轮：前缀复用与解码 命中已有前缀 KV Cache ↓ Lightning Indexer 逐层产生 top-k 索引 ↓ CPU 从 CXL 内存按需读取对应 KV 条目 ↓ 所需条目传入 GPU，完成稀疏注意力计算 在第一轮请求中，prefill 仍要生成完整 KV Cache，并将其写入内存池。SAC 的主要优势体现在后续命中相同前缀的请求：它无需把全部缓存重新搬回本地，只取当前步骤真正需要的数据。\n4.2 系统拓扑 SAC 的原型系统包含计算节点、CXL 交换结构和多个 CXL 内存设备。计算实例通过统一的 CXL 地址空间访问内存池中的 KV Cache。\n论文实现采用 CPU 作为 CXL 内存访问发起方：CPU 从 CXL 内存读取稀疏 KV 数据，再经 PCIe 传给 GPU。这是受实验硬件能力限制的现实选择；未来如果 GPU 能直接发起 CXL.mem 访问，就可以进一步缩短路径、减少 CPU 中转。\n4.3 基于 CXL 的 KV Cache 管理 4.3.1 统一 CXL 内存资源 SAC 把多块 CXL 设备整合为统一内存资源，并向上层 KV Cache 管理器暴露连续地址空间。应用无需理解每块设备的物理细节，只需根据对象偏移定位 KV 数据。\n系统管理两类信息：\nKV Cache 数据本身； 请求、层、token 位置与物理地址之间的映射元数据。 当某层产生 top-k 索引后，SAC 根据元数据计算对应 KV 条目的地址，并发起细粒度读取。\n4.3.2 CXL 操作实现 SAC 的 CXL 访问采用普通内存读写语义。CXL 内存映射进入进程地址空间后，CPU 可以使用 load/store 指令直接访问。\n为了提高吞吐量，系统使用多线程并行处理不同 KV 条目，并通过非临时访问、预取和批量拷贝等方式减少缓存污染与软件开销。其目标不是把所有 KV 搬到本地，而是在每次解码时快速形成一个紧凑的 top-k KV 缓冲区，再传给 GPU。\n4.3.3 CXL 带宽优化 单块 CXL 设备及其链路带宽仍然有限。SAC 因此采用设备感知的交错布局，把 KV Cache 分散存放在多块 CXL 设备上，使并发读取可以利用聚合带宽。\n连续 KV 条目： 0 1 2 3 4 5 6 7 设备交错映射： C0 C1 C0 C1 C0 C1 C0 C1 这种布局能够缓解单链路竞争。实验显示，两块设备交错相较单设备平均提升 9.2% 解码吞吐量，在 128K 上下文下最高提升 14.2%。\n5. 实验评估 论文在 SGLang 上集成 SAC，并使用 DeepSeek-V3.2 进行评测。主要比较对象包括：\nSAC：KV Cache 位于 CXL 解耦内存，按需读取 top-k； RDMA 基线：从远端内存池全量预取前缀 KV Cache； 本地 DRAM：KV Cache 位于计算节点本地内存，可视为性能上界之一； 仅 HBM：依赖 GPU 本地显存，低并发快，但容量受限。 评测关注吞吐量、TTFT、TBT，以及系统随并发和上下文长度增长时的扩展能力。\n5.1 端到端性能 在第一轮 prefill 中，CXL 与 RDMA 都需要把 GPU 生成的完整 KV Cache 写入内存池，因此两者性能接近。\n差异主要出现在第二轮前缀复用与解码：\nSAC 仅按需读取 top-k KV； RDMA 必须把完整前缀 KV Cache 拉回本地； 高并发时，RDMA 链路容易饱和，传输排队使 TTFT 急剧增加； RDMA 全量流量还会与 HiSparse 的 swap-in 共同争用 PCIe，进一步提高 TBT。 总体上，SAC 的平均吞吐量达到本地 DRAM 基线的约 91%，仅带来有限的 TTFT 与 TBT 增量；相较 RDMA，最高实现约 2.1 倍吞吐量、9.7 倍更低 TTFT 和 1.8 倍更低 TBT。\n5.2 吞吐量扩展能力 随着并发增加，稀疏注意力能够利用更大的 batch size 提升 GPU 利用率。SAC 的吞吐量可随并发持续扩展，而 RDMA 很快受到全量 KV 传输带宽限制。\n这说明在稀疏模型中，扩大计算规模并不一定能解决问题；如果存储后端仍采用粗粒度传输，瓶颈只会从 GPU 转移到网络。\n5.3 与非解耦基线比较 SAC 虽然把 KV Cache 放在解耦 CXL 内存中，但性能接近本地 DRAM。\n仅使用 HBM 时，低并发下吞吐量最高，因为访问路径最短；但并发提高后，HBM 容量限制了可容纳的请求数，batch size 无法继续增长。相比之下，较低层级的大容量内存能支撑更多并发请求，最终获得更高系统吞吐量。\n这项结果强调：稀疏注意力推理的关键不只是追求最低单次访问延迟，还要在容量、带宽与并发之间取得平衡。\n5.4 CXL 设备交错的影响 两块 CXL 设备交错部署始终优于单设备。平均解码吞吐量提高 9.2%，128K 上下文下峰值提升 14.2%。这证明多设备交错可以有效缓解链路竞争，也暗示增加 CXL 设备数量与聚合带宽，有望进一步缩小 SAC 与本地 DRAM 的性能差距。\n5.5 HiSparse 配置的影响 SAC 构建于 SGLang HiSparse 之上。关键参数 device_buffer_size 决定 GPU HBM 中热 KV Cache 缓冲区的大小，也直接影响从解耦内存传入 GPU 的数据量。\n论文比较了 4K 与 6K 两种配置。6K 配置的平均吞吐量比 4K 高 10.4%，原因是更大的 GPU 缓冲区降低了 KV Cache miss rate，减少 CXL 数据传输量与链路压力。\n6. 讨论 6.1 对其他稀疏模型的适用性 论文认为，SAC 的设计不局限于 DeepSeek-V3.2。只要模型具有类似的动态 top-k KV 访问模式，就能受益于 CXL 的细粒度 load/store 语义。\n论文特别讨论了 DeepSeek-V4：其混合使用压缩稀疏注意力与高度压缩注意力，并支持最长 1M token 上下文。由于其中的稀疏注意力仍具有相似的 top-k 访问模式，原则上可以直接受益于 SAC。\n6.2 内存池范式正在变化 随着模型稀疏性增强，KV Cache 访问正在从大块、连续、可提前预测的模式，转向细粒度、异构、运行时动态决定的模式。\n传统消息式协议擅长大块传输，却难以高效服务这类访问。大模型集群可能逐步转向具有内存语义的互连，让计算节点与内存节点更紧密地协作。CXL 是这一变化的重要基础，而未来统一的 scale-up 互连也可能提供更强的解耦 KV Cache 能力。\n6.3 局限与未来工作 本文实验主要集中在 DeepSeek-V3.2。对 GLM-5.1、DeepSeek-V4 等模型的完整评测仍属于未来工作。\nSAC 本身也还有优化空间，例如更充分地联合使用 HBM、DRAM 与 CXL 内存，依据访问热度组织 KV Cache，形成更精细的多级内存层次。\n此外，当前实现的数据路径仍由 CPU 从 CXL 读取后再传给 GPU。未来 GPU 直接访问 CXL 内存的硬件与软件支持成熟后，SAC 的架构还可以进一步简化。\n7. 结论 传统 RDMA 解耦 KV Cache 系统依赖全量预取，这在稠密注意力时代合理，却与稀疏注意力的细粒度访问模式不匹配。结果是大量无效传输、远端链路拥塞，以及 TB 级本地内存需求。\nSAC 使用 CXL 的低延迟、缓存行粒度 load/store 能力，在运行时只取当前层所需的 top-k KV 条目。它把解耦内存从“大块对象搬运仓库”变成可直接访问的内存层，从根本上改变了 KV Cache 的传输方式。\n在 DeepSeek-V3.2 与 SGLang 上，SAC 的性能接近本地 DRAM，并显著超过 RDMA 基线。论文给出的核心结论是：当模型架构继续向更长上下文和更高稀疏度发展时，CXL 这类内存语义互连，比传统消息式 RDMA 更适合作为下一代解耦 KV Cache 基础设施。\n附录要点 实验环境 论文附录详细列出了服务器、GPU、CPU、DRAM、CXL 内存设备、RDMA 网络，以及 SGLang、CUDA 和相关软件配置。不同后端尽量使用相同计算资源，主要改变 KV Cache 所在内存层级和访问路径，以确保比较公平。\n与 Beluga、TraCT 的区别 论文将 SAC 与已有 CXL KV Cache 系统区分开来：\nBeluga 重点解决 CXL 共享内存中的大块 KV Cache 管理、统一地址空间与容量扩展； TraCT 关注机架级 GPU 到 CXL 内存的直接 DMA 数据路径； SAC 则专门面向稀疏注意力，核心是利用缓存行粒度访问，在运行时只读取动态选出的 top-k KV。 因此，SAC 的主要创新不只是“用 CXL 存 KV Cache”，而是让 CXL 的访问粒度与稀疏注意力算法的数据选择粒度对齐。\nHiSparse 与 SAC 的关系 HiSparse 通过 GPU HBM 中的热缓存和主机内存中的大容量 KV Cache，突破单纯依赖 HBM 的容量限制。SAC 在此基础上，把原本位于本地 DRAM 的大容量后端替换为 CXL 解耦内存，并针对 CXL 带宽、设备交错和细粒度读取进行优化。\n可以把两者关系概括为：HiSparse 提供稀疏注意力的分层 KV Cache 执行框架，SAC 提供可跨计算节点扩展的 CXL 内存后端。\n扩展实验 附录还评测了不同输出长度、尾延迟与请求级吞吐量。总体趋势与正文一致：输出越长，RDMA 全量预取及 PCIe 竞争的影响越明显；SAC 依靠按需访问，在高并发和长解码场景下保持更稳定的延迟与吞吐量。\n译者点评 这篇论文最值得关注的地方，不是简单证明“CXL 比 RDMA 延迟低”，而是指出了模型算法与系统互连之间的粒度匹配问题：\n稠密注意力访问全部 KV，适合大块搬运； 稀疏注意力动态选择少量 KV，适合细粒度内存访问； 如果底层仍坚持全量搬运，算法节省下来的计算量会被数据移动重新吞掉。 SAC 代表了一种很明确的系统趋势：未来推理基础设施需要感知模型的数据访问结构。随着稀疏注意力、超长上下文和多级 KV Cache 普及，HBM、DRAM、CXL 内存和远端存储不会只按“快慢”简单分层，而会按访问热度、粒度、可预测性和共享范围共同组织。\n原文地址：https://arxiv.org/html/2606.19746v1\n","permalink":"https://yangyang233333.github.io/posts/sac-cxl-sparse-attention/","summary":"\u003cblockquote\u003e\n\u003cp\u003e本文翻译并整理自论文 \u003cstrong\u003eSAC: Disaggregated KV Cache System for Sparse Attention LLMs with CXL\u003c/strong\u003e（arXiv:2606.19746v1，2026 年 6 月 18 日）。原论文采用 CC BY 4.0 许可。为适应博客阅读，本文省略参考文献列表中的完整出版信息，但保留正文结构、关键论证、实验结果与附录要点。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e随着大模型进入长上下文与稀疏注意力时代，推理系统的主要瓶颈正从算力转向内存容量。传统 RDMA 解耦 KV Cache 系统会在解码前把完整前缀 KV Cache 搬回本地；但稀疏注意力每一步只访问少量 top-k 条目，这种“全量搬运、少量使用”的方式会同时浪费网络带宽和本地内存。\u003c/p\u003e\n\u003cp\u003eSAC 的核心思路是：使用 CXL 的低延迟、缓存行粒度 load/store 语义，把 KV Cache 留在解耦内存池中，只在计算时按需读取被选中的 top-k 条目。论文在 DeepSeek-V3.2 与 SGLang 上的实验显示，相比 RDMA 基线，SAC 可实现最高约 \u003cstrong\u003e2.1 倍吞吐量、9.7 倍更低的 TTFT，以及 1.8 倍更低的 TBT\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"摘要\"\u003e摘要\u003c/h2\u003e\n\u003cp\u003e大模型向长上下文推理扩展，使服务系统的主要瓶颈从计算能力转向内存容量。面向稠密注意力模型的传统方案通常采用基于 RDMA 的解耦内存池，在解码开始前，以粗粒度方式将整个前缀 KV Cache 从远端存储取回本地内存。\u003c/p\u003e\n\u003cp\u003e然而，这种方法并不适合新兴的稀疏注意力模型。解码过程中虽然只有很少一部分 KV 条目真正活跃，系统仍然要把完整 KV Cache 拉回本地，从而造成严重的传输瓶颈和本地内存浪费。\u003c/p\u003e","title":"SAC：面向稀疏注意力大模型的 CXL 解耦 KV Cache 系统"},{"content":"Mooncake 确实使用了协程，但它并不是把整个系统改造成“全异步架构”。截至本文分析的主分支提交 3d1665a，协程主要出现在控制面 RPC、连接管理和阻塞任务卸载等 I/O 密集路径；对外接口仍大量保留同步调用形式。\n这形成了一个很实用的分层：内部用 C++20 协程组织异步流程，边界处再按需要转换成同步返回值或回调。\n技术栈 Mooncake 的协程代码主要建立在两层库之上：\nasync_simple::coro::Lazy\u0026lt;T\u0026gt; 表示一个惰性异步任务； yalantinglibs 的 coro_rpc 和 coro_io 提供异步 RPC、网络连接与线程池调度。 典型代码形态如下：\nasync_simple::coro::Lazy\u0026lt;Result\u0026gt; request() { auto response = co_await client.send_request(...); co_return response; } Lazy\u0026lt;T\u0026gt; 创建后通常不会立刻执行。它需要被另一个协程 co_await，通过 .start(...) 启动，或者由 syncAwait(...) 驱动至完成。\n路径一：Mooncake Store 的 Master RPC mooncake-store/src/master_client.cpp 中的 MasterClient::invoke_rpc 是最清晰的例子。它的外部签名是同步的：\ntl::expected\u0026lt;ReturnType, ErrorCode\u0026gt; MasterClient::invoke_rpc(Args\u0026amp;\u0026amp;... args); 函数内部却先构造 Lazy，再连续等待两个异步阶段：\nreturn async_simple::coro::syncAwait( [\u0026amp;]() -\u0026gt; async_simple::coro::Lazy\u0026lt; tl::expected\u0026lt;ReturnType, ErrorCode\u0026gt;\u0026gt; { auto pending = co_await pool-\u0026gt;send_request( [\u0026amp;](coro_io::client_reuse_hint, coro_rpc::coro_rpc_client\u0026amp; client) { return client.send_request\u0026lt;ServiceMethod\u0026gt;(...); }); if (!pending.has_value()) { co_return tl::make_unexpected(ErrorCode::RPC_FAIL); } auto result = co_await std::move(pending.value()); co_return result-\u0026gt;result(); }()); 这里有两次 co_await：\n从客户端池取得可用连接并发出请求； 等待 RPC 响应返回。 最外层使用 syncAwait，所以调用者看到的仍是普通阻塞函数。换句话说，协程在这里主要用于简化内部异步控制流，而不是把异步类型传播给所有上层调用者。\n同步业务代码 │ ▼ invoke_rpc() │ syncAwait ▼ Lazy 协程 ├── co_await 获取连接并发送 └── co_await 等待响应 │ ▼ tl::expected 返回给调用者 这种设计减少了接口改造范围，但 syncAwait 所在线程仍会等待结果。因此，不能仅凭内部出现 co_await，就断言整个调用链不会阻塞线程。\n路径二：Transfer Engine 同时提供三种调用方式 mooncake-transfer-engine/tent/src/rpc/rpc.cpp 中的 CoroRpcAgent 更完整地展示了协程如何作为统一内核。\n核心实现 callCoroutine 返回：\nLazy\u0026lt;std::pair\u0026lt;Status, std::string\u0026gt;\u0026gt; CoroRpcAgent::callCoroutine(...); 它负责：\n从连接池租用客户端； 必要时通过 co_await client-\u0026gt;connect(...) 建立连接； 通过 co_await client-\u0026gt;call(...) 发起 RPC； 根据错误类型决定是否清理连接池； 返回状态和响应数据。 在这个协程内核之上，Mooncake 暴露了三种接口。\n同步接口 auto [status, response] = async_simple::coro::syncAwait( callCoroutine(server_addr, func_id, request)); 适合已有同步调用链，代价是当前线程需要等待。\n所有权友好的同步接口 callOwned 接收可移动的 std::string，随后将其移动进协程，避免异步生命周期依赖调用者的 string_view。\n这和 Rust 异步代码里先把 \u0026amp;[u8] 转成 Vec\u0026lt;u8\u0026gt; 的动机相似：异步任务可能暂停，任务内部持有的数据必须活得足够久。\n回调接口 callCoroutine(server_addr, func_id, request) .start([callback = std::move(callback)](auto\u0026amp;\u0026amp; result) { // 把协程结果转换为回调 }); 这里没有 syncAwait。.start(...) 启动协程，完成后执行回调，调用线程无需同步等待结果。\n因此，CoroRpcAgent 的结构可以概括为：\n┌─ syncAwait ─→ call()/callOwned() callCoroutine ─┤ └─ start(callback) ─→ callAsync() 协程承担一次 RPC 的真实状态机，同步和回调 API 只是不同适配层。这避免了为不同调用风格各写一套连接、错误处理和响应解析逻辑。\n路径三：Mooncake-PG 的异步控制面 Mooncake-PG 的 RpcClient 更进一步，直接提供 fire-and-forget 风格的异步调用。\ngetOrCreateClient 是一个协程：\nasync_simple::coro::Lazy\u0026lt;std::shared_ptr\u0026lt;coro_rpc::coro_rpc_client\u0026gt;\u0026gt; RpcClient::getOrCreateClient(...) 它先查询连接缓存；没有可用客户端时创建新客户端，并执行：\nauto error = co_await client-\u0026gt;connect(address); 异步调用路径随后等待三个阶段：\nauto client = co_await getOrCreateClient(...); auto send_result = co_await std::move(send_operation).coAwaitTry(); auto reply = co_await std::move(receive_operation).coAwaitTry(); 这里的 coAwaitTry() 很重要：异常被包装进结果对象，而不是直接穿过协程边界。代码可以分别处理连接、发送和接收错误，并保证回调只收到明确的成功或失败结果。\n任务最终通过执行器启动：\nstd::move(task).via(executor).start([](auto\u0026amp;\u0026amp;) {}); via(executor) 指定协程在哪个执行器上推进； start(...) 启动惰性任务； 空完成回调用于结束 detached 风格任务。 这说明协程本身不等于线程。协程保存暂停点和局部状态，真正执行它的仍是底层 executor 线程。\n协程也用于卸载阻塞工作 协程最怕的一件事，是在 executor 线程上直接执行耗时阻塞操作。Mooncake 使用 coro_io::post 将部分同步工作提交到线程池：\nauto result = co_await coro_io::post([\u0026amp;] { return blocking_operation(); }); 在 Transfer Engine 的 RPC 服务端，处理函数通过 coro_io::post 执行；Mooncake Store 的部分卸载读取和传输处理也使用同样模式。\n其运行过程是：\nRPC 协程运行 │ ├── post 阻塞任务到线程池 │ └── 工作线程执行同步函数 │ ├── 当前协程暂停 │ └── 任务完成后恢复协程 这不是让阻塞函数突然变成非阻塞函数，而是把阻塞从承载网络事件循环的线程转移到工作线程，避免卡住其他 RPC 协程。\n协程解决了什么问题 让异步状态机保持顺序代码形态 如果完全使用回调，一次 RPC 的连接、发送、接收和错误处理容易形成多层嵌套。co_await 让逻辑仍按执行顺序书写，同时允许等待期间让出执行权。\n复用连接和执行线程 客户端池配合协程，可以在请求等待网络时让 executor 处理其他任务，而不需要为每个并发请求创建一个线程。\n统一多种 API 风格 同一个 Lazy 内核可以通过：\nco_await 组合到另一个协程； syncAwait 暴露同步接口； .start(callback) 暴露回调接口。 这也是 Mooncake 中最值得借鉴的设计点。\n使用时需要注意的边界 syncAwait 仍然会阻塞调用线程 它只是驱动协程并同步取得结果，不会自动把同步调用者变成非阻塞调用者。判断性能模型时，必须继续向外追踪 syncAwait 运行在哪类线程上。\nLazy 是惰性的 只创建 Lazy 而不 co_await、.start() 或 syncAwait()，任务通常不会执行。返回 Lazy 的函数更像生成一个“异步执行计划”。\n参数生命周期必须覆盖暂停期 协程可能在 co_await 处暂停，因此引用或 string_view 不能指向提前销毁的数据。Mooncake 的 callOwned 通过把字符串所有权移进协程来处理这一问题。\n阻塞任务要主动隔离 协程只能在遇到可挂起操作时让出线程。如果在协程体里直接执行长时间同步 I/O 或 CPU 重任务，仍然会堵塞 executor。coro_io::post 正是为这一边界服务。\n总结 Mooncake 对协程的使用不是“所有函数都异步化”，而是围绕 I/O 路径建立了一套分层方案：\n使用 Lazy\u0026lt;T\u0026gt;、co_await 和 co_return 编写异步核心逻辑； 使用 coro_rpc 管理连接和 RPC 请求； 使用 syncAwait 兼容同步业务接口； 使用 .start(callback) 提供真正的异步回调入口； 使用 coro_io::post 将阻塞工作移出协程执行线程。 理解 Mooncake 的关键不是统计源码里有多少个 co_await，而是识别三种边界：协程与网络事件循环的边界、协程与同步调用者的边界，以及协程与阻塞线程池的边界。\n源码索引 Mooncake 主仓库 MasterClient 的协程 RPC 桥接 Transfer Engine 的 CoroRpcAgent Mooncake-PG RpcClient async_simple yalantinglibs ","permalink":"https://yangyang233333.github.io/posts/mooncake-coroutines/","summary":"\u003cp\u003eMooncake 确实使用了协程，但它并不是把整个系统改造成“全异步架构”。截至本文分析的主分支提交 \u003ccode\u003e3d1665a\u003c/code\u003e，协程主要出现在控制面 RPC、连接管理和阻塞任务卸载等 I/O 密集路径；对外接口仍大量保留同步调用形式。\u003c/p\u003e\n\u003cp\u003e这形成了一个很实用的分层：内部用 C++20 协程组织异步流程，边界处再按需要转换成同步返回值或回调。\u003c/p\u003e\n\u003ch2 id=\"技术栈\"\u003e技术栈\u003c/h2\u003e\n\u003cp\u003eMooncake 的协程代码主要建立在两层库之上：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003easync_simple::coro::Lazy\u0026lt;T\u0026gt;\u003c/code\u003e 表示一个惰性异步任务；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eyalantinglibs\u003c/code\u003e 的 \u003ccode\u003ecoro_rpc\u003c/code\u003e 和 \u003ccode\u003ecoro_io\u003c/code\u003e 提供异步 RPC、网络连接与线程池调度。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e典型代码形态如下：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003easync_simple\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003ecoro\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eLazy\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003eResult\u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e request() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eauto\u003c/span\u003e response \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eco_await\u003c/span\u003e client.send_request(...);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eco_return\u003c/span\u003e response;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eLazy\u0026lt;T\u0026gt;\u003c/code\u003e 创建后通常不会立刻执行。它需要被另一个协程 \u003ccode\u003eco_await\u003c/code\u003e，通过 \u003ccode\u003e.start(...)\u003c/code\u003e 启动，或者由 \u003ccode\u003esyncAwait(...)\u003c/code\u003e 驱动至完成。\u003c/p\u003e\n\u003ch2 id=\"路径一mooncake-store-的-master-rpc\"\u003e路径一：Mooncake Store 的 Master RPC\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003emooncake-store/src/master_client.cpp\u003c/code\u003e 中的 \u003ccode\u003eMasterClient::invoke_rpc\u003c/code\u003e 是最清晰的例子。它的外部签名是同步的：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003etl\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eexpected\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003eReturnType, ErrorCode\u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMasterClient\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003einvoke_rpc(Args\u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e... args);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e函数内部却先构造 \u003ccode\u003eLazy\u003c/code\u003e，再连续等待两个异步阶段：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e async_simple\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003ecoro\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003esyncAwait(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    [\u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e]() \u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e async_simple\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003ecoro\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eLazy\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                tl\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eexpected\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003eReturnType, ErrorCode\u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eauto\u003c/span\u003e pending \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eco_await\u003c/span\u003e pool\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003esend_request(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            [\u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e](coro_io\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eclient_reuse_hint,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                coro_rpc\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003ecoro_rpc_client\u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e client) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e client.send_request\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003eServiceMethod\u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e(...);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003epending.has_value()) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003eco_return\u003c/span\u003e tl\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003emake_unexpected(ErrorCode\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003eRPC_FAIL);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eauto\u003c/span\u003e result \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eco_await\u003c/span\u003e std\u003cspan style=\"color:#f92672\"\u003e::\u003c/span\u003emove(pending.value());\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eco_return\u003c/span\u003e result\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003eresult();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }());\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里有两次 \u003ccode\u003eco_await\u003c/code\u003e：\u003c/p\u003e","title":"Mooncake 中的协程：从 coro_rpc 到同步接口桥接"},{"content":"如果你已经知道 Transformer 的基本结构，却仍然不清楚 vLLM 一类推理系统为什么需要 KV Cache、Continuous Batching、Paged Attention，以及这些技术最终如何支撑 Agent，skyzh/tiny-llm 是一条很好的动手路径。\n它不是一个追求生产可用的迷你框架，而是一门以 Qwen3 和 MLX 为载体的系统课程：先用可读的数组运算实现模型，再围绕真实性能瓶颈写 Metal 内核，随后搭出动态批处理和分页缓存，最后把同一个本地模型接入带工具、检查点、压缩、评测和分支选择的 Agent 循环。\n截至 2026 年 8 月 27 日，项目约有 4.5k stars，使用 Apache-2.0 许可证。课程主体分为四周，已覆盖从注意力到受控 Agent 的完整主线。\n项目定位：不是“再写一个 Transformer” 很多教学项目止步于“加载权重并生成一句话”。tiny-llm 的不同之处，是把模型实现当作起点，而不是终点。\n它试图回答三个递进的问题：\n一个现代开源模型如何由矩阵运算变成文本？ 一个正确但缓慢的实现，如何通过测量和内核优化接近成熟框架？ 一个推理引擎如何进一步成为可暂停、可审计、可恢复的 Agent 执行器？ 项目选择 Apple Silicon、MLX 和 Qwen3。统一内存让 CPU、GPU 和模型权重处在相对简单的硬件环境里；MLX 提供熟悉的 Python 数组接口，同时允许下沉到 Metal；Qwen3 则带有 GQA、QK Norm、BF16 激活和 4-bit 权重，足以暴露真实推理系统中的带宽、注意力与缓存问题。\n代码结构刻意分成两套：\nsrc/tiny_llm/ 是学习者逐步补全的实现； src/tiny_llm_ref/ 是参考实现，用于测试和基准对照； src/extensions/ 与 src/extensions_ref/ 分别放置 Metal/C++ 扩展； tests_refsol/ 按周和天组织验收测试； book/ 是完整课程正文。 这种布局把“阅读—实现—测试—测量”闭成了环。\n第一周：先把 Qwen3 讲清楚 第一周从注意力开始，依次实现 RoPE、GQA、RMSNorm、MLP、Transformer Block、模型加载、解码和采样。默认模型是 Qwen/Qwen3-0.6B-MLX-4bit，但教学实现会先解量化，再以 BF16 权重和激活运行。\n这一阶段最重要的不是代码量，而是接口边界。以 Qwen3 注意力为例，学习者需要处理：\nQuery 头数与 KV 头数不同的 GQA； Q、K 投影后的独立 RMSNorm； RoPE 对位置的编码； causal mask； logits 到 token 的采样策略。 最终模型仍然很朴素：每次生成新 token 都重新处理完整上下文。它足以证明模型实现正确，也恰好暴露出下一阶段必须解决的问题——重复计算。\n第二周：从 KV Cache 到自定义 Metal 内核 第二周的主线很接近真实系统优化方法：先建立基线，再用 profiling 选择优化对象，而不是凭感觉改代码。\nKV Cache 改变了计算形态 有了 KV Cache，prefill 只执行一次，后续 decode 每步只输入一个 token。注意力不再重复计算历史 token 的 K 和 V，但模型进入了另一种瓶颈：单 token 解码中的大量线性层更像 memory-bound 的矩阵向量乘，而不是吞吐友好的大矩阵乘。\n这也是课程接下来优化 4-bit 权重投影的理由。\n优化顺序由数据决定 项目附录给出的 M4 Pro、Qwen3-4B 数据很有代表性：\n阶段 Decode 吞吐 加入 Dense KV Cache 21.73 tok/s Packed W4A16 MatVec 55.96 tok/s Fast RMSNorm 63.70 tok/s Fast RoPE 66.20 tok/s Fused SwiGLU 67.83 tok/s 其中，Packed W4A16 MatVec 一步就把解码吞吐提高约 157.5%。原因不是减少了模型参数，而是避免在每个 token 上反复展开 4-bit 权重，并让内核调度更适合 M=1 的 decode 形态。\n之后，课程继续实现或融合 RMSNorm、RoPE、SwiGLU 与短上下文 decode attention。它传达了一个很重要的工程经验：优化对象会随前一轮优化而变化。量化投影优化后，原本不起眼的逐点算子才成为值得处理的成本。\nPrefill 与 Decode 需要不同内核 同一个线性层，在 prefill 时面对多行输入，在 decode 时通常只有一行。tiny-llm 因此没有试图用一个万能内核解决全部形态，而是逐步引入：\n面向单 token 的 packed matvec； 面向矩阵输入的 SIMD/cooperative matrix prefill； 面向短行、低占用投影的 Split-K； 面向不同上下文和 query 长度的 attention dispatch guard。 这比只展示一个“快多少倍”的 kernel demo 更接近推理引擎：性能来自工作负载分类和调度边界，而不只是某段 shader。\n第三周：搭出一个 tiny vLLM 第三周从单请求转向服务系统，核心是把请求调度、缓存分配和 attention 执行联系起来。\nContinuous Batching 传统静态批处理要等整个 batch 结束后才能接纳新请求。Continuous Batching 则在每轮 decode 后移除已完成请求，并把等待请求补入空位。\n项目中的 TinyLlmBatch 维护请求状态、当前位置、生成结果和 slot；调度器每轮执行后更新 batch，而不是把批次视为不可变张量。模型侧接收的是动态 slot 映射，因此不同请求可以在同一轮中处于 prefill 或 decode 阶段。\nChunked Prefill 长 prompt 如果一次性 prefill，会阻塞已经在 decode 的短请求。Chunked Prefill 把长 prompt 切成有界块，让调度器可以在轮次之间重新安排工作，从而在吞吐和交互延迟之间取得更好的平衡。\n这项技术的价值不在于减少总计算量，而在于降低 head-of-line blocking。课程把它放在 Continuous Batching 之后，顺序非常自然：只有调度器能逐轮重排请求，切块才真正有意义。\nPaged KV Cache Dense KV Cache 往往按最大上下文连续预留空间，容易产生内部碎片，也不利于请求动态增长。tiny-llm 用固定大小 page 组成共享池：\n请求 A: [page 7] -\u0026gt; [page 2] -\u0026gt; [page 11] 请求 B: [page 4] -\u0026gt; [page 9] 共享池: 按需分配、完成后归还 TinyKvPagedPool 管理物理 page，TinyKvPagedCache 保存单请求的逻辑序列与 page table。随后 attention 内核不再要求先把 KV 拼成连续张量，而是直接按照 page table 读取离散页面。这就是从“分页存储”走向“Paged Attention”的关键一步。\n课程又进一步实现 Paged FlashAttention，把分页寻址与在线 softmax 放到同一内核中，避免为完整 attention score 矩阵分配中间存储。\n此外，第三周还提供 Speculative Decoding 与 MoE 作为可选章节。它们不是孤立的高级主题：前者复用草稿模型减少主模型步数，后者引入专家路由和稀疏计算，都建立在前面清晰的模型与调度边界之上。\n第四周：推理系统如何变成 Agent 第四周是这个项目最有辨识度的部分。许多课程在实现 Paged Attention 后结束，tiny-llm 则继续追问：当模型能够调用工具、修改文件、执行命令时，系统需要哪些新语义？\n答案不是简单地套一个 while 循环。\n可验证的工具协议 Agent 输出必须解析为结构化的 FinalAction 或 ToolAction。工具执行受 ToolPolicy 和工作区边界约束，文件修改需要审批，命令必须精确匹配配置。系统记录的不只是聊天消息，还包括工具动作、结果和状态事件。\n这让失败是显式状态，而不是被隐藏在自然语言里。\nCheckpoint、恢复与压缩 Agent 执行具有副作用。恢复时如果简单重放历史，就可能再次写文件或重复执行命令。项目因此在“完整工具观察边界”创建检查点，保存模型状态、消息和执行记录；恢复使用新模型实例，但不会重放已经完成的副作用。\n长会话还会遇到上下文膨胀。课程将旧交互压缩为模型可见摘要，同时保留精确 receipt，记录动作、结果与变更产物。模型上下文可以缩短，审计事实不能丢失。\n分支不是回滚世界 课程支持从同一个 tokenizer/KV checkpoint 派生多个 continuation，给每个分支加入不同 steering 指令，再用统一评测器选择通过者。\n值得注意的是，每个分支使用隔离的副作用工作区。所谓 fork 只是复用模型前缀，不是假装现实世界的修改可以自动回滚。这一点比“让模型多生成几个答案再投票”严谨得多。\n有界工具证据 工具输出可能大到无法安全塞进 prompt。项目把原始字节存到 ArtifactStore，只向模型暴露身份、摘要、长度和头尾片段；模型需要更多内容时，再按明确字节范围读取。\n这种设计同时解决了上下文预算、可审计性和内容完整性问题，也把推理层的 KV 前缀复用与 Agent 层的证据管理连接起来。\n为什么这套课程设计有效 1. 每一周都保留上一周的因果链 KV Cache 不是凭空出现，而是为消除 Week 1 的重复计算；量化 matvec 是 profiling 选出的瓶颈；Paged KV Cache 是动态批处理后的内存问题；Agent checkpoint 又复用了前面已经建立的生成状态概念。\n学习者看到的不只是功能列表，而是系统为何逐步长成现在的形状。\n2. 正确性与性能证据分开 测试负责验证数值、边界和协议；benchmark 负责说明性能；书中还明确区分 CI 持续验证与单机研究数据。这避免了常见的错误：用一次微基准证明整个模型更快，或者把固定硬件上的数据包装成普遍结论。\n3. 学习者实现与参考实现并存 双包结构既方便课程挖空练习，也能让测试和 benchmark 始终有可信对照。按天复制测试的工具则保证学习路径是渐进的，不要求一开始就通过整个仓库。\n4. 把限制写进课程 项目明确说明未覆盖量化 KV Cache、跨请求 prefix caching、微调和更多长上下文技术；Week 2/3 的性能结论也限定在具体芯片、模型与 MLX 版本。对系统课程来说，边界声明和代码同样重要。\n适合谁，不适合谁 它最适合以下读者：\n理解 Transformer，但缺少推理系统实现经验； 使用 Apple Silicon，希望在本地学习 GPU kernel； 想理解 vLLM 的设计动机，而不是直接阅读大型生产代码库； 对 Agent 的可靠执行、恢复和评测机制感兴趣。 它不适合直接用作生产服务，也不是 CUDA/vLLM 源码的替代品。课程依赖 macOS Apple Silicon，性能数据不能直接外推到 NVIDIA GPU；实现也刻意省略了分布式推理、跨请求前缀缓存、完整服务协议和生产级故障处理。\n建议的学习方式 不要先通读整本书再开始写代码。更有效的节奏是：\n先完成 Week 1，用 0.6B 模型跑通生成； 每做一项 Week 2 优化，先记录基线，再看 profile 是否支持下一步； 在 Week 3 画出请求状态、slot、page table 和物理 page 的关系； 到 Week 4 时，把“模型状态”和“外部副作用状态”分开思考； 最后再读 performance appendix，检查自己的结果是否具有相同趋势，而不是追求完全相同的数字。 总结 tiny-llm 的价值不在于代码“tiny”，而在于学习路径足够完整：它用一个小而可运行的系统串起模型结构、硬件内核、服务调度、缓存管理和 Agent 可靠性。\n如果你想理解现代 LLM 推理系统各组件之间的因果关系，而不只是记住 KV Cache、Paged Attention、Continuous Batching 这些名词，这个项目值得按课程顺序亲手完成。\n项目地址：https://github.com/skyzh/tiny-llm\n课程文档：https://skyzh.github.io/tiny-llm/\n","permalink":"https://yangyang233333.github.io/posts/tiny-llm-source-reading/","summary":"\u003cp\u003e如果你已经知道 Transformer 的基本结构，却仍然不清楚 vLLM 一类推理系统为什么需要 KV Cache、Continuous Batching、Paged Attention，以及这些技术最终如何支撑 Agent，\u003ccode\u003eskyzh/tiny-llm\u003c/code\u003e 是一条很好的动手路径。\u003c/p\u003e\n\u003cp\u003e它不是一个追求生产可用的迷你框架，而是一门以 Qwen3 和 MLX 为载体的系统课程：先用可读的数组运算实现模型，再围绕真实性能瓶颈写 Metal 内核，随后搭出动态批处理和分页缓存，最后把同一个本地模型接入带工具、检查点、压缩、评测和分支选择的 Agent 循环。\u003c/p\u003e\n\u003cp\u003e截至 2026 年 8 月 27 日，项目约有 4.5k stars，使用 Apache-2.0 许可证。课程主体分为四周，已覆盖从注意力到受控 Agent 的完整主线。\u003c/p\u003e\n\u003ch2 id=\"项目定位不是再写一个-transformer\"\u003e项目定位：不是“再写一个 Transformer”\u003c/h2\u003e\n\u003cp\u003e很多教学项目止步于“加载权重并生成一句话”。tiny-llm 的不同之处，是把模型实现当作起点，而不是终点。\u003c/p\u003e\n\u003cp\u003e它试图回答三个递进的问题：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e一个现代开源模型如何由矩阵运算变成文本？\u003c/li\u003e\n\u003cli\u003e一个正确但缓慢的实现，如何通过测量和内核优化接近成熟框架？\u003c/li\u003e\n\u003cli\u003e一个推理引擎如何进一步成为可暂停、可审计、可恢复的 Agent 执行器？\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e项目选择 Apple Silicon、MLX 和 Qwen3。统一内存让 CPU、GPU 和模型权重处在相对简单的硬件环境里；MLX 提供熟悉的 Python 数组接口，同时允许下沉到 Metal；Qwen3 则带有 GQA、QK Norm、BF16 激活和 4-bit 权重，足以暴露真实推理系统中的带宽、注意力与缓存问题。\u003c/p\u003e\n\u003cp\u003e代码结构刻意分成两套：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003esrc/tiny_llm/\u003c/code\u003e 是学习者逐步补全的实现；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esrc/tiny_llm_ref/\u003c/code\u003e 是参考实现，用于测试和基准对照；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esrc/extensions/\u003c/code\u003e 与 \u003ccode\u003esrc/extensions_ref/\u003c/code\u003e 分别放置 Metal/C++ 扩展；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003etests_refsol/\u003c/code\u003e 按周和天组织验收测试；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ebook/\u003c/code\u003e 是完整课程正文。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这种布局把“阅读—实现—测试—测量”闭成了环。\u003c/p\u003e","title":"tiny-llm：在 Apple Silicon 上从 Transformer 算子走到推理系统与 Agent"},{"content":"GEMM 与 GEMV 看起来都只是乘加：\nGEMM: C = alpha * A * B + beta * C GEMV: y = alpha * A * x + beta * y 但二者的最佳实现完全不同。GEMM 可以反复复用矩阵块，通常有机会逼近计算峰值；GEMV 中矩阵元素通常只读一次，往往受内存带宽限制。高性能算子的第一步不是写 SIMD 或 CUDA，而是先判断瓶颈究竟在哪里。\n本文给出一条从正确基线走向高性能内核的完整路线。重点不是某段固定代码，而是每一步为什么有效、如何验证，以及何时应该停止优化。\n一、先建立性能上限 1. 计算量 对于矩阵尺寸：\nA: M × K B: K × N C: M × N GEMM 约执行：\nFLOPs = 2 * M * N * K GEMV 是 N = 1 的特殊形态：\nFLOPs = 2 * M * K 乘法和加法各算一次浮点操作。\n2. 算术强度 Roofline 模型使用算术强度判断内核倾向于计算受限还是带宽受限：\nArithmetic Intensity = FLOPs / Bytes moved Attainable Performance = min(Peak FLOPS, Bandwidth × Arithmetic Intensity) 理想 GEMM 中，A、B、C 从主存各读写一次：\nAI_GEMM ≈ 2MNK / element_size(MK + KN + MN) 当 M、N、K 同时增大，计算量按三次方增长，数据量按二次方增长，因此算术强度持续提高。\n以方阵 M=N=K=L、FP32 为例：\nAI_GEMM ≈ 2L³ / (12L²) = L / 6 FLOP/Byte GEMV 则不同。矩阵 A 的 MK 个元素通常只能贡献一次乘加：\nAI_GEMV ≈ 2MK / (4MK) ≈ 0.5 FLOP/Byte 即使忽略向量和输出流量，FP32 GEMV 的算术强度也只有约 0.5。若显存带宽为 2 TB/s，其 Roofline 上限约为 1 TFLOP/s，远低于现代 GPU 的矩阵计算峰值。\n结论是：\nGEMM 的核心任务是制造数据复用，让计算单元持续工作； GEMV 的核心任务是把每个字节尽可能高效地搬进来，并减少额外流量。 二、第零步：建立可信的正确性与 Benchmark 性能优化最容易犯的错误，是测量一个错误结果或错误范围。\n1. 保留参考实现 先写最朴素、最容易验证的三重循环：\nfor (int m = 0; m \u0026lt; M; ++m) { for (int n = 0; n \u0026lt; N; ++n) { float acc = 0.0f; for (int k = 0; k \u0026lt; K; ++k) { acc += A[m * K + k] * B[k * N + n]; } C[m * N + n] = acc; } } 它不快，但适合作为数值参考。测试至少应覆盖：\n非 tile 整数倍尺寸； M、N 或 K 为 1； 转置与非转置布局； alpha、beta 和累加路径； FP16/BF16 输入、FP32 累加； NaN、Inf、极大值和极小值； 不同 leading dimension 和非连续输入。 2. 正确计时 GPU kernel launch 是异步的，计时必须使用 CUDA Event 或在边界同步。还要区分：\n内核时间 端到端时间 = 数据准备 + 拷贝 + 内核 + 同步 每个尺寸先 warmup，多次重复并报告中位数或分位数。不要只测一个规则方阵；生产负载中的 skinny GEMM、small-M GEMM 和 GEMV 往往更重要。\n3. 对比成熟库 用同精度、同布局、同 epilogue 的 BLAS 结果作为参考：CPU 对比 BLIS、OpenBLAS 或 oneDNN，GPU 对比 cuBLAS/cuBLASLt。目标不一定是击败库，而是判断自定义 kernel 距离合理上限还有多远。\n三、第一步：修正循环顺序和数据布局 朴素 GEMM 的性能首先取决于内存访问顺序。假设矩阵按 row-major 保存，B[k][n] 在 n 方向连续。\nm-n-k 循环每计算一个 C 元素，都沿 K 跳跃读取 B；更适合缓存的形式是 m-k-n：\nfor (int m = 0; m \u0026lt; M; ++m) { for (int k = 0; k \u0026lt; K; ++k) { float a = A[m * K + k]; for (int n = 0; n \u0026lt; N; ++n) { C[m * N + n] += a * B[k * N + n]; } } } 现在 B 和 C 都在最内层连续访问，A 的一个值被整行复用。编译器也更容易对 n 循环自动向量化。\n如果业务反复使用同一个权重矩阵，预先转置或 pack 的成本可以被多次调用摊销。高性能库通常不会直接在原始矩阵上完成全部计算，而是把数据变换成适合微内核访问的面板布局。\n四、第二步：分块，让工作集进入缓存 仅调整循环顺序仍会在矩阵较大时不断逐出缓存。Blocking 将问题拆成小块：\nfor jc in N with block NC for pc in K with block KC pack B[pc:pc+KC, jc:jc+NC] for ic in M with block MC pack A[ic:ic+MC, pc:pc+KC] macro_kernel(packed_A, packed_B, C_block) 典型目标是：\nB 的 KC × NC panel 驻留 LLC； A 的 MC × KC panel 驻留较近缓存； 微内核使用的 A、B 小片段来自 L1； C 的 MR × NR tile 尽量驻留寄存器。 参数不是越大越好。一个实用约束是：工作集加上其他活跃数据，应明显小于目标缓存容量，并考虑缓存组冲突、TLB 和多线程共享。\n五、第三步：设计寄存器微内核 CPU GEMM 的核心不是外层循环，而是计算 MR × NR 输出 tile 的微内核：\nC[MR × NR] += A[MR × K] × B[K × NR] 微内核沿 K 循环，每轮：\n加载 A 的若干标量或向量； 加载 B 的一个 SIMD 向量； 用 FMA 更新多个 C 累加器； K 结束后一次性写回 C。 以 AVX-512 为例，一个向量保存 16 个 FP32。若 NR=16，每个 A 标量可广播后与一整行 B 做 FMA。多个 MR 行并行累加，既复用 B，又增加独立指令链以隐藏 FMA 延迟。\n微内核尺寸受寄存器数量约束：\naccumulators + A operands + B operands + addresses \u0026lt; architectural registers MR × NR 太小，数据复用不足；太大则寄存器溢出到栈，性能断崖式下降。\n高性能 CPU GEMM 因此形成五层循环与一个架构专用微内核。BLIS 将 micro-kernel 作为清晰接口，外围 packing 与 blocking 基本保持通用。\n六、第四步：并行化，但不要破坏局部性 GEMM 可以沿 M、N 或 batch 维度并行。线程划分要尽量满足：\n每个线程写不同 C tile，避免 false sharing； 共享只读 packed B，减少重复 packing； NUMA 环境中让内存靠近执行线程； 小矩阵不要启动过多线程； 避免线程数、BLAS 内部线程和上层并发三重过度订阅。 大矩阵通常适合二维划分输出矩阵，小 M 或小 N 时应选择仍有足够并行度的方向。Batch 中存在大量小矩阵时，跨 batch 并行往往优于拆分单个矩阵。\n七、GPU 第一步：合并访存与 Shared Memory Tiling 朴素 CUDA GEMM 常让一个线程计算一个 C 元素。虽然简单，但每个输出都从全局内存重复读取 A 行和 B 列。\n标准改进是让一个 thread block 负责 BM × BN 输出 tile，并分段遍历 K：\nfor k_tile in K: global -\u0026gt; shared: A[BM × BK] global -\u0026gt; shared: B[BK × BN] synchronize shared -\u0026gt; registers: accumulate C tile synchronize 一个 A 元素可被 BN 方向多个输出复用，一个 B 元素可被 BM 方向多个输出复用。理想情况下，全局内存流量相比朴素实现下降约一个 tile 维度。\n加载阶段必须满足：\nwarp 中线程访问连续地址，形成 coalesced transaction； 向量化加载满足地址对齐； shared-memory layout 避免 bank conflict； 边界 tile 使用 predicate，而不是让整个 warp 严重分歧。 八、GPU 第二步：线程级分块与寄存器复用 若每个线程只计算一个 C 元素，从 shared memory 读取数据的次数仍然过多。让每个线程计算 TM × TN 小 tile，可把 A、B 片段装入寄存器后重复使用。\n层次变成：\nCTA tile : BM × BN × BK Warp tile : WM × WN × WK Thread tile: TM × TN 每向下一级，数据从更慢、更大的存储移动到更快、更小的存储：\nHBM -\u0026gt; L2 -\u0026gt; Shared Memory -\u0026gt; Registers -\u0026gt; FMA/Tensor Core 真正的优化目标不是“少一次 load”，而是让一个字节在离计算单元最近的位置被消费尽可能多次。\n九、GPU 第三步：流水线隐藏访存延迟 完成 tiling 后，加载下一块数据与计算当前块仍可能串行：\nload tile 0 -\u0026gt; compute tile 0 -\u0026gt; load tile 1 -\u0026gt; compute tile 1 双缓冲或多 stage pipeline 将其改成：\nload tile 0 compute tile 0 || load tile 1 compute tile 1 || load tile 2 CUDA 的异步 global-to-shared copy 可减少中间寄存器使用，并允许数据搬运与计算重叠。CUTLASS 的 GEMM mainloop 正是围绕多级 pipeline 组织，较新架构还会使用 TMA、warp specialization 和更深的 producer-consumer 管线。\nstage 数并非越多越好。更多 stage 会消耗更多 shared memory，降低 occupancy。应以“是否足以覆盖内存延迟”为目标，而不是追求最大缓冲深度。\n十、GPU 第四步：使用 Tensor Core FP16、BF16、TF32、FP8 或部分整数 GEMM 应优先使用 Tensor Core 指令。Tensor Core 以小矩阵片段执行 MMA：\nD = A × B + C 高性能实现需要同时满足：\ntile 尺寸符合 MMA 指令形状； shared-memory layout 适合 warp/warpgroup 装载； K 维和地址满足对齐要求； 使用足够大的 tile 摊销指令与调度开销； 累加精度符合数值要求。 直接写 PTX 通常不是第一选择。更现实的开发路径是：\n用 cuBLASLt 建立性能上限； 用 CUTLASS 组合 tile、pipeline 与 epilogue； 用 Triton 快速搜索 block size、warp 数和 stage 数； 只有框架无法表达关键优化时，再编写更底层内核。 十一、Epilogue Fusion 往往比继续抠 GEMM 更值 真实模型很少只计算裸 A × B。Linear 层后面可能还有 bias、activation、residual、quantization 或 gated operation。\n若每步都独立启动 kernel：\nGEMM -\u0026gt; write C -\u0026gt; read C -\u0026gt; bias -\u0026gt; write -\u0026gt; read -\u0026gt; activation -\u0026gt; write 融合 epilogue 可以让累加结果仍在寄存器时完成后处理，只写回一次：\naccumulator -\u0026gt; bias -\u0026gt; activation -\u0026gt; cast -\u0026gt; store 这不仅减少 HBM 流量，也减少 launch overhead。对于中小 GEMM，融合带来的端到端收益可能高于进一步提高主循环的峰值 FLOPS。\ncuBLASLt 和 CUTLASS 都把 epilogue 视为一等能力；自定义 kernel 也应从完整算子边界衡量性能。\n十二、GEMV 必须走另一条优化路线 把高性能 GEMM kernel 的 N 设成 1，通常得不到高性能 GEMV。原因是大量 tiling 和同步开销无法通过数据复用摊销。\n1. 优先保证连续读取 每个 warp 或线程块处理矩阵的一段连续区域，使用宽加载读取 A。向量 x 应尽量驻留缓存、constant cache 或 shared memory，但不要为了复制一个很大的 x 引入过高同步成本。\n2. 做好归约 常见映射是多个线程共同计算一行输出：\nthread 0: a[0] * x[0] + a[32] * x[32] + ... thread 1: a[1] * x[1] + a[33] * x[33] + ... ... warp reduce -\u0026gt; y[row] 优先使用 warp shuffle 完成寄存器归约，跨 warp 时再使用少量 shared memory。不要在每个元素上做 atomic add。\n3. 增加批量，恢复矩阵复用 推理 Decode 中常见的 matrix-vector，实际可以通过连续批处理变成 matrix-matrix：多个请求或多个 token 同时计算，让权重被复用。\n因此优化 GEMV 的最高杠杆有时不在 kernel 内，而在调度层：\n单请求 GEMV -\u0026gt; 多请求 batched GEMV -\u0026gt; small-N GEMM 只要延迟预算允许，提高 batch size 通常能显著提升权重带宽利用率和 Tensor Core 使用率。\n4. 压缩权重减少字节数 GEMV 受带宽限制，FP16、INT8、FP8 或 INT4 权重量化可直接减少主存流量。但反量化必须与乘加融合，否则中间张量写回会抵消收益。\n一个理想的 weight-only 路径是：\nload packed quantized weights -\u0026gt; register 中解包/反量化 -\u0026gt; 与 activation 相乘并累加 -\u0026gt; 一次写回输出 GEMV 的优化指标应优先看有效带宽，而不是峰值 FLOPS。\n十三、处理不规则尺寸 只优化 4096 的整数倍会制造漂亮但无用的 benchmark。生产矩阵尺寸来自 hidden size、head size、MoE expert、LoRA rank 和 batch，形状差异很大。\n常用方法包括：\n主 kernel 处理完整 tile，专门的 residue kernel 处理边界； 使用 predicated load/store； 为 small-M、small-N、split-K 和 batched 场景准备不同配置； 对固定模型尺寸离线 autotune； 将 layout、dtype、alignment 和 epilogue 纳入 dispatch key。 Split-K 可让多个 CTA 并行处理同一个输出 tile 的不同 K 区间，在 M、N 很小而 K 很大时增加并行度；代价是额外归约或 atomic 写入。\n十四、Autotune 应搜索什么 一个通用 GEMM 配置至少包括：\nBM, BN, BK warps per CTA pipeline stages instruction shape swizzle / cluster shape split-K factor 最佳配置与 GPU 架构、dtype、矩阵形状、布局和 epilogue 都相关。Triton 教程通过多个 config 进行自动调优；cuBLASLt 提供 heuristic 和算法选择；CUTLASS profiler 可枚举 kernel 组合。\nAutotune 不是无限搜索。应先用硬件约束剪枝：\nshared memory 不得超限； 寄存器压力不能导致严重 spill； CTA 数需足以占满设备； tile 长宽应匹配矩阵形状； pipeline 深度应与计算/访存比例匹配。 生产系统还需要缓存调优结果，避免首次请求承担长时间搜索。\n十五、用 profiler 判断下一步，而不是凭感觉 优化循环应是：\n测量 -\u0026gt; 提出瓶颈假设 -\u0026gt; 只改一个变量 -\u0026gt; 再测量 GPU 上至少关注：\n指标 暗示的问题 DRAM throughput 接近峰值 带宽受限，减少字节或提升合并访问 Tensor/FMA pipe 利用率低 tile、并行度或指令选择不足 Long scoreboard stall 高 全局内存延迟未被覆盖 Short scoreboard stall 高 shared memory 依赖或 bank conflict Register spill thread tile 或 pipeline 过大 Occupancy 低 寄存器/shared memory/CTA 形状受限 Launch 数量多 需要 fusion 或 persistent kernel CPU 上对应检查 IPC、SIMD 利用率、缓存 miss、TLB miss、内存带宽、NUMA remote access 与线程扩展效率。\nOccupancy 不是最终目标。一个使用更多寄存器、occupancy 较低但数据复用更好的 GEMM，可能明显更快。最终指标始终是实际形状上的延迟或吞吐。\n十六、一条实际可执行的优化顺序 建议按以下顺序推进，避免过早进入汇编细节：\n写正确参考实现，建立随机与边界测试； 用 Roofline 判断计算或带宽上限； 修正布局、循环顺序和连续访问； 做 cache/shared-memory blocking； 引入寄存器 tile 与 SIMD/FMA； 调整线程、warp 和 CTA 映射； 使用双缓冲或异步流水线； 切换 Tensor Core 或目标 ISA 的矩阵指令； 融合 bias、activation、quantization 等 epilogue； 为特殊形状添加 split-K、batched 或 GEMV kernel； Autotune 并按形状 dispatch； 用端到端工作负载验证，而不是只看方阵峰值。 每一步都应同时检查正确性、性能、资源占用和适用范围。若自定义 kernel 只在一个尺寸领先，却在其他尺寸严重回退，就需要调度器，而不是宣称得到“通用最优实现”。\n十七、极致性能的真正含义 高性能 GEMM 的本质是构造分层数据复用：\n主存中的一个 tile -\u0026gt; 被一个 CTA 复用 -\u0026gt; 被多个 warp 复用 -\u0026gt; 被多个线程寄存器累加器复用 -\u0026gt; 由矩阵指令一次完成大量 FMA 高性能 GEMV 的本质则是承认复用有限：\n减少权重字节数 + 连续宽加载 + 低成本归约 + 融合后处理 + 尽可能通过 batching 转回 GEMM 所谓“优化到极致”，不是把一个 kernel 写得最复杂，而是逼近该形状、精度和完整算子链的真实 Roofline。先减少不必要的数据移动，再增加有效并行，最后才是手工指令级优化。\n参考资料 NVIDIA CUDA C++ Programming Guide：Shared Memory、异步拷贝与 Tensor Core 编程模型 NVIDIA CUTLASS Documentation：Hierarchical GEMM、Pipelining 与 Collective Mainloop NVIDIA cuBLASLt Documentation：Matmul heuristic、算法选择与 epilogue fusion Triton Matrix Multiplication Tutorial：Block-level GEMM 与 autotune BLIS：GotoBLAS 风格分块、packing 与 micro-kernel 架构 NVIDIA Nsight Compute Profiling Guide：Roofline 与 GPU 性能指标 ","permalink":"https://yangyang233333.github.io/posts/gemm-gemv-optimization/","summary":"\u003cp\u003eGEMM 与 GEMV 看起来都只是乘加：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGEMM: C = alpha * A * B + beta * C\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGEMV: y = alpha * A * x + beta * y\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e但二者的最佳实现完全不同。GEMM 可以反复复用矩阵块，通常有机会逼近计算峰值；GEMV 中矩阵元素通常只读一次，往往受内存带宽限制。高性能算子的第一步不是写 SIMD 或 CUDA，而是先判断瓶颈究竟在哪里。\u003c/p\u003e\n\u003cp\u003e本文给出一条从正确基线走向高性能内核的完整路线。重点不是某段固定代码，而是每一步为什么有效、如何验证，以及何时应该停止优化。\u003c/p\u003e\n\u003ch2 id=\"一先建立性能上限\"\u003e一、先建立性能上限\u003c/h2\u003e\n\u003ch3 id=\"1-计算量\"\u003e1. 计算量\u003c/h3\u003e\n\u003cp\u003e对于矩阵尺寸：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eA: M × K\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eB: K × N\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eC: M × N\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eGEMM 约执行：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eFLOPs = 2 * M * N * K\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eGEMV 是 \u003ccode\u003eN = 1\u003c/code\u003e 的特殊形态：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eFLOPs = 2 * M * K\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e乘法和加法各算一次浮点操作。\u003c/p\u003e","title":"从朴素循环到硬件极限：GEMM / GEMV 高性能算子优化指南"},{"content":"Dynamo 的推理组件能够跨进程、跨节点组合，核心依赖 lib/runtime。这一层不理解模型，也不关心 token 的语义；它提供的是一套分布式组件模型：服务如何命名、实例如何注册、客户端如何发现、请求如何流式传输。\n本文基于 Dynamo 提交 27f09d5。\n一、运行时的核心抽象 源码从 DistributedRuntime 开始。它持有运行时配置、服务发现实现、网络管理器和关闭信号，并向上提供 Namespace。\n对象关系可以简化为：\nDistributedRuntime └── Namespace(\u0026#34;dynamo\u0026#34;) ├── Component(\u0026#34;frontend\u0026#34;) │ └── Endpoint(\u0026#34;generate\u0026#34;) ├── Component(\u0026#34;router\u0026#34;) │ └── Endpoint(\u0026#34;generate\u0026#34;) └── Component(\u0026#34;worker\u0026#34;) └── Endpoint(\u0026#34;generate\u0026#34;) × N instances 这四层各有不同职责：\nRuntime：进程级资源和生命周期； Namespace：隔离一组服务； Component：表示逻辑服务角色； Endpoint：表示可调用接口及其多个实例。 Endpoint 名字相同不代表只有一个服务器。每个 Worker 可注册自己的 instance，客户端通过 discovery 得到动态实例集合。\n二、服务发现保存什么 lib/runtime/src/discovery 定义了发现层。一个 Endpoint 注册时，系统不仅记录地址，还附带实例、组件和元数据。客户端查询的关键维度是：\n(namespace, component, endpoint) -\u0026gt; [instance...] 在裸机模式中，注册与租约可由 etcd 支持；Kubernetes 模式使用 DynamoWorkerMetadata CRD 和 EndpointSlice。运行时把发现后端抽象掉，上层 Router 不需要分别实现 etcd watcher 与 Kubernetes watcher。\n发现层还必须处理动态变化：Worker 启动、退出、租约失效或滚动升级。Component::list_instances 从 discovery 拉取 Endpoint 实例并排序，给更高层建立稳定视图。\n服务发现解决“现在有哪些目标”，不负责传输每个生成 token。\n三、Endpoint 不只是一个地址 component/endpoint.rs 将逻辑 Endpoint 与本地服务、远程客户端和指标注册关联起来。调用方可通过同一个 Endpoint：\n注册本地 handler； 建立远程 client； 观察实例变化； 选择单个实例或进行负载分发； 收集 Endpoint 维度指标。 这种设计使组件代码不依赖具体传输方式。上层看到的是异步请求流，下层可以选择直接 TCP、NATS 或其他实现。\n值得注意的是，生成请求天然是流式 RPC：输入通常是单个请求，输出是一串增量 token 或事件。因此 Runtime 的 pipeline 抽象围绕 stream 而非普通 request-response 展开。\n四、Pipeline 如何把本地与远程统一 lib/runtime/src/pipeline 把处理过程表示成可连接节点。典型路径如下：\nIngress -\u0026gt; Deserialize -\u0026gt; Handler -\u0026gt; Serialize -\u0026gt; Egress 本地组件可以直接连接内存节点；跨进程时，网络 ingress/egress 将同样的流编码并传输。上层逻辑不需要因部署拓扑改变而重写。\n网络目录分成几个层次：\n模块 作用 network/manager.rs 管理监听器、连接和共享网络资源 network/ingress 接收远端请求并转入本地 pipeline network/egress 把请求发送到选定实例 network/codec 编解码消息和流帧 network/tcp TCP 客户端与服务端实现 默认请求平面使用 TCP，避免所有 token 都绕经中心消息系统。NATS 仍可作为可配置传输和事件通道，但已不是理解主请求链的唯一入口。\n五、流式响应如何保持上下文 LLM 请求的特殊之处在于响应会持续较长时间。Dynamo 的 pipeline context 随流传播，用于携带请求标识、取消信号和元数据。\n这使几个行为成为可能：\n客户端断开时向下游传播取消； Router 在转发时保留请求上下文； Worker 逐块返回结果，而不等待完整生成； 指标能够把排队、首 token 和生成阶段关联到同一请求。 如果只把 Runtime 看成 RPC 库，就难以解释其大量 context、stream 与 cancellation 代码。它实际针对的是长生命周期、可取消、持续回传的推理任务。\n六、控制路径为什么没有侵入模型执行 Runtime 只处理“组件之间如何通信”，模型执行仍由后端完成。一个自定义 Worker 只需：\n创建 Runtime -\u0026gt; 取得 Namespace -\u0026gt; 创建 Component 与 Endpoint -\u0026gt; 注册异步 handler -\u0026gt; 将 Endpoint 暴露到 discovery Frontend 和 Router 通过同一套发现与 client API 调用它。后端可以是 Python 推理框架，也可以是 Rust mocker；运行时无须知道张量布局。\n这一边界也解释了 Dynamo 的双语言设计。Rust 提供稳定、高并发的数据通路，Python 负责把不同推理框架的启动参数和生命周期接到 Endpoint 上。\n七、故障语义来自两层 运行时故障大致分成两类：\n发现层故障：实例列表过期、租约失效、注册中心不可用； 请求层故障：连接失败、流中断、目标退出或响应超时。 二者不能混为一谈。一个实例仍出现在 discovery 中，不代表 TCP 连接一定可用；一次连接失败，也不意味着应立刻从全局注册表删除实例。\n因此生产环境需要同时观测：\nEndpoint 实例数量与变更； 连接建立和重连； 请求排队、处理与取消； 每个组件的健康状态； Runtime 与后端各自的错误。 八、源码阅读结论 lib/runtime 的价值不是提供新颖的 RPC 语法，而是给推理系统建立统一的组件生命周期和流式请求模型。它让同一套 Frontend、Router 和 Worker 既能在单机进程中组合，也能在 Kubernetes 上拆成多个服务。\n下一篇进入 lib/kv-router：当 Runtime 已经知道“有哪些 Worker”后，Router 如何知道“哪个 Worker 最适合当前 token 前缀”。\n参考源码 lib/runtime/src/distributed.rs lib/runtime/src/component/component.rs lib/runtime/src/component/endpoint.rs lib/runtime/src/discovery/mod.rs lib/runtime/src/pipeline.rs lib/runtime/src/pipeline/network/manager.rs lib/runtime/src/pipeline/network/ingress/unified_server.rs lib/runtime/src/pipeline/network/egress/unified_client.rs ","permalink":"https://yangyang233333.github.io/posts/nvidia-dynamo-runtime-source-reading/","summary":"\u003cp\u003eDynamo 的推理组件能够跨进程、跨节点组合，核心依赖 \u003ccode\u003elib/runtime\u003c/code\u003e。这一层不理解模型，也不关心 token 的语义；它提供的是一套分布式组件模型：服务如何命名、实例如何注册、客户端如何发现、请求如何流式传输。\u003c/p\u003e\n\u003cp\u003e本文基于 Dynamo 提交 \u003ccode\u003e27f09d5\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一运行时的核心抽象\"\u003e一、运行时的核心抽象\u003c/h2\u003e\n\u003cp\u003e源码从 \u003ccode\u003eDistributedRuntime\u003c/code\u003e 开始。它持有运行时配置、服务发现实现、网络管理器和关闭信号，并向上提供 Namespace。\u003c/p\u003e\n\u003cp\u003e对象关系可以简化为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eDistributedRuntime\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e└── Namespace(\u0026#34;dynamo\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    ├── Component(\u0026#34;frontend\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    │   └── Endpoint(\u0026#34;generate\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    ├── Component(\u0026#34;router\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    │   └── Endpoint(\u0026#34;generate\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    └── Component(\u0026#34;worker\u0026#34;)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── Endpoint(\u0026#34;generate\u0026#34;) × N instances\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这四层各有不同职责：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eRuntime：进程级资源和生命周期；\u003c/li\u003e\n\u003cli\u003eNamespace：隔离一组服务；\u003c/li\u003e\n\u003cli\u003eComponent：表示逻辑服务角色；\u003c/li\u003e\n\u003cli\u003eEndpoint：表示可调用接口及其多个实例。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eEndpoint 名字相同不代表只有一个服务器。每个 Worker 可注册自己的 instance，客户端通过 discovery 得到动态实例集合。\u003c/p\u003e\n\u003ch2 id=\"二服务发现保存什么\"\u003e二、服务发现保存什么\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003elib/runtime/src/discovery\u003c/code\u003e 定义了发现层。一个 Endpoint 注册时，系统不仅记录地址，还附带实例、组件和元数据。客户端查询的关键维度是：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e(namespace, component, endpoint) -\u0026gt; [instance...]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e在裸机模式中，注册与租约可由 etcd 支持；Kubernetes 模式使用 \u003ccode\u003eDynamoWorkerMetadata\u003c/code\u003e CRD 和 EndpointSlice。运行时把发现后端抽象掉，上层 Router 不需要分别实现 etcd watcher 与 Kubernetes watcher。\u003c/p\u003e","title":"NVIDIA Dynamo 源码阅读（二）：分布式运行时如何组织服务与请求"},{"content":"普通负载均衡只关心哪个 Worker 更空闲。LLM 推理还需要回答另一个问题：哪个 Worker 已经缓存了当前请求的最长前缀？如果忽略缓存局部性，相同 system prompt、文档或多轮会话会在不同 GPU 上反复 Prefill。\nDynamo 的 KV-aware Router 将缓存命中与运行负载放进同一次决策。本文基于提交 27f09d5，重点阅读 lib/kv-router 与 lib/llm/src/kv_router。\n一、路由问题如何形式化 设请求 token 被按固定 block size 切成若干块：\n[t0 ... t15] [t16 ... t31] [t32 ... t47] [剩余 token] B0 B1 B2 每个完整块计算链式哈希。Router 维护如下关系：\nB0 -\u0026gt; {worker 1, worker 3} B1 -\u0026gt; {worker 1} B2 -\u0026gt; {worker 1} 如果新请求具有相同前三块，Worker 1 的重叠长度最大。但它可能已经非常繁忙，因此最终决策不是简单的最长前缀匹配，而是 locality 与 load 的权衡。\n二、为什么使用链式 block hash compute_block_hash_for_seq 不会孤立地哈希每个 token 块，而是把前一个块的 hash 纳入下一个块。这使相同内容出现在不同前缀后时不会被错误视为同一缓存位置。\n概念上可写成：\nH0 = hash(tokens[0:block]) H1 = hash(H0, tokens[block:2*block]) H2 = hash(H1, tokens[2*block:3*block]) 这种结构天然对应前缀树。查找时从根沿请求块依次前进，直到某个块不存在；走过的深度就是可复用的缓存前缀长度。\n不完整尾块通常不能作为稳定共享单元，因为后续 token 到来后其内容仍会变化。\n三、Router 如何知道 Worker 的缓存状态 推理后端在 block 存储、移除或全部清空时产生 KV event。事件经 Sidecar 或后端集成转换成统一的 RouterEvent，其中包含：\nWorker ID 与 data-parallel rank； event ID； block hash 列表； store、remove 或 clear 类型； 存储层级等附加信息。 KvEventSender 把事件送入索引器的 FIFO mutation queue。索引器串行应用会改变逻辑状态的操作，同时允许查询通过独立通道进入。这个边界很重要：如果增删事件乱序，Router 可能把请求发送到实际上已经驱逐缓存的 Worker。\n源码还提供事件确认、Worker reset、恢复查询和近似 LRU 等机制，用来处理重启、丢事件与大规模状态维护。\n四、Radix Tree 中保存什么 RadixTree 的节点代表一段连续 block 前缀，节点关联拥有该前缀的 Worker 集合。压缩版本会合并只有单分支的路径，降低长上下文下的节点开销。\n一次查询返回的不只是“是否命中”，而是各候选 Worker 的 overlap score。对于多层缓存，还可得到 device、host 或外部存储层的命中信息。\n请求块： A -\u0026gt; B -\u0026gt; C -\u0026gt; D Worker 1：A -\u0026gt; B -\u0026gt; C overlap = 3 Worker 2：A -\u0026gt; B overlap = 2 Worker 3：无 overlap = 0 Indexer 与 Scheduler 分开：Indexer 回答缓存事实，Scheduler 决定如何使用事实。这样可替换数据结构或远程索引，而不把路由策略写死在树中。\n五、负载模型补上缓存视角的盲区 最长命中不一定是最佳选择。假设 Worker 1 命中三个块但排队很深，Worker 2 只命中两个块却立即可执行，把请求发给 Worker 2 可能更早完成。\nDynamo 维护活动序列和 Worker 负载投影，估计请求加入后带来的 Prefill 与 Decode 工作量。调度输入大致包括：\nWorker cache overlap + active sequence load + queued request load + worker availability + request-specific constraints PrefillLoadEstimator、SchedulerQueue 和 WorkerLoadProjection 分别承担负载估计、等待队列和前瞻更新。路由决策后立即把潜在负载计入视图，而不是等后端稍后上报，否则高并发下多个请求可能同时涌向同一 Worker。\n六、选择过程被拆成 Filter、Score、Pick 新版 Router 将策略抽象为三段：\nFilter：排除不满足条件的 Worker； Score：根据 KV 命中、负载或自定义指标评分； Pick：从候选集中选出最终 Worker。 DefaultWorkerSelector 提供内置行为，WorkerSelectionPolicy 允许插件化组合。仓库中的 lib/router-plugins 和 examples/router/custom-policy-example 展示了自定义策略入口。\n这比一个巨大的 select_worker() 更利于演进。例如：\nLoRA 请求可先过滤没有对应 adapter 的 Worker； 多数据中心环境可先过滤不可接受的区域； PD 分离可按 WorkerType 区分 Prefill 与 Decode； 业务可把缓存分数与排队时间按自身 SLO 重新加权。 七、路由状态为什么只能近实时 KV Cache 变化速度很快。若每次路由都同步查询每张 GPU，查询成本会抵消收益；若完全依赖异步事件，视图又可能短暂落后。\nDynamo 选择事件驱动的近实时副本，并配套恢复机制。这意味着 Router 的目标不是获得强一致全局真相，而是在可控成本下做高质量决策。\n因此代码中会看到以下工程措施：\nevent ID 检测缺口与乱序； Worker 注册和移除时清理索引； recovery query 重建 Worker 状态； dedup 与 batching 降低事件开销； pruning 和近似 LRU 控制索引体积。 KV-aware routing 的难点并非 Radix Tree 查找本身，而是持续维护可信、可恢复且足够便宜的缓存视图。\n八、什么时候 KV-aware routing 最有效 它最适合存在明显前缀复用的负载：\n固定 system prompt； RAG 中重复文档块； 多轮会话； agent 工作流的共享上下文； 大量请求使用相同 few-shot 示例。 如果 prompt 几乎随机且很短，KV 事件、索引和路由计算可能没有足够回报。评估时应同时观察 prefix hit rate、TTFT、Router 开销和负载倾斜，而不能只看命中率。\n九、源码阅读结论 Dynamo Router 的核心不是“按 hash 分片”，而是一个闭环：\nWorker 产生 KV 事件 -\u0026gt; Indexer 更新前缀位置 -\u0026gt; 请求查询 overlap -\u0026gt; Scheduler 结合负载选择 -\u0026gt; 预测负载立即回写 -\u0026gt; Worker 执行并产生新事件 下一篇将进入解耦服务：为何一次请求需要先选 Prefill Worker、再选 Decode Worker，以及 Router 如何让 KV 绕过自身直接传输。\n参考源码 lib/kv-hashing lib/kv-router/src/protocols.rs lib/kv-router/src/indexer/kv_indexer.rs lib/kv-router/src/indexer/radix_tree.rs lib/kv-router/src/scheduling/mod.rs lib/kv-router/src/scheduling/selector lib/kv-router/src/sequences lib/llm/src/kv_router/publisher ","permalink":"https://yangyang233333.github.io/posts/nvidia-dynamo-kv-aware-router-source-reading/","summary":"\u003cp\u003e普通负载均衡只关心哪个 Worker 更空闲。LLM 推理还需要回答另一个问题：哪个 Worker 已经缓存了当前请求的最长前缀？如果忽略缓存局部性，相同 system prompt、文档或多轮会话会在不同 GPU 上反复 Prefill。\u003c/p\u003e\n\u003cp\u003eDynamo 的 KV-aware Router 将缓存命中与运行负载放进同一次决策。本文基于提交 \u003ccode\u003e27f09d5\u003c/code\u003e，重点阅读 \u003ccode\u003elib/kv-router\u003c/code\u003e 与 \u003ccode\u003elib/llm/src/kv_router\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一路由问题如何形式化\"\u003e一、路由问题如何形式化\u003c/h2\u003e\n\u003cp\u003e设请求 token 被按固定 block size 切成若干块：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[t0 ... t15] [t16 ... t31] [t32 ... t47] [剩余 token]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      B0            B1            B2\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e每个完整块计算链式哈希。Router 维护如下关系：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eB0 -\u0026gt; {worker 1, worker 3}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eB1 -\u0026gt; {worker 1}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eB2 -\u0026gt; {worker 1}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果新请求具有相同前三块，Worker 1 的重叠长度最大。但它可能已经非常繁忙，因此最终决策不是简单的最长前缀匹配，而是 locality 与 load 的权衡。\u003c/p\u003e\n\u003ch2 id=\"二为什么使用链式-block-hash\"\u003e二、为什么使用链式 block hash\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ecompute_block_hash_for_seq\u003c/code\u003e 不会孤立地哈希每个 token 块，而是把前一个块的 hash 纳入下一个块。这使相同内容出现在不同前缀后时不会被错误视为同一缓存位置。\u003c/p\u003e","title":"NVIDIA Dynamo 源码阅读（三）：KV-aware Router 如何选择 Worker"},{"content":"Prefill 与 Decode 使用同一个 Transformer，却具有不同资源特征。Prefill 面向长序列并行计算，关注首 token 延迟；Decode 每轮只处理少量 token，受显存带宽和迭代调度影响。把两者固定绑在同一副本上，资源比例只能随实例一起扩缩。\nDynamo 的 disaggregated serving 将两阶段放到不同 Worker 池，并用直接 KV 传输连接起来。本文基于提交 27f09d5。\n一、为什么要做两次路由 解耦请求包含两个独立选择：\nFrontend | v 选择 Prefill Worker ----\u0026gt; 计算输入 KV | | |\u0026lt;---- transfer metadata--+ | v 选择 Decode Worker \u0026lt;==== 直接搬运 KV | v 逐 token 生成 -\u0026gt; Frontend -\u0026gt; Client Prefill Worker 的选择偏向输入前缀命中与 Prefill 排队；Decode Worker 的选择偏向持续生成负载、可用 KV 容量和传输拓扑。将二者合成一次普通负载均衡，会丢失阶段差异。\nDynamo 的 PrefillRouter 位于 Frontend 与 Worker 之间，负责这段编排，而不是让客户端感知两阶段。\n二、第一阶段返回的不是 KV 本体 Prefill 完成后不会把庞大的 KV 张量返回 Router。它返回 disaggregated_params，其中携带后端完成传输所需的描述信息。\n不同后端使用不同语义：\nSGLang 可使用 bootstrap 连接信息； TensorRT-LLM 可携带 opaque state； vLLM 可围绕 block ID 与 connector metadata 协调。 Router 只负责转交元数据。实际数据路径是 Prefill Worker 到 Decode Worker，避免形成中心带宽瓶颈。\n这个设计对应一个经典原则：控制消息经过编排层，大对象走点对点数据面。\n三、NIXL 在数据面的位置 NIXL，即 NVIDIA Inference Xfer Library，为 GPU、主存和存储之间的数据移动提供统一接口。根据机器拓扑，它可以利用 NVLink、PCIe、InfiniBand/UCX 等路径。\n在 Dynamo 中，NIXL 不决定请求发给哪个 Worker。Router 做调度，后端 connector 根据 metadata 发起或配合传输，NIXL 承担数据移动。\nRouter：谁给谁 Connector：搬哪些 block、何时可用 NIXL：通过哪种能力完成搬运 分清这三层，可以避免把 PD 分离误解为一个简单的远程 memcpy。\n四、PrefillRouter 如何编排请求 lib/llm/src/kv_router/prefill_router 包含 activation、admission、query 与 conditional bypass 等模块。其职责可以概括为：\n判断请求是否需要远程 Prefill； 从 Prefill Worker 集合中选择目标； 发送 Prefill 请求并等待元数据； 将元数据注入原 Decode 请求； 选择 Decode Worker并继续流式调用； 在异常或策略允许时退化到本地/聚合执行。 Conditional disaggregation 尤其重要。并非所有请求都值得搬运 KV：短 prompt 的传输协调成本可能高于独立 Prefill。策略可以基于输入长度、负载或其他条件决定是否拆分。\n五、拓扑会改变最优 Worker 假设两个 Decode Worker 都很空闲，但一个与 Prefill Worker 位于同一 NVLink 域，另一个需要跨节点 RDMA。只比较 Decode 队列会做出次优选择。\nDynamo 的 topology-aware KV transfer 将传输代价纳入路由。选择过程需要同时考虑：\nPrefill Worker 已有的缓存重叠； Decode Worker 当前负载； 两个 Worker 的连接能力与拓扑； KV 大小及可用传输路径； 目标 Worker 的缓存容量。 因此 PD 分离并不自动带来收益。若网络慢、输入短或调度忽略拓扑，额外传输可能恶化 TTFT。\n六、Sidecar 为什么适合后端集成 Dynamo 为 vLLM、SGLang 和 TensorRT-LLM 提供 Sidecar crate。Sidecar 负责连接后端事件、传输控制和 Dynamo Runtime，而模型执行仍留在原后端。\n这种方式有三点优势：\n后端升级时不必把完整执行循环 fork 到 Dynamo； Rust Sidecar 可稳定处理高频事件和协议转换； 各后端保留自己的 paged KV 与调度实现。 代价是集成边界更复杂：后端版本、connector 协议、block size 与生命周期必须匹配。生产部署应把 Dynamo、Sidecar 和推理引擎视为一个经过联调的版本集合。\n七、多层 KVBM 如何扩展缓存容量 PD 分离解决 Worker 间 KV 移动，KVBM 进一步解决“KV 放在哪里”。其设计把缓存层次扩展为：\nGPU HBM -\u0026gt; Host DRAM -\u0026gt; Local SSD / external storage KVBM 由多个 Rust crate 组成：\ncrate 作用 kvbm-logical 逻辑块和地址空间 kvbm-physical 物理存储与分配 kvbm-engine 搬运和操作编排 kvbm-kernels 与数据移动相关的底层能力 kvbm-consolidator 块整合与状态处理 kvbm-config 配置模型 后端 scheduler 构造 onboard/offload metadata，Worker 在 forward pass 边界执行异步搬运。事件平面再把 Store/Remove 生命周期广播给 Router 或外部 storage advisor。\n这里形成了两个相互独立但协作的优化：Router 决定请求靠近哪份缓存，KVBM 决定缓存驻留在哪个层级。\n八、故障与回退是实现重点 解耦路径比聚合路径多出多个失败点：\nPrefill Worker 在返回 metadata 前退出； KV 传输建立失败或超时； Decode Worker 在接收后不可用； Router 视图滞后，选择了错误的缓存位置； 两端 block layout 或版本不兼容。 因此系统需要明确的 admission、取消、超时、重试和 bypass 语义。盲目重试尤其危险：Prefill 可能已产生状态，Decode 也可能已开始返回 token。\n源码中的 cancellation guard、request guard、recovery 和 conditional bypass，说明 Dynamo 把 PD 分离当成一个分布式事务式流程，而非两个独立 HTTP 调用。\n九、如何判断是否值得部署 评估 PD 分离至少要同时测量：\nTTFT：Prefill 排队、计算和 KV 传输总和； ITL：Decode 池在目标并发下的稳定性； GPU 利用率：两类 Worker 是否分别达到合理饱和度； KV 传输带宽与尾延迟； Prefill/Decode 池扩缩时的缓存损失； 聚合模式与解耦模式的单位 token 成本。 长 prompt、长输出、阶段资源比例差异明显时，解耦通常更有价值。短请求或小规模部署中，聚合模式更简单，也可能更快。\n十、系列总结 从四篇源码阅读可以看到，Dynamo 的核心不是单个算法，而是一组边界清晰的协作机制：\nRuntime 组织分布式组件 Router 维护 KV 与负载视图 PrefillRouter 编排两阶段执行 Connector + NIXL 移动 KV KVBM 扩展缓存层次 Inference Engine 专注模型执行 这种分层让 Dynamo 能站在多个推理引擎之上，但也意味着生产价值必须通过系统级 benchmark 证明。最合理的阅读和落地顺序，是先跑通聚合模式，再启用 KV-aware routing，最后评估 PD 分离与多层缓存。\n参考源码 lib/llm/src/kv_router/prefill_router lib/llm/src/kv_router/routing_host lib/sidecar/vllm lib/sidecar/sglang lib/sidecar/trtllm lib/kvbm-engine lib/kvbm-logical lib/kvbm-physical docs/fern/pages/developer-guide/knowledge-base/concepts/system-architecture/disaggregated-serving.md docs/fern/pages/developer-guide/knowledge-base/modular-components/kvbm/kvbm-design.md ","permalink":"https://yangyang233333.github.io/posts/nvidia-dynamo-disaggregated-serving-source-reading/","summary":"\u003cp\u003ePrefill 与 Decode 使用同一个 Transformer，却具有不同资源特征。Prefill 面向长序列并行计算，关注首 token 延迟；Decode 每轮只处理少量 token，受显存带宽和迭代调度影响。把两者固定绑在同一副本上，资源比例只能随实例一起扩缩。\u003c/p\u003e\n\u003cp\u003eDynamo 的 disaggregated serving 将两阶段放到不同 Worker 池，并用直接 KV 传输连接起来。本文基于提交 \u003ccode\u003e27f09d5\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一为什么要做两次路由\"\u003e一、为什么要做两次路由\u003c/h2\u003e\n\u003cp\u003e解耦请求包含两个独立选择：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eFrontend\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e选择 Prefill Worker ----\u0026gt; 计算输入 KV\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   |                         |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   |\u0026lt;---- transfer metadata--+\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e选择 Decode Worker \u0026lt;==== 直接搬运 KV\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e逐 token 生成 -\u0026gt; Frontend -\u0026gt; Client\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePrefill Worker 的选择偏向输入前缀命中与 Prefill 排队；Decode Worker 的选择偏向持续生成负载、可用 KV 容量和传输拓扑。将二者合成一次普通负载均衡，会丢失阶段差异。\u003c/p\u003e","title":"NVIDIA Dynamo 源码阅读（四）：Prefill/Decode 分离与 KV 传输"},{"content":"NVIDIA Dynamo 不是另一个 vLLM 或 TensorRT-LLM。它不负责重新实现 Attention、Continuous Batching 或模型执行，而是把多个推理引擎实例组织成一个可路由、可拆分、可扩缩的服务。\n这一区分决定了阅读源码的入口：不要从 CUDA kernel 开始，而应先找到请求如何跨过 Frontend、Router、Prefill Worker 和 Decode Worker，以及服务发现、事件传播、KV 传输分别位于哪一层。\n本文基于 Dynamo 提交 27f09d5，仓库版本为 1.5.0。\n一、Dynamo 解决的不是单卡执行问题 单个推理引擎已经能完成一次生成：接收 token、执行 Prefill、循环 Decode、管理本机 KV Cache。规模扩大后，系统出现新的问题：\n同一前缀可能被多个副本重复 Prefill。 Prefill 与 Decode 的算力和延迟特征不同，固定配比容易浪费 GPU。 Worker 上下线后，路由器必须快速获得最新拓扑。 KV Cache 不仅存在于显存，还可能分布在主存、SSD 或其他节点。 不同后端有不同请求格式与 KV 传输协议，但上层服务不应被绑定。 Dynamo 把这些问题放在推理引擎之上：\n控制面 Discovery / Planner / Metrics | v Client -\u0026gt; Frontend -\u0026gt; Router -\u0026gt; Inference Worker | | | | +---- KV Events+ | +------ 流式响应 ----------\u0026gt; KV 数据面（按需） Prefill Worker ======\u0026gt; Decode Worker NIXL / backend connector 请求平面回答“请求发给谁”；KV 数据面回答“缓存如何移动”；控制面回答“哪些实例存在、负载怎样、是否扩缩容”。三者分开，是 Dynamo 架构最重要的设计。\n二、一次请求穿过哪些组件 官方架构文档把解耦部署的一次请求拆成九步，源码中可归纳成五个阶段。\n1. Frontend 完成协议适配 Frontend 对外暴露 OpenAI 兼容接口，完成聊天模板、tokenize、参数校验与流式响应封装。此时请求从 HTTP 对象转成 Dynamo 内部可路由的请求。\nFrontend 不是简单反向代理。KV 路由需要 token 序列，而 HTTP 请求通常只有文本；因此预处理必须发生在路由之前。\n2. Router 选择执行实例 聚合部署中，Router 直接选择一个同时执行 Prefill 和 Decode 的 Worker。解耦部署中，PrefillRouter 先选择 Prefill Worker，再把 Prefill 返回的传输元数据注入 Decode 请求。\n选择依据并非只有排队长度。KV-aware 路由同时考虑：\n请求前缀与各 Worker 已缓存块的重叠； Worker 当前活动序列与预计工作量； Worker 类型、数据并行 rank 和路由分区； 自定义过滤、打分与选择策略。 3. Prefill Worker 计算 KV Cache Prefill Worker 消费完整输入，建立 KV Cache，并返回后续传输需要的 backend-specific metadata。元数据描述“如何取缓存”，实际 KV 张量不经过 Frontend 或 Router。\n4. Decode Worker 拉取 KV 并生成 Decode Worker 根据元数据与 Prefill Worker 建立传输，通过 NIXL 或后端连接器直接搬运 KV。随后进入逐 token Decode，并把输出流式返回。\n5. KV 事件更新全局视图 Worker 在缓存块写入、移除或清空时发布事件。Router 的索引器消费这些事件，维护“某段 token 前缀目前在哪些 Worker 上”的近实时视图，为下一次调度提供依据。\n因此 Dynamo 的主循环不是一次简单 RPC，而是两条相互配合的链：\n请求链：HTTP -\u0026gt; preprocess -\u0026gt; select -\u0026gt; execute -\u0026gt; stream 状态链：KV store/remove -\u0026gt; event -\u0026gt; index -\u0026gt; next selection 三、仓库如何映射到架构 根目录 Cargo.toml 展示了 Dynamo 的模块边界。\n目录 职责 lib/runtime 分布式运行时、Endpoint、服务发现、网络传输与流水线 lib/llm LLM 协议、Frontend、模型管理和较高层 KV 路由编排 lib/kv-router KV 索引、调度策略、Worker 负载与路由协议 lib/kv-hashing token block 的稳定哈希 lib/kvbm-* 多层 KV Block Manager lib/sidecar/* vLLM、SGLang、TensorRT-LLM 的 Sidecar 集成 lib/bindings/python Rust 能力的 Python 绑定与高层组件 deploy Kubernetes Operator、Helm、Inference Gateway 与观测组件 examples/backends 三类推理后端的启动与部署示例 这是一套明显的“Rust 内核 + Python 编排”结构。请求传输、并发状态和索引等性能敏感部分放在 Rust；后端启动、配置组合与用户扩展保留在 Python。\n四、控制面与数据面为何要分离 lib/runtime 同时支持服务发现和请求传输，但二者不是同一条通道。\n服务发现负责持久状态：\nnamespace / component / endpoint / instance 裸机环境可使用 etcd 或文件系统，Kubernetes 环境默认使用 CRD 与 EndpointSlice。请求平面默认使用直接 TCP，也可选择 HTTP 或 NATS。\n这种拆分避免了两个常见问题：\n不让注册中心承载高频 token 流量； 不让点对点数据连接承担成员关系的一致性。 NATS 在当前架构中也不是所有请求必经的总线。它更多承担可选的 KV 事件传播；请求平面默认已经转向直接 TCP。阅读早期文章时若把 Dynamo 简化成“NATS + etcd 的 RPC 框架”，会错过现在的实现重点。\n五、后端适配边界在哪里 Dynamo 支持 SGLang、TensorRT-LLM 和 vLLM，但不会强迫它们共享同一内部调度器。统一的是外围契约：\n接收标准化请求； 暴露可发现的 Endpoint； 上报负载和 KV 事件； 在解耦模式下交换 KV 传输元数据； 以流方式返回生成结果。 真正涉及 paged KV、block ID、bootstrap handle 或 opaque state 的部分留在各后端连接器中。这样，Dynamo 可以统一集群调度，而不抹平引擎差异。\nSidecar 模式进一步降低侵入性：后端进程保留自身生命周期与核心逻辑，Sidecar 负责把其事件和能力接入 Dynamo 数据面。\n六、为什么这个架构适合数据中心规模 Dynamo 的价值随规模增长而增长。单 GPU 服务引入它通常没有必要；多个副本、多个节点和长上下文负载下，系统级优化才可能覆盖额外复杂度。\n其收益来自组合，而非单个功能：\nKV-aware routing 减少重复 Prefill； Prefill/Decode 分离允许独立扩缩； 多层 KV 存储提高缓存容量； Planner 根据 SLO 调整资源； 统一运行时屏蔽部署和传输差异。 代价同样明确：组件数量、状态传播、故障模式和观测需求都会增加。是否采用 Dynamo，应该以端到端吞吐、TTFT、ITL 与成本为依据，而不是仅比较单引擎 benchmark。\n七、后续阅读路线 本系列接下来沿源码继续深入：\nlib/runtime 如何用 Namespace、Component、Endpoint 组合分布式流水线； KV Router 如何把 token 前缀变成 Worker 选择； Prefill/Decode 分离如何完成两次调度与一次直接 KV 传输。 建立这张全景图后，Dynamo 的大量目录不再是平铺功能，而是围绕三条平面形成的组合：请求、状态与数据。\n参考源码 Cargo.toml lib/runtime/src/distributed.rs lib/runtime/src/component/endpoint.rs lib/kv-router/src/lib.rs docs/fern/pages/developer-guide/knowledge-base/concepts/system-architecture/architecture-flow.md docs/fern/pages/developer-guide/knowledge-base/concepts/system-architecture/distributed-runtime.md ","permalink":"https://yangyang233333.github.io/posts/nvidia-dynamo-architecture-overview/","summary":"\u003cp\u003eNVIDIA Dynamo 不是另一个 vLLM 或 TensorRT-LLM。它不负责重新实现 Attention、Continuous Batching 或模型执行，而是把多个推理引擎实例组织成一个可路由、可拆分、可扩缩的服务。\u003c/p\u003e\n\u003cp\u003e这一区分决定了阅读源码的入口：不要从 CUDA kernel 开始，而应先找到请求如何跨过 Frontend、Router、Prefill Worker 和 Decode Worker，以及服务发现、事件传播、KV 传输分别位于哪一层。\u003c/p\u003e\n\u003cp\u003e本文基于 Dynamo 提交 \u003ccode\u003e27f09d5\u003c/code\u003e，仓库版本为 \u003ccode\u003e1.5.0\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一dynamo-解决的不是单卡执行问题\"\u003e一、Dynamo 解决的不是单卡执行问题\u003c/h2\u003e\n\u003cp\u003e单个推理引擎已经能完成一次生成：接收 token、执行 Prefill、循环 Decode、管理本机 KV Cache。规模扩大后，系统出现新的问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e同一前缀可能被多个副本重复 Prefill。\u003c/li\u003e\n\u003cli\u003ePrefill 与 Decode 的算力和延迟特征不同，固定配比容易浪费 GPU。\u003c/li\u003e\n\u003cli\u003eWorker 上下线后，路由器必须快速获得最新拓扑。\u003c/li\u003e\n\u003cli\u003eKV Cache 不仅存在于显存，还可能分布在主存、SSD 或其他节点。\u003c/li\u003e\n\u003cli\u003e不同后端有不同请求格式与 KV 传输协议，但上层服务不应被绑定。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eDynamo 把这些问题放在推理引擎之上：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                         控制面\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e              Discovery / Planner / Metrics\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                         |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                         v\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eClient -\u0026gt; Frontend -\u0026gt; Router -\u0026gt; Inference Worker\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             |          |              |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             |          +---- KV Events+\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             |\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             +------ 流式响应 ----------\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                 KV 数据面（按需）\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        Prefill Worker ======\u0026gt; Decode Worker\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    NIXL / backend connector\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e请求平面回答“请求发给谁”；KV 数据面回答“缓存如何移动”；控制面回答“哪些实例存在、负载怎样、是否扩缩容”。三者分开，是 Dynamo 架构最重要的设计。\u003c/p\u003e","title":"NVIDIA Dynamo 源码阅读（一）：数据中心级推理栈的整体架构"},{"content":"前四篇已经解释请求如何到达 Scheduler、KV Cache 如何管理，以及 Engine 怎样执行一个 Batch。最后还剩一个问题：几十亿参数怎样分布到多张 GPU，并在每层计算后重新组合？\n本文先推导 Tensor Parallel 的基本原理，再分析 Mini-SGLang 的 Layers、Models、Distributed、MoE 和 Kernel 模块。源码基于提交 9a91cfa。\n一、为什么需要 Tensor Parallel 如果模型权重或 KV Cache 放不进单张 GPU，最直接的方法是把不同层放到不同 GPU，也就是 Pipeline Parallel。但自回归 Decode 每轮 token 很少，流水线容易出现气泡，而且跨 stage 传递激活会增加延迟。\nTensor Parallel 把同一层的矩阵切到多张 GPU，让所有 GPU 同时计算同一批 token：\n同一 Transformer Layer GPU 0：处理部分权重 GPU 1：处理部分权重 GPU 2：处理部分权重 GPU 3：处理部分权重 ↓ collective 组合为完整层输出 它需要更频繁的 GPU 间通信，但能共同承载单层权重，并保持每个 rank 的执行进度一致。\n二、Column Parallel Linear 考虑线性层：\nY = XW 按输出维切分权重：\nW = [W0 | W1 | ... | Wn] 每个 rank 都拥有完整输入 X，分别计算：\nY0 = XW0 Y1 = XW1 ... 输出自然是 Y 的不同列。如果下一层也能消费分片输出，就不需要立即通信。\nQKV projection 和 MLP 的 gate/up projection 通常适合 Column Parallel，因为 Attention heads 或中间维可以按 rank 切分。\nMini-SGLang 在 layers/linear.py 中实现对应的列并行 Linear，并在加载权重时只读取本 rank 所需分片。\n三、Row Parallel Linear 按输入维切分权重：\nW = [W0; W1; ...; Wn] X = [X0 | X1 | ... | Xn] 每个 rank 计算部分结果：\nP0 = X0W0 P1 = X1W1 ... Y = P0 + P1 + ... 最后必须执行 all-reduce sum。\nAttention 的 output projection 和 MLP down projection 常用 Row Parallel。它们接收前一列并行层产生的分片激活，并在残差连接前还原完整 hidden states。\n一组典型 MLP 因而是：\n完整 hidden states -\u0026gt; Column Parallel gate/up -\u0026gt; 每 rank 计算局部激活 -\u0026gt; Row Parallel down -\u0026gt; all-reduce -\u0026gt; 完整 hidden states 四、Attention head 如何切分 Q heads 通常可以均匀分到各 rank。KV heads 则要考虑 GQA：KV head 数可能少于 TP size。\nMini-SGLang 的配置和 Layer 工具允许 KV heads 在必要时复制，而不是强制每个 rank 至少获得一个独立 KV head。每个 rank 的 KV Cache 只保存本地需要的 heads，因此缓存容量计算使用 local_kv_heads。\nAttention 输出经过 Row Parallel output projection 和 all-reduce 后，各 rank 再次得到一致的完整 hidden states。\n五、Vocab Parallel Embedding 与 LM Head 词表也可以沿 vocab 维切分。每个 rank 只保存一段 token embedding：\nRank 0：token [0, V/2) Rank 1：token [V/2, V) 输入 token 不属于本 rank 时，本地输出置零；所有 rank 的 embedding 结果 all-reduce 后得到完整向量。\nLM Head 使用相同的词表分片思路，各 rank 产生局部 logits。随后可根据采样实现选择 all-gather 完整词表，或使用分布式选择。Mini-SGLang 的抽象把通信集中在并行 Layer 和 Distributed backend 中，模型代码保持接近普通 Transformer。\n六、Distributed 模块的两套实现 distributed/impl.py 提供统一的：\nall_reduce()； all_gather()。 底层可使用：\ntorch.distributed + NCCL； 项目封装的 PyNCCL； 单 GPU 空操作实现。 控制面另有 Gloo group，用于广播 Python 消息、同步配置和检查显存。数据面的模型张量则走 NCCL 类通信。\n把控制与计算通信分开，可以避免小型 CPU 消息依赖 GPU collective，也便于 Rank 0 驱动请求广播。\n七、模型注册与构建 models/register.py 使用 Hugging Face config 中的 architectures[0] 选择模型类。当前注册：\nLlamaForCausalLM； Qwen2ForCausalLM； Qwen3ForCausalLM； Qwen3MoeForCausalLM； MistralForCausalLM； Mistral3ForConditionalGeneration。 模型文件主要负责组装 Layer：Embedding、重复的 Decoder Layer、Norm 和 LM Head。运行时调度、KV Cache 与具体 Attention kernel 并没有复制到每种模型中。\n这种结构的扩展路径很明确：新模型如果只是层组合不同，可以复用已有 Layer；只有新的算子或权重布局才需要扩展底层模块。\n八、权重加载与分片 models/weight.py 和各 Layer 的 weight loader 负责将 Hugging Face 权重映射到本地参数。\nTensor Parallel 下不能先在每张 GPU 加载完整权重再切分，否则峰值显存过高。更合理的流程是：\n读取权重 Tensor -\u0026gt; 根据参数类型和 TP rank 选取 shard -\u0026gt; 转换 dtype -\u0026gt; 复制到本地参数 不同参数的切分维不同：\nColumn Parallel 沿输出维切； Row Parallel 沿输入维切； QKV 合并参数分别处理 Q、K、V； GQA 的 KV heads 可能复制； Norm 和部分 bias 在各 rank 完整保留。 权重加载代码因此是理解 TP 正确性的关键，而不只是 I/O 工具。\n九、RoPE 与位置编码 layers/rotary.py 实现 Rotary Position Embedding。RoPE 将位置相关旋转应用于 Query 和 Key，使注意力分数携带相对位置信息。\nScheduler 为每个 token 准备 position：\nPrefill：cached_len ... device_len-1 Decode：当前最后位置 Layer 只消费 positions，不关心请求经历了多少次 chunk。这样 Chunked Prefill 在语义上仍等价于一次完整 Prefill。\n十、MoE 的额外路径 Qwen3 MoE 不再让每个 token 经过同一个 MLP，而是由 router 选择少量 experts：\nToken hidden state -\u0026gt; Router logits -\u0026gt; Top-k experts -\u0026gt; Expert GEMM -\u0026gt; 按权重合并 moe 目录定义 backend 接口与 fused 实现，kernel/triton/fused_moe.py 提供 Triton kernel。Engine 根据模型配置选择 MoE backend。\n这里的核心性能问题是把按 expert 离散分组的 token 高效送入矩阵乘法，同时避免大量小 kernel 和中间 Tensor。\n十一、自定义 Kernel 模块 kernel 目录包含几类底层能力：\nindex.py：索引和二维映射操作； store.py：将新 K/V 写入缓存； radix.py：Radix 相关 C++ 绑定； pynccl.py：轻量 NCCL 封装； triton/fused_moe.py：融合 MoE； csrc：CUDA/C++ 与 TVM-FFI JIT 代码。 为什么不全部使用 PyTorch 算子？因为调度器经常需要执行“小而特殊”的操作，例如根据二维请求索引生成扁平缓存位置。组合多个通用算子会产生额外中间 Tensor 和 kernel launch，自定义 kernel 可以一次完成。\n十二、完整 Transformer 层中的通信点 以常见 Decoder Layer 为例：\n完整 hidden states -\u0026gt; RMSNorm -\u0026gt; Column Parallel QKV -\u0026gt; 每 rank 本地 RoPE + Attention -\u0026gt; Row Parallel Output -\u0026gt; all-reduce -\u0026gt; Residual -\u0026gt; RMSNorm -\u0026gt; Column Parallel Gate/Up -\u0026gt; 本地激活 -\u0026gt; Row Parallel Down -\u0026gt; all-reduce -\u0026gt; Residual 每层通常有两次主要 all-reduce。TP 越大，单卡计算越少，但通信比例越高。因此 TP size 不是越大越好，还取决于 GPU 互联、模型规模和 batch 形态。\n十三、这套模块化设计的意义 Mini-SGLang 将系统拆成几条稳定边界：\nScheduler：选择工作 Cache：管理状态 Engine：组织执行 Model：定义网络结构 Layer：封装并行语义 Backend/Kernel：实现硬件优化 这使读者可以单独替换一个调度策略、缓存算法或 Attention backend，而不必修改整条推理链路。它也是 Mini-SGLang 作为教学型高性能引擎最有价值的地方。\n至此，整个系列从 HTTP 请求一直走到了 GPU kernel。建议下一步结合 profiler 实验：分别关闭 Radix Cache、Overlap Scheduling 和 CUDA Graph，观察 TTFT、ITL、吞吐和显存利用率如何变化。\n参考源码 python/minisgl/layers/linear.py python/minisgl/layers/embedding.py python/minisgl/layers/attention.py python/minisgl/layers/rotary.py python/minisgl/layers/moe.py python/minisgl/models/register.py python/minisgl/models/weight.py python/minisgl/distributed/impl.py python/minisgl/kernel python/minisgl/moe ","permalink":"https://yangyang233333.github.io/posts/mini-sglang-source-reading-tensor-parallel/","summary":"\u003cp\u003e前四篇已经解释请求如何到达 Scheduler、KV Cache 如何管理，以及 Engine 怎样执行一个 Batch。最后还剩一个问题：几十亿参数怎样分布到多张 GPU，并在每层计算后重新组合？\u003c/p\u003e\n\u003cp\u003e本文先推导 Tensor Parallel 的基本原理，再分析 Mini-SGLang 的 Layers、Models、Distributed、MoE 和 Kernel 模块。源码基于提交 \u003ccode\u003e9a91cfa\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一为什么需要-tensor-parallel\"\u003e一、为什么需要 Tensor Parallel\u003c/h2\u003e\n\u003cp\u003e如果模型权重或 KV Cache 放不进单张 GPU，最直接的方法是把不同层放到不同 GPU，也就是 Pipeline Parallel。但自回归 Decode 每轮 token 很少，流水线容易出现气泡，而且跨 stage 传递激活会增加延迟。\u003c/p\u003e\n\u003cp\u003eTensor Parallel 把同一层的矩阵切到多张 GPU，让所有 GPU 同时计算同一批 token：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e同一 Transformer Layer\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 0：处理部分权重\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 1：处理部分权重\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 2：处理部分权重\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 3：处理部分权重\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ↓ collective\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e组合为完整层输出\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e它需要更频繁的 GPU 间通信，但能共同承载单层权重，并保持每个 rank 的执行进度一致。\u003c/p\u003e\n\u003ch2 id=\"二column-parallel-linear\"\u003e二、Column Parallel Linear\u003c/h2\u003e\n\u003cp\u003e考虑线性层：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eY = XW\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e按输出维切分权重：\u003c/p\u003e","title":"Mini-SGLang 源码阅读（五）：模型层、Tensor Parallel 与自定义 Kernel"},{"content":"Scheduler 决定本轮执行哪些请求，Engine 则负责把这个决定变成 GPU 计算。它连接模型、KV Cache、Attention backend、CUDA Graph、Tensor Parallel 和采样器，是 Mini-SGLang 的执行核心。\n本文先解释 Attention backend 与 CUDA Graph 的基本原理，再分析一次 Engine.forward_batch()。源码基于提交 9a91cfa。\n一、GPU 推理不只有矩阵乘法 一次 Transformer forward 包含许多 GPU 操作：\nEmbedding -\u0026gt; RMSNorm -\u0026gt; QKV Linear -\u0026gt; RoPE -\u0026gt; 写入 KV Cache -\u0026gt; Attention -\u0026gt; Output Linear -\u0026gt; Residual -\u0026gt; MLP / MoE -\u0026gt; LM Head -\u0026gt; Sampling 大模型 Prefill 中矩阵较大，GPU 容易被计算填满；Decode 中每个请求通常只有一个 query token，大量小 kernel 的 CPU launch 开销会变得突出。\n因此高性能 Engine 不仅要选快 kernel，还要减少 Python 和 CUDA runtime 在每轮 Decode 中的固定成本。\n二、Engine 初始化做了什么 Engine.__init__() 的主要步骤是：\n绑定当前 TP rank 的 CUDA device -\u0026gt; 初始化 torch.distributed -\u0026gt; 创建模型结构 -\u0026gt; 加载并切分权重 -\u0026gt; 估算剩余显存 -\u0026gt; 分配 KV Cache -\u0026gt; 创建 Attention backend -\u0026gt; 创建 MoE backend -\u0026gt; 创建 Sampler -\u0026gt; 捕获 CUDA Graph 顺序不能随意改变。只有模型权重加载后，Engine 才知道剩余显存能分配多少 KV pages；只有 KV Cache 和 Attention backend 就绪后，才能捕获真实执行图。\n三、KV Cache 容量如何估算 Engine 在模型加载前后分别测量空闲显存，估算模型实际占用。单页 KV 成本由下式决定：\n2 × num_layers × local_kv_heads × head_dim × page_size × dtype_size 再根据 memory_ratio 决定多少显存可以用于缓存：\navailable = memory_ratio × initial_free - model_memory num_pages = available / cache_per_page 多 TP rank 会同步空闲显存并取较小值，确保每个 rank 创建相同容量。若各 GPU 可用显存差距过大，Engine 直接报错，而不是让某个 rank 在运行中先 OOM。\n四、Attention backend 的统一接口 attention/base.py 定义 BaseAttnBackend。具体后端主要负责两件事：\n根据 Batch 和页表构建 metadata； 执行 Prefill 或 Decode Attention。 当前实现包括：\nFlashAttention； FlashInfer； TensorRT-LLM FMHA。 模型中的 AttentionLayer 不关心具体库。它完成 QKV 投影和 RoPE 后，把 Query、Key、Value 交给全局 Context 中的 backend。\n这种接口隔离使模型定义和 kernel 选择彼此独立。\n五、Prefill 和 Decode 为什么适合不同后端 Prefill 的 query 长度通常大于 1，多个请求长度不等，适合支持变长 packed sequence 的高吞吐 kernel。\nDecode 的 query 长度固定为 1，但每个请求的 KV 长度不同，并且需要高效读取 paged cache。此时针对单 token decode 优化的 kernel 更有优势。\nHybridBackend 因而允许分别选择：\n--attn fa,fi 表示 Prefill 使用 FlashAttention，Decode 使用 FlashInfer。自动模式会根据 GPU 架构选择组合，例如 Hopper 默认倾向 FlashAttention + FlashInfer，更新架构可选择 TensorRT-LLM backend。\n六、Attention metadata 的作用 Scheduler 传给模型的是扁平 token Tensor，但 Attention kernel 需要知道：\n每个请求的 query 从哪里开始； 每个请求有多少 query token； 历史 KV 长度是多少； 页表位于哪一行； 新 K、V 写到哪些物理位置。 这些信息被封装为 backend 特定的 BaseAttnMetadata。例如 Prefill 可以使用 cumulative sequence lengths，Decode 可以使用 batch indices、sequence lengths 和 page table。\n将 metadata 构建放在 CPU 调度流上，也为 Overlap Scheduling 提供了空间：CPU 准备下一批 metadata 时，GPU 可以执行当前批。\n七、一次普通 forward Scheduler 完成 Batch 准备后调用 Engine：\nwith self.ctx.forward_batch(batch): logits = self.model.forward() Context.forward_batch() 暂时把 Batch 设为当前上下文。模型层随后通过 get_global_ctx() 访问：\nctx.batch.positions； ctx.batch.out_loc； ctx.batch.attn_metadata； ctx.kv_cache； ctx.attn_backend。 这是一个刻意的工程折中：全局 Context 降低了模型接口复杂度，但也意味着同一进程不能嵌套或并发执行两个 Batch。每个 GPU 一个 Engine 进程正好满足这一约束。\n八、Attention Layer 如何使用缓存 Attention Layer 的逻辑可以概括为：\nhidden_states -\u0026gt; QKV projection -\u0026gt; 按本 rank 的 heads 拆分 -\u0026gt; 应用 RoPE -\u0026gt; 将新 K/V 写入 ctx.kv_cache[out_loc] -\u0026gt; backend.forward(q, k, v, metadata) -\u0026gt; 合并输出 Prefill 和 Decode 都经过同一层，但 backend 根据 batch.phase 选择不同实现。分页、变长序列和缓存读取细节被隐藏在 backend 内部。\n九、CUDA Graph 解决什么问题 普通 PyTorch 每轮都由 CPU 逐个发起 kernel：\nPython -\u0026gt; launch norm Python -\u0026gt; launch GEMM Python -\u0026gt; launch RoPE Python -\u0026gt; launch attention ... Decode 的单个 kernel 很短，CPU launch 间隙可能占据显著比例。CUDA Graph 可以先捕获固定执行序列，之后一次 replay 整张图：\n首次：分配静态输入 -\u0026gt; capture kernels 之后：复制新输入 -\u0026gt; graph.replay() 它减少 launch 开销，但要求地址和主要形状稳定。\n十、GraphRunner 如何处理动态 batch 在线 batch size 持续变化，而 CUDA Graph 通常针对固定 batch size 捕获。GraphRunner 预先选择一组可覆盖的 batch size，并为每个尺寸建立 Graph。\n实际 batch 较小时，Scheduler 用 dummy request 补齐到最近的已捕获尺寸：\n实际 batch size = 13 可用 graph size = 16 补 3 个 dummy request 只返回前 13 个请求结果 GraphCaptureBuffer 保存固定地址的输入 token、position、页表索引和其他 metadata。Replay 前把本轮数据复制进去，图中的 kernel 始终引用同一组地址。\n超出最大捕获 batch、处于不支持的 Prefill 形态，或配置关闭 CUDA Graph 时，Engine 回退到普通 eager forward。\n十一、Sampler 如何生成 token 模型输出 logits 后，Sampler 根据每个请求的参数执行：\ngreedy； temperature scaling； top-k； top-p。 批内请求可以有不同 sampling 参数，因此 BatchSamplingArgs 将参数整理成 GPU Tensor。Greedy 请求可以直接 argmax，随机采样请求则经过过滤与 multinomial。\n生成的 token 保留一份 GPU Tensor，用于下一轮 Decode 输入；同时异步复制一份到 CPU，供 Scheduler 判断 EOS 和发送给 Detokenizer。\n十二、Engine 与 Scheduler 的异步边界 Engine 在自己的 CUDA stream 上执行，并记录 copy_done_event。Scheduler 可以在另一个 stream 和 CPU 上准备工作，直到必须读取输出 token 时才同步。\n这条边界是 Overlap Scheduling 的关键：\nGPU stream：模型 forward -\u0026gt; sampling -\u0026gt; D2H copy CPU/调度流：收消息 -\u0026gt; 更新队列 -\u0026gt; 准备 metadata 如果每轮 torch.cuda.synchronize()，所有 CPU 工作都会排在 GPU 之后，重叠机会就消失了。\n下一篇进入模型和分布式层，分析 Column/Row Parallel Linear、Embedding、权重切分与 NCCL collective 如何组成 Tensor Parallel 模型。\n参考源码 python/minisgl/engine/engine.py python/minisgl/engine/graph.py python/minisgl/engine/sample.py python/minisgl/attention/base.py python/minisgl/attention/fa.py python/minisgl/attention/fi.py python/minisgl/attention/trtllm.py python/minisgl/layers/attention.py ","permalink":"https://yangyang233333.github.io/posts/mini-sglang-source-reading-engine/","summary":"\u003cp\u003eScheduler 决定本轮执行哪些请求，Engine 则负责把这个决定变成 GPU 计算。它连接模型、KV Cache、Attention backend、CUDA Graph、Tensor Parallel 和采样器，是 Mini-SGLang 的执行核心。\u003c/p\u003e\n\u003cp\u003e本文先解释 Attention backend 与 CUDA Graph 的基本原理，再分析一次 \u003ccode\u003eEngine.forward_batch()\u003c/code\u003e。源码基于提交 \u003ccode\u003e9a91cfa\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一gpu-推理不只有矩阵乘法\"\u003e一、GPU 推理不只有矩阵乘法\u003c/h2\u003e\n\u003cp\u003e一次 Transformer forward 包含许多 GPU 操作：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eEmbedding\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; RMSNorm\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; QKV Linear\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; RoPE\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; 写入 KV Cache\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; Attention\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; Output Linear\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; Residual\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; MLP / MoE\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; LM Head\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e -\u0026gt; Sampling\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e大模型 Prefill 中矩阵较大，GPU 容易被计算填满；Decode 中每个请求通常只有一个 query token，大量小 kernel 的 CPU launch 开销会变得突出。\u003c/p\u003e","title":"Mini-SGLang 源码阅读（四）：Engine、Attention Backend 与 CUDA Graph"},{"content":"KV Cache 是在线 LLM 推理中最重要的状态。它避免 Decode 每轮重新计算全部历史 token，却也通常成为显存容量和并发数的主要限制。\n本文先解释 KV Cache、分页管理和前缀复用的基本原理，再阅读 Mini-SGLang 的 MHAKVCache、CacheManager 与 RadixPrefixCache。源码基于提交 9a91cfa。\n一、为什么需要 KV Cache 自回归 Attention 在第 t 步需要当前 Query 与位置 0...t 的 Key、Value 做注意力。如果每轮都重新计算历史 K、V，生成 n 个 token 会重复执行大量投影计算。\nKV Cache 将每层历史 K、V 保存下来：\n第 1 轮：计算 K0,V0，保存 第 2 轮：只计算 K1,V1，读取 [K0,K1] 第 3 轮：只计算 K2,V2，读取 [K0,K1,K2] 缓存大小大致为：\n2 × 层数 × token 数 × KV head 数 × head_dim × 元素字节数 其中 2 表示 Key 和 Value。长上下文、大 batch 和高精度都会快速放大显存消耗。\n二、连续分配的问题 最直观的方案是为每个请求预留一块能容纳最大长度的连续缓存，但会产生两个问题。\n一是内部浪费。用户设置 max_tokens=4096，实际可能只生成几十个 token，预留空间长期闲置。\n二是外部碎片。请求长度和生命周期不同，释放后形成许多小洞，剩余总空间足够也未必能找到一块大连续区域。\nPaged KV Cache 将缓存切成固定大小的物理页，请求通过页表把逻辑 token 位置映射到物理位置：\n请求逻辑页： [0] [1] [2] | | | 物理 KV 页： 17 3 28 新增长度时只需再分配一页，不要求物理连续。\n三、Mini-SGLang 的缓存分层 它将缓存系统拆成三层：\nMHAKVCache 管理 GPU 上真正存放 K/V 的物理张量 CacheManager 管理空闲页、请求页表、分配和回收 BasePrefixCache 决定哪些 token 前缀可以跨请求复用 这三层分别回答：字节存在哪里、页属于谁、内容能否共享。\n四、MHAKVCache 的物理布局 kvcache/mha_pool.py 中的 MHAKVCache 按层创建 K 和 V 张量。每个 TP rank 只保存本 rank 负责的 KV heads。\n逻辑形态可理解为：\nLayer 0: K[num_tokens, local_kv_heads, head_dim] V[num_tokens, local_kv_heads, head_dim] Layer 1: K[...] V[...] ... 实际布局会考虑 Attention backend 和 page size。Attention Layer 在模型前向中将新 K、V 写入 Scheduler 给出的 out_loc，无需知道这些位置属于哪个请求。\n五、页表如何连接逻辑与物理位置 全局 Context.page_table 是二维 GPU Tensor：\n行：table_idx，也就是请求槽位 列：请求内的逻辑 token 位置 值：物理 KV Cache token index 虽然缓存分配以 page 为单位，这张表对 kernel 暴露的是 token 级索引。scheduler/cache.py 中的 _write_page_table() 将分配出的页展开后写入对应行。\n例如 page size 为 4：\n逻辑页 0 -\u0026gt; 物理页 7 -\u0026gt; token index 28,29,30,31 逻辑页 1 -\u0026gt; 物理页 2 -\u0026gt; token index 8,9,10,11 Attention backend 根据 table_idx 和序列长度读取历史 K、V。\n六、CacheManager 如何分配缓存 当一个请求进入 Prefill，CacheManager 先查询 Prefix Cache，得到最长匹配长度和缓存句柄。\n若输入为：\n[System Prompt] [User Prompt] 而 [System Prompt] 已缓存，则：\ncached_len = system prompt 长度 extend_len = user prompt 长度 Scheduler 只为 extend_len 分配新页和执行 Prefill。\n空闲页不足时，CacheManager 请求 Prefix Cache 驱逐一定数量的可回收 token，并把返回的物理索引重新加入空闲池。仍不足则本轮不能接纳该请求。\n七、Naive Cache 的语义 NaivePrefixCache 不匹配跨请求前缀。每个请求从零开始分配，完成后缓存直接释放。\n它的价值不只是提供简单模式，还能作为性能消融基线：\npython -m minisgl --model ... --cache naive 对比 Naive 与 Radix 模式，可以判断工作负载究竟有多少共享前缀，以及管理前缀树的成本是否值得。\n八、Radix Tree 为什么适合 token 前缀 Radix Tree 是压缩前缀树。普通 Trie 每条边只保存一个 token，Radix Tree 可以在边或节点中保存一段 token，因此深度更小。\n假设有三个缓存序列：\n[1, 2, 3, 4] [1, 2, 3, 8] [1, 2, 9] 压缩后的结构类似：\n[1,2] ├─ [3] │ ├─ [4] │ └─ [8] └─ [9] 每个节点同时保存 token 片段和对应物理 KV index。查找输入时沿 token 前缀向下走，即可得到最长匹配缓存。\n九、RadixPrefixCache 的关键对象 RadixTreeNode 保存：\n压缩 token key； 对应 KV index； 父节点与子节点； 引用计数； 最近访问时间。 RadixCacheHandle 保存某个请求当前匹配到的节点和长度。Scheduler 不直接操作树节点，而是持有 handle。\nRadixPrefixCache 维护两个容量：\nprotected_size：正在被请求引用，不能驱逐 evictable_size：没有活跃引用，可以驱逐 这比简单的“已用/空闲”更准确，因为缓存页即使不属于活跃请求，也可能作为可复用前缀继续存在。\n十、最长前缀匹配 match_prefix() 调用 _tree_walk()。遍历时通过当前页的 token key 找子节点，再比较该节点保存的 token 片段。\n如果只匹配到节点的一部分，就调用 split_at()：\n原节点：[3,4,5,6] 输入仅匹配：[3,4] 拆分后： [3,4] └─ [5,6] 匹配长度会向下对齐到 page size。原因是缓存分配和 Attention 读取的最小共享单位是完整页；不完整页不能安全地交给另一个请求复用。\n十一、前缀如何插入 请求完成或缓存状态更新时，insert_prefix(input_ids, indices) 将已经计算的 token 与物理 KV index 插入树中。\n流程是：\n将长度按 page size 向下对齐 -\u0026gt; 查找已有最长前缀 -\u0026gt; 未覆盖后缀创建新节点 -\u0026gt; 保存后缀 token 和 KV index -\u0026gt; 返回新的 handle 如果另一个请求后来输入相同 system prompt，它会命中这些节点，跳过对应 Prefill。\n十二、引用计数与驱逐 当活跃请求使用某个缓存节点时，lock_handle() 沿该节点向根递增引用计数。节点从 0 变为 1 时，其容量从 evictable_size 转入 protected_size。\n请求结束或不再持有该前缀后，反向递减引用计数。变为 0 的节点重新可驱逐。\n驱逐从未引用的叶子节点开始，并按时间戳组成最小堆，近似实现 LRU：\n收集 ref_count == 0 的叶子 -\u0026gt; 按 timestamp 选择最旧节点 -\u0026gt; 删除节点并回收其物理 KV index -\u0026gt; 若父节点也成为可驱逐叶子，再加入堆 只删除叶子可以避免破坏仍被更长前缀依赖的内部路径。\n十三、Radix Cache 的收益边界 它最适合共享前缀明显的场景：\n多轮对话共享历史； 大量请求共享 system prompt； few-shot 示例重复； agent 工作流中重复读取同一上下文。 如果所有 prompt 都随机且没有公共前缀，收益有限，还会增加树操作和元数据成本。因此 Mini-SGLang 保留 Naive Cache 作为可选策略。\n下一篇进入 Engine，分析模型加载、Attention backend、CUDA Graph 和采样如何组成一次真正的 GPU forward。\n参考源码 python/minisgl/kvcache/base.py python/minisgl/kvcache/mha_pool.py python/minisgl/kvcache/naive_cache.py python/minisgl/kvcache/radix_cache.py python/minisgl/scheduler/cache.py python/minisgl/core.py ","permalink":"https://yangyang233333.github.io/posts/mini-sglang-source-reading-kv-cache/","summary":"\u003cp\u003eKV Cache 是在线 LLM 推理中最重要的状态。它避免 Decode 每轮重新计算全部历史 token，却也通常成为显存容量和并发数的主要限制。\u003c/p\u003e\n\u003cp\u003e本文先解释 KV Cache、分页管理和前缀复用的基本原理，再阅读 Mini-SGLang 的 \u003ccode\u003eMHAKVCache\u003c/code\u003e、\u003ccode\u003eCacheManager\u003c/code\u003e 与 \u003ccode\u003eRadixPrefixCache\u003c/code\u003e。源码基于提交 \u003ccode\u003e9a91cfa\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一为什么需要-kv-cache\"\u003e一、为什么需要 KV Cache\u003c/h2\u003e\n\u003cp\u003e自回归 Attention 在第 \u003ccode\u003et\u003c/code\u003e 步需要当前 Query 与位置 \u003ccode\u003e0...t\u003c/code\u003e 的 Key、Value 做注意力。如果每轮都重新计算历史 K、V，生成 \u003ccode\u003en\u003c/code\u003e 个 token 会重复执行大量投影计算。\u003c/p\u003e\n\u003cp\u003eKV Cache 将每层历史 K、V 保存下来：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 1 轮：计算 K0,V0，保存\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 2 轮：只计算 K1,V1，读取 [K0,K1]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 3 轮：只计算 K2,V2，读取 [K0,K1,K2]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e缓存大小大致为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e2 × 层数 × token 数 × KV head 数 × head_dim × 元素字节数\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e其中 2 表示 Key 和 Value。长上下文、大 batch 和高精度都会快速放大显存消耗。\u003c/p\u003e","title":"Mini-SGLang 源码阅读（三）：Paged KV Cache 与 Radix Prefix Cache"},{"content":"在线 LLM 调度器要回答的不是“下一个运行哪个进程”，而是：这一轮把哪些请求、哪些 token 放入同一个 GPU batch，并确保显存、KV Cache 和计算预算都不超限。\n本文先建立 Continuous Batching、Chunked Prefill 与 Overlap Scheduling 的理论模型，再阅读 Mini-SGLang 的 Scheduler 实现。源码基于提交 9a91cfa。\n一、调度器面对三种资源 LLM 请求至少消耗三类资源。\n第一是计算量。Prefill 的计算大致随输入 token 数增长，Decode 每个请求每轮只增加一个 token。\n第二是 KV Cache。一个请求即使本轮只计算一个 token，也必须继续持有全部历史 K、V。\n第三是 batch 槽位和 CUDA Graph 形状。请求数、总 token 数和序列长度都会限制可执行 batch。\n因此 Scheduler 需要同时维护两个预算：\n本轮计算预算：最多处理多少新 token 长期缓存预算：这些请求最多还会占多少 KV 页 只看当前空闲页是不够的。如果 Prefill 阶段把缓存全部吃完，已经进入 Decode 的请求可能无法继续生成。\n二、Continuous Batching 的状态划分 Mini-SGLang 将请求分成两个主要集合：\nwaiting queue：尚未完成 Prefill，等待进入 GPU running batch：已经 Prefill，正在逐 token Decode 传统静态 batch 的生命周期以“整批”为单位；Continuous Batching 的生命周期以“请求”为单位。某个请求完成后立即离开，空出的槽位可以接纳新请求。\n每轮调度的基本选择是：\n如果能接纳 Prefill：组织 Prefill batch 否则：让 running batch 做一轮 Decode 实际判断还必须考虑 Chunked Prefill、缓存容量和 GPU 上一轮是否完成。\n三、Scheduler 的组成 Scheduler 组合了几个职责明确的对象：\nEngine：执行模型前向； CacheManager：分配和回收 KV 页； PrefillManager：从等待队列选择 Prefill 请求； DecodeManager：维护运行中的 Decode 请求； TableManager：分配请求页表槽位； SchedulerIOMixin：收发 ZMQ 与 TP 广播消息。 这种拆分很重要。调度策略不直接操作模型权重，Engine 也不决定请求优先级。\n四、主循环的流水线结构 Scheduler.run_forever() 不断调用一次调度 step。其逻辑可以抽象成：\nCPU：接收请求 CPU：处理上一轮输出 CPU：选择并准备下一批 GPU：执行下一批 CPU：继续处理控制工作 Engine 返回的 ForwardOutput 不只有 token，还包含一个 CUDA Event：\nclass ForwardOutput(NamedTuple): next_tokens_gpu: torch.Tensor next_tokens_cpu: torch.Tensor copy_done_event: torch.cuda.Event GPU 计算完成后，token 被异步复制到 CPU。Scheduler 不必立刻全局同步，而是在真正读取结果前等待 Event。这样可以把一部分 Python 调度开销隐藏在 GPU 工作之后。\n五、请求如何进入等待队列 Rank 0 从 Tokenizer 收取后端消息，再广播给其他 TP rank。每个 rank 都将同一请求转换为 PendingReq。\nPendingReq 尚未拥有完整的 GPU 运行状态。只有被 PrefillManager 选中后，它才会获得：\ntable_idx； Prefix Cache handle； 已命中的 cached_len； 分配的 KV Cache 页； 对应的 Req 对象。 这种延迟分配避免等待队列中的大量请求提前占用 GPU 资源。\n六、PrefillAdder 如何做资源判断 PrefillAdder 是 Prefill 调度的核心辅助对象。它尝试逐个加入请求，并检查两个方向的容量。\n首先是本轮 token 数：\nextend_len = 输入长度 - 已缓存前缀长度 若请求命中 Radix Cache，只需计算未命中的后缀。\n其次是缓存容量。新请求需要为未命中的 token 和未来生成保留空间。调度器结合空闲页、可驱逐前缀和运行请求占用情况判断能否接纳。\n这种准入控制保证 Scheduler 不会生成一个 Engine 无法实际执行的 batch。\n七、为什么需要 Chunked Prefill 假设一个 100K token 的长请求和几十个 Decode 请求同时存在。如果长请求一次完成 Prefill，它可能长时间占据 GPU，并产生很大的中间张量，其他用户的单 token 解码延迟会明显升高。\nChunked Prefill 把它切成多段：\n100K prompt -\u0026gt; chunk 0：0～8191 -\u0026gt; chunk 1：8192～16383 -\u0026gt; ... -\u0026gt; final chunk ChunkedReq 继承 Req，但只暴露当前 chunk 的逻辑边界。完成一个 chunk 后，请求回到等待状态；最终 chunk 完成后才进入 DecodeManager。\nChunking 带来三点收益：\n限制单轮 token 数和临时显存； 让 Decode 请求在长 Prefill 之间获得执行机会； 让 GPU batch 形状更可控。 代价是同一 prompt 需要多轮调度，且 chunk 边界会增加少量元数据处理。\n八、Prefill batch 如何构造 PrefillManager 的流程可以概括为：\n遍历 waiting queue -\u0026gt; 查询最长可复用前缀 -\u0026gt; 计算 extend_len -\u0026gt; 检查 token/cache budget -\u0026gt; 必要时裁剪成 ChunkedReq -\u0026gt; 分配 table slot 和 KV pages -\u0026gt; 组成 Batch(phase=\u0026#34;prefill\u0026#34;) Prefill 的 input_ids 不是完整 prompt，而是所有请求尚未缓存的后缀拼接：\n请求 A：缓存 4，输入 7 -\u0026gt; 提交 3 token 请求 B：缓存 0，输入 2 -\u0026gt; 提交 2 token Batch input_ids = A[4:7] + B[0:2] positions 则分别从各请求的 cached_len 开始生成。Attention metadata 记录每个请求的 query 长度和 KV 长度，kernel 才能解释这段扁平 token 数组。\n九、Decode batch 为什么更规则 完成 Prefill 的请求进入 DecodeManager. 每个请求每轮只提交最新 token，因此普通 Decode batch 的输入长度等于请求数：\nA 最新 token B 最新 token C 最新 token 但每个请求要读取的历史 KV 长度不同，因此仍需要 page table 和 sequence length metadata。\nDecodeManager 还要保证不同 TP rank 的请求顺序稳定。若 rank 间 batch 顺序不同，Tensor Parallel collective 会把不同请求的中间结果混合，轻则结果错误，重则通信死锁。当前仓库最新提交正是加强了这一稳定性。\n十、Prefill 与 Decode 如何取舍 调度器不能永远优先 Prefill，否则已有请求的首 token 之后会长时间停顿；也不能永远优先 Decode，否则新请求永远无法获得首 token。\nMini-SGLang 的策略是受预算约束地尝试加入 Prefill，并在不可加入时执行 Decode。Chunked Prefill 进一步限制单次 Prefill 体量，使两类工作能够交错。\n从用户体验看，这对应两个指标：\nTTFT：Time To First Token，主要受排队和 Prefill 影响； ITL：Inter-Token Latency，主要受 Decode 调度影响。 调度策略本质上是在吞吐、TTFT 和 ITL 之间取舍。\n十一、请求完成后的处理 Scheduler 等待 copy_done_event 后读取 CPU token，逐个更新请求：\n将 token 追加到 host token 序列 -\u0026gt; cached_len/device_len 前进 -\u0026gt; 判断 EOS 或长度上限 -\u0026gt; 未完成：保留在 DecodeManager -\u0026gt; 已完成：发送结束消息并释放资源 释放并不一定意味着立即删除所有 KV。启用 Radix Cache 时，可复用的完整前缀会进入前缀树，未被请求引用的页变成可驱逐缓存。\n十二、调度层的边界 Scheduler 决定“做什么”，Engine 决定“怎样在 GPU 上执行”。这一边界可以用下表概括：\n问题 负责模块 哪些请求进入本轮 Scheduler 本轮是 Prefill 还是 Decode Scheduler KV 页分配给谁 CacheManager Attention metadata 如何组织 Scheduler + Backend 模型权重如何执行 Engine + Model 下一个 token 如何采样 Sampler 下一篇将深入 KV Cache：为什么需要 Paged KV Cache、页表如何工作，以及 Radix Tree 怎样实现跨请求前缀复用。\n参考源码 python/minisgl/scheduler/scheduler.py python/minisgl/scheduler/prefill.py python/minisgl/scheduler/decode.py python/minisgl/scheduler/cache.py python/minisgl/scheduler/table.py python/minisgl/scheduler/io.py python/minisgl/core.py ","permalink":"https://yangyang233333.github.io/posts/mini-sglang-source-reading-scheduler/","summary":"\u003cp\u003e在线 LLM 调度器要回答的不是“下一个运行哪个进程”，而是：\u003cstrong\u003e这一轮把哪些请求、哪些 token 放入同一个 GPU batch，并确保显存、KV Cache 和计算预算都不超限。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e本文先建立 Continuous Batching、Chunked Prefill 与 Overlap Scheduling 的理论模型，再阅读 Mini-SGLang 的 Scheduler 实现。源码基于提交 \u003ccode\u003e9a91cfa\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一调度器面对三种资源\"\u003e一、调度器面对三种资源\u003c/h2\u003e\n\u003cp\u003eLLM 请求至少消耗三类资源。\u003c/p\u003e\n\u003cp\u003e第一是计算量。Prefill 的计算大致随输入 token 数增长，Decode 每个请求每轮只增加一个 token。\u003c/p\u003e\n\u003cp\u003e第二是 KV Cache。一个请求即使本轮只计算一个 token，也必须继续持有全部历史 K、V。\u003c/p\u003e\n\u003cp\u003e第三是 batch 槽位和 CUDA Graph 形状。请求数、总 token 数和序列长度都会限制可执行 batch。\u003c/p\u003e\n\u003cp\u003e因此 Scheduler 需要同时维护两个预算：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e本轮计算预算：最多处理多少新 token\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e长期缓存预算：这些请求最多还会占多少 KV 页\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e只看当前空闲页是不够的。如果 Prefill 阶段把缓存全部吃完，已经进入 Decode 的请求可能无法继续生成。\u003c/p\u003e\n\u003ch2 id=\"二continuous-batching-的状态划分\"\u003e二、Continuous Batching 的状态划分\u003c/h2\u003e\n\u003cp\u003eMini-SGLang 将请求分成两个主要集合：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewaiting queue：尚未完成 Prefill，等待进入 GPU\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003erunning batch：已经 Prefill，正在逐 token Decode\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e传统静态 batch 的生命周期以“整批”为单位；Continuous Batching 的生命周期以“请求”为单位。某个请求完成后立即离开，空出的槽位可以接纳新请求。\u003c/p\u003e","title":"Mini-SGLang 源码阅读（二）：Scheduler、Continuous Batching 与 Chunked Prefill"},{"content":"大模型推理框架并不只是执行一次 model.forward()。在线服务面对的是持续到达、长度不同、生成进度不同的请求，它必须同时解决文本编解码、动态批处理、KV Cache、GPU 执行和流式返回。\nMini-SGLang 把这些问题压缩在一套相对紧凑的代码中。本文先不进入具体优化，而是建立阅读后续源码所需的整体模型：一次请求如何从 HTTP 文本进入系统，经过 Prefill 和多轮 Decode，最后以流式文本返回。\n本文基于 Mini-SGLang 提交 9a91cfa。\n一、推理为何分成 Prefill 和 Decode 输入 prompt 包含多个 token。模型第一次执行时，需要同时处理全部输入 token，并为每一层生成 Key、Value。这一阶段称为 Prefill。\n输入： [t0, t1, t2, t3] 计算： 同时处理多个位置 产物： 最后位置的 logits + 四个位置的 KV Cache 接下来每轮只生成一个 token。已有 token 的 K、V 不应重复计算，只需读取缓存并计算新 token：\n已有： [t0, t1, t2, t3] 第 1 轮 Decode：输入 t3，生成 t4 第 2 轮 Decode：输入 t4，生成 t5 第 3 轮 Decode：输入 t5，生成 t6 因此两阶段的计算形态不同：Prefill 计算量大、输入长度不等；Decode 单次计算小，但会循环很多轮。现代推理引擎通常分别为两者设计调度与 Attention kernel。\n二、为什么不能简单逐请求运行 如果每个请求独占 GPU，短请求之间会留下大量空闲时间。静态批处理也不适合在线服务：必须等待整批请求到齐，并且整批通常要等最长请求结束。\nContinuous Batching 的做法是每一轮重新组织 batch：\n时刻 0：A、B 做 Prefill 时刻 1：A、B 做 Decode，C 到达 时刻 2：A、B 做 Decode；C 做 Prefill 时刻 3：A 完成，B、C 做 Decode，D 加入 请求完成后立即退出，等待请求可以随时进入。Scheduler 因此成为推理引擎真正的控制中心。\n三、Mini-SGLang 的进程拓扑 server/launch.py 启动四类角色：\nZeroMQ 用户 -\u0026gt; API Server ----------\u0026gt; Tokenizer × N | v Scheduler Rank 0 / | \\ Rank 1 Rank 2 Rank 3 \\ | / Tensor Parallel | v Detokenizer | v API Server -\u0026gt; 用户 API Server 接收 HTTP 请求并维护流式响应。 Tokenizer 把字符串编码为 token IDs。 每个 TP rank 有一个 Scheduler 和一个 Engine。 Rank 0 接收请求并同步给其他 rank。 Detokenizer 把输出 token 增量还原为文本。 控制消息走 ZeroMQ；多 GPU 的张量通信走 NCCL 或 PyNCCL。\n四、进程如何启动 入口 python/minisgl/__main__.py 调用 launch_server()。server/launch.py 先解析参数，再为每个 TP rank 创建 Scheduler 进程：\nfor rank in range(world_size): rank_args = replace( server_args, tp_info=DistributedInfo(rank, world_size), ) mp.Process(target=_run_scheduler, args=(rank_args, ack_queue)).start() 随后启动一个 Detokenizer 和多个 Tokenizer。主进程通过 ack_queue 等待后端全部就绪，再让 FastAPI 对外服务。\n这里有两个值得注意的选择。\n第一，使用 spawn 而不是 fork。CUDA 运行时不适合在初始化后直接 fork，独立启动进程更安全。\n第二，每个 GPU 都有完整 Scheduler，而不是一个中央 Scheduler 远程控制多个 GPU。Rank 0 广播相同请求后，各 rank 独立构造相同 batch，模型层只同步必要张量。\n五、API Server 如何跟踪流式请求 server/api_server.py 中的 FrontendManager 保存：\n请求 ID 计数器； 每个请求对应的异步队列； 发往 Tokenizer 的 ZMQ 队列； 来自 Detokenizer 的回复； 后端健康状态。 用户请求到达后，Frontend 创建 UserMsg，并不断从对应队列读取 UserReply。OpenAI 流式接口再把它包装为 SSE chunk。\n这说明流式返回不是一次 HTTP 请求等待最终字符串，而是贯穿整个后端的数据流：\n生成一个 token -\u0026gt; Rank 0 发给 Detokenizer -\u0026gt; 得到新增文本 -\u0026gt; FrontendManager 找到请求队列 -\u0026gt; SSE 立即返回客户端 如果客户端断开，Frontend 发送 Abort 消息，Scheduler 释放该请求占用的槽位和 KV Cache。\n六、Tokenizer 为什么独立成进程 Tokenizer 的工作主要发生在 CPU：解析消息、应用 chat template、编码字符串。高并发下，它可能与 Python API Server 争抢 CPU 和 GIL，因此 Mini-SGLang 允许启动多个 Tokenizer worker。\ntokenizer/server.py 的 tokenize_worker() 同时支持编码和解码角色。编码路径将 UserMsg 转成 TokenizeMsg；解码路径维护每个请求此前已产生的 token，并执行增量 decode，避免反复解码完整序列。\n独立 Detokenizer 还有一个好处：Scheduler 只处理整数 token，不需要知道 Unicode 拼接、特殊 token 和文本停止条件。\n七、请求进入 Scheduler 后发生什么 Tokenizer 产生的消息包含：\n请求 ID； 输入 token IDs； 最大输出长度； temperature、top-k、top-p； 是否忽略 EOS。 Scheduler 将其转换为 PendingReq，先放入等待队列。之后每次调度循环依次处理：\n接收新消息 -\u0026gt; 回收已经完成的 GPU 结果 -\u0026gt; 更新请求状态 -\u0026gt; 尝试组织 Prefill batch -\u0026gt; 否则组织 Decode batch -\u0026gt; 准备 page table 和 Attention metadata -\u0026gt; Engine.forward_batch() -\u0026gt; 异步等待下一轮结果 这就是 Overlap Scheduling 的基础：CPU 在处理上一轮 GPU 输出并准备下一批元数据时，GPU 可以继续计算另一批任务。\n八、Req、Batch 与 Context core.py 定义了三个贯穿全系统的对象。\nReq 表示单个请求，记录输入 token、已缓存长度、当前长度、最大长度、采样参数和缓存句柄。最关键的三个长度是：\ncached_len：已有多少 token 的 KV 可以直接复用 device_len：GPU 逻辑上已经处理到哪里 max_device_len：输入长度 + 最大输出长度 Batch 是一次模型执行的单位，分为 prefill 和 decode。Scheduler 为它补充输入 token、position、KV 写入位置和 Attention metadata。\nContext 保存当前 Batch、Attention backend、KV Cache 和 GPU page table。模型层通过全局 Context 获取运行时信息，因此模型定义不需要把十几个调度参数逐层传递。\n九、Engine 执行一次 Batch engine/engine.py 的 forward_batch() 可以概括为：\nwith self.ctx.forward_batch(batch): if self.graph_runner.can_use_cuda_graph(batch): logits = self.graph_runner.replay(batch) else: logits = self.model.forward() next_tokens = self.sampler.sample(logits[: batch.size], args) 模型执行前，Scheduler 已经完成了三件事：\n指定本轮真正输入哪些 token； 给新产生的 K、V 分配物理位置； 构造查询历史 K、V 所需的页表和 metadata。 Engine 因而专注于 GPU 执行：模型前向、CUDA Graph 和采样。\n十、请求何时结束 每轮生成后，Scheduler 检查：\n是否生成 EOS； 是否达到 max_tokens； 是否收到 Abort； 是否发生错误。 完成的请求从 Decode batch 移除，其前缀和 KV Cache 根据缓存策略处理。使用 Radix Cache 时，可复用前缀继续留在缓存树中；不能保留的物理页返回空闲池。\n十一、阅读主线 理解整体流程后，推荐按以下顺序进入源码：\npython/minisgl/core.py：请求、Batch 和运行时上下文； python/minisgl/scheduler/scheduler.py：主调度循环； python/minisgl/scheduler/prefill.py：Chunked Prefill； python/minisgl/scheduler/cache.py：页表和缓存分配； python/minisgl/kvcache/radix_cache.py：前缀复用； python/minisgl/engine/engine.py：GPU 执行； python/minisgl/attention：Attention 后端； python/minisgl/layers：Tensor Parallel 模型层。 下一篇从 Scheduler 主循环出发，分析 Continuous Batching、Prefill/Decode 选择和 Overlap Scheduling 如何落到代码中。\n参考源码 docs/structures.md python/minisgl/server/launch.py python/minisgl/server/api_server.py python/minisgl/tokenizer/server.py python/minisgl/scheduler/scheduler.py python/minisgl/core.py python/minisgl/engine/engine.py ","permalink":"https://yangyang233333.github.io/posts/mini-sglang-source-reading-request-lifecycle/","summary":"\u003cp\u003e大模型推理框架并不只是执行一次 \u003ccode\u003emodel.forward()\u003c/code\u003e。在线服务面对的是持续到达、长度不同、生成进度不同的请求，它必须同时解决文本编解码、动态批处理、KV Cache、GPU 执行和流式返回。\u003c/p\u003e\n\u003cp\u003eMini-SGLang 把这些问题压缩在一套相对紧凑的代码中。本文先不进入具体优化，而是建立阅读后续源码所需的整体模型：\u003cstrong\u003e一次请求如何从 HTTP 文本进入系统，经过 Prefill 和多轮 Decode，最后以流式文本返回。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e本文基于 Mini-SGLang 提交 \u003ccode\u003e9a91cfa\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一推理为何分成-prefill-和-decode\"\u003e一、推理为何分成 Prefill 和 Decode\u003c/h2\u003e\n\u003cp\u003e输入 prompt 包含多个 token。模型第一次执行时，需要同时处理全部输入 token，并为每一层生成 Key、Value。这一阶段称为 Prefill。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e输入： [t0, t1, t2, t3]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e计算： 同时处理多个位置\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e产物： 最后位置的 logits + 四个位置的 KV Cache\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e接下来每轮只生成一个 token。已有 token 的 K、V 不应重复计算，只需读取缓存并计算新 token：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e已有： [t0, t1, t2, t3]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 1 轮 Decode：输入 t3，生成 t4\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 2 轮 Decode：输入 t4，生成 t5\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第 3 轮 Decode：输入 t5，生成 t6\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e因此两阶段的计算形态不同：Prefill 计算量大、输入长度不等；Decode 单次计算小，但会循环很多轮。现代推理引擎通常分别为两者设计调度与 Attention kernel。\u003c/p\u003e","title":"Mini-SGLang 源码阅读（一）：一次 LLM 请求如何穿过推理引擎"},{"content":"Rust 的 async/await 写起来很像同步代码：调用异步函数、等待结果，然后继续向下执行。但它的底层既不会为每个任务创建一个线程，也不会在 await 时阻塞当前线程。\n它真正做的事情是：编译器把异步函数转换成一个可以暂停和恢复的状态机，这个状态机实现 Future；Executor 反复调用 poll 推进状态机，Waker 在资源就绪后通知 Executor 再次调度。\n本文从一段普通异步代码出发，逐层解释：\nasync fn 返回的到底是什么； await 为什么能暂停函数； 异步代码和状态机是什么关系； Future::poll、Context 和 Waker 分别负责什么； Executor 与 Reactor 如何配合； 为什么 Rust 异步需要 Pin； Tokio 在这套模型中处于什么位置。 本文定位为 《Rust Tokio Runtime 原理与最佳实践》 的前置教程。建议先理解本文中的 Future::poll、状态机、Waker 和 Executor，再继续阅读 Tokio 的调度器、I/O Driver、任务取消与工程实践。\n阅读前准备 读者只需要了解 Rust 的基本语法、所有权、枚举和 trait，不要求提前掌握 Tokio。\n文中的普通 Rust 代码可以放入一个空项目中实验：\ncargo new rust-async-basics cd rust-async-basics 涉及 Tokio 的示例可加入依赖：\n[dependencies] tokio = { version = \u0026#34;1\u0026#34;, features = [\u0026#34;full\u0026#34;] } 学习时建议始终追问三个问题：\n当前是谁在调用 poll？ Future 返回 Pending 前是否安排了唤醒？ 哪些局部变量必须跨越 await 保存？ 一、为什么需要异步编程 假设程序需要从两个远程服务读取数据。最直接的同步写法是：\nfn load() -\u0026gt; Result\u0026lt;Data\u0026gt; { let user = read_user_from_network()?; let orders = read_orders_from_network()?; Ok(Data { user, orders }) } 如果网络请求需要 100 毫秒，当前线程会在系统调用或等待结果时被挂起。对于少量请求，这种模型简单可靠；但当服务器同时处理数万个连接时，为每个连接准备一个线程会带来明显成本：\n每个线程需要独立栈空间； 创建和销毁线程有成本； 大量线程会增加上下文切换； 多数线程实际上只是在等待网络、磁盘或定时器。 异步编程的核心目标不是“让单个操作执行得更快”，而是：\n当一个任务等待 I/O 时，让出线程去执行其他任务；I/O 就绪后，再从原来的位置继续执行。\n这要求函数具备两个同步函数没有的能力：\n在某个位置暂停； 之后保留局部变量，从暂停位置继续。 状态机正是实现这两个能力的关键。\n二、async fn 返回的不是结果，而是 Future 看一段最简单的代码：\nasync fn answer() -\u0026gt; u32 { 42 } 调用它时：\nlet value = answer(); 此时 value 不是 42，而是某种编译器生成的匿名类型，这个类型实现了 Future\u0026lt;Output = u32\u0026gt;。\n可以把 async fn 粗略理解为：\nfn answer() -\u0026gt; impl Future\u0026lt;Output = u32\u0026gt; { async { 42 } } Future 只是一个“未来可能产生结果的计算”。创建 Future 通常不会立即执行函数体，必须有人主动调用它的 poll 方法，它才会向前运行。\nFuture trait 的核心形式是：\ntrait Future { type Output; fn poll( self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;, ) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt;; } poll 只有两种结果：\nenum Poll\u0026lt;T\u0026gt; { Ready(T), Pending, } Ready(value)：计算完成，返回最终结果； Pending：现在还不能完成，稍后需要再来 poll。 Future 不是后台线程，也不是自动运行的回调。它更像一个被动对象：\nExecutor 调用 poll │ ├── Ready(value)：任务完成 │ └── Pending：暂时停止，等待唤醒 三、await 与状态机的关系 考虑下面这个异步函数：\nasync fn fetch_and_parse() -\u0026gt; Result\u0026lt;Data, Error\u0026gt; { let response = fetch().await?; let body = response.body().await?; let data = parse(body)?; Ok(data) } 这个函数中有两个 await。每个 await 都可能成为暂停点：\n开始 │ ▼ poll fetch │ Pending ▼ 暂停点 1 │ fetch Ready ▼ 保存 response，poll response.body │ Pending ▼ 暂停点 2 │ body Ready ▼ parse(body) │ ▼ 返回 Ready(data) 编译器会把它转换为一个类似 enum 的状态机。以下代码不是编译器的真实输出，但可以帮助理解：\nenum FetchAndParseFuture { Start, WaitingFetch { fetch_future: FetchFuture, }, WaitingBody { body_future: BodyFuture, response: Response, }, Done, } 它的 poll 逻辑可以粗略写成：\nfn poll(mut self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;) -\u0026gt; Poll\u0026lt;Result\u0026lt;Data, Error\u0026gt;\u0026gt; { loop { match self.state { Start =\u0026gt; { self.state = WaitingFetch { fetch_future: fetch(), }; } WaitingFetch { ref mut fetch_future } =\u0026gt; { match Pin::new(fetch_future).poll(cx) { Poll::Pending =\u0026gt; return Poll::Pending, Poll::Ready(Err(error)) =\u0026gt; return Poll::Ready(Err(error)), Poll::Ready(Ok(response)) =\u0026gt; { self.state = WaitingBody { body_future: response.body(), response, }; } } } WaitingBody { ref mut body_future, .. } =\u0026gt; { match Pin::new(body_future).poll(cx) { Poll::Pending =\u0026gt; return Poll::Pending, Poll::Ready(Err(error)) =\u0026gt; return Poll::Ready(Err(error)), Poll::Ready(Ok(body)) =\u0026gt; { let data = parse(body)?; self.state = Done; return Poll::Ready(Ok(data)); } } } Done =\u0026gt; panic!(\u0026#34;future polled after completion\u0026#34;), } } } 真实实现会考虑字段布局、借用、析构和优化，不会简单地生成上面这个 enum。但核心思想一致：\nasync fn 被编译为状态机，await 是状态之间可能返回 Pending 的边界，跨越 await 仍然存活的局部变量会成为状态机的字段。\n没有跨越 await 的变量不需要保存 例如：\nasync fn example() { let first = 1; consume(first); wait_for_io().await; let second = 2; consume(second); } first 在 await 前已经用完，不需要保存进状态机。second 在恢复后才创建，也不需要出现在等待状态中。\n而下面的 buffer 必须跨越 await：\nasync fn example() { let mut buffer = Vec::new(); read_into(\u0026amp;mut buffer).await; consume(buffer); } 因此 buffer 会成为 Future 状态的一部分。Future 的大小通常由“所有暂停状态中需要保存的最大数据组合”决定，而不是简单等于所有局部变量之和。\n四、await 本质上做了什么 表达式：\nlet value = future.await; 可以粗略理解为：\nloop { match future.poll(cx) { Poll::Ready(value) =\u0026gt; break value, Poll::Pending =\u0026gt; return Poll::Pending, } } 注意这里的 return Poll::Pending 不是从普通函数返回，而是当前状态机的 poll 方法返回。状态机对象本身仍然存在，内部字段也不会丢失。\n下一次 Executor 再次调用 poll 时，状态机根据保存的状态，直接从对应暂停点继续，而不是从函数第一行重新开始。\n因此，await 不是：\n阻塞当前线程； 不断循环检查 Future； 创建一个新线程； 保存和恢复 CPU 调用栈。 它是：\npoll 子 Future；如果子 Future 未完成，就保存当前状态并把控制权返回 Executor。\n这种实现也被称为无栈协程。异步任务不需要保留完整线程栈，只需要保存跨越暂停点的变量和状态编号。\n五、谁来再次 poll Future：Waker Future 返回 Pending 后，Executor 不能不停地 poll：\nloop { future.poll(cx); } 这种方式会形成 busy loop，即使网络数据还要 10 秒才到达，也会持续占用 CPU。\n正确做法是：Future 在返回 Pending 前，保存 Context 中的 Waker。当资源就绪时，底层 I/O 驱动调用 wake()，通知 Executor：这个任务可能有进展了，可以重新放回运行队列。\n流程如下：\nExecutor poll Task A │ ▼ Future 尝试读取 socket │ 暂未就绪 ▼ 向 Reactor 注册 socket + Waker │ ▼ 返回 Pending，线程执行其他任务 ……网络数据到达…… Reactor 收到可读事件 │ ▼ 调用对应 Waker::wake() │ ▼ Task A 重新进入 Executor 就绪队列 │ ▼ Executor 再次 poll Task A Waker 不负责直接执行 Future。它通常只是把任务标记为 ready，并放入调度队列。真正调用 poll 的仍然是 Executor 的工作线程。\n为什么允许伪唤醒 被唤醒只表示“Future 现在可能有进展”，不保证下一次 poll 一定返回 Ready。\n因此合法流程可能是：\nwake -\u0026gt; poll -\u0026gt; Pending -\u0026gt; 再次注册 Waker Future 的 poll 必须始终能够处理这种情况，不能假设收到唤醒就一定完成。\n六、Executor、Reactor 与 Runtime Rust 标准库定义了 Future、Poll、Context 和 Waker，但没有提供完整异步运行时。Tokio、async-std、smol 等库在此基础上实现运行时。\n一个异步 Runtime 通常包括三部分。\n1. Executor Executor 负责调度任务：\n就绪任务队列 │ ▼ 取出 Task │ ▼ 构造 Context，调用 Future::poll │ ├── Ready：销毁任务并保存结果 │ └── Pending：等待 Waker 重新入队 一个 Task 通常包含：\n被 Pin 住的 Future； 调度状态； 指向 Executor 队列的引用； 用于构造 Waker 的信息。 多线程 Executor 还会实现工作窃取、任务迁移和并发队列。\n2. Reactor Reactor 负责等待外部事件，例如：\nLinux epoll； macOS/BSD kqueue； Windows IOCP； io_uring； 定时器到期。 它维护 I/O 资源和 Waker 的对应关系。事件发生时，Reactor 不执行整个业务 Future，而是唤醒对应任务。\n3. Runtime Runtime 把 Executor、Reactor、定时器、异步 I/O 类型和辅助 API 组合起来。\n以 Tokio 为例：\n#[tokio::main] async fn main() { let body = reqwest::get(\u0026#34;https://example.com\u0026#34;) .await .unwrap() .text() .await .unwrap(); println!(\u0026#34;{}\u0026#34;, body.len()); } #[tokio::main] 大致负责：\n创建 Tokio Runtime； 把 main 的 Future 交给 Runtime； 驱动它直到返回 Ready； 管理网络、定时器和任务调度。 七、为什么 poll 接收 Pin\u0026lt;\u0026amp;mut Self\u0026gt; Future trait 不是使用普通的 \u0026amp;mut self，而是：\nself: Pin\u0026lt;\u0026amp;mut Self\u0026gt; 这与状态机的内存安全有关。\n异步状态机可能包含自引用关系。考虑：\nasync fn read_line() { let mut buffer = String::new(); let future = read_into(\u0026amp;mut buffer); future.await; println!(\u0026#34;{buffer}\u0026#34;); } 概念上，生成的状态机可能同时保存：\nbuffer: String read_future: 内部引用 buffer 的 Future 如果状态机在内存中移动，read_future 内部保存的地址可能仍指向旧位置，从而形成悬空引用。\nPin 提供的关键保证是：\n一旦一个可能自引用的 Future 被 Pin，就不能再通过安全代码移动它。\n需要注意，创建 Future 时并不要求它立刻固定地址。通常是在 Executor 准备开始 poll 时，将 Future 放进 Box::pin 或任务存储中，之后保持地址稳定。\nUnpin 是什么 大多数普通类型移动后不会破坏内部关系，因此自动实现 Unpin。对 Unpin 类型而言，Pin\u0026lt;\u0026amp;mut T\u0026gt; 基本可以安全地当作 \u0026amp;mut T 使用。\n编译器生成的 async Future 往往不能假设为 Unpin，因为它可能在某些状态中包含自引用。因此手写组合器和底层 Future 时经常需要 pin projection，将 Pin\u0026lt;\u0026amp;mut Struct\u0026gt; 安全地投影到被 Pin 的字段。\n日常业务代码很少需要直接处理这些细节，但它解释了为什么 Future trait 的签名看起来比普通 trait 复杂。\n八、手写一个 Future 下面用一个简化的定时 Future 串起 poll 和 Waker。示例重点是原理，并不是生产级定时器实现：\nuse std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll, Waker}; use std::thread; use std::time::Duration; struct SharedState { completed: bool, waker: Option\u0026lt;Waker\u0026gt;, } struct TimerFuture { shared: Arc\u0026lt;Mutex\u0026lt;SharedState\u0026gt;\u0026gt;, } impl TimerFuture { fn new(duration: Duration) -\u0026gt; Self { let shared = Arc::new(Mutex::new(SharedState { completed: false, waker: None, })); let thread_shared = Arc::clone(\u0026amp;shared); thread::spawn(move || { thread::sleep(duration); let mut state = thread_shared.lock().unwrap(); state.completed = true; if let Some(waker) = state.waker.take() { waker.wake(); } }); Self { shared } } } impl Future for TimerFuture { type Output = (); fn poll( self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;, ) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt; { let mut state = self.shared.lock().unwrap(); if state.completed { Poll::Ready(()) } else { state.waker = Some(cx.waker().clone()); Poll::Pending } } } 执行过程是：\nExecutor 第一次 poll TimerFuture； 定时器未完成，保存当前任务的 Waker； 返回 Pending，Executor 去运行其他任务； 后台线程结束 sleep，将 completed 设为 true； 后台线程调用 waker.wake()； Executor 把任务重新放入就绪队列； 第二次 poll 看到 completed = true，返回 Ready(())。 真实 Tokio 定时器不会为每个 sleep 创建线程，而会使用统一的时间轮或定时器驱动，但 Future 与 Waker 的交互模式是一致的。\n更新 Waker 很重要 示例每次 poll 都执行：\nstate.waker = Some(cx.waker().clone()); 因为同一个 Future 后续可能由另一个任务上下文或工作线程 poll，旧 Waker 不一定仍代表当前调度位置。更严谨的实现可以使用 will_wake 判断是否需要替换。\n九、一个任务如何被完整驱动 把前面的组件连接起来，一次异步网络读取的完整过程如下：\n1. async fn 被调用 只创建状态机 Future，通常尚未执行函数体 2. Future 被 spawn 到 Runtime Runtime 将其包装成 Task，放入 Executor 就绪队列 3. Executor 第一次 poll 状态机从 Start 开始运行，直到 socket_read.await 4. socket Future 尝试读取 数据未就绪，向 Reactor 注册 fd 和 Waker 5. 返回 Pending 外层状态机保存局部变量和当前状态，线程去执行其他 Task 6. 网络数据到达 epoll/kqueue/IOCP 通知 Reactor 7. Reactor 调用 Waker Task 被重新加入 Executor 就绪队列 8. Executor 再次 poll 状态机从等待 socket 的状态继续 9. socket Future 返回 Ready(data) await 表达式得到 data，异步函数继续向下执行 10. 最外层 Future 返回 Ready(output) Task 完成，JoinHandle 得到结果 异步运行时的本质，就是高效地重复以下循环：\npoll -\u0026gt; Pending -\u0026gt; wake -\u0026gt; poll -\u0026gt; ... -\u0026gt; Ready 一个最小 block_on 的概念模型 下面的伪代码故意省略线程安全、任务队列和高效唤醒，只用于说明 Executor 为什么需要响应 Waker：\nfn block_on\u0026lt;F: Future\u0026gt;(future: F) -\u0026gt; F::Output { let mut future = Box::pin(future); loop { let waker = make_waker_for_current_task(); let mut context = Context::from_waker(\u0026amp;waker); match future.as_mut().poll(\u0026amp;mut context) { Poll::Ready(output) =\u0026gt; return output, Poll::Pending =\u0026gt; park_until_woken(), } } } 它体现了 Executor 的最小职责：\n固定 Future 的内存位置； 构造能重新调度任务的 Waker； 调用 poll； 遇到 Pending 时休眠或执行其他任务； 被唤醒后再次调用 poll。 真实 Tokio Runtime 不会只驱动一个 Future，也不会用如此简单的等待方式。它需要管理大量 Task、就绪队列、工作窃取、I/O 事件和定时器，但最底层仍然遵循同一个 poll -\u0026gt; Pending -\u0026gt; wake -\u0026gt; poll 循环。\n十、状态机和线程栈有什么区别 同步函数暂停时，操作系统线程会保留完整调用栈：\nThread Stack ┌─────────────────────┐ │ caller frame │ │ async-like function │ │ nested call │ │ local variables │ └─────────────────────┘ Rust async Future 是无栈状态机：\nFuture Object ┌─────────────────────┐ │ state = WaitingBody │ │ response │ │ body_future │ │ other live fields │ └─────────────────────┘ 只有跨越 await 仍然需要的数据才被保存，因此单个异步任务通常比线程栈轻量得多。\n代价是：\nFuture 类型可能很大； 深层 async 调用会形成嵌套 Future； 编译器需要生成复杂状态机； 错误信息和类型有时更复杂； 阻塞代码不会自动让出线程。 十一、async 并不等于并行 下面的代码仍然是顺序执行：\nlet user = fetch_user().await; let orders = fetch_orders().await; 第二个 Future 要等第一个完成后才创建或 poll。\n如果两个操作互不依赖，可以并发等待：\nlet (user, orders) = tokio::join!( fetch_user(), fetch_orders(), ); join! 会在同一个任务中轮流 poll 两个子 Future。它提供并发，但不保证在不同 CPU 核上并行执行。\ntokio::spawn 则创建独立 Task：\nlet user_task = tokio::spawn(fetch_user()); let order_task = tokio::spawn(fetch_orders()); let user = user_task.await??; let orders = order_task.await??; 在多线程 Runtime 中，不同 Task 可能被不同工作线程并行 poll。但是否并行取决于 Runtime、任务是否可迁移，以及代码是否有足够的 CPU 工作。\n可以简单区分：\nasync/await：描述可暂停的计算 concurrency：多个计算在时间上交错推进 parallelism：多个计算在不同 CPU 核上同时执行 十二、为什么异步代码中不能随便阻塞 异步任务共享少量 Executor 工作线程。如果在 async 函数中执行长时间阻塞：\nasync fn bad() { std::thread::sleep(Duration::from_secs(10)); } 这 10 秒内，当前工作线程无法 poll 其他任务。线程上的所有异步任务都可能出现延迟。\n应改用异步定时器：\nasync fn good() { tokio::time::sleep(Duration::from_secs(10)).await; } 异步 sleep 会注册定时器并返回 Pending，工作线程可以继续运行其他任务。\n必须执行同步阻塞操作时，可以使用专用阻塞线程池：\nlet result = tokio::task::spawn_blocking(|| { blocking_computation() }) .await?; CPU 密集型任务也不会因为加上 async 就自动变快。它们应根据场景使用 Rayon、专用线程池、spawn_blocking 或其他并行计算方案。\n十三、常见理解误区 1. await 会阻塞线程 不会。await 在子 Future 返回 Pending 时，让当前状态机的 poll 返回，把线程还给 Executor。\n2. Future 创建后会自动运行 通常不会。Future 是惰性的，必须被 .await、block_on 或 spawn 到 Executor，才会有人 poll 它。\n3. Waker 会直接执行 Future 通常不会。Waker 负责通知调度器，将任务重新标记为 ready；Executor 之后才调用 poll。\n4. 每个 async Task 对应一个线程 不是。大量 Task 可以复用少量工作线程。Task 保存的是状态机对象，而不是独立系统线程栈。\n5. 每次 poll 都从函数开头执行 不是。状态机保存了当前状态，下一次 poll 会跳到上次暂停点对应的分支。\n6. 收到 wake 后下一次 poll 必须 Ready 不是。唤醒只表示“可能取得进展”，Future 仍然可以再次返回 Pending。\n7. async fn 本身就是状态机 enum 更准确地说，调用 async fn 得到的匿名 Future 类型内部实现了状态机语义。enum 是理解它的常用近似模型，不代表编译器一定按手写 enum 的方式布局。\n十四、总结 Rust 异步编程可以浓缩为下面几句话：\nasync fn 返回一个实现 Future 的匿名状态机； 每个 await 都是潜在暂停点； 跨越 await 的局部变量被保存为状态机字段； Executor 通过 poll 主动推进 Future； Future 暂时无法继续时返回 Pending，不会阻塞线程； I/O 或定时器就绪后通过 Waker 通知 Executor； Executor 再次 poll，状态机从暂停状态继续； Pin 保证可能自引用的状态机在被 poll 后不会移动。 最终执行模型是：\nasync fn │ 编译 ▼ Future 状态机 │ Executor 调用 ▼ poll │ ├── Ready(output) ──\u0026gt; 完成 │ └── Pending │ 保存 Waker ▼ 等待事件 │ wake ▼ 再次 poll 因此，状态机不是 Rust 异步实现中的一个附带概念，而是 async/await 能够暂停、保存现场并恢复执行的核心机制。Future 描述状态机的推进接口，Executor 负责调度，Waker 负责重新激活，Reactor 负责感知外部事件，几者共同组成了 Rust 的异步运行模型。\n理解这些基础后，可以继续阅读 《Rust Tokio Runtime 原理与最佳实践》，进一步学习 Tokio 的 Runtime 类型、Task 调度、I/O Driver、定时器、取消安全、背压和优雅退出。\n","permalink":"https://yangyang233333.github.io/posts/rust-async-state-machine/","summary":"\u003cp\u003eRust 的 \u003ccode\u003easync/await\u003c/code\u003e 写起来很像同步代码：调用异步函数、等待结果，然后继续向下执行。但它的底层既不会为每个任务创建一个线程，也不会在 \u003ccode\u003eawait\u003c/code\u003e 时阻塞当前线程。\u003c/p\u003e\n\u003cp\u003e它真正做的事情是：\u003cstrong\u003e编译器把异步函数转换成一个可以暂停和恢复的状态机，这个状态机实现 \u003ccode\u003eFuture\u003c/code\u003e；Executor 反复调用 \u003ccode\u003epoll\u003c/code\u003e 推进状态机，Waker 在资源就绪后通知 Executor 再次调度。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e本文从一段普通异步代码出发，逐层解释：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003easync fn\u003c/code\u003e 返回的到底是什么；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eawait\u003c/code\u003e 为什么能暂停函数；\u003c/li\u003e\n\u003cli\u003e异步代码和状态机是什么关系；\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eFuture::poll\u003c/code\u003e、\u003ccode\u003eContext\u003c/code\u003e 和 \u003ccode\u003eWaker\u003c/code\u003e 分别负责什么；\u003c/li\u003e\n\u003cli\u003eExecutor 与 Reactor 如何配合；\u003c/li\u003e\n\u003cli\u003e为什么 Rust 异步需要 \u003ccode\u003ePin\u003c/code\u003e；\u003c/li\u003e\n\u003cli\u003eTokio 在这套模型中处于什么位置。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e本文定位为 \u003ca href=\"/posts/rust-tokio-runtime-and-best-practices/\"\u003e《Rust Tokio Runtime 原理与最佳实践》\u003c/a\u003e 的前置教程。建议先理解本文中的 \u003ccode\u003eFuture::poll\u003c/code\u003e、状态机、Waker 和 Executor，再继续阅读 Tokio 的调度器、I/O Driver、任务取消与工程实践。\u003c/p\u003e\n\u003ch2 id=\"阅读前准备\"\u003e阅读前准备\u003c/h2\u003e\n\u003cp\u003e读者只需要了解 Rust 的基本语法、所有权、枚举和 trait，不要求提前掌握 Tokio。\u003c/p\u003e\n\u003cp\u003e文中的普通 Rust 代码可以放入一个空项目中实验：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecargo new rust-async-basics\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecd rust-async-basics\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e涉及 Tokio 的示例可加入依赖：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003edependencies\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003etokio\u003c/span\u003e = { \u003cspan style=\"color:#a6e22e\"\u003eversion\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;1\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efeatures\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;full\u0026#34;\u003c/span\u003e] }\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e学习时建议始终追问三个问题：\u003c/p\u003e","title":"Rust 异步编程原理：Future、状态机、Waker 与 Executor"},{"content":"在大模型在线推理中，Prefill 和 Decode 的计算特征非常不同：\nPrefill 一次处理整个 Prompt，矩阵计算密集，适合大 Batch 和高算力利用率； Decode 每轮通常只生成一个 token，更依赖 KV Cache 容量、访存延迟和调度响应。 如果二者运行在同一组 GPU 上，长 Prompt 的 Prefill 会阻塞正在 Decode 的请求，Decode 的小 Batch 又会降低 Prefill 的吞吐。因此越来越多推理系统采用 Prefill/Decode Disaggregation（P/D 分离）：用不同实例或节点分别完成两个阶段。\n但 P/D 分离会产生一个新问题：\nPrefill 在自己的 GPU 上计算出 KV Cache 后，Decode 节点怎样以足够低的延迟拿到这些 KV，并继续生成？\nvLLM 通过统一的 KVConnector 接口回答“什么时候需要外部 KV、需要哪些 block、传输何时完成”；Mooncake 则提供真正的数据通路，使用 RDMA、GPUDirect RDMA、TCP 等协议在节点间传输 GPU KV Cache。\n本文基于以下源码提交阅读：\nvLLM：0ecc284790e5403f74b899524ef82ecb69f83cb3，提交日期 2026 年 8 月 24 日； Mooncake：bfca1ce2af8419c50dc8d464820a95d97d43c930，提交日期 2026 年 8 月 24 日。 当前集成仍在快速迭代，本文以这些提交的实际实现为准。\n一、先区分两种 Mooncake 对接模式 当前 vLLM 代码中与 Mooncake 相关的 Connector 不止一个，容易混淆。\n1. MooncakeConnector：P/D 节点之间直接搬 KV 这是本文的主角。它用于一次请求的 Prefill 和 Decode 分别运行在不同 vLLM Engine 上的场景：\nPrefill GPU KV Cache | Mooncake Transfer Engine | RDMA / GPUDirect RDMA / TCP v Decode GPU KV Cache 特点是：\nKV 在 Prefill 结束后直接传给指定 Decode 实例； 传输以请求和 KV block 为单位； Prefill 必须暂时保留 KV block，直到 Decode 确认接收完成； 重点是低延迟、点对点或少量节点间传输； vLLM 0.13.0 及以后已内置实现。 2. MooncakeStoreConnector：把 KV 放入共享存储层 Mooncake Store 更偏向一个分布式 KV Cache 池：\nvLLM Engine | Mooncake Store |-- DRAM Cache |-- SSD Cache `-- Distributed Storage 它适用于：\n多请求前缀复用； 跨实例共享 KV； KV Cache 分层与持久化； 从外部缓存查询已有前缀； 不是只服务一次 P/D handoff。 vLLM 还可以通过 MultiConnector 组合多个 Connector，例如同时使用 Mooncake 的 P/D 直接传输和外部 KV Store。\n下面重点分析 MooncakeConnector。\n二、部署形态与最小配置 典型部署包含三个角色：\n┌──────────────────┐ Client ----------\u0026gt;│ Disagg Proxy │ └──────┬─────┬─────┘ │ │ Prompt │ │ Full Request v v ┌────────────┐ ┌────────────┐ │ Prefill │ │ Decode │ │ Producer │ │ Consumer │ └─────┬──────┘ └─────▲──────┘ │ Mooncake RDMA │ └────────────────┘ Prefill 实例：\nvllm serve Qwen/Qwen2.5-7B-Instruct \\ --port 8010 \\ --kv-transfer-config \\ \u0026#39;{\u0026#34;kv_connector\u0026#34;:\u0026#34;MooncakeConnector\u0026#34;,\u0026#34;kv_role\u0026#34;:\u0026#34;kv_producer\u0026#34;}\u0026#39; Decode 实例：\nvllm serve Qwen/Qwen2.5-7B-Instruct \\ --port 8020 \\ --kv-transfer-config \\ \u0026#39;{\u0026#34;kv_connector\u0026#34;:\u0026#34;MooncakeConnector\u0026#34;,\u0026#34;kv_role\u0026#34;:\u0026#34;kv_consumer\u0026#34;}\u0026#39; 代理：\npython examples/disaggregated/mooncake_connector/mooncake_connector_proxy.py \\ --prefill http://prefill-host:8010 \\ --decode http://decode-host:8020 Mooncake Python 包需要与 vLLM 的 CUDA 主版本匹配：\nCUDA 13：mooncake-transfer-engine-cuda13； CUDA 12：mooncake-transfer-engine。 安装错误版本时，Python import 会因找不到对应 libcudart.so 失败。\n三、双方的职责边界 理解源码前，先明确 vLLM 和 Mooncake 各做什么。\nvLLM 负责语义和生命周期 vLLM 知道：\n哪个请求处于 Prefill 或 Decode； Prompt 有多少 token； KV Cache 分成哪些 block； 每层 KV tensor 的布局； Tensor Parallel（TP）和 Pipeline Parallel（PP）拓扑； 哪些 block 可以释放； 调度何时能继续执行模型。 因此 vLLM 负责：\n计算远程命中的 token 数； 为 Decode 分配本地 KV block； 构造传输元数据； 管理请求状态和 block 生命周期； 在模型执行前启动接收； 等待必要的 KV 到达； 将传输完成反馈给 Scheduler。 Mooncake 负责数据面 Mooncake Transfer Engine 知道：\n本地注册了哪些内存区； 远端节点和网卡 endpoint； 如何通过 RDMA、GPUDirect RDMA、TCP 或其他 transport 搬运数据； 如何把多段地址组织为批量传输； 如何查询异步传输状态。 它不理解 token、attention layer 或请求调度，只接收：\n源 endpoint + 源地址 + 目标地址 + 长度 这种分层让 vLLM 不必自己实现 RDMA transport，Mooncake 也不必侵入模型执行和 KV block manager。\n四、控制面：代理怎样拆分一次请求 mooncake_connector_proxy.py 展示了最小 P/D 控制面。\n1. 生成统一 transfer_id 代理为每个用户请求生成 UUID，并构造：\ntransfer_id = \u0026#34;xfer-\u0026#34; + request_id Prefill 和 Decode 两边必须使用相同 transfer_id，Mooncake Connector 才能把两阶段识别为同一次 KV handoff。\n2. 先调用 Prefill 代理发给 Prefill 的请求带有：\n{ \u0026#34;kv_transfer_params\u0026#34;: { \u0026#34;do_remote_decode\u0026#34;: true, \u0026#34;do_remote_prefill\u0026#34;: false, \u0026#34;transfer_id\u0026#34;: \u0026#34;xfer-...\u0026#34; } } 含义是：\n当前实例负责计算 Prompt； 计算结果将由远端 Decode 消费； Prefill 不负责正常持续生成。 通常 Prefill 只运行极少 token，用于完成 KV 生成并触发请求结束逻辑。\n3. 再调用 Decode Prefill 请求结束后，代理向 Decode 发送原始请求，并附加：\n{ \u0026#34;kv_transfer_params\u0026#34;: { \u0026#34;do_remote_decode\u0026#34;: false, \u0026#34;do_remote_prefill\u0026#34;: true, \u0026#34;remote_bootstrap_addr\u0026#34;: \u0026#34;prefill-host:8998\u0026#34;, \u0026#34;remote_engine_id\u0026#34;: \u0026#34;prefill-engine-id\u0026#34;, \u0026#34;transfer_id\u0026#34;: \u0026#34;xfer-...\u0026#34; } } Decode 由此知道：\nPrompt KV 已在远端计算； 应从哪个 Prefill Engine 获取； 去哪个 bootstrap server 查询 worker endpoint； 本次传输如何与 Prefill 端匹配。 代理本身不传 KV，也不直接操作 RDMA。它只完成请求拆分与控制元数据拼接。\n五、为什么需要 Bootstrap Server vLLM Engine 可能使用：\nData Parallel； Tensor Parallel； Pipeline Parallel； 一台主机上的多块网卡； 多个 Prefill 实例。 Decode 不能只知道一个 HTTP 地址，它需要发现真正持有 KV Cache 的每个 Prefill worker 及其 Mooncake endpoint。\nPrefill 端因此启动 MooncakeBootstrapServer，默认端口为：\nVLLM_MOONCAKE_BOOTSTRAP_PORT=8998 每个 worker 初始化 Transfer Engine 后，将以下信息注册给 bootstrap：\nEngine ID； Data Parallel rank； Tensor Parallel rank； Pipeline Parallel rank； Mooncake server/segment 名称； 可用 transport endpoint； 必要的拓扑信息。 Decode 收到新的 remote_engine_id 时，会查询 Prefill bootstrap，把远端 Engine 展开为：\nremote_engine_id -\u0026gt; TP rank -\u0026gt; PP rank -\u0026gt; Mooncake endpoint 之后的数据面不再经过 bootstrap。它只承担服务发现和连接建立，不搬运 KV。\n六、Scheduler 侧：把远程 KV 当作“外部命中” MooncakeConnector 在 vLLM 中分成 Scheduler 和 Worker 两部分。\nScheduler 侧不接触 GPU 地址，只回答调度问题。\n1. get_num_new_matched_tokens() Decode 请求携带 do_remote_prefill=true 时，Scheduler 把远端 Prefill 产生的 Prompt KV 视为外部缓存命中。\n它根据 Prompt token 数和当前本地已计算 token 数，返回：\n外部可加载 token 数 是否异步加载 这会影响 vLLM 的 token budget：Decode 不必重新执行完整 Prompt，只需为将要导入的 KV 分配 block，然后从合适位置继续生成。\n对于普通 Transformer，通常整个 Prompt 都可从远端加载。\n对于带 Mamba/GDN 状态的模型，源码会保留最后一个 token 在 Decode 端重算。原因是 Decode 需要从 h(N-1) 推导正确的 h(N)，不能简单把所有状态当作标准 attention KV 搬运。\n2. update_state_after_alloc() 当 vLLM KV Cache Manager 为请求分配完本地 block 后，Connector 记录：\n请求 ID； transfer_id； 远端 Engine ID； bootstrap 地址； Decode 本地目标 block IDs； 每个 KV Cache group 的 block 分布。 这些信息稍后被打包进 MooncakeConnectorMetadata，随 SchedulerOutput 发送给 Worker。\nPrefill 侧也在这里记录“该请求完成后需要发送 KV”，但此时 block 可能仍在计算，暂不能发。\n3. build_connector_meta() 每个调度 step 结束前，Scheduler 将待发送、待接收和未处理请求构造为 metadata：\nreqs_to_recv reqs_to_send reqs_not_processed Worker 不需要重新理解 Request 对象，只按 metadata 启动后台传输。\n七、Worker 初始化：把 vLLM KV Tensor 注册给 Mooncake Mooncake 要执行 RDMA，必须先注册本地 GPU 内存。\n1. 创建 Transfer Engine Worker 初始化时导入：\nfrom mooncake.engine import TransferEngine 随后以本机 endpoint、metadata/handshake 模式和 transport protocol 初始化 Engine。默认 protocol 是 rdma，也可通过 kv_connector_extra_config 配置。\n常用配置包括：\n{ \u0026#34;kv_connector\u0026#34;: \u0026#34;MooncakeConnector\u0026#34;, \u0026#34;kv_role\u0026#34;: \u0026#34;kv_producer\u0026#34;, \u0026#34;kv_connector_extra_config\u0026#34;: { \u0026#34;num_workers\u0026#34;: 10, \u0026#34;mooncake_protocol\u0026#34;: \u0026#34;rdma\u0026#34;, \u0026#34;device_name\u0026#34;: \u0026#34;mlx5_0,mlx5_1\u0026#34; } } device_name 可以限制参与拓扑发现的 RDMA 网卡，避免双方选择不同 link layer 或错误端口。\n2. register_kv_caches() 模型 Runner 建立 KV Cache tensor 后，Connector 遍历每一层或每个 KV Cache group，提取：\nTensor data_ptr()； 总字节数； 每个 block 的字节长度； attention layer 名称和 layer index； KV Cache group； tensor 是否 dense/contiguous； 实际 kernel block layout。 然后调用 Mooncake：\nTransferEngine.register_memory(base_addr, total_size) 注册完成后，RDMA NIC 才能直接访问 GPU KV Cache 区域。\n3. WITH_NVIDIA_PEERMEM Mooncake 注册 GPU 内存有两条常见路径：\nWITH_NVIDIA_PEERMEM=1：通过 ibv_reg_mr()，要求加载 nvidia-peermem； WITH_NVIDIA_PEERMEM=0：使用 DMA-BUF 路径，不依赖 nvidia-peermem，GB200 等环境可能需要此模式。 如果环境没有加载 nvidia-peermem 却保持默认配置，典型错误是：\nFailed to register memory \u0026lt;addr\u0026gt;: Bad address [14] 这不是 vLLM block 分配错误，而是 RDMA Memory Region 注册路径不匹配。\n八、数据面：Decode 主动从 Prefill 拉取 KV 当前 MooncakeConnector 的关键模式是：Decode/Consumer 发起远程读取，Prefill/Producer 提供源地址。\n完整时序如下：\nProxy Prefill Scheduler/Worker Decode Scheduler/Worker | | | |-- prefill request ---\u0026gt;| | | |-- compute prompt KV | | |-- retain KV blocks | |\u0026lt;-- prefill done ------| | | | |---------------- decode request ---------------------\u0026gt;| | |-- allocate local blocks | |-- query bootstrap | |-- send xfer metadata | |\u0026lt;--- ask source block addrs ---| | |--- source addrs/endpoints ---\u0026gt;| | |-- RDMA READ KV | |-- mark recv finished | |\u0026lt;------ completion ack --------| | |-- release source blocks | | |-- start/continue decode 1. Decode 向每个 Prefill worker 请求源地址 Decode 已知道自己的目标 block IDs，但还不知道 Prefill 的源 block IDs 和具体 GPU 地址。\n它通过 Connector side channel 向 Prefill worker 发送 MooncakeXferMetadata，其中包含：\ntransfer_id； Decode endpoint； 目标 block IDs； TP/PP rank 与 KV layout； 异构并行需要的 region 信息。 Prefill worker 根据 transfer_id 找到被保留的源 block，并构造源地址列表。\n2. 地址不是逐 token 传，而是逐 region/block 计算 vLLM KV Cache 通常是大 tensor，每个逻辑 block 对应固定字节跨度：\nblock_addr = base_addr + block_id * block_len Connector 先把每层 KV tensor 展开成 TransferRegion：\nlayer_name layer_index base_addr block_len kv_block_len group_index 随后将源 block 和目标 block 对齐，生成批量 copy entry。\n相邻且地址连续的 block 会被合并为更大的传输，减少 RDMA Work Request 数量和 Python/C++ 调用开销。\n3. Mooncake 执行批量远程读 最终数据面调用类似：\nbatch_transfer_sync_read( remote_segment, local_addresses, remote_addresses, lengths ) 源数据位于 Prefill GPU，目标位于 Decode GPU。配置 GPUDirect RDMA 时，数据可由 NIC 直接在两端 GPU 显存之间搬运，不经过 CPU 数据拷贝。\nCPU 仍参与控制面、地址规划和完成处理，但不作为 KV payload 的 bounce buffer。\n九、为什么 Prefill 不能计算完就释放 KV block 在同机推理中，请求结束后 block 可以立即归还 KV Cache Manager。但 P/D 分离中，Prefill 请求结束只表示 KV 已计算完，不代表 Decode 已经读完。\n因此 request_finished() 会改变释放语义：\nPrefill 将请求对应的 block IDs 记录到 reqs_need_send； block 被标记为仍由 Connector 使用； sender thread 等待 Decode 提供传输 metadata； Decode 完成 RDMA read； 完成消息回到 Prefill； Connector 将请求放入 finished_sending； Scheduler 最终释放这些 block。 这是一种跨节点引用计数协议。若没有它，Prefill block 可能在 Decode 读取期间被新请求复用，造成静默数据破坏。\n十、完成通知、超时与请求中止 分布式推理的困难往往不是 happy path，而是请求中止和节点故障。\n1. Prefill block 超时回收 环境变量：\nVLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT=480 控制 Prefill 最长保留某请求 KV block 的时间。\n如果客户端中止、代理失败或 Decode 永远没有发来请求，Prefill 不能无限持有 block。超时后 Connector 自动释放，防止 KV Cache 容量永久泄漏。\n2. Decode 接收完成 Worker 周期性获取：\nfinished_recving finished_sending 并通过 KVConnectorOutput 返回 Scheduler。Scheduler 只有在收到完成状态后，才允许依赖这些 KV 的请求正常推进或释放相应资源。\n3. 传输失败 Connector 记录失败 block，并通过：\ninvalid_block_ids 传回模型 Runner/Scheduler。这样 vLLM 可以避免把部分写入或失败的 KV block 当成有效缓存继续使用。\n4. Proxy 的职责有限 示例 Proxy 主要展示基本请求编排，并不是完整生产级控制面。生产部署还需要：\nPrefill/Decode 负载感知路由； transfer_id 全局唯一性； 请求取消传播； 节点故障重试； Prefill 成功但 Decode 失败时的清理； 跨可用区网络策略； 限流和背压； 端到端 trace。 十一、TP/PP 不一致时怎样搬 KV 生产环境中 Prefill 和 Decode 不一定采用相同并行度。例如：\nPrefill: TP=8，追求吞吐 Decode: TP=4，追求单请求效率 当前 Connector 包含异构 TP 传输规划。\n1. 本地 TP 大于远端 TP 一个 Decode rank 可能需要接收多个 Prefill shard，或一个 Prefill shard 的数据需要写入 Decode 更大的 KV region。\nConnector 计算 TP ratio，并为每个 rank 生成源/目标 region offset。\n2. 本地 TP 小于远端 TP 一个本地 rank 可能需要从多个远端 rank gather 数据。Connector 按远端 TP rank 选择多个 endpoint，并把不同 shard 写入目标 KV block 的不同偏移。\n3. KV 是否复制会改变规划 某些 attention backend 或并行拓扑会复制 KV Cache，而不是严格分片。Connector 通过 TransferTopology 判断 producer cache 是否 replicated，避免重复传输或错误拼接。\n4. PP 维度 每个 PP rank 只持有部分 layer。Bootstrap 保存 TP rank 到 PP rank endpoint 的映射；Decode 按本地 layer 对应的远端 PP rank拉取。\n如果两侧 PP 划分不同，Connector 会根据 layer index 和 region metadata 对齐，而不是假设“同 rank 即同层”。\n十二、Hybrid KV Cache 和特殊模型布局 vLLM 已支持多种 KV Cache spec：\nFull Attention； Sliding Window Attention； Mamba/GDN 状态； Hybrid KV Cache Manager； 不同 attention backend 的物理布局。 Mooncake Connector 因此不能简单假设所有层都有相同 block 大小。\nSliding Window 只需传输窗口内仍有效的 block。Scheduler 会按每个 KV Cache group 的 sliding window 截断 block IDs，避免搬运已经不会再被 attention 使用的历史 KV。\nHybrid KV Cache 不同 group 可能拥有不同 block size 和布局。Metadata 按 group 保存 block list，Worker 再展开为独立 TransferRegion。\nKernel Block Size 逻辑 block 与 attention kernel 实际使用的 block 可能不完全一致。Connector 初始化时会同步 kernel block size，并将逻辑 block IDs 转换为 kernel block IDs，确保地址步幅正确。\n这也是 Mooncake 集成放进 vLLM 主仓库的重要原因：它需要紧跟 KV Cache layout、attention backend 和 Scheduler 内部变化，单纯的外部插件很难长期稳定适配。\n十三、从 OOT Connector 到 vLLM 内置实现 Mooncake 仓库仍保留 mooncake_connector_v1.py，用于兼容 vLLM 0.10.1–0.12.0。\n该模块明确提示：\nvLLM 0.13.0 之前使用 Mooncake wheel 中的 Out-of-Tree Connector； vLLM 0.13.0 及以后应使用 vLLM 内置 MooncakeConnector。 这个迁移有现实原因。\nConnector 深度依赖：\nSchedulerOutput； Request 状态； KV Cache group/spec； attention backend； block table； TP/PP 拓扑； Model Runner 生命周期。 这些内部接口随 vLLM 快速演进。内置实现可以与 KV Cache Manager、Scheduler 和测试一起修改，降低版本漂移成本；Mooncake wheel 则专注稳定的数据传输 API。\n十四、性能取决于哪些因素 Mooncake 提供零拷贝或低拷贝通路，不代表任何部署都会自动变快。\n1. Prompt 长度 Prompt 很短时，远程传输和两次 HTTP 调度的固定成本可能高于重算。P/D 分离更适合长 Prompt 或可批量化 Prefill。\n2. KV 大小 KV 字节数大致随以下因素增长：\n层数 × KV heads × head dimension × token 数 × dtype bytes GQA/MQA、量化 KV、MLA 会显著改变传输量。\n3. 网络拓扑 需要关注：\nGPU 与 NIC 是否位于同一 NUMA/PCIe Root； 是否启用 GPUDirect RDMA； RoCE PFC/ECN 配置； InfiniBand 路由； 多 NIC 是否被正确选择； PCIe ACS/IOMMU； nvidia-peermem 或 DMA-BUF 支持。 4. Block 合并率 连续 block 越多，Connector 越能合并传输。高度碎片化的 block table 会增加 RDMA WR 数量和控制开销。\n5. Prefill 与 Decode 的资源配比 P/D 分离最终是排队系统问题。Prefill 太少会让请求等待 Prompt 计算；Decode 太少会让 KV 已传到但无法及时生成。Proxy 需要根据 Prompt 长度、输出长度和当前队列动态路由。\n十五、一次完整请求的源码调用链 把所有步骤串起来：\nClient | v mooncake_connector_proxy.py |-- 生成 request_id / transfer_id |-- 请求 Prefill: do_remote_decode=true | v Prefill Scheduler |-- 正常分配 KV blocks |-- update_state_after_alloc(): 记录待发送请求 | v Prefill Model Runner |-- 计算 Prompt KV |-- KV tensor 已 register_memory 到 Mooncake | v Prefill request_finished() |-- 不立即释放 block `-- 保存 transfer_id -\u0026gt; source block IDs Proxy |-- 请求 Decode: do_remote_prefill=true | remote_engine_id | remote_bootstrap_addr v Decode Scheduler |-- get_num_new_matched_tokens(): Prompt 外部命中 |-- 为外部 KV 分配本地 blocks |-- build_connector_meta(): reqs_to_recv v Decode Worker |-- start_load_kv() |-- 查询 Prefill bootstrap |-- 获取 TP/PP worker endpoints |-- 向 Prefill side channel 请求源地址 |-- 对齐 source/destination TransferRegion |-- 合并连续 blocks `-- Mooncake batch remote read | `-- RDMA/GDR: Prefill GPU -\u0026gt; Decode GPU Decode Worker |-- finished_recving |-- KV 可被 attention kernel 使用 `-- 开始/继续 Decode Prefill Worker |-- 收到完成通知 |-- finished_sending `-- Scheduler 释放源 KV blocks 十六、设计评价 优点 vLLM 保留调度主导权 Mooncake 不接管请求和 block manager。它只实现 transport，避免把模型语义耦合进网络层。\n数据面可以真正绕过 CPU payload copy 使用 GPUDirect RDMA 时，KV 从 Prefill GPU 直接进入 Decode GPU。CPU 负责控制，不承载 payload。\n与 vLLM 多种 KV 布局共同演进 内置 Connector 能处理 Hybrid KV、Sliding Window、TP/PP 和特殊模型状态，而不只是固定 shape 的 tensor copy。\n生命周期协议完整 Prefill block 保留、完成确认、超时释放和失败 block 标记，解决了分布式传输最容易被忽略的资源安全问题。\n代价 控制面复杂 一次请求经历两次推理服务调用、bootstrap 查询、side channel 握手和 RDMA 传输。生产 Proxy 远比示例复杂。\n基础设施要求高 GPUDirect RDMA 依赖网卡、驱动、PCIe 拓扑和网络配置。任何一层不匹配都可能退化或失败。\n远程传输不是免费的 对于短 Prompt、小模型或低速网络，重算可能比搬 KV 更快。系统需要成本模型决定是否 P/D 分离。\n版本耦合仍然存在 虽然 Connector 已内置 vLLM，但 Mooncake Transfer Engine、CUDA、RDMA 驱动和 vLLM 版本仍需兼容验证。\n总结 vLLM 与 Mooncake 的对接并不是简单地“在 vLLM 中调用一个 RDMA copy API”。它是一套跨控制面、调度器、模型执行器和传输引擎的协议：\nProxy 用统一 transfer_id 把 Prefill 和 Decode 请求关联起来； vLLM Scheduler 把远程 Prompt KV 建模为外部 cache hit； Decode 先分配本地 KV block，再构造传输 metadata； Prefill bootstrap 暴露真正持有 KV 的 TP/PP worker endpoint； Worker 将 vLLM KV tensor 注册给 Mooncake Transfer Engine； Decode 通过 RDMA/GDR 从 Prefill GPU 批量读取 block； 完成协议保证 Prefill 只在远端读完后释放 KV； 超时和失败路径防止 block 泄漏与错误 KV 被继续使用。 二者的职责划分非常清晰：\nvLLM 决定搬什么、何时搬、搬到哪个 block； Mooncake 决定通过哪张网卡和哪种 transport 把字节搬过去。 这也是该集成能够支持复杂 KV Cache layout 和异构并行的根本原因。对于准备部署 P/D 分离的团队，真正需要评估的不只是 RDMA 带宽，还包括请求路由、Prompt 长度分布、KV block 碎片、TP/PP 组合、失败回收和网络拓扑。数据通路只是系统的一半，另一半是如何让 KV 的生命周期与 vLLM 调度状态始终一致。\n参考资料 vLLM MooncakeConnector Usage Guide vLLM MooncakeConnector 源码 vLLM P/D 分离示例 Mooncake 项目 Mooncake 文档 ","permalink":"https://yangyang233333.github.io/posts/vllm-mooncake-integration/","summary":"深入分析 vLLM 内置 MooncakeConnector：从代理拆分请求、Scheduler 元数据、GPU KV Cache 注册，到 Mooncake Transfer Engine 通过 RDMA 将 Prefill KV 直接搬到 Decode 节点。","title":"vLLM 与 Mooncake 对接源码解读：Prefill/Decode 分离中的 KV Cache 如何跨节点传输"},{"content":"2026 年 8 月 4 日至 6 日，Future of Memory and Storage（FMS）在美国圣克拉拉举行。作为这场会议的 20 周年节点，FMS 2026 展示出的变化并不只是 NAND 层数继续增加、SSD 带宽继续翻倍，而是整个行业开始重新回答一个基础问题：\n当 AI 加速器的计算能力增长速度远高于内存容量、内存带宽和数据装载速度时，系统应该如何重新组织 HBM、DRAM、CXL 内存、闪存与远程存储？\n从 SK hynix 与 Sandisk 联合推动的 High Bandwidth Flash（HBF），到 Samsung 的 zHBM/zNAND-O 概念，再到 CXL 内存池、PCIe 6.0 企业 SSD和液冷设计，FMS 2026 的主线可以概括为：\n存储不再只是计算完成后的持久化终点， 而正在成为 AI 加速器旁边可调度的数据与容量层。 本文将 FMS 2026 的核心进展分为五条技术主线，并区分哪些已经形成标准或产品，哪些仍处于概念和路线图阶段。\n一、为什么 AI 迫使内存和存储重新分层 传统服务器的层级大致是：\nCPU Cache -\u0026gt; DRAM -\u0026gt; 本地 SSD -\u0026gt; 网络存储 GPU 时代在最前端加入了 HBM：\nGPU SRAM/Cache -\u0026gt; HBM -\u0026gt; CPU DRAM -\u0026gt; SSD -\u0026gt; 网络存储 问题是，这个层级中存在两个越来越大的断层。\n容量断层 HBM 带宽极高，但容量小、价格高、封装面积有限。大模型参数、Embedding、KV Cache 和训练状态的增长速度远快于单个加速器的 HBM 容量。\n例如，一个拥有数百亿到数千亿参数的模型，即使量化后也可能无法完整放入单卡 HBM。推理系统还要为并发请求保留 KV Cache，训练系统则要保存优化器状态、激活值和 checkpoint。\n带宽断层 NAND SSD 容量大、单位成本低，但访问方式和并行度仍按传统块设备设计。即使 PCIe 链路继续升级，单个请求的延迟和面向 AI tensor 的访问粒度仍远不如 HBM。\n因此，AI 基础设施需要的不是简单地“扩大内存”或“加快 SSD”，而是增加新的中间层，并让软件按照数据热度和访问模式迁移数据。\nFMS 2026 展示的 HBF、CXL 内存池和新型封装，本质上都在填补这些断层。\n核心进展一：HBF——把 NAND 做成 HBM 的容量伙伴 1. HBF 是什么 SK hynix 与 Sandisk 在 FMS 2026 发布首个 High Bandwidth Flash 架构规范，并将其提交至 Open Compute Project。公开规范版本为 OCP HBF Architecture Specification v0.7。\nHBF 的目标不是用 NAND 取代 HBM，而是设计一种：\n容量远高于 HBM； 并行度远高于传统 SSD； 能够紧邻 AI 加速器部署； 软件上更像内存层而非独立块设备； 成本与功耗低于用 HBM 承载全部冷数据； 的新层级。\n可以把 HBF 放在以下位置理解：\nGPU Cache / SRAM | HBM 极高带宽，低容量，高成本 | HBF 高并行闪存，较大容量 | CXL Memory / SSD 更大容量，延迟更高 2. 为什么普通 SSD 不够 传统 NVMe SSD 的并行度被封装在控制器内部，对主机暴露的是队列和逻辑块地址。AI 加速器要访问一批 tensor、Embedding 向量或 KV block，需要经历：\n构造 NVMe 命令； 经 PCIe 传给 SSD 控制器； 控制器进行 FTL 地址转换； 调度 NAND channel、die 和 plane； 数据通过相对较窄的 PCIe 接口返回。 SSD 内部可能有很多 NAND die，但这些并行度并未以 HBM 式宽接口直接暴露给加速器。\nHBF 的基本思路是改变这一点：通过更多独立通道、宽接口和高密度堆叠，让大量 NAND die 可以并发服务请求。它试图把 NAND 从“一个拥有很多内部芯片的 I/O 设备”变为“一个可被大规模并行访问的容量层”。\n3. HBF 最适合什么数据 HBF 并不适合所有 GPU 数据。它最适合以下特征：\n容量远大于 HBM； 对带宽敏感，但允许比 HBM 更高的延迟； 读取多于写入； 数据能够以较大块或可预测模式预取； 热度低于当前计算工作集。 典型对象包括：\n模型权重 推理时，并非每一层、每个专家或每组权重都需要长期驻留 HBM。Mixture-of-Experts 模型尤其适合把不活跃专家放在大容量层中，在路由确定后预取。\nKV Cache 长上下文和高并发会快速耗尽 HBM。活跃 token 的 KV block 留在 HBM，较老或低优先级会话可以下沉到 HBF。\nEmbedding Table 推荐系统和检索系统的 Embedding 容量巨大，但访问高度稀疏。HBF 的容量和并行读取能力比昂贵 HBM 更匹配。\nCheckpoint 与训练状态 HBF 可以成为训练节点中的快速 checkpoint 层，减轻每次保存都穿过主机网络和远程存储的压力。\n4. HBF 真正困难的地方 HBF 面临的挑战不只是做出更宽的闪存接口。\nNAND 延迟不会因为换名字消失 NAND 读取延迟仍显著高于 DRAM/HBM。更多通道只能提高并行吞吐，不能从物理上把单次访问变成纳秒级。\n因此 HBF 必须依靠：\n大规模并发； 请求合并； 软件预取； 数据布局优化； HBM Cache； 热度预测。 如果工作负载是强依赖链上的随机小读，HBF 仍可能让 GPU 等待。\n写入耐久性与尾延迟 KV Cache 和 checkpoint 都可能包含写入。NAND 的擦写、垃圾回收和磨损均衡会引入尾延迟。若 HBF 想表现得更像内存，控制器必须把这些存储特性隐藏得更好，或让软件感知介质约束。\n编程模型尚未定型 HBF 应该暴露成内存地址、块设备、对象空间，还是由 GPU runtime 管理的专用层？\n不同选择会影响：\n一致性； 页故障处理； 数据持久性语义； 多进程共享； 隔离与安全； 操作系统是否参与调度。 当前 HBF 已出现架构规范，但它仍是早期生态。规范不等于商业产品已经普及，更不意味着软件栈已经成熟。\n5. 对产业格局的影响 HBF 最值得关注的不是一款产品，而是 HBM 厂商和 NAND 厂商第一次更明确地争夺同一段 AI 数据层级。\n过去二者分工清晰：HBM 服务计算，NAND 服务持久化。HBF 试图建立新的产品类别，让 NAND 进入加速器封装附近。\n如果成功，未来 AI 节点可能按以下方式配置：\n数百 GB HBM 数 TB HBF 数 TB CXL DRAM 数十 TB 本地 SSD PB 级共享存储 这将改变 GPU 服务器的 BOM、封装设计、内存控制器、运行时和集群调度器。\n核心进展二：Samsung zHBM 与 zNAND-O——从平面互连走向垂直系统 Samsung 在 FMS 2026 提出了 zHBM 与 zNAND-O 等概念，并展示 400 层以上 V10 BV-NAND、HBM4E/HBM5 和 LPDDR5X-PIM 路线。\n这些命名背后体现的是同一个方向：通过 3D 集成缩短计算与数据之间的物理距离。\n1. zHBM：把 HBM 放到加速器上方 当前 HBM 通常与 GPU/AI ASIC 并排放在硅中介层上：\n[HBM] [HBM] [GPU] [HBM] [HBM] Silicon Interposer 这种 2.5D 封装已经提供很宽的接口，但中介层面积、信号距离、封装良率与散热逐渐成为限制。\nzHBM 概念尝试把 HBM 垂直堆叠到逻辑芯片上方：\nHBM Stack | Vertical Interconnect | AI Accelerator 潜在收益包括：\n更短互连； 更高带宽密度； 更小封装占地； 更多内存堆叠位置； 更低每 bit 传输能耗。 2. 垂直堆叠的真正瓶颈是热 AI 加速器本身可能达到数百瓦甚至上千瓦。HBM 也会产生大量热。如果将内存直接堆在计算芯片上方，热流必须穿过多个层级排出。\n因此 zHBM 是否可行，不仅取决于 TSV、hybrid bonding 或微凸点，还取决于：\n背面供电； 层间散热结构； 微流体冷却； 热点感知布局； 逻辑层与存储层的功率调度； 堆叠后的测试与修复能力。 3D 封装会把“内存墙”转化为“热墙”和“良率墙”。\n3. zNAND-O：NAND 也要进入垂直 AI 封装 zNAND-O 代表将高性能 NAND 与 AI 逻辑更紧密集成的设想。相比 HBF 更偏向接口和存储层级标准，zNAND-O 更强调封装形态和物理集成。\n其目标可能包括：\n在加速器附近提供更大容量； 缩短 NAND 到计算逻辑的通路； 让封装内互连替代部分 PCIe 往返； 为推理权重、KV Cache 和 Embedding 提供本地容量层。 但截至 FMS 2026，它更接近技术愿景，而非可直接采购的标准产品。评价这类发布时，需要把“概念展示”“路线图”“工程样品”和“量产”严格区分。\n4. 400 层以上 NAND：密度仍在推进，但不再是唯一指标 Samsung 展示 V10 BV-NAND，层数超过 400，采用 wafer bonding。NAND 厂商继续通过键合方式分别制造外围逻辑与存储阵列，再把晶圆连接起来。\n这能改善：\n单位面积密度； 外围逻辑工艺选择； I/O 速度； 阵列与控制逻辑的独立优化。 但 FMS 2026 释放的信号是：AI 市场不再只问“多少层、多少 TB”，而是同时追问：\n能否提供稳定低尾延迟？ 能否与 GPU 直接互连？ 能否高并行读取？ 能否被内存分层软件有效管理？ 核心进展三：CXL 从扩容卡走向内存池与可组合基础设施 CXL 早期最容易理解的卖点是：给 CPU 增加更多内存。但 FMS 2026 的讨论重点已经转向更大的系统问题——如何让一组服务器和加速器共享、分配和迁移内存容量。\n1. CXL 的价值不只是“慢一点的 DRAM” 单机内存扩展的结构是：\nCPU / Accelerator | CXL Link | CXL Memory Expander 而内存池化结构更接近：\nHost A ----\\ Host B ----- CXL Switch ---- Shared Memory Pool Host C ----/ DRAM / SCM / Flash-backed tier 资源可以按工作负载动态分配，而不是在服务器采购时永久固定。\n2. 为什么 AI 需要池化 AI 集群的内存需求经常不均衡：\n训练作业在 checkpoint 或数据预处理阶段突然需要大量主机内存； 推理实例的 KV Cache 随并发和上下文长度波动； Embedding 或模型权重在不同节点间重复缓存； GPU 数量固定，但 CPU DRAM 容量可能成为调度约束； 某些节点内存空闲，另一些节点因容量不足无法启动任务。 CXL 池化允许调度器把内存视为集群资源，而不是主板上的固定部件。\n3. CXL 需要真正的软件控制面 硬件能把远端内存映射到地址空间，只解决了“能访问”。要在生产环境中使用，还需要解决：\n哪些页面放在本地 DRAM，哪些放在 CXL； 何时迁移； 多租户隔离； 带宽与延迟 QoS； 故障域与热拔插； NUMA 拓扑感知； 可观测性； 内存池碎片整理； 与 Kubernetes、虚拟机和 AI 调度器集成。 因此 FMS 2026 的 CXL 展示越来越强调“全栈”：控制器、交换芯片、设备固件、内核、管理软件和遥测，而不是单独一张扩展卡。\n4. CXL 与 HBF 不是替代关系 两者解决不同问题：\nHBF 追求靠近加速器的闪存级大容量和高并行带宽； CXL 更强调一致性语义、资源池化和跨主机可组合； HBM 仍负责最热、延迟最敏感的工作集； NVMe SSD 继续承担高容量持久化。 未来软件可能管理如下层级：\nHBM：当前 kernel/tensor 工作集 HBF：冷权重、较冷 KV、Embedding CXL DRAM：可共享的扩展内存 NVMe SSD：checkpoint、本地持久数据 NVMe-oF/Object：集群共享数据 真正困难的是跨层迁移策略，而不是单个介质的峰值指标。\n核心进展四：PCIe 6.0 SSD 进入产品期，液冷成为设计条件 FMS 2026 上，PCIe 6.0 企业 SSD 不再只是控制器演示，而开始形成明确产品线。相较 PCIe 5.0，PCIe 6.0 采用 64 GT/s、PAM4 信号和 FLIT 模式，x4 链路理论双向能力再次提升。\n1. 带宽增长服务于 AI 数据供应 AI 节点需要同时执行：\ncheckpoint 写入； 模型权重加载； 数据集流式读取； KV Cache 卸载； 向 GPU Direct Storage 提供数据； 为多个 GPU 或租户共享 SSD。 PCIe 6.0 SSD 的意义不是让单线程 read() 自动翻倍，而是为高并发队列、多 GPU 和存储池提供更高链路上限。\n2. SSD 的瓶颈正在从 NAND 转向整机功耗与散热 高性能企业 SSD 集成更多 NAND channel、更强控制器和更高速 SerDes，功耗随之增长。在密集 GPU 服务器中，前端 GPU 已经消耗大部分散热预算，SSD 很难继续依赖低速风冷。\n因此 FMS 2026 上液冷 SSD、EDSFF 形态和面向液冷服务器的热设计受到更多关注。\n这意味着 SSD 设计指标正在增加：\n峰值带宽； 稳态性能； P99/P999 延迟； 每瓦 IOPS； 冷板接触与热阻； 固件温控降频曲线； 多盘同时满载时的机架热密度。 3. PCIe 6.0 不能自动解决端到端问题 即使 SSD 链路达到数十 GB/s，应用仍可能受限于：\n文件系统锁； 页缓存或 direct I/O 对齐； CPU 提交开销； IOMMU 映射； GPU buffer 注册； PCIe 拓扑绕行； 单个 NAND die 延迟； 写放大和垃圾回收； 网络存储后端。 因此 PCIe 6.0 必须与 GDS、P2P-DMA、io_uring、SPDK、NVMe-oF 和计算存储结合，才能转化为 AI 应用可见的收益。\n4. 对软件的直接影响 更快的设备会让软件固定开销更显眼。例如，若设备完成一次小 I/O 只需几十微秒，buffer 注册、系统调用、队列锁和完成处理可能占据大部分端到端延迟。\n这与 Phoenix 等 GDS 重构工作的观察一致：硬件越快，传统软件栈中的“辅助步骤”越可能成为主要瓶颈。\n核心进展五：NVMe 的重点从性能协议转向可运维 AI 基础设施 NVMe 已经完成从单盘协议到存储网络基础的演进。FMS 2026 上更值得注意的是，行业开始强调如何让 NVMe 在 AI 集群中变得可组合、可观测、可隔离和可持续运行。\n1. AI 需要的是共享数据面 大型 AI 集群不能让每个 GPU 节点都保存完整数据副本。它们需要：\nNVMe-oF 共享高性能存储； 多路径与故障切换； GPU Direct RDMA； Namespace 与租户隔离； 端到端遥测； 计算存储或近数据处理； 对象存储与块存储协作。 NVMe 在这里承担的是从本地设备到 Fabric 数据面的统一协议角色。\n2. 可管理性比峰值 IOPS 更重要 AI 作业可能持续数小时或数周，一次尾延迟抖动、路径故障或 SSD 热降频都可能拖慢整个同步训练集群。\n因此企业真正关心：\n是否能快速定位慢盘和慢路径； 是否支持细粒度健康数据； 固件升级是否不中断业务； 多路径是否能按负载动态切换； 是否能对训练、推理和 checkpoint 流量做 QoS； 故障是否会扩散到整个 GPU Pod。 NVMe 的竞争正在从“设备能跑多快”转向“数千设备是否能长期一致地快”。\n3. 计算存储会以专用功能而非万能计算回归 把 CPU/加速器放进 SSD 的计算存储概念已经出现多年。AI 时代重新提供了适合近数据处理的任务：\n解压缩； 校验和与加密； 数据过滤； 格式转换； 向量索引扫描； checkpoint 去重与压缩； 数据集预处理。 但通用计算存储面临开发、隔离和调度困难。更可能落地的形式是固定功能或受限可编程的数据处理单元，而不是让每块 SSD 变成通用服务器。\n六、如何理解这五条进展之间的关系 FMS 2026 的发布看似分散，实际可以放进同一张数据层级图：\n┌──────────────────────────────────────┐ │ GPU/AI ASIC │ │ SRAM / Cache │ ├──────────────────────────────────────┤ │ HBM / zHBM │ 最热工作集 ├──────────────────────────────────────┤ │ HBF / zNAND-O │ 大容量近加速器层 ├──────────────────────────────────────┤ │ CXL DRAM Pool │ 可组合扩展内存 ├──────────────────────────────────────┤ │ PCIe 6.0 NVMe SSD │ 本地持久化与缓存 ├──────────────────────────────────────┤ │ NVMe-oF / Distributed / Object Store │ 集群共享容量 └──────────────────────────────────────┘ 它们不是相互替代，而是在重新切分“速度、容量、成本、持久性和共享范围”。\n数据应如何分布 数据 更可能的层级 当前计算 tile、激活值 HBM 活跃 KV Cache HBM 冷 KV Cache HBF / CXL / SSD 活跃专家权重 HBM 非活跃 MoE 专家 HBF / CXL 超大 Embedding HBF / CXL / SSD 最近 checkpoint 本地 PCIe 6.0 SSD 长期 checkpoint 和数据集 NVMe-oF / 对象存储 软件将成为决定因素 硬件层级越多，错误放置数据的成本越高：\n把冷数据长期放 HBM，浪费昂贵容量； 把热数据放 NAND，GPU 因等待而空闲； 迁移过于频繁，会消耗互连带宽； 预取错误会放大写入与缓存污染； 多租户争抢共享层会产生严重尾延迟。 未来关键软件包括：\nGPU runtime 的统一虚拟地址与页迁移； KV Cache 调度器； MoE 权重预取； CXL 内存 tiering； GDS/P2P-DMA 数据路径； 跨层 admission control； 基于模型语义的数据放置； 端到端遥测和反馈控制。 七、哪些已经成熟，哪些仍需谨慎 技术 FMS 2026 状态判断 主要风险 PCIe 6.0 企业 SSD 进入明确产品阶段 功耗、散热、平台支持、软件利用率 CXL 内存扩展 产品化推进中 延迟、交换生态、软件管理与故障语义 CXL 内存池 早期部署/生态建设 多主机共享、QoS、编排复杂度 HBF 已有早期 OCP 架构规范 控制器、接口、软件模型、耐久性、量产时间 zHBM 概念与长期封装路线 散热、供电、良率、测试、成本 zNAND-O 概念性架构 介质延迟、封装与生态尚未确定 NVMe-oF/GDS 已部署并持续演进 拓扑、尾延迟、运维复杂度 FMS 展会信息通常混合标准、原型、路线图与量产产品。理解技术进展时，不能把“发布概念”直接等价为“明年可大规模采购”。\n八、对 AI 基础设施的几个判断 1. HBM 不会被替代，但会被更精细地使用 未来 HBM 更像 CPU 的大容量最后级缓存：只存放正在计算或即将计算的数据。容量型数据会逐渐下沉到 HBF、CXL 和 SSD。\n2. KV Cache 将成为新存储层级的首批杀手级负载 KV Cache 兼具容量大、访问有阶段性、可按 block 管理、对延迟敏感等特点，天然适合展示多层内存的价值。FMS 2026 的大量技术都能在 KV Cache 分层中找到应用场景。\n3. 封装、互连和冷却已成为同一个问题 zHBM、HBF 和 PCIe 6.0 SSD 都受到功耗密度限制。未来内存与存储产品不能脱离机架供电和液冷系统单独设计。\n4. 标准接口会比封闭数据路径更有生命力 AI 系统需要同时连接 GPU、NIC、SSD、CXL 内存和远程存储。能复用 POSIX、NVMe、CXL、RDMA 和开放管理接口的方案，更容易进入复杂生产环境。\n5. 峰值带宽的重要性会下降，尾延迟与调度效率更重要 训练同步、推理 SLO 和大规模存储共享都对尾延迟极为敏感。单设备 benchmark 中的峰值 GB/s，不能代表上千 GPU 集群中的有效吞吐。\n总结 FMS 2026 的核心不是某个厂商把 NAND 堆到 400 多层，也不是 PCIe 6.0 SSD 再次刷新顺序读带宽。\n真正的变化是：\nAI 正迫使内存与存储从一条固定的硬件层级，演进为一组可由软件动态管理的数据资源池。\nHBF 试图用 NAND 填补 HBM 与 SSD 之间的容量带宽空白；zHBM 和 zNAND-O 把竞争推进到垂直封装；CXL 将内存从单机部件变成可组合资源；PCIe 6.0 和液冷 SSD提高本地持久层上限；NVMe 则继续成为连接本地设备、Fabric 与 GPU 数据路径的基础协议。\n接下来真正决定成败的，将不只是芯片和介质，而是软件能否理解模型结构、请求生命周期和数据热度，在 HBM、HBF、CXL、SSD 和远程存储之间正确地放置与迁移数据。\nFMS 2026 展示的是新硬件层级的起点。下一阶段的竞争，会发生在“谁能把这些层级组合成一个稳定、透明、可运维的 AI 内存系统”。\n参考资料 Future of Memory and Storage 2026 NVM Express：FMS 2026 活动页面 OCP High Bandwidth Flash Architecture Specification v0.7 SK hynix：HBF at FMS 2026 Samsung：Next-Generation 3D Memory Vision at FMS 2026 Linux PCI Peer-to-Peer DMA Support Compute Express Link Consortium ","permalink":"https://yangyang233333.github.io/posts/fms-2026-core-trends/","summary":"深入解读 FMS 2026 的五条核心技术主线：High Bandwidth Flash、3D 内存封装、CXL 内存池、PCIe 6.0 液冷 SSD，以及面向 AI 的 NVMe 可运维基础设施。","title":"FMS 2026 深度解读：AI 正在重构内存与存储层级"},{"content":" 译者说明：本文是对 SC'25 论文 Phoenix: A Refactored I/O Stack for GPU Direct Storage without Phony Buffers 的章节级中文忠实详译。为适合公开博客阅读，本文保留原论文的完整论证顺序、设计要点、实验设置、关键数据和作者结论，但不逐句复制论文排版文本；部分图表以文字和表格重述。建议研究引用使用论文原文。\n论文 DOI：10.1145/3712285.3759862 会议：SC \u0026lsquo;25（The International Conference for High Performance Computing, Networking, Storage and Analysis） 作者：Jianqin Yan、Shi Qiu、Yina Lv、Yifan Hu、Hao Chen、Zhirong Shen、Xin Yao、Renhai Chen、Jiwu Shu、Gong Zhang、Yiming Zhang 开源实现：xPU-IO/Phoenix 术语约定 英文 本文译法 含义 GPU Direct Storage, GDS GPU 直接存储 存储设备与 GPU 显存之间直接传输数据的技术栈 Peer-to-Peer DMA, P2P-DMA 点对点 DMA PCIe 设备之间不经过主机 DRAM 的 DMA bounce buffer 中转缓冲区 传统路径中位于主机内存的数据中转区 phony buffer 伪缓冲区 GDS 为满足 Linux struct page 语义而创建、代表 GPU buffer 的主机内存 GPU buffer GPU 缓冲区 GPU 显存中的应用数据区域 ZONE_DEVICE 设备内存区 Linux 将设备内存纳入页模型的机制 HBM 高带宽显存 GPU 板载高带宽内存 摘要 GPU Direct Storage（GDS）是 GPU 训练与推理系统中的重要组成部分。它利用 PCIe 的 P2P-DMA，在 GPU 与存储设备之间建立直接数据通路。与传统的 CPU 中转路径相比，这条直达路径能够降低存储访问延迟和 CPU 开销，提高数据传输效率。\n然而，现有 GDS 为了与 Linux 内核交互，会在主机内存中使用一种“伪缓冲区”。这种设计带来三个问题：I/O 性能不理想、资源消耗额外增加，以及部署与编程复杂度较高。\n论文提出 Phoenix：一种不依赖伪缓冲区的重构版 GDS I/O 栈。Phoenix 使用 Linux 4.3 起支持的 ZONE_DEVICE 内存映射服务，在系统启动阶段将 GPU 内存映射进 Linux 页表。Phoenix 内核模块保存映射产生的地址信息，在用户空间分配虚拟内存，并将其与指定 GPU 内存建立映射。\n作者在新近发布的 GPU 和 NPU 上实现了 Phoenix。实验表明，相比当时最先进的 GDS I/O 栈，Phoenix：\n将 I/O 关键路径上的平均软件处理开销降低 70.3%； 将 KV Cache 等小粒度 I/O 的性能最高提升到 2.29 倍； 将 checkpoint 等大文件加载性能最高提升到 4.11 倍。 1. 引言 大语言模型等 GPU 加速应用对存储容量的需求持续增长。模型、训练数据和中间状态可能分布在大量文件中，总规模达到数十 TB，并且仍在扩大。GPU 与存储设备之间的数据传输必须足够快，否则存储访问会成为阻塞 GPU 计算的瓶颈。\n传统 GPU 存储访问通常依赖主机中转缓冲区。以 PyTorch DataLoader 为例，数据先通过主机文件系统接口从存储设备读到 CPU 内存，然后再由设备驱动复制到 GPU 显存：\n存储设备 -\u0026gt; 主机中转缓冲区 -\u0026gt; GPU 显存 额外的数据复制拉长了路径，降低 I/O 性能。\n为绕过中转缓冲区，NVIDIA 提出了 GDS，使 GPU 显存与 NVMe 等存储设备之间能够直接传输数据。GDS 以 PCIe P2P-DMA 为基础。传统 DMA 的目标通常是主机内存，而 P2P-DMA 的地址指向另一个 PCIe 外设的内存。数据传输由 PCIe Switch 或 Root Complex 协调，不必经过低效的主机 DRAM 中转。\nGDS 对 I/O 密集的 GPU 应用尤其重要，例如：\nKV Cache 卸载与回载； 模型 checkpoint 加载和保存； 分布式文件系统中的 GPU 数据访问； 大模型服务系统中的存储分层。 但是，论文认为当时的 GDS 软件栈没有充分发挥 P2P-DMA 的优势。\n1.1 为什么 GDS 需要“伪缓冲区” Linux 内核处理 DMA I/O 时，需要为相关内存页取得 struct page 引用，保证 I/O 进行期间页面不会被释放。普通 CPU 内存天然属于 Linux 页管理体系；GPU 显存却不一定具有可供通用文件系统和块层直接使用的 struct page。\n为解决这个问题，GDS 在主机内存中申请一块与 GPU buffer 对应的伪缓冲区。文件系统和块设备驱动先把它当成普通用户缓冲区；NVMe 驱动接收到请求后，再把伪缓冲区中的 DMA 地址替换成 GPU buffer 的 DMA 地址，由此实现 P2P-DMA。\n可以把它理解为：\n应用 GPU buffer | | 由主机内存中的伪 buffer 代表 v Linux 文件系统 / 块层 | NVMe 驱动替换 DMA 地址 | v 真正 DMA 到 GPU 显存 伪缓冲区不承载实际数据，但承担了“让 Linux 相信这里有一组合法内存页”的角色。\n1.2 伪缓冲区带来的三个问题 性能不理想 为了创建、维护和释放伪缓冲区，GDS 引入了复杂且耗时的逻辑。伪缓冲区的申请和释放也会增加关键路径开销，最终影响整体 I/O 性能。\n资源消耗过高 伪缓冲区与 GPU buffer 大小相同，因此会额外消耗主机内存。异步 I/O、批量 I/O、伪缓冲区管理及其状态维护还会消耗大量 CPU 周期。\n兼容性较差 GDS 需要定制设备驱动来替换 DMA 地址。I/O 过程中还必须增加伪缓冲区的引用计数，避免它被提前释放。因此应用不能简单使用标准 POSIX 接口，而要使用 GDS 提供的专用接口，增加了编程和异步 I/O 实现的复杂度。\n1.3 Phoenix 的思路与贡献 Phoenix 的目标是重构 GDS，彻底去掉伪缓冲区。\n它使用 ZONE_DEVICE 把 GPU 内存直接映射进 Linux 页表，在系统初始化时为 GPU 显存建立 struct page。随后，Phoenix 内核模块保存地址信息，在用户空间建立虚拟地址，并把这段虚拟地址与指定 GPU 显存映射起来。\n这样，GPU 显存具备了 Linux I/O 所需要的页语义，文件系统不再需要主机伪缓冲区，也不再需要在 NVMe 驱动中替换 DMA 地址。\n论文总结的主要贡献是：\n分析现有 GDS I/O 栈，指出伪缓冲区会造成软件处理开销、资源浪费和兼容性问题； 提出 Phoenix，通过 ZONE_DEVICE 直接为 GPU 内存提供 Linux 页描述和用户空间映射； 重新设计面向 GDS、GDR 和 CUDA Stream 的编程模型，使应用可以使用 POSIX 和 io_uring； 在本地 NVMe、远程存储、KV Cache 和模型加载场景中验证性能收益。 2. 背景与动机 2.1 ZONE_DEVICE Linux 4.3 引入 ZONE_DEVICE，将设备内存接入内核的内存区域和页管理体系。驱动可以为设备暴露的物理地址范围建立 dev_pagemap，内核则为该范围构造对应的 struct page。\n这项机制最初常用于持久内存和 GPU 等设备内存。建立映射后，设备内存可以通过 mmap 等标准接口暴露给用户空间。由于内核能够识别这些页，应用可以避免中间缓冲区和额外复制，也更容易实施直接 I/O 与 P2P-DMA。\nPhoenix 正是利用 ZONE_DEVICE 为 GPU 显存提供 struct page 服务，从根本上消除 GDS 对伪缓冲区的需求。\n2.2 NVIDIA GDS 的架构与流程 论文把 NVIDIA GDS 分为三部分：\n用户态 libcufile； 内核模块 nvidia-fs； 支持 GDS 的定制 NVMe 或网络设备驱动。 一次典型同步 GDS 操作包含以下阶段：\n打开 GDS 驱动并检查系统兼容性； 打开普通文件； 向 GDS 注册文件句柄； 注册 GPU buffer，并为它创建伪缓冲区； 通过 cuFileRead 或 cuFileWrite 发起 I/O； 注销 GPU buffer 并释放伪缓冲区； 注销文件句柄； 关闭文件； 关闭 GDS 驱动。 真正的数据面在第 5 步。应用把请求提交给 nvidia-fs，内核再把请求交给文件系统、块层和 NVMe 驱动。定制驱动用 GPU buffer 的 DMA 地址替换伪缓冲区地址，最终完成 P2P-DMA。\n伪缓冲区导致 GDS 必须：\n使用定制设备驱动； 在初始化和清理阶段执行额外兼容性检查与函数注册； 在注册阶段分配主机内存； 在 I/O 期间维护引用计数； 强制应用使用非 POSIX 的专用 I/O 接口。 2.3 软件栈开销分析 作者使用 Intel Optane P5800X 测量 GDS 各步骤延迟。该设备执行 64 KiB 读取的底层介质延迟约为 25 微秒。\n对一笔 64 KiB 同步读取，GDS 除真正 I/O 外还需要多个管理步骤。论文把它们分为：\n驱动管理：驱动打开和关闭； 文件管理：文件句柄注册和注销； 缓冲区管理：GPU buffer 注册和注销。 驱动和文件管理通常只在应用开始与结束时执行一次，但在 Serverless、RPC 或频繁初始化的场景中仍会影响端到端性能。\n缓冲区管理更加关键：每个参与 I/O 的 GPU buffer 都需要注册。其开销位于关键路径上，甚至可能超过真正的数据传输时间。\n作者测得，对 64 KiB 读取而言，GDS 软件栈开销占关键路径总延迟的 82.5%。这正是 Phoenix 要重点消除的部分。\n3. Phoenix 的设计与实现 Phoenix 的设计目标是：\n高兼容性； 高性能； 低资源消耗。 核心挑战在于：如何把应用的用户空间虚拟地址与 GPU 显存地址建立可靠映射，并使 Linux 文件系统能够取得这些页的 struct page。\n3.1 总体架构 Phoenix 包含两部分：\n内核模块 phoenix.ko：负责 GPU 内存页映射和管理； 用户态 libphoenix：负责设备与缓冲区管理，向应用提供 I/O 接口。 数据路径为：\n应用 | libphoenix | POSIX / io_uring | 文件系统 -\u0026gt; 块层 -\u0026gt; 标准 NVMe 驱动 | P2P-DMA | GPU HBM 与 GDS 相比，Phoenix 不需要伪缓冲区，也不需要 NVMe 驱动在请求中替换 DMA 地址。\n3.2 缓冲区管理 3.2.1 内存初始化 Phoenix 在内核模块初始化阶段读取 GPU BAR 地址和显存容量。随后调用 ZONE_DEVICE 映射服务，为 GPU 可寻址空间构造 struct page，并保存物理地址、页描述和映射元数据。\n作者实验中的 GPU 有 48 GiB 板载显存，但通过 PCIe 暴露 64 GiB 可寻址空间，其中包含额外的 host-mapped 区域。为这 64 GiB 地址空间构造 struct page 大约需要 150 ms。\n这项成本发生在模块初始化阶段，而不是每笔 I/O 或每次 buffer 注册时。\n3.2.2 GPU Buffer 注册 应用调用 phxfs_regmem 注册 GPU buffer。其接口大致为：\nint phxfs_regmem( int device_id, const void *addr, size_t len, void **target_addr); 其中：\naddr 是应用持有的 GPU 虚拟地址； len 是注册长度； target_addr 返回 Phoenix 创建的用户空间虚拟地址。 注册流程可以概括为：\n内核通过 GPU 驱动接口 pin 指定 GPU buffer； 获取 GPU 页表和物理地址信息； 应用使用 mmap 分配一段用户虚拟地址； Phoenix 把该虚拟地址映射到此前由 ZONE_DEVICE 建立的 GPU 页； 保存“应用 GPU 地址—Phoenix 虚拟地址—物理页”的映射关系。 得到的 target_addr 对 CPU 来说是合法用户虚拟地址，对 Linux 内核来说背后有合法 struct page，但实际数据位于 GPU 显存。\n因此，标准文件 I/O 可以直接使用 target_addr：\npread(fd, target_addr, size, file_offset); 底层 DMA 最终写入 GPU HBM，而不是主机 DRAM。\n注销时，Phoenix 解除映射并释放 GPU 页表引用，不需要申请或释放同等大小的主机伪缓冲区。\n3.3 编程模型 3.3.1 与 GPU Direct Storage 集成 Phoenix 提供设备打开、关闭、注册和注销接口。完成注册后，应用可通过 phxfs_xfer 或标准 POSIX 文件接口发起 I/O。\n与 GDS 不同，Phoenix 不需要 cuFileHandleRegister 一类文件句柄注册。它直接使用 POSIX 文件描述符，并将映射后的用户虚拟地址作为 I/O buffer。\n由于不再维护伪缓冲区状态，Phoenix 可以直接复用 Linux 原生 I/O 能力：\n同步 pread/pwrite； 异步 io_uring； 批量 I/O； 文件系统和远程存储的标准访问路径。 3.3.2 与 GPU Direct RDMA 集成 NVIDIA Magnum IO 同时包含 GDS 和 GPU Direct RDMA（GDR）。传统方案通常分别安装 nvidia-fs 和 nvidia-peermem 等内核模块。\nPhoenix 映射后的用户虚拟地址既可以交给文件系统，也可以注册为 RDMA Memory Region。这样，同一段 GPU 内存只需注册一次，就可以同时用于存储访问和 RDMA 网络传输。\n论文认为，这提供了一种更统一的 GPU P2P 访问机制，并减少重复模块依赖。\n3.3.3 与 CUDA Stream 集成 CUDA Stream 可以表达 GPU 操作之间的顺序和依赖。为了把文件 I/O 纳入 stream，Phoenix 使用 CUDA callback/host function：\n前序 GPU 工作 | CUDA callback：执行同步 POSIX I/O | 后续 GPU 工作 Phoenix 将文件描述符、buffer 地址、长度、文件偏移等 I/O 元数据传给 callback。callback 通过 POSIX 接口发起 GDS I/O，并把结果写入调用者提供的 bytes_done。\n为了保证 stream 内的数据完整性，一笔 I/O 的发起与结果检查必须完全包含在 callback 中。因此论文实现选择在 callback 内直接执行同步 I/O，而不是使用异步接口。作者认为这样可以避免额外异步管理开销。\n4. 实验评估 实验试图回答四个问题：\nPhoenix 能降低多少 GDS 软件栈开销？ 在同步、异步、批量和 CUDA Stream I/O 中，Phoenix 有多少性能优势？ 在本地 NVMe 和远程网络存储上表现如何？ 在 KV Cache 和 checkpoint 加载等 LLM 工作负载中表现如何？ 4.1 实验平台 项目 配置 CPU 128 核 Intel Xeon 6530 主机内存 512 GiB 操作系统 Ubuntu 22.04 Linux 内核 6.1.0 GPU 48 GiB 显存 CUDA 12.4 NVIDIA GDS nvidia-fs 2.19 网络 Mellanox ConnectX-5 100G，支持 RDMA 本地/后端存储 Intel Optane P5800X 远程协议 NVMe-oF 与 NFS GPU、NIC 和 NVMe 位于同一 PCIe Switch 下，以保证能够执行 P2P-DMA。\n基线为 NVIDIA GDS，使用官方 Magnum IO 示例和 cuFile 接口实现。除专门评估注册开销的实验外，其他实验都预先注册 GPU buffer，尽量把数据面性能与注册性能分开。每组实验总 I/O 量至少为 40 GiB。\n4.2 操作开销分解 作者将 GPU buffer 大小从 4 KiB 增加到 16 MiB，重复测量 GDS 和 Phoenix 的注册、注销和 I/O 开销。\nGDS 为平衡内存开销和效率，把单次注册的 GPU 内存限制为 16 MiB。当请求大于等于 16 MiB 时，GDS 内部固定使用 16 MiB 伪缓冲区，并将大请求拆成多个 16 MiB 分段顺序提交。这会损害大粒度 I/O 性能。\n驱动管理 Phoenix 的 driver open/close 延迟几乎可以忽略。GDS 在打开阶段需要检查系统兼容性、注册定制驱动函数，关闭时需要释放伪缓冲区和注销函数；Phoenix 不需要这些步骤。\nPhoenix 的 ZONE_DEVICE 初始化虽然需要约 150 ms，但只在内核模块初始化时执行一次。\nBuffer 注册与注销 相较 GDS：\nPhoenix 的注册延迟降低 63%–90%； Phoenix 的注销延迟降低 54%–75%； 关键路径平均软件处理开销降低 70.3%。 收益主要来自不再申请和释放主机伪缓冲区。\n4.3 与 NVIDIA GDS 的本地 NVMe 对比 同步 I/O 在 4 KiB 到 1 GiB 的不同块大小下，Phoenix 对同步读取的带宽提升为 1%–76%。\n小 I/O 的提升最明显，因为 GDS 的状态管理和 DMA 地址查询等固定软件开销占比更大。随着块大小增加，设备数据传输时间成为主导，固定开销影响降低。\n当请求大于 16 MiB 时，GDS 的内部拆分又带来额外开销，因此 Phoenix 在大块请求上重新拉开差距。同步写入呈现相似趋势。\n多线程小 I/O 论文使用 4 KiB 同步读测试并发扩展性。随着线程数从 1 增加到 512，Phoenix 能更充分地把并行请求交给 Linux I/O 栈，而 GDS 受专用接口和内部管理开销影响更大。\nPhoenix 在高并发下更接近底层 NVMe 的带宽上限，说明移除伪缓冲区不仅降低单请求延迟，也减少了共享状态和 CPU 管理成本。\n批量异步 I/O Phoenix 使用 Linux io_uring 实现异步与 batch I/O。GDS 使用 cuFileBatchIO。\nPhoenix 不需要为每个 batch 请求维护伪缓冲区状态，也不受 GDS 单批最多 256 个请求的接口限制。对于大量小请求，Phoenix 可以在一次批量提交中覆盖更多 I/O，并减少多轮提交开销。\nCUDA Stream 作者还比较了 CUDA Stream 中的异步 I/O。Phoenix 通过 callback 把同步 POSIX I/O插入 stream，避免 GDS 异步接口的额外状态管理。\n这一实现重点不是让单笔存储 I/O变成异步，而是让 I/O 与 stream 中的 GPU 工作保持正确顺序，并减少软件栈成本。\n4.4 端到端结果 作者设计了两类端到端场景。\n小粒度 I/O 每个请求都包含 GPU buffer 注册和读取，用于模拟数据集与 KV Cache 等频繁小 I/O。\nPhoenix 同时降低 buffer 管理和数据传输开销。论文摘要给出的代表性结果是，小粒度 I/O 性能最高达到 GDS 的 2.29 倍。\n大文件加载 该场景模拟 checkpoint 加载，每次操作包含：\n驱动管理； 文件管理； GPU 内存注册； 数据传输； 清理过程。 Phoenix 移除了大量启动、注册和分段开销。论文摘要报告，大文件加载性能最高达到 GDS 的 4.11 倍。\n4.5 远程网络存储 GDS 的远程存储路径要求网络或存储客户端与其定制接口协作。Phoenix 依赖标准 POSIX I/O 和 Linux 页语义，因此更容易复用现有 NVMe-oF、NFS 和 RDMA 栈。\n作者在 100G ConnectX-5 环境中测试 NVMe-oF 与 NFS。结果显示，Phoenix 在远程路径上仍能提供与本地实验一致的收益：小请求受益于更低的软件开销，大请求最终受网络和后端设备带宽限制。\n论文强调，Phoenix 的优势不仅是某个本地 NVMe microbenchmark 更快，还包括更大的存储访问范围。与直接把 NVMe 队列交给 GPU 的方案相比，它保留文件系统语义，也能扩展到网络存储。\n4.6 LLM 工作负载 卸载 KV Cache 的回载 LLM 推理系统通常使用 PagedAttention，以 block 为粒度管理 KV Cache。所有 token 的 KV block 分散在一块预分配的连续 GPU 内存中。\n作者使用四组真实对话 trace：\nTrace 序列数 Block 数 Paper Assistant 23 15,708 GSM-100 100 33,600 QuALITY 15 6,428 ShareGPT-197 197 20,195 实验用大文件模拟卸载到存储的 KV Cache，按模型层读取。作者考虑三种 KV block 大小：\nINT8 + MLA：8 KiB； FP16 + MLA：16 KiB； FP16：64 KiB。 其中假设 MLA 将 KV Cache 大小减少 75%。\n读取时，系统尽量在一次 batch 中取回一个序列的所有 block，并把物理连续 block 合并成更大的请求。\nGDS 每次 batch 最多支持 256 个请求，某些长序列必须多次提交；Phoenix 没有这一限制，因为它去掉伪缓冲区依赖，并直接集成 io_uring。\n相较 GDS，Phoenix 在三种配置下分别提升：\n246%（8 KiB）； 101%（16 KiB）； 5%（64 KiB）。 随着 KV block 增大，Phoenix 的优势逐渐缩小。这与前面的结论一致：Phoenix 主要消除了固定软件开销，因此对碎片化、小粒度访问帮助最大；大粒度传输更容易被设备带宽主导。\n模型 Checkpoint 加载 作者使用开源 C++ safetensors 解析库。程序先把 safetensors header 读入主机内存，解析每个 tensor 的元数据，然后按照文件 offset 将 tensor 内容直接传到 GPU buffer。\n对比三种方案：\n原生 mmap + cudaMemcpy； NVIDIA GDS； Phoenix。 结果显示：\nGDS 相对原生方案减少模型加载延迟 39%； Phoenix 相对原生方案减少模型加载延迟 54%； Phoenix 相对 GDS 再减少 23% 的 checkpoint 加载延迟。 Phoenix 的收益同时来自 buffer 管理与数据传输两部分。\n5. 讨论 5.1 Direct I/O 的对齐限制 GDS 本质上仍是 direct I/O，因此 NVIDIA GDS 和 Phoenix 都受直接 I/O 约束。GPU buffer 地址、I/O 长度和文件 offset 必须按特定粒度对齐，例如 4 KiB；否则请求会失败。\n应用需要显式保证内存分配和访问模式满足对齐要求。这可能增加显存浪费和编程复杂度。\n例如，实际数据没有按 GPU 页粒度（如 64 KiB）对齐时，应用仍要按整页申请显存。为了满足对齐而执行的 padding 或额外数据复制，也可能降低整体效率。\n5.2 单次映射大小限制 Phoenix 通过 mmap 建立应用虚拟地址与 GPU 重映射页之间的关系。论文实现受 mmap 单次映射规模限制：使用大页时，每次最多映射约 1 GiB。\n注册更大的 GPU buffer 时，应用必须管理多个映射段，例如向 phxfs_regmem 提供指针数组保存多个返回地址。\n相比之下，论文中的 NVIDIA GDS 单次 buffer 注册上限是 16 MiB，因此不会遇到同一种 1 GiB 单映射限制，但会更频繁地进行内部拆分。\n5.3 统一 P2P 访问 Phoenix 的注册区既可以服务 GDS，也可以注册为 GDR 的 RDMA Memory Region。因此用户有机会用一个模块和一次内存注册同时支持：\n存储设备到 GPU； RDMA NIC 到 GPU； 在必要时通过内存语义访问映射区。 作者也提醒，直接使用 CPU 内存访问语义读写 Phoenix 映射区可能产生一致性问题，需要应用谨慎处理 GPU、CPU 和设备之间的同步。\n6. 相关工作 6.1 P2P 直接存储访问 SPIN SPIN 较早使用主机伪缓冲区代表 GPU buffer，与 direct disk I/O 接口交互。它能无缝接入操作系统，但继承了伪缓冲区的性能和资源问题。\nBaM BaM 更激进：把 NVMe 队列直接映射到 GPU 内存，让 GPU 通过 P2P-DMA 发送 NVMe 命令，甚至接管 NVMe 控制面，完全绕过 CPU。\n这种方案性能很强，但类似 SPDK，放弃了通用文件系统抽象。它难以满足典型训练和推理场景中的高并发共享与文件语义需求。\nGeminiFS GeminiFS 通过扩展 NVMe 驱动，在 CPU 与 GPU 之间共享控制面，并在文件头中嵌入文件系统元数据，为 GPU 程序提供配套文件系统抽象。\n但 BaM 和 GeminiFS 的访问范围主要局限于本地 NVMe，难以像 Phoenix/GDS 一样扩展到网络存储。\nFlashNeuron 与 hcache 这类方案使用 GDRCopy 暴露 GPU 内存、获取 DMA 地址，再通过 SPDK 用户态块 I/O 发起存储与 GPU 之间的 P2P 传输。它们同样缺乏文件系统语义。\n作者认为，相比 SPIN/GDS 的伪缓冲区路线，Phoenix 的栈更简单、性能更高、Linux 兼容性更好；相比 GPU 接管 NVMe 的路线，Phoenix 的存储访问范围和易用性更强。\n6.2 其他 GPU 直接数据传输技术 论文写作时，NVIDIA GDS 是唯一实用的 GPU—存储双向直接传输方案。\nAMD DirectGMA 将 GPU 内存映射到 PCIe BAR，使 PCIe 设备内存之间可以直接传输，但普通商用存储设备不能主动协调完整 GPU Direct Storage 流程。\nAMD peer memory client 可以支持 GPU 与 RDMA NIC 之间的直接传输，但不提供 GPU 存储访问。\nMicrosoft DirectStorage 通过允许 GPU 直接读取 NVMe 数据来加速游戏资源加载，但主要支持存储到 GPU 的单向传输。\n7. 结论 论文提出 Phoenix：一种去除伪缓冲区的重构版 GDS 软件栈。\nPhoenix 使用 Linux 4.3 起提供的 ZONE_DEVICE，在系统初始化阶段直接将 GPU 内存接入 Linux 页表。通过重新设计映射和注册流程，它减少了 I/O 关键路径的软件开销与资源消耗，并使应用可以使用标准 POSIX 和 io_uring 接口。\n实验显示，在小粒度 I/O、多线程 I/O、端到端加载和真实 LLM 工作负载中，Phoenix 相比当时的 NVIDIA GDS 具有更高的存储性能、更低的软件栈开销和更好的兼容性。\n作者计划继续把 Phoenix 集成到 PyTorch、TensorFlow 等框架中，以优化大语言模型、图神经网络等 GPU 应用。\n译者小结 这篇论文最重要的观察，不是“P2P-DMA 比 CPU 中转更快”——这已经是 GDS 的基本前提——而是：\n现有 GDS 为了适配 Linux 页模型，引入了一个与数据无关却主导软件开销的主机伪缓冲区；如果直接把 GPU 显存纳入 Linux 的 struct page 与用户虚拟地址体系，就能删掉大段专用 I/O 栈。\nPhoenix 选择保留 Linux 文件系统、POSIX 和 io_uring，只重构 GPU 内存如何进入内核页模型。它没有把 NVMe 控制面搬到 GPU，也没有放弃文件系统，因此性能上未必在所有场景压过 GPU-centric 存储栈，但兼容性和可组合性更强。\n论文结果也清楚说明了优化边界：收益最大的是注册频繁、请求细碎、batch 数量大、软件固定成本占主导的场景；当 I/O 足够大、设备或网络带宽成为瓶颈时，去掉伪缓冲区带来的相对优势会缩小。\n结合当前开源代码看，Phoenix 已从论文原型继续演进，加入了 NUMA worker pool、io_uring 引擎、vLLM/LMCache 适配器、staging 映射模式和更完整的生命周期管理。论文解释了它为什么成立，源码则展示了这套思路如何逐步工程化。\n参考资料 论文 DOI：Phoenix: A Refactored I/O Stack for GPU Direct Storage without Phony Buffers Phoenix 当前开源仓库 Phoenix 论文版本开源仓库 Linux ZONE_DEVICE / Device Memory 文档 Linux PCI Peer-to-Peer DMA 文档 ","permalink":"https://yangyang233333.github.io/posts/phoenix-paper-chinese-translation/","summary":"按原论文结构详译 SC'25 论文 Phoenix：解释 NVIDIA GDS 的伪缓冲区问题、基于 ZONE_DEVICE 的 GPU 显存映射、POSIX/io_uring 编程模型，以及本地 NVMe、远程存储、KV Cache 和模型加载实验。","title":"Phoenix 论文阅读：一种不使用伪缓冲区的 GPU Direct Storage 重构方案"},{"content":"在大模型推理、训练和 KV Cache 分层存储中，数据搬运经常走这样一条路：\nSSD -\u0026gt; CPU 内存缓冲区 -\u0026gt; GPU 显存 第二跳通常由 cudaMemcpy 完成。CPU 内存只是中转站，却会消耗内存容量、内存带宽、PCIe 带宽和 CPU 周期。NVIDIA GPUDirect Storage（GDS）尝试让存储设备直接 DMA 到 GPU，但它把文件系统、内核模块、用户态库和 CUDA 驱动紧密地绑定在一起，部署与调试并不轻量。\nxPU-IO/Phoenix 的目标，是重新拆分这条 I/O 栈：让 Linux 原生文件 I/O 能够直接读写一段代表 GPU 显存的 CPU 地址，从而复用 pread/pwrite、io_uring、线程池和现有文件系统，而不是再造一套文件访问接口。\n本文基于 Phoenix 仓库提交 638942ee63e8ce691adc967de3825440d55e3936 阅读。该提交日期为 2026 年 8 月 21 日。项目仍在快速演进，文档和代码之间偶尔存在版本差异，以下分析以源码实际行为为准。\n一句话理解 Phoenix Phoenix 的核心不是“用户态直接驱动 NVMe”，也不是“绕过 Linux 内核”。它做的是：\n将 GPU BAR 中的一段显存重映射为 Linux 可识别的页，再把这些页映射到用户进程；之后普通 pread/pwrite 或 io_uring 就能以这段用户地址作为 I/O 缓冲区，底层块设备 DMA 最终落到 GPU 显存。\n整体分成三层：\n应用与框架 vLLM / LMCache / 自定义应用 | v 应用适配器 phxloader / phxcache / phxfile | v 用户态 libphoenix 设备发现、显存注册、地址解析、同步/批量/异步 I/O | v 内核模块 phxfs GPU BAR 管理、dev_pagemap、P2P 页、mmap/ioctl | v Linux 文件系统与块层 pread/pwrite 或 io_uring -\u0026gt; NVMe/RDMA DMA 这个拆分带来一个重要结果：文件系统和存储后端基本看不到 CUDA API。GPU 厂商相关逻辑被压缩到内核 phxfs_p2p_ops 和用户态 DevConnector 两个接口后面。\n代码地图 仓库的主要目录很清晰：\n目录 职责 module/ phxfs 内核模块，管理 GPU BAR、页重映射、字符设备、mmap/ioctl libphoenix/ 用户态 C/C++ 库，实现设备生命周期、显存注册和多种 I/O API libphoenix/io_engine/ 同步与 io_uring 引擎、NUMA 感知 worker pool libphoenix/connectors/ CUDA 等厂商运行时封装 adapters/vLLM/phxloader/ safetensors 权重直接加载到 GPU 的 vLLM 适配器 adapters/lmcache/phxcache/ 面向 LMCache KV Cache 的 Python/C++ 批量 I/O 封装 adapters/lmcache/phxfile/ 仿 cuFile/hipFile 风格的稳定 C ABI 与 stream 异步接口 下面沿着一笔“把模型权重从文件读进 GPU”的请求，逐层阅读实现。\n1. 内核层：让 GPU BAR 进入 Linux 内存模型 1.1 设备发现不是简单枚举 PCI 设备 module/phxfs.c 在加载时寻找目标厂商的 PCI 设备，读取 GPU BAR 和显存信息，并为每个 GPU 建立 Phoenix 设备描述。当前真正实现的是 NVIDIA 后端；AMD、华为等厂商已有接口和构建选项，但仍属于待实现路径。\nPhoenix 默认不会粗暴接管整个 BAR。它先读取：\n/sys/kernel/debug/x86/pat_memtype_list 检查 GPU BAR 范围内是否存在 PAT 内存类型冲突，然后：\n在显存头尾各保留 128 MiB； 将中间区域按 16 MiB 单元扫描； 跳过与现有 PAT 映射冲突的单元； 合并连续可用单元，形成可重映射段。 这段逻辑揭示了 Phoenix 的现实约束：GPU BAR 不是一块可以随意重新声明的裸物理地址。CUDA、驱动和其他内核模块可能已经为其中一部分建立缓存属性；重复映射会触发冲突，严重时会让模块加载失败。\n1.2 dev_pagemap 是桥梁 Phoenix 要让文件 I/O 接受 GPU 显存作为用户缓冲区，必须让 Linux 能从用户虚拟地址获得合法的 struct page。其做法是为 BAR 区域创建 dev_pagemap，将设备内存纳入 ZONE_DEVICE/PCI P2P 内存模型。\n概念上可以理解为：\nGPU 显存地址 | GPU BAR 物理窗口 | dev_pagemap / ZONE_DEVICE page | 用户态 VMA | pread / io_uring 使用的用户缓冲区 这样，Linux 块层在 pin 用户页、构造 bio 或散列表时，拿到的不再是普通 DRAM 页，而是指向 GPU BAR 的设备页。支持 PCI P2P DMA 的设备可以直接把数据送到这些页背后的显存。\n1.3 厂商差异被收敛为 phxfs_p2p_ops module/phxfs-backend.h 定义了一组后端函数指针，内核核心代码不直接调用 NVIDIA 私有接口。NVIDIA 实现在 module/nvidia-backend.c 中，主要负责：\n获取 GPU 显存大小与 BAR 信息； pin 一段用户 GPU 虚拟地址； 获取对应的物理页表/总线地址； 释放页表和 pin； 响应驱动侧的失效回调。 这个边界是 Phoenix 可移植性的关键。若要支持 AMD 或 NPU，理论上只需实现新的内核后端和用户态 connector，不必改动注册表、I/O 引擎和适配器的主体逻辑。\n但“接口存在”不等于“已支持”。当前代码、测试和部署前提都明显围绕 NVIDIA CUDA 环境设计，不能把构建选项误解成生产可用的多厂商实现。\n2. 显存注册：建立同一段显存的双重地址 应用拿到的是 CUDA 设备指针，例如：\nvoid *gpu_ptr; cudaMalloc(\u0026amp;gpu_ptr, size); Linux 文件 API 不能直接把这个设备指针当作普通用户地址。phxfs_regmem() 的任务，就是为它建立一个 CPU 进程可见、但实际指向同一段 GPU 显存的映射地址。\n2.1 用户态注册流程 libphoenix/phx_mem.cpp 中的主流程可概括为：\nphxfs_regmem(device, gpu_ptr, len) | |-- 对齐到 Phoenix 页大小 |-- mmap(/dev/phxfsN, len) |-- ioctl(PHXFS_IOCTL_MAP, gpu_ptr, len, mmap_addr) |-- 插入进程内注册区间表 `-- 返回 target_addr（host-visible 映射） 这里出现了两个不同地址：\n原始地址：CUDA 返回的 GPU 设备地址，应用持有； 目标地址：Phoenix mmap 得到的 CPU 虚拟地址，I/O 引擎使用。 两者指向同一段 GPU 数据，但服务于不同执行环境。应用继续使用原始 GPU 指针做 kernel 计算；Phoenix 将目标地址交给 pread/pwrite。\n2.2 内核 MAP ioctl 做了什么 PHXFS_IOCTL_MAP 进入 module/phxfs-mem.c 后，大致经历：\n调用厂商 P2P 后端 pin 用户指定的 GPU 内存； 获取 GPU 页表和 DMA 地址； 把页表与当前 mmap 创建的 VMA 关联； 建立原始 GPU 地址到 Phoenix 映射区间的元数据； 注册失效回调，处理 CUDA 释放或驱动撤销映射的情况。 phxfs_deregmem() 走相反路径：先 UNMAP ioctl 释放 GPU pin，再解除 mmap 和用户态区间记录。代码还处理进程异常退出：即使应用忘记显式 deregister，文件释放路径也会尝试回收残留映射。\n2.3 为什么必须有注册表 后续 I/O API 接收的仍是应用熟悉的 GPU 指针加偏移：\nphxfs_read(fd, device_id, gpu_ptr, buf_offset, nbytes, file_offset); libphoenix 需要从区间表中找到包含 [gpu_ptr + offset, gpu_ptr + offset + nbytes) 的注册项，再计算对应目标地址：\nhost_addr = mapping.target_addr + (gpu_ptr + buf_offset - mapping.original_addr) 如果范围跨出注册区间，代码返回 -EFAULT，而不是允许文件系统写入未知地址。批量 API 还会按设备一次持锁、批量解析多个请求，避免每个请求重复获取注册表锁。\n3. I/O 路径：主体其实是朴素的 POSIX I/O 地址解析完成后，Phoenix 的数据面出奇地简单。\n3.1 同步 I/O libphoenix/phx_io.cpp 最终调用共享的 phxfs_io_loop()：\nresolve GPU pointer -\u0026gt; host_addr | v pread(fd, host_addr, chunk, file_offset) 写路径则是 pwrite。循环会：\n将单次请求切成不超过 1 GiB 的块； 对 EINTR 重试； 累加短读/短写； 返回实际完成字节数或负 errno。 1 GiB 分块不是性能调优噱头，而是为了不超过 Linux MAX_RW_COUNT 一类边界，并让超大模型文件的偏移与返回值更可控。\n因此 Phoenix 并未重新实现 ext4、XFS、NFS 或分布式文件系统协议。只要目标文件系统能够把 I/O 正确下发到支持相应 DMA 的块设备/网络路径，Phoenix 可以继续使用标准文件描述符语义。\n3.2 CPU 缓冲区也是一等公民 批量请求的 device_id \u0026lt; 0 表示普通 CPU 地址。也就是说，同一套 batch 引擎可以混合处理 CPU 和 GPU 缓冲区。这让适配器可以在不额外维护两套调度代码的情况下，决定某些小对象走 DRAM，某些大对象走显存直达。\n4. Batch 与异步：io_uring 外面还有一层 NUMA worker pool Phoenix 的批量接口使用 phxfs_io_req_t 描述请求，每项包含：\nfd + device_id + buf + buf_offset + nbytes + file_offset + result 同步批量调用为：\nphxfs_read_batch(reqs, n); phxfs_write_batch(reqs, n); 异步批量分成 submit/wait：\nhandle = phxfs_batch_submit_read(reqs, n); // CPU/GPU compute phxfs_batch_wait(handle); 4.1 为什么不是主线程直接提交 io_uring libphoenix/io_engine/io_pool.cpp 构建了共享 worker pool。请求按 NUMA 节点分组后，交给绑定在对应节点的工作线程；每个 worker 拥有自己的 I/O engine。\n这样设计有几个理由：\nio_uring ring 与完成事件由固定线程管理，避免多个调用线程争用； 可以将提交和回收放在更接近 GPU/NVMe 的 NUMA 节点； 同步和异步上层 API 共用同一套任务模型； 当 io_uring 不可用时，可以切换到同步 pread/pwrite 引擎。 4.2 io_uring 是并发器，不改变 DMA 本质 io_engine_uring.cpp 为请求准备 SQE，提交后批量收割 CQE。真正决定数据是否直达 GPU 的，不是 io_uring 本身，而是 SQE 中用户缓冲区背后的页是不是 Phoenix 创建的 P2P 设备页。\n因此可以把职责分开理解：\nphxfs/dev_pagemap：决定“DMA 到哪里” io_uring/worker pool：决定“多少 I/O 如何并发” 这是 Phoenix 架构中非常干净的一处解耦。\n4.3 异步句柄持有生命周期引用 异步提交前，Phoenix 已经完成地址解析，并持有相关设备和映射节点的引用。wait() 复制每项结果后才释放这些引用。这样可以防止批量 I/O 在后台运行时，设备或注册映射被正常关闭。\n不过 API 契约仍要求调用方不要并发释放正在使用的原始 GPU 内存。库能保护自己的元数据生命周期，却无法替应用修复“CUDA 指针已经被释放”的逻辑错误。\n5. 两种映射模式：性能与兼容性的正面权衡 这是当前 Phoenix 源码中最值得关注的演进。\n5.1 Full BAR 模式 full 模式在模块加载时重映射可用 GPU BAR 区域。数据路径最短：\nSSD -\u0026gt; GPU 用户缓冲区 优点是没有额外拷贝，最接近“真正的存储直达显存”。代价是 Phoenix 对 BAR 映射的占用可能与 nvidia-peermem、RDMA、其他 GDS/RDMA 组件产生资源或 PAT 属性冲突。\n源码和文档明确把 full 模式设为显式 opt-in，而不是默认值。\n5.2 Staging 模式 默认 staging 模式不直接重映射用户的 GPU buffer，而是在 GPU 上创建 Phoenix 自己管理的 staging pool：\nSSD -\u0026gt; GPU staging buffer -\u0026gt; D2D copy -\u0026gt; 用户 GPU buffer 这仍然绕过 CPU DRAM；第二跳是 GPU 内部/设备到设备复制，而不是回到主机。但它不再是严格的一跳 DMA。\nlibphoenix/phx_staging.cpp 默认配置为：\n每个设备 2 个 staging slot； 默认总大小 256 MiB，可通过环境变量调整； 大小按 2 MiB 粒度对齐； 读取时在 slot 之间形成流水线：一个 slot 做存储 I/O，另一个 slot 做 D2D； 写入时反向执行：先把用户数据 D2D 到 staging，再从 staging 写文件。 其价值是让 Phoenix 与需要直接 pin 用户 GPU 内存的 RDMA/peermem 方案更容易共存。代价也很明确：多一次 D2D、额外 staging 显存、复杂的流水线错误处理。\n5.3 默认安全，但 API 能力不完全等价 Staging 模式支持同步和 batch 路径，但 stream API 当前明确拒绝 staging 设备，返回 -EOPNOTSUPP。原因是 CUDA host callback 内不能调用 CUDA API，而 staging 的 D2D 腿恰好需要 CUDA 调用。\n异步 batch 在 staging 模式下也不是“提交后后台执行”：源码会把整批工作推迟到 wait() 阶段。内部两 slot 流水仍可重叠存储 DMA 与 D2D，但它不提供跨调用的计算/I/O 重叠。\n因此使用者不能只看 API 名称判断异步语义，必须同时检查当前 map mode。\n6. Stream API：用 CUDA Host Function 建立顺序语义 Phoenix 的 stream API 不是让 CUDA stream 自己发起文件 I/O，而是调用 cudaLaunchHostFunc，把一个纯 CPU I/O 回调插进 stream：\nGPU kernel A | cudaLaunchHostFunc(Phoenix pread/pwrite) | GPU kernel B CUDA 保证 host function 在前序任务完成后执行，并阻塞后续 stream 工作。因此文件 I/O 与 GPU kernel 获得了 stream-ordered 语义。\nphxfs_read_stream()/write_stream() 的关键契约包括：\nnbytes、文件偏移、buffer 偏移和结果变量必须活到 stream 越过该操作； 文件描述符、buffer 和注册映射也必须保持有效； 提交成功只表示 callback 已入队，真正 I/O 结果写入 bytes_done； callback 无论 I/O 成败都返回，不能把 CUDA stream 永久卡死； callback 内只运行主机侧 I/O，不调用 CUDA API。 这个实现很巧妙：它不需要 Phoenix 维护每个 stream 的状态，也不需要 CUDA Graph 或自定义 GPU kernel。但 host callback 执行同步 pread/pwrite，会占用 CUDA runtime 的回调执行资源；如果提交大量细碎 I/O，开销和调度公平性值得专项评估。\n7. vLLM 适配器：从 safetensors 元数据到批量 DMA adapters/vLLM/phxloader 展示了 Phoenix 如何进入真实 AI 框架。\n7.1 Python 负责语义，C++ 负责数据搬运 Python 侧解析 safetensors：\n读取文件头部长度； 解析 JSON tensor metadata； 计算每个 tensor 在文件中的绝对偏移与字节数； 把多个 tensor 按目标连续 buffer 组织成 read group。 C++ PhxLoader 则负责：\n打开对应 Phoenix 设备； 注册 PyTorch/CUDA 分配的目标显存； 为每个 tensor 构造 phxfs_io_req_t； 调用同步或异步 batch read； 检查每个请求的 result 是否等于预期字节数； 等待后 deregister。 这种分层非常合理：模型格式、tensor 名称映射和框架约定留在 Python；对齐、注册和 I/O 热路径留在 C++。\n7.2 为什么不是一个 tensor 调一次 read 模型权重可能包含成百上千个 tensor。逐 tensor 发起系统调用和锁操作会放大固定开销。Phoenix 先构造请求数组，再由 batch 层一次解析注册区间、按 NUMA 分发并由 io_uring 并发提交，更符合模型加载的访问形态。\n7.3 safetensors 很适合直接加载 safetensors 的 payload 是可按偏移定位的连续原始 tensor 数据，没有 pickle 反序列化，也不要求 CPU 先构造复杂对象。因此：\n文件 offset + tensor size + 目标 GPU offset 已经足以描述一笔 DMA。这也是 Phoenix 适配器能够保持轻量的根本原因。\n8. LMCache 的两种接法 仓库同时提供了两种 LMCache 风格封装。\n8.1 phxcache：面向 Python 的批量 API phxcache 与 phxloader 类似，通过 pybind11 暴露注册、批量读写和文件对象。它更适合由 LMCache 自己组织 KV block，再批量下发多个离散 offset。\n8.2 phxfile：兼容 cuFile/hipFile 风格的 C ABI phxfile 暴露 driver open、buffer register、file handle register、stream register 和 async read/write。实现中：\n启动时探测并打开可用 Phoenix 设备； buffer 注册时依次尝试设备，找到覆盖该 GPU 地址的设备； file handle 只是 POSIX fd 的 identity boxing； stream register 当前不保存任何 per-stream 状态； async read/write 调用 Phoenix stream API，并修正两套 ABI 中 buffer/file offset 参数顺序的差异。 这一层的意义不是增加能力，而是降低已有 cuFile/hipFile 调用方切换后端的成本。\n9. 设计亮点 9.1 把创新集中在内存语义，而不是重造存储栈 Phoenix 最漂亮的地方，是把问题转化为“如何让 GPU 内存成为合法 I/O buffer”。一旦地址与页模型打通，文件访问继续复用 Linux 的成熟设施。\n9.2 内核后端与用户态 connector 双重隔离厂商差异 内核侧处理 pin/page table/BAR，用户态侧处理设备 ID 映射、D2D、stream callback 和 profiler range。核心逻辑不散落 CUDA 调用，为未来多厂商支持留出了相对清楚的边界。\n9.3 同步、batch、异步、stream 使用同一注册模型 不同 API 最终都围绕同一张“原始设备地址 -\u0026gt; host-visible P2P 地址”注册表工作，减少了路径分叉和一致性风险。\n9.4 Staging 是工程化妥协，不是退化成 CPU bounce buffer 它牺牲一跳直达，换取与 RDMA/peermem 的兼容，而且中间缓冲仍在 GPU 显存中。对于需要同时运行分布式通信和存储加载的 AI 系统，这种默认选择可能比追求理论最短路径更实用。\n10. 当前限制与风险 10.1 真正可用的厂商后端仍只有 NVIDIA 接口虽然是 vendor-neutral，但 AMD/Huawei 的实现尚未落地。跨厂商能力目前更多是架构承诺。\n10.2 内核兼容性是最大部署成本 Phoenix 依赖 GPU 驱动导出的 P2P 能力、dev_pagemap、PCI P2PDMA、内核模块编译环境以及合适的文件系统/设备路径。它并不是安装一个 Python wheel 就能工作的组件。\n10.3 大注册区仍有限制 项目 roadmap 指出，单次 regmem 超过 32 GiB 仍可能因为映射描述使用 kmalloc 而失败，后续计划切换到 kvalloc。大模型应用应把权重 buffer 分段注册，而不是假设任意大的连续区域都能成功。\n10.4 full 模式具有资源排他性 GPU BAR 与 PAT 映射可能已被其他驱动或进程占用。部署 full 模式前需要清理冲突组件，并根据 dmesg、PAT 列表和 GPU 拓扑定位问题。\n10.5 “支持某文件系统”仍需逐环境验证 Phoenix 复用 POSIX I/O，不代表任意文件系统、网络文件系统和块设备组合都会自动形成有效 P2P DMA。页面 pin、GUP 对 ZONE_DEVICE/PCI_P2PDMA 的处理、IOMMU、ACS、NUMA 拓扑和存储驱动实现都可能改变结果。\n10.6 Stream callback 适合顺序集成，不一定适合海量小 I/O 一个 callback 内执行主机阻塞 I/O，语义直观但成本不低。大块权重、KV block 更匹配 Phoenix；大量几 KB 的随机请求需要实测，并可能更适合 batch 聚合。\n11. 一次完整读请求的调用链 最后把 full 模式下的读取串起来：\nPyTorch 分配 GPU buffer | phxloader.PhxLoader.regmem() | phxfs_regmem() |-- mmap /dev/phxfsN `-- ioctl(PHXFS_IOCTL_MAP) |-- NVIDIA P2P pin GPU pages `-- 建立 GPU VA -\u0026gt; BAR/device pages -\u0026gt; host VA 映射 解析 safetensors metadata | 构造 phxfs_io_req_t[] | phxfs_batch_submit_read() |-- 批量查注册区间，计算 host_addr |-- 持有设备与 mapping 引用 `-- 提交 NUMA worker pool `-- io_uring prep_read `-- Linux FS / block layer `-- NVMe DMA 到 P2P device pages `-- 数据落入 GPU buffer phxfs_batch_wait() |-- 收割 CQE |-- 回填每个 req.result `-- 释放 mapping/device 引用 GPU kernel 直接消费权重 Staging 模式只是在 I/O 落点后增加：\nPhoenix GPU staging buffer -\u0026gt; cudaMemcpy D2D -\u0026gt; 用户 GPU buffer 总结 Phoenix 并不是简单复刻一组 cuFile API。它的核心思想是重新划分 GDS 的职责：\n内核模块负责把 GPU BAR/显存接入 Linux 页与 P2P DMA 模型； 用户态库负责注册表、地址转换、生命周期与 I/O 调度； Linux 原生 pread/pwrite/io_uring 继续负责文件访问； 适配器只负责把框架对象翻译成文件 offset、GPU offset 和长度。 从源码看，它最有价值的贡献不是某个单独的性能技巧，而是一种可组合的系统结构。尤其是 full 与 staging 两种模式，清楚展示了高性能系统软件常见的现实选择：最短数据路径和生态兼容性往往不能同时最大化。\n如果后续能补齐多厂商后端、DKMS/发行包、跨内核回归测试、超大内存注册，以及对更多文件系统和拓扑的可重复 benchmark，Phoenix 有机会从研究型 GDS 重构项目，成长为 AI 存储到 xPU 数据面的通用中间层。\n参考资料 xPU-IO/Phoenix GitHub 仓库 Phoenix 架构文档 Phoenix 内核模块文档 Phoenix 用户态库文档 Phoenix 应用适配器文档 Linux PCI Peer-to-Peer DMA Support ","permalink":"https://yangyang233333.github.io/posts/phoenix-source-code-reading/","summary":"深入解析 xPU-IO/Phoenix：它如何通过内核模块、用户态库和应用适配器，将 SSD 数据直接送入 GPU 显存；并比较 full BAR、staging、batch、stream 等数据路径的实现与取舍。","title":"Phoenix 源码解读：把 GPU 显存变成 Linux I/O 可直达的内存"},{"content":"Mooncake Store 把对象位置和副本状态集中在 Master。这样简化了一致性，但也意味着 Master 的状态不能只存在进程内存中。\n本篇基于提交 777cc77，阅读 oplog、snapshot、standby controller 和恢复相关源码。\n一、先澄清高可用目标 Mooncake Store 面向高速缓存池。缓存数据本身可以因为节点故障而丢失，系统并不等价于强持久化对象存储。\n但控制面仍需要可靠：Master 重启后必须尽可能恢复对象元数据、Segment、配额和副本状态，否则即使真实字节还在，系统也不知道如何访问和回收它们。\n因此需要区分两件事。\n数据副本是否仍存在。 Master 是否记得这些副本及其状态。 本篇主要讨论第二件事。\n二、为什么只做定期快照不够 假设 Master 每十分钟保存一次完整快照。第九分钟发生的大量 Put、Remove 和 Eviction，在崩溃后都会消失。\n缩短快照间隔又会频繁扫描和序列化庞大元数据。\n常见解决方案是 snapshot 加 oplog：\nSnapshot 保存某个时刻的完整状态。 OpLog 记录快照之后的增量变化。 恢复时先加载 Snapshot，再重放 OpLog。 Mooncake Store 的 Master 也采用了这一思路。\n三、OpLog 记录什么 不是每个函数调用都值得写日志。需要持久化的是会改变可恢复状态的操作，例如对象元数据建立、提交、删除、副本变化和 Segment 相关更新。\n当前 master_service.cpp 中可以看到 AppendOpLogWithDurableFinalize、批量预留和 durable finalize 等路径。\n这里最关键的顺序是：某些不可逆的内存状态变化，要等对应日志达到持久条件后才能最终确认。\n否则进程可能在“内存已经释放，但日志还没记住”时崩溃，恢复后重新得到一个指向已释放空间的副本。\n四、Durable Finalize 的设计含义 名字里的 durable finalize 表明操作被拆成了准备与最终完成。\n大致过程如下：\n准备元数据变化 -\u0026gt; 追加 OpLog -\u0026gt; 等待达到持久条件 -\u0026gt; 执行最终回收或确认 这和 Put 的两阶段思路相似。系统先建立可回滚或可恢复的中间状态，再跨过明确的持久化边界。\n源码中的 finalize 回调负责在日志成功后更新配额、移除副本或释放其他资源。\n五、为什么批量操作先预留日志槽位 批量淘汰可能一次处理很多对象。如果做到一半才发现 oplog 队列或存储无法接受更多记录，前半部分已经改变、后半部分无法提交，恢复语义会非常困难。\n因此源码提供 batch oplog reservation。执行批量元数据修改前，先确认日志系统能容纳这组变化。\n这是一种 admission control：不是让所有操作先执行再处理失败，而是在跨越危险边界之前确认后续资源可用。\n六、Snapshot Manager 做什么 master_snapshot_manager.cpp 和 master_snapshot_repository.cpp 负责快照生命周期。\n快照不仅需要序列化对象 map，还要处理：\n元数据格式版本。 写入临时文件与原子替换。 快照生成期间的并发变化。 与 oplog 位点的对应关系。 旧快照清理。 加载失败时的错误处理。 如果快照和 oplog 没有共同的序列边界，恢复时就可能重复应用或漏掉一段操作。\n七、序列化格式为什么必须谨慎演进 master_service.cpp 中存在 ObjectMetadata 的显式序列化和反序列化逻辑，并检查数组长度、UUID、时间范围和可选字段类型。\n显式校验看起来啰嗦，但它保护了两个重要场景：\n旧版本快照被新版本程序加载。 损坏或截断的数据不会悄悄变成错误对象。 源码还明确处理某些不恢复的瞬时字段。例如 soft pin 属于运行时策略状态，不一定应该跨重启原样继承。\n这提醒我们：持久化状态不应简单等于“把整个 C++ 对象内存 dump 下来”。\n八、Standby 如何接管 ha/standby_controller.cpp、standby_state_machine.cpp 和 hot_standby_service.cpp 组成热备相关逻辑。\nStandby 需要持续获得主节点的状态变化，并维护足以接管的元数据视图。发生切换时，还要避免旧 Master 与新 Master 同时对外做决定。\n因此高可用不仅是复制日志，还涉及 leader 身份、租约和状态机阶段。\n九、Kubernetes Lease 解决什么 源码包含 k8s_lease_helper。在 Kubernetes 环境中，Lease 可以作为 leader 活性与续约机制的一部分。\n它的目标不是保存对象数据，而是帮助系统判断谁当前有权作为主节点服务。\n如果没有 fencing，仅靠“我联系不到旧主，所以我成为新主”是不安全的。网络分区可能让两个 Master 都认为自己有效。\n生产系统仍需要结合部署配置，确保旧主失去租约后无法继续修改共享控制面状态。\n十、恢复后为什么还要重新校验现实世界 Snapshot 和 oplog 描述的是 Master 最后知道的状态，但节点可能在 Master 停机期间变化。\n例如：\n某个 Segment 所在进程已经退出。 Client 注册的内存已经失效。 本地 SSD 文件被运维清理。 节点重启后获得新的实例身份。 因此恢复不能盲目信任历史地址。Master 还需要等待资源重新注册、检查 lease，并清理无法再证明有效的副本。\n持久化让控制面“不失忆”，重新校验让控制面“不活在过去”。\n十一、HA 与数据复制是两套机制 Master 热备保护元数据服务的连续性。\n对象多副本保护数据读取路径，并提高并发带宽。\n两者缺一不可，但不能互相替代：即使有两个 Master，某个对象唯一的数据副本损坏后仍无法读取；即使对象有三份副本，唯一 Master 的元数据彻底丢失也难以找到它们。\n十二、源码中的工程化信号 当前 Store 已包含以下能力：\nOpLog 批处理与预留。 Snapshot repository。 Standby 状态机。 Kubernetes Lease 辅助。 配额与副本变更的 durable finalize。 元数据格式严格校验。 失败路径指标与后台清理。 这些代码说明 Mooncake Store 已经从“一个 KV Cache Demo”演进成需要认真处理控制面恢复的分布式系统。\n十三、整个系列的阅读主线 四篇文章可以归纳为四个问题。\n架构篇回答谁负责元数据，谁负责搬数据。 Put 篇回答对象何时从不可见变成可读。 Get 与 Eviction 篇回答副本怎样被选择和回收。 HA 篇回答 Master 崩溃后如何重建状态。 理解这四条主线后，再阅读动态复制、多租户、本地 SSD、NoF 和 Engram 等扩展模块会容易很多。\n参考源码 mooncake-store/src/master_service.cpp mooncake-store/src/master_snapshot_manager.cpp mooncake-store/src/master_snapshot_repository.cpp mooncake-store/src/ha/standby_controller.cpp mooncake-store/src/standby_state_machine.cpp mooncake-store/src/hot_standby_service.cpp mooncake-store/src/k8s_lease_helper.cpp mooncake-store/include/ha/ha_types.h ","permalink":"https://yangyang233333.github.io/posts/mooncake-store-source-reading-master-ha/","summary":"\u003cp\u003eMooncake Store 把对象位置和副本状态集中在 Master。这样简化了一致性，但也意味着 Master 的状态不能只存在进程内存中。\u003c/p\u003e\n\u003cp\u003e本篇基于提交 \u003ccode\u003e777cc77\u003c/code\u003e，阅读 oplog、snapshot、standby controller 和恢复相关源码。\u003c/p\u003e\n\u003ch2 id=\"一先澄清高可用目标\"\u003e一、先澄清高可用目标\u003c/h2\u003e\n\u003cp\u003eMooncake Store 面向高速缓存池。缓存数据本身可以因为节点故障而丢失，系统并不等价于强持久化对象存储。\u003c/p\u003e\n\u003cp\u003e但控制面仍需要可靠：Master 重启后必须尽可能恢复对象元数据、Segment、配额和副本状态，否则即使真实字节还在，系统也不知道如何访问和回收它们。\u003c/p\u003e\n\u003cp\u003e因此需要区分两件事。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e数据副本是否仍存在。\u003c/li\u003e\n\u003cli\u003eMaster 是否记得这些副本及其状态。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e本篇主要讨论第二件事。\u003c/p\u003e\n\u003ch2 id=\"二为什么只做定期快照不够\"\u003e二、为什么只做定期快照不够\u003c/h2\u003e\n\u003cp\u003e假设 Master 每十分钟保存一次完整快照。第九分钟发生的大量 Put、Remove 和 Eviction，在崩溃后都会消失。\u003c/p\u003e\n\u003cp\u003e缩短快照间隔又会频繁扫描和序列化庞大元数据。\u003c/p\u003e\n\u003cp\u003e常见解决方案是 snapshot 加 oplog：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eSnapshot 保存某个时刻的完整状态。\u003c/li\u003e\n\u003cli\u003eOpLog 记录快照之后的增量变化。\u003c/li\u003e\n\u003cli\u003e恢复时先加载 Snapshot，再重放 OpLog。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMooncake Store 的 Master 也采用了这一思路。\u003c/p\u003e\n\u003ch2 id=\"三oplog-记录什么\"\u003e三、OpLog 记录什么\u003c/h2\u003e\n\u003cp\u003e不是每个函数调用都值得写日志。需要持久化的是会改变可恢复状态的操作，例如对象元数据建立、提交、删除、副本变化和 Segment 相关更新。\u003c/p\u003e\n\u003cp\u003e当前 \u003ccode\u003emaster_service.cpp\u003c/code\u003e 中可以看到 \u003ccode\u003eAppendOpLogWithDurableFinalize\u003c/code\u003e、批量预留和 durable finalize 等路径。\u003c/p\u003e\n\u003cp\u003e这里最关键的顺序是：某些不可逆的内存状态变化，要等对应日志达到持久条件后才能最终确认。\u003c/p\u003e\n\u003cp\u003e否则进程可能在“内存已经释放，但日志还没记住”时崩溃，恢复后重新得到一个指向已释放空间的副本。\u003c/p\u003e\n\u003ch2 id=\"四durable-finalize-的设计含义\"\u003e四、Durable Finalize 的设计含义\u003c/h2\u003e\n\u003cp\u003e名字里的 durable finalize 表明操作被拆成了准备与最终完成。\u003c/p\u003e\n\u003cp\u003e大致过程如下：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e准备元数据变化\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 追加 OpLog\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 等待达到持久条件\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 执行最终回收或确认\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这和 Put 的两阶段思路相似。系统先建立可回滚或可恢复的中间状态，再跨过明确的持久化边界。\u003c/p\u003e","title":"Mooncake Store 源码阅读（四）：Master 持久化、快照与热备恢复"},{"content":"写入建立对象，读取和淘汰决定缓存系统能否长期稳定运行。本篇沿 Get、Remove 与后台 Eviction 三条路径，观察 Mooncake Store 如何维护副本可读性和容量平衡。\n源码基线仍为 777cc77。\n一、Get 首先读取的是元数据 Client 不知道对象当前在哪台机器，也不应缓存一份永远不变的地址。\n读取开始时，Client 向 Master 请求副本列表。核心入口之一是 MasterService::GetReplicaList。\nMaster 查找 key 对应的 ObjectMetadata，然后筛选当前允许读取的副本。返回结果包含 Segment、offset、长度和介质等信息。\n随后 Client 才通过 Transfer Engine 把对象复制到本地 Buffer。\n二、不是列表里的每个副本都可读 一个对象可能同时存在以下副本：\n已完成并可读的内存副本。 正在复制的动态副本。 写入失败、等待清理的副本。 位于本地盘的副本。 所在 Segment 正在卸载的副本。 因此 Get 不能只检查 replicas 是否非空。源码中的 HasReadableReplica 以及各种状态判断，负责把控制面中的暂态副本排除掉。\n读取正确性的一个重要来源，就是“只从已提交的副本集合中选择”。\n三、副本选择优化什么 拥有多个可读副本后，系统还要选择来源。\n理想选择通常考虑：\n是否位于本机或同一故障域。 介质是 DRAM、VRAM 还是磁盘。 传输协议和拓扑成本。 副本是否正在被回收。 是否能满足本次 Buffer 类型。 Mooncake 将元数据筛选与实际传输分开，使选择策略可以持续演进。Master 保证候选集合合法，Client 和 Transfer Engine 完成具体数据路径。\n四、读取失败不等于对象不存在 控制面返回副本后，数据面仍可能失败。例如节点刚好退出、网络断开，或 Segment 已经不可访问。\n客户端可以在多个候选副本之间重试。只要还有另一份完整副本，对象就可能继续读取。\n这也是复制的价值之一：它既提升热点读取带宽，也为短暂的数据面故障提供备用路径。\n五、Remove 的第一目标是停止可见 删除操作首先需要让后续 Get 不再把对象当作可读对象返回，然后再释放各副本占用的资源。\n如果先释放内存，再更新元数据，并发 Get 可能拿到已经失效的地址。\n如果只删除元数据，不回收 allocator 中的空间，容量会持续泄漏。\n因此 Remove 同样是一段状态转换，而不是对 map 调一次 erase。\nMasterService::Remove、RemoveByRegex 和 RemoveAll 展示了单对象、模式匹配和全量清理的不同入口。\n六、淘汰与显式删除有什么不同 Remove 是调用者明确要求删除对象。\nEviction 是系统为了释放容量，自主选择可以牺牲的缓存对象。它必须尊重更多约束：\n硬 pin 对象不能淘汰。 未过期租约可能阻止淘汰。 正在写入或复制的对象需要谨慎处理。 某些策略要求至少保留一份副本。 本地盘副本和内存副本的回收代价不同。 配额账本必须与真实回收保持一致。 七、Eviction Strategy 与执行分离 eviction_strategy.h 描述“谁应优先被淘汰”。真正的副本移除、allocator 释放、元数据更新和 oplog 持久化则由 Master 执行。\n这是策略与机制分离：策略给出候选，机制保证删除过程安全。\nStore 中还包含 Count-Min Sketch 等数据结构，可用于近似访问频率。近似统计比维护每个 key 的精确全局计数更适合大规模缓存。\n八、为什么需要批量淘汰 每淘汰一个对象都可能涉及锁、元数据修改、日志和配额更新。逐对象执行会让固定成本非常高。\n源码中的 BatchEvict 和 NoF BatchEvict 路径尝试把多个候选一起处理，并预留批量 oplog 空间。\n批量化提高吞吐，但也增加一致性难度：任何中途失败都不能让日志、元数据、allocator 与 quota 出现四套不同答案。\n九、Pin 与 Lease 的角色不同 Pin 表达策略意图：这个对象暂时不希望被缓存淘汰。\nLease 表达时间约束：某个进行中的操作或持有关系在截止时间前仍然有效。\n硬 pin 通常是强约束。软 pin 可以结合系统压力降级。租约过期则帮助系统回收失联 Client 遗留的暂态资源。\n把两者混为一个布尔字段，会很难同时处理业务优先级和故障恢复。\n十、Segment 卸载怎样影响对象 节点下线或资源池缩容时，Master 通过 UnmountSegment 处理 Segment。\n它必须找到受影响对象，移除或迁移位于该 Segment 的副本，并重新判断对象是否还有可读副本。\n这说明对象元数据既需要从 key 找副本，也需要能够从 Segment 反向找到受影响对象。资源拓扑变化是 Store 控制面的常态，而不是罕见异常。\n十一、Local SSD 是缓存层而非简单文件输出 当前源码包含 local_ssd、file_storage、nvme_kv_backend 等实现。它们让 Store 能在 DRAM 之外使用更大但更慢的本地介质。\n分层存储会引入新的决策：\n什么时候从内存下沉到 SSD。 Get 是否先查本地热缓存。 SSD 副本是否计入可读副本。 删除时如何协调内存和磁盘空间。 重启后哪些磁盘状态可以恢复。 因此 SSD 不是 Transfer Engine 多一个协议那么简单，而是对象生命周期的一部分。\n十二、读路径与回收路径的共同原则 Get、Remove 和 Eviction 看似是三个功能，实际共享一个原则：只有 Master 才能改变“哪些副本仍然有效”这一事实。\nClient 可以报告传输结果，后台线程可以提出淘汰候选，allocator 可以释放地址，但对象可见性最终由元数据状态机统一决定。\n十三、下一篇 最后一篇讨论 Master 自身的可靠性：oplog、snapshot、standby、lease 与故障恢复。重点不是宣称缓存永不丢失，而是解释控制面怎样避免重启后失去对资源的认知。\n参考源码 mooncake-store/src/real_client.cpp mooncake-store/src/master_service.cpp mooncake-store/include/replica.h mooncake-store/include/eviction_strategy.h mooncake-store/include/count_min_sketch.h mooncake-store/src/local_hot_cache.cpp mooncake-store/src/local_ssd/manager.cpp ","permalink":"https://yangyang233333.github.io/posts/mooncake-store-source-reading-get-eviction/","summary":"\u003cp\u003e写入建立对象，读取和淘汰决定缓存系统能否长期稳定运行。本篇沿 Get、Remove 与后台 Eviction 三条路径，观察 Mooncake Store 如何维护副本可读性和容量平衡。\u003c/p\u003e\n\u003cp\u003e源码基线仍为 \u003ccode\u003e777cc77\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"一get-首先读取的是元数据\"\u003e一、Get 首先读取的是元数据\u003c/h2\u003e\n\u003cp\u003eClient 不知道对象当前在哪台机器，也不应缓存一份永远不变的地址。\u003c/p\u003e\n\u003cp\u003e读取开始时，Client 向 Master 请求副本列表。核心入口之一是 \u003ccode\u003eMasterService::GetReplicaList\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003eMaster 查找 key 对应的 \u003ccode\u003eObjectMetadata\u003c/code\u003e，然后筛选当前允许读取的副本。返回结果包含 Segment、offset、长度和介质等信息。\u003c/p\u003e\n\u003cp\u003e随后 Client 才通过 Transfer Engine 把对象复制到本地 Buffer。\u003c/p\u003e\n\u003ch2 id=\"二不是列表里的每个副本都可读\"\u003e二、不是列表里的每个副本都可读\u003c/h2\u003e\n\u003cp\u003e一个对象可能同时存在以下副本：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e已完成并可读的内存副本。\u003c/li\u003e\n\u003cli\u003e正在复制的动态副本。\u003c/li\u003e\n\u003cli\u003e写入失败、等待清理的副本。\u003c/li\u003e\n\u003cli\u003e位于本地盘的副本。\u003c/li\u003e\n\u003cli\u003e所在 Segment 正在卸载的副本。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e因此 Get 不能只检查 \u003ccode\u003ereplicas\u003c/code\u003e 是否非空。源码中的 \u003ccode\u003eHasReadableReplica\u003c/code\u003e 以及各种状态判断，负责把控制面中的暂态副本排除掉。\u003c/p\u003e\n\u003cp\u003e读取正确性的一个重要来源，就是“只从已提交的副本集合中选择”。\u003c/p\u003e\n\u003ch2 id=\"三副本选择优化什么\"\u003e三、副本选择优化什么\u003c/h2\u003e\n\u003cp\u003e拥有多个可读副本后，系统还要选择来源。\u003c/p\u003e\n\u003cp\u003e理想选择通常考虑：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e是否位于本机或同一故障域。\u003c/li\u003e\n\u003cli\u003e介质是 DRAM、VRAM 还是磁盘。\u003c/li\u003e\n\u003cli\u003e传输协议和拓扑成本。\u003c/li\u003e\n\u003cli\u003e副本是否正在被回收。\u003c/li\u003e\n\u003cli\u003e是否能满足本次 Buffer 类型。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMooncake 将元数据筛选与实际传输分开，使选择策略可以持续演进。Master 保证候选集合合法，Client 和 Transfer Engine 完成具体数据路径。\u003c/p\u003e\n\u003ch2 id=\"四读取失败不等于对象不存在\"\u003e四、读取失败不等于对象不存在\u003c/h2\u003e\n\u003cp\u003e控制面返回副本后，数据面仍可能失败。例如节点刚好退出、网络断开，或 Segment 已经不可访问。\u003c/p\u003e","title":"Mooncake Store 源码阅读（三）：Get、副本选择与淘汰回收"},{"content":"Mooncake Store 的 Put 不是一次 RPC。它是一段跨越 Client、Master、分配器和 Transfer Engine 的两阶段流程。\n本文继续使用提交 777cc77。阅读重点是 real_client.cpp 与 master_service.cpp 中的写入路径。\n一、为什么 Put 必须拆成两段 如果 Master 收到 Put 请求后立刻把对象标记为可读，其他 Client 可能读到尚未传完的数据。\n如果等所有数据都发送给 Master，再由 Master 落到目标节点，控制面又会成为数据面瓶颈。\nMooncake Store 的做法是：Master 先预留副本，Client 直接传输，最后由 Master 提交状态。\nPutStart -\u0026gt; 返回目标副本 -\u0026gt; 直接传输对象 -\u0026gt; PutEnd 这相当于围绕一次数据面操作建立轻量级提交协议。\n二、Client 侧的准备工作 Put 开始前，RealClient 需要确定源 Buffer 的位置和长度。源数据可能来自普通主机内存，也可能来自已经注册的加速器内存。\nClient 会把大对象切成适合传输的任务。每个任务包含本地地址、远端 Segment、远端 offset 和长度，然后交给 Transfer Engine。\nStore 不重复实现 RDMA、TCP 或 GPU Direct 细节。它只把“对象副本”翻译成 Transfer Engine 能执行的传输描述。\n三、进入 MasterService::PutStart PutStart 位于 mooncake-store/src/master_service.cpp。它承担的职责远多于生成几个地址。\n典型检查包括：\nkey 和对象大小是否合法。 租户是否允许继续占用空间。 同名对象是否已经存在或正在写入。 复制配置是否合法。 当前有哪些 Segment 可用。 能否为目标副本预留足够空间。 检查通过后，Master 创建 ObjectMetadata，并记录本次写入的 Client、租约与副本列表。\n此时对象还不应被普通 Get 当作完整对象读取。\n四、副本怎样被分配 副本放置由 allocation strategy 和 allocator 协作完成。\nallocation strategy 决定优先选择哪些 Segment。它需要考虑介质、容量、节点分布和复制要求。\nallocator 负责在选中的 Segment 内找到具体空间，也就是 offset 和 length。\n两者分开有一个明显好处：放置策略可以演进，而底层空间管理不必跟着重写。\n五、切片与并行传输 大对象不一定只对应一个连续副本描述。Store 可以把对象切成多个 slice，分布在不同资源上，并由 Transfer Engine 并行执行。\nClient 为每个目标片段创建传输任务，大致包含：\n本地 Buffer 区间 目标 Segment ID 目标 offset 传输长度 Transfer Engine 根据 Segment 元数据选择 RDMA、TCP 或其他传输路径，并等待各任务完成。\nStore 的对象层只关心所有必要片段是否成功，不需要知道每个 RDMA work request 的实现细节。\n六、失败时为什么不能只返回错误 假设 Master 已经预留两份副本，但第二份传输失败。如果 Client 直接向调用者返回错误，Master 中仍会留下预留空间和未完成元数据。\n因此写入失败路径必须做补偿：\n取消或终止未完成任务。 告知 Master 哪些副本失败。 回收对应空间。 清理未提交的对象状态。 更新配额和统计值。 源码中大量边界处理正是为了保证“错误返回之后，系统还能继续正确分配资源”。\n七、PutEnd 才是可见性边界 数据传输完成后，Client 调用 MasterService::PutEnd 对应的 RPC。\nMaster 根据 Client 上报的结果更新副本状态。成功副本进入可读集合，失败副本被排除或回收。若复制策略要求的最低条件无法满足，整个写入不能作为成功对象对外可见。\n因此对象原子性不是 Transfer Engine 提供的。Transfer Engine 只报告一个个数据任务是否完成；对象级原子性由 Master 的元数据状态转换提供。\n八、这里的原子性意味着什么 Mooncake 架构文档强调：Get 总会读取一个一致版本，但不一定是最新版本。\n源码角度可以这样理解：\n未完成写入不会以完整对象身份暴露。 Get 只选择满足可读条件的副本。 同一对象的元数据更新由 Master 串行化和持久化机制约束。 并发覆盖写不会让读者拼接两个版本的片段。 这不是数据库事务的全部语义，而是面向大对象缓存非常关键的完整性保证。\n九、租约解决 Client 消失问题 Client 可能在 PutStart 后崩溃，永远不会调用 PutEnd。\n对象元数据中的租约和时间戳让 Master 能识别长期未完成的写入。后台任务可在租约过期后回收副本，避免空间永久泄漏。\n租约的意义是把“等待一个进程回复”转换成“等待一个有截止时间的状态”。这是分布式资源管理中很实用的设计。\n十、写入路径中的配额核算 当前 Store 支持 tenant 维度的容量治理。Put 预留空间时就需要考虑配额，而不能等到传输结束才检查。\n否则多个并发 Put 可能同时通过检查，最终一起突破上限。\n因此配额通常伴随预留、完成、失败回滚和删除四类状态变化。源码里的 accounting 辅助函数虽然看似繁琐，却决定了系统在并发和失败场景下是否会“越算越多”或“越算越少”。\n十一、Put 链路的阅读方法 建议按下面顺序跟踪。\n从 RealClient 的 Put 入口观察参数和 Buffer。 找到 Master Client 发出的 PutStart RPC。 进入 MasterService::PutStart 查看元数据创建。 跟进 allocation strategy 和 allocator。 回到 Client 查看 transfer task 的构造和等待。 进入 MasterService::PutEnd 查看状态提交。 最后检查所有提前返回分支是否释放空间。 这条顺序比直接阅读整个 Master 文件更容易建立完整心智模型。\n十二、下一篇 下一篇分析 Get、Remove 和 Eviction，解释 Store 如何选择可读副本，以及“删除元数据”和“回收真实空间”为什么不能简单地视为同一个动作。\n参考源码 mooncake-store/src/real_client.cpp mooncake-store/src/master_client.cpp mooncake-store/src/master_service.cpp mooncake-store/src/allocation_strategy.cpp mooncake-store/src/allocator.cpp mooncake-store/src/transfer_task.cpp mooncake-store/include/replica.h ","permalink":"https://yangyang233333.github.io/posts/mooncake-store-source-reading-put-path/","summary":"\u003cp\u003eMooncake Store 的 Put 不是一次 RPC。它是一段跨越 Client、Master、分配器和 Transfer Engine 的两阶段流程。\u003c/p\u003e\n\u003cp\u003e本文继续使用提交 \u003ccode\u003e777cc77\u003c/code\u003e。阅读重点是 \u003ccode\u003ereal_client.cpp\u003c/code\u003e 与 \u003ccode\u003emaster_service.cpp\u003c/code\u003e 中的写入路径。\u003c/p\u003e\n\u003ch2 id=\"一为什么-put-必须拆成两段\"\u003e一、为什么 Put 必须拆成两段\u003c/h2\u003e\n\u003cp\u003e如果 Master 收到 Put 请求后立刻把对象标记为可读，其他 Client 可能读到尚未传完的数据。\u003c/p\u003e\n\u003cp\u003e如果等所有数据都发送给 Master，再由 Master 落到目标节点，控制面又会成为数据面瓶颈。\u003c/p\u003e\n\u003cp\u003eMooncake Store 的做法是：Master 先预留副本，Client 直接传输，最后由 Master 提交状态。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ePutStart\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 返回目标副本\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 直接传输对象\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; PutEnd\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这相当于围绕一次数据面操作建立轻量级提交协议。\u003c/p\u003e\n\u003ch2 id=\"二client-侧的准备工作\"\u003e二、Client 侧的准备工作\u003c/h2\u003e\n\u003cp\u003ePut 开始前，\u003ccode\u003eRealClient\u003c/code\u003e 需要确定源 Buffer 的位置和长度。源数据可能来自普通主机内存，也可能来自已经注册的加速器内存。\u003c/p\u003e\n\u003cp\u003eClient 会把大对象切成适合传输的任务。每个任务包含本地地址、远端 Segment、远端 offset 和长度，然后交给 Transfer Engine。\u003c/p\u003e\n\u003cp\u003eStore 不重复实现 RDMA、TCP 或 GPU Direct 细节。它只把“对象副本”翻译成 Transfer Engine 能执行的传输描述。\u003c/p\u003e","title":"Mooncake Store 源码阅读（二）：Put 写入链路与对象原子性"},{"content":"Mooncake Transfer Engine 解决的是“怎样快速搬数据”，Mooncake Store 解决的则是更上层的问题：一个对象叫什么、放在哪些节点、有哪些副本、何时可读、空间不足时淘汰谁，以及 Master 重启后怎样恢复这些事实。\n本文使用 Mooncake 仓库提交 777cc77 作为源码基线。这个版本的 Store 已经远超早期原型，包含多租户配额、内存与本地盘分层、动态副本、批量淘汰、快照和热备等能力。\n一、先看整体分层 Mooncake Store 可以拆成四层。\n第一层是上层调用者。vLLM、SGLang 或其他推理系统通过 Python、C++、Rust、Go 等接口访问对象。\n第二层是 Client。RealClient 负责本地缓冲区、Master RPC、数据传输、重试和资源清理。\n第三层是 Master。MasterService 保存对象元数据，管理 Segment、分配副本、维护租户配额，并驱动淘汰和复制。\n第四层是数据资源。DRAM、VRAM、本地 SSD 或其他后端提供真正保存字节的空间，Transfer Engine 执行跨节点传输。\n数据不会先发送到 Master，再由 Master 转发。Master 只告诉 Client 应该访问哪些副本，真正的大块数据直接在 Client 与目标内存之间移动。\n二、源码目录怎样阅读 建议先抓住以下文件。\nmooncake-store/include/real_client.h：Client 的主要状态和接口。 mooncake-store/src/real_client.cpp：Put、Get、Remove 与初始化流程。 mooncake-store/include/master_service.h：Master 的核心数据结构。 mooncake-store/src/master_service.cpp：元数据状态机和控制逻辑。 mooncake-store/include/replica.h：副本描述与状态。 mooncake-store/include/segment.h：可分配存储资源。 mooncake-store/src/allocation_strategy.cpp：副本放置策略。 mooncake-store/src/allocator.cpp：具体空间分配。 mooncake-store/src/transfer_task.cpp：Store 到 Transfer Engine 的任务封装。 不要一开始就顺序阅读体量巨大的 master_service.cpp。更有效的方法是从 RPC 接口出发，沿 PutStart、PutEnd、GetReplicaList 和 Remove 四条链路向下追踪。\n三、Client 不是一层简单包装 RealClient::setup_real 会组装 Store 客户端运行所需的大部分组件：Transfer Engine、Master Client、本地缓冲区、Client Service、SSD offload 以及可选的 HTTP 服务。\n它同时向 Master 注册自身可用的 Segment。Master 只有知道某个节点提供了多大的空间、属于什么介质，才能把对象副本分配到该节点。\nResourceTracker 也值得注意。它持有 Client 实例的弱引用，并通过信号处理线程在异常退出时清理资源。这说明 Store 的 Client 并非无状态 RPC stub，而是持有已注册内存、后台线程和服务端点的长期运行组件。\n四、Segment 是资源管理边界 Transfer Engine 中的 Segment 描述“哪些内存可以被远程访问”。Store 在此基础上又加入容量和分配语义。\nMaster 挂载 Segment 时，会建立对应的分配器和资源记录。后续 Put 不直接选择某个裸地址，而是先选择 Segment，再从 Segment 中分配一段 offset 和 length。\n一个副本因而至少包含以下信息：\n位于哪个 Segment。 从什么 offset 开始。 长度是多少。 当前处于什么状态。 使用内存、磁盘还是其他介质。 这层抽象把“对象副本”与“机器上的一段可寻址空间”连接起来。\n五、Master 管理的是状态机 MasterService 的核心不是 RPC，而是对象元数据状态机。\n一个对象从写入开始到可读，大致经历以下过程。\nPutStart -\u0026gt; 创建对象元数据 -\u0026gt; 选择并预留副本 -\u0026gt; Client 传输数据 -\u0026gt; PutEnd -\u0026gt; 副本变为可读 如果传输失败，预留空间必须回收；如果 Client 在中途失联，后台清理也必须识别未完成状态。于是元数据不能只有“key 到地址”的静态映射，还要记录写入者、租约、时间戳和副本状态。\n六、控制面与数据面如何配合 一次典型 Put 包含两类通信。\n控制面通信通过 Master RPC 完成。Client 请求创建对象并获得目标副本列表。\n数据面通信通过 Transfer Engine 完成。Client 将本地 Buffer 直接写入目标 Segment。\n传输完成后，Client 再调用 Master，提交写入结果。只有提交成功的副本才应被 Get 返回。\n这种设计让 Master 不承担对象字节流，避免中心节点成为带宽瓶颈；同时所有对象状态仍由一个明确的控制面裁决。\n七、Store 与传统对象存储的差异 Mooncake Store 的对象通常是推理过程中的 KV Cache 或张量数据。它们有几个特点：\n对吞吐和尾延迟极其敏感。 对象可能位于 GPU 显存。 生命周期通常较短。 缓存副本可以重建，不一定要求传统存储级持久性。 大对象适合切片并行传输。 因此 Store 优先优化高速缓存池，而不是把所有语义都建立在慢速持久化存储之上。\n八、一次 Get 的最短路径 Get 的核心路径可以概括为：\nClient 请求副本列表 -\u0026gt; Master 筛选可读副本 -\u0026gt; Client 选择来源 -\u0026gt; Transfer Engine 拉取数据 -\u0026gt; 返回本地 Buffer Master 不参与数据复制。若对象有多个副本，Client 和控制面还可以结合 locality、介质类型及可用性选择更合适的来源。\n九、为什么 Store 仍需要中心化 Master 完全去中心化看起来更有扩展性，但对象写入原子性、副本分配、租户配额和统一淘汰都会变复杂。\nMooncake Store 选择把元数据集中管理，把大流量数据面分散出去。这是许多高性能存储系统常用的折中：中心控制面负责做决定，分布式数据面负责跑带宽。\n随着规模扩大，源码又通过 metadata shard、批处理、oplog 和后台任务降低 Master 压力，而不是放弃统一状态机。\n十、后续文章 下一篇沿一条完整 Put 链路阅读 RealClient、PutStart、副本分配、Transfer Engine 任务和 PutEnd，重点解释对象写入原子性从哪里来。\n参考源码 docs/source/design/architecture.md mooncake-store/include/real_client.h mooncake-store/src/real_client.cpp mooncake-store/include/master_service.h mooncake-store/src/master_service.cpp mooncake-store/include/segment.h mooncake-store/include/replica.h ","permalink":"https://yangyang233333.github.io/posts/mooncake-store-source-reading-architecture/","summary":"\u003cp\u003eMooncake Transfer Engine 解决的是“怎样快速搬数据”，Mooncake Store 解决的则是更上层的问题：一个对象叫什么、放在哪些节点、有哪些副本、何时可读、空间不足时淘汰谁，以及 Master 重启后怎样恢复这些事实。\u003c/p\u003e\n\u003cp\u003e本文使用 Mooncake 仓库提交 \u003ccode\u003e777cc77\u003c/code\u003e 作为源码基线。这个版本的 Store 已经远超早期原型，包含多租户配额、内存与本地盘分层、动态副本、批量淘汰、快照和热备等能力。\u003c/p\u003e\n\u003ch2 id=\"一先看整体分层\"\u003e一、先看整体分层\u003c/h2\u003e\n\u003cp\u003eMooncake Store 可以拆成四层。\u003c/p\u003e\n\u003cp\u003e第一层是上层调用者。vLLM、SGLang 或其他推理系统通过 Python、C++、Rust、Go 等接口访问对象。\u003c/p\u003e\n\u003cp\u003e第二层是 Client。\u003ccode\u003eRealClient\u003c/code\u003e 负责本地缓冲区、Master RPC、数据传输、重试和资源清理。\u003c/p\u003e\n\u003cp\u003e第三层是 Master。\u003ccode\u003eMasterService\u003c/code\u003e 保存对象元数据，管理 Segment、分配副本、维护租户配额，并驱动淘汰和复制。\u003c/p\u003e\n\u003cp\u003e第四层是数据资源。DRAM、VRAM、本地 SSD 或其他后端提供真正保存字节的空间，Transfer Engine 执行跨节点传输。\u003c/p\u003e\n\u003cp\u003e数据不会先发送到 Master，再由 Master 转发。Master 只告诉 Client 应该访问哪些副本，真正的大块数据直接在 Client 与目标内存之间移动。\u003c/p\u003e\n\u003ch2 id=\"二源码目录怎样阅读\"\u003e二、源码目录怎样阅读\u003c/h2\u003e\n\u003cp\u003e建议先抓住以下文件。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/include/real_client.h\u003c/code\u003e：Client 的主要状态和接口。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/src/real_client.cpp\u003c/code\u003e：Put、Get、Remove 与初始化流程。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/include/master_service.h\u003c/code\u003e：Master 的核心数据结构。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/src/master_service.cpp\u003c/code\u003e：元数据状态机和控制逻辑。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/include/replica.h\u003c/code\u003e：副本描述与状态。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/include/segment.h\u003c/code\u003e：可分配存储资源。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/src/allocation_strategy.cpp\u003c/code\u003e：副本放置策略。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/src/allocator.cpp\u003c/code\u003e：具体空间分配。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003emooncake-store/src/transfer_task.cpp\u003c/code\u003e：Store 到 Transfer Engine 的任务封装。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e不要一开始就顺序阅读体量巨大的 \u003ccode\u003emaster_service.cpp\u003c/code\u003e。更有效的方法是从 RPC 接口出发，沿 \u003ccode\u003ePutStart\u003c/code\u003e、\u003ccode\u003ePutEnd\u003c/code\u003e、\u003ccode\u003eGetReplicaList\u003c/code\u003e 和 \u003ccode\u003eRemove\u003c/code\u003e 四条链路向下追踪。\u003c/p\u003e","title":"Mooncake Store 源码阅读（一）：从对象存储接口到控制面与数据面"},{"content":"在操作系统驱动、高性能网卡、NVMe SSD 和 GPU 系统中，经常会同时看到 BAR、MMIO、DMA、IOMMU、peer-to-peer DMA、GPUDirect RDMA 和 GPUDirect Storage 等概念。这些名词都与“设备怎样通过 PCIe 交换控制信息和数据”有关，但它们处在不同层次。\n最简洁的理解是：\nBAR：让 CPU 能够定位并访问 PCIe 设备中的寄存器或显存窗口 DMA：让 PCIe 设备能够主动读取或写入系统内存 P2P DMA：让一个 PCIe 设备直接访问另一个 PCIe 设备暴露的地址空间 GDS：利用 DMA、GPU 内存映射和驱动协作，让存储数据尽量直接进入 GPU 显存 本文从 PCIe 地址空间和事务模型出发，解释 BAR 是如何分配和映射的，驱动为什么通过 BAR 下发命令，DMA 地址为什么不能简单等同于物理地址，以及 GPUDirect Storage 如何把这些机制组合成一条高性能数据路径。\n一、先建立 PCIe 系统视图 一个典型服务器的 PCIe 拓扑如下：\nCPU │ Memory Controller │ Host RAM │ Root Complex ┌──────┴──────┐ │ │ PCIe Switch PCIe Endpoint ┌────┴────┐ GPU │ │ NVMe SSD NIC CPU 和内存构成主机侧。Root Complex 把 CPU/内存系统连接到 PCIe fabric。NVMe、网卡和 GPU 通常是 Endpoint。PCIe Switch 用于扩展端口和转发事务。\nPCIe 并不是简单的“串行总线寄存器协议”。它传输的是事务层数据包，即 TLP。常见事务包括：\nMemory Read：读取某个地址。 Memory Write：写入某个地址。 Configuration Read/Write：访问 PCIe 配置空间。 Completion：返回读请求结果或状态。 Message：传递中断、电源管理等消息。 理解 BAR 和 DMA 的关键，不是只看“谁拷贝数据”，而是看：\n谁发起 PCIe Memory Read/Write； 请求中的地址属于哪个地址空间； Root Complex、IOMMU 或 Switch 如何路由该请求； 最终由主机内存还是某个 PCIe Endpoint 响应。 二、PCIe 设备有哪些地址空间 PCIe 设备通常涉及三类容易混淆的地址空间。\n1. 配置空间 每个 PCIe Function 都有配置空间，用于描述设备身份、能力和资源需求。常见字段包括：\nVendor ID 和 Device ID； Class Code； Command 和 Status； BAR0 到 BAR5； MSI/MSI-X capability； PCIe capability； SR-IOV、ATS、Resizable BAR 等扩展能力。 操作系统通过配置事务枚举设备。Linux 中常见的 BDF：\n0000:65:00.0 分别表示 domain、bus、device 和 function。\n配置空间不是设备传输大块业务数据的主要通道。它主要用于发现设备、启用功能和分配资源。\n2. 主机物理地址空间 CPU 看到的是统一的物理地址空间，其中既可以包含 DRAM，也可以预留若干 MMIO 区域：\nCPU Physical Address Space 0x0000_0000 ┌──────────────────────┐ │ Host DRAM │ │ ... │ 0x8000_0000 ├──────────────────────┤ │ PCIe MMIO window │──\u0026gt; Device BAR resources │ Firmware / APIC ... │ 0xFFFF_FFFF └──────────────────────┘ CPU 对 DRAM 地址执行 load/store，最终访问内存控制器；对 MMIO 地址执行 load/store，Root Complex 会把访问转换为 PCIe Memory Read/Write TLP，并路由到目标设备。\n3. 设备使用的 DMA 地址空间 PCIe 设备发起 DMA 时，请求中携带的是设备可见地址。在没有 IOMMU 或采用恒等映射时，它可能等于主机物理地址；启用 IOMMU 后，它通常是 IOVA：\nDevice-visible DMA address / IOVA │ ▼ IOMMU │ ▼ Host physical address │ ▼ Host RAM 因此，程序中的虚拟地址、CPU 物理地址和 DMA 地址不能天然互换：\nCPU virtual address != CPU physical address != DMA address / IOVA 驱动必须使用内核 DMA API 建立映射，而不能随意把普通指针交给设备。\n三、BAR 到底是什么 BAR 是 Base Address Register。它位于 PCIe 配置空间中，用于描述某个 Function 希望暴露的一段资源窗口。\n一个 PCIe Function 最多通常有六个常规 BAR。每个 BAR 可以描述：\nMemory BAR：设备寄存器或设备内存窗口； I/O BAR：传统端口 I/O 空间，现代 PCIe 设备很少依赖； 32 位或 64 位地址； prefetchable 或 non-prefetchable 属性。 64 位 BAR 会占用两个连续 BAR 寄存器。例如 BAR0 和 BAR1 共同保存一个 64 位基地址。\n需要注意：\nBAR 寄存器保存的是系统分配给设备资源窗口的基地址，不是设备寄存器内容本身。\n例如，NVMe 控制器内部定义了一组寄存器：\noffset 0x0000: Controller Capabilities 偏移 0x0014: Controller Configuration 偏移 0x1000 起: Submission Queue Doorbell 如果系统将 BAR0 映射到主机物理地址 B，那么 CPU 访问控制器配置寄存器时，实际访问的是：\nMMIO address = B + register_offset 其中 B 由固件或操作系统分配，offset 由设备规范定义。\n四、操作系统怎样确定 BAR 大小 PCIe 设备在硬件中实现了 BAR 的可写掩码。系统枚举设备时，可以通过经典探测过程确定资源大小：\n保存 BAR 原值； 向 BAR 写入全 1； 读回设备实现的地址掩码； 根据最低有效地址位计算窗口大小； 恢复或重新分配 BAR 地址。 假设读回的有效掩码表示低 16 位地址不可配置，则设备要求一个 64 KiB、按 64 KiB 对齐的窗口。\n概念上可写为：\nsize = ~(address_mask) + 1 实际处理还要排除 BAR 低位中的类型和属性位，并正确组合 64 位 BAR。\n操作系统随后从可用 MMIO 地址区间中分配一段满足大小和对齐要求的地址，把基地址写回 BAR，并设置 PCI Command 寄存器中的 Memory Space Enable。设备才会响应相应 Memory Request。\n因此 BAR 同时连接了两个世界：\n配置空间中的 BAR 值 │ ▼ 主机物理地址中的 MMIO 窗口 │ ▼ 设备内部寄存器或设备内存 五、BAR 背后映射的资源 BAR 并不限定只能映射几 KB 控制寄存器。它可以对应不同设备资源。\n1. 控制和状态寄存器 最常见用途包括：\n启停设备； 配置队列地址和长度； 查询设备状态； 设置中断； 更新生产者/消费者索引； 敲 doorbell 通知设备有新工作。 这些寄存器通常是 non-prefetchable，因为读取可能有副作用，且访问必须严格到达设备。\n2. Doorbell 区域 NVMe、网卡和 GPU 常使用 doorbell。驱动先在内存中准备命令或描述符，再通过一次很小的 MMIO Write 通知设备：\nHost RAM: command queue prepared │ CPU writes BAR doorbell │ ▼ Device observes new queue tail │ Device DMA reads command descriptors Doorbell 的价值是控制面只传递“队列推进到哪里”，而不通过 MMIO 搬运整个数据包。\n3. 设备本地内存窗口 GPU、FPGA 和某些加速器可以通过 BAR 暴露一部分设备本地内存。CPU 对该窗口访问时，事务会抵达设备内存，而不是主机 DRAM。\n传统 GPU BAR 可能只暴露显存的一小段 aperture，需要驱动切换窗口。Resizable BAR 允许系统为设备分配更大的 BAR，在平台资源允许时甚至映射整个显存范围。\n但“BAR 足够大”只解决地址可见性问题，不自动保证 CPU 访问显存的带宽、缓存一致性或访问延迟等同于访问 DRAM。\n六、驱动如何使用 BAR Linux 驱动通常经历以下过程：\npci_enable_device() │ pci_request_regions() │ pci_iomap() / ioremap() │ readl() / writel() │ pci_iounmap() 简化示例：\nvoid __iomem *regs; regs = pci_iomap(pdev, 0, 0); if (!regs) return -ENOMEM; writel(queue_tail, regs + DOORBELL_OFFSET); status = readl(regs + STATUS_OFFSET); 这里有三个重要点。\n第一，regs 是内核用于访问 I/O 内存的映射，不能当普通 RAM 指针随意解引用。\n第二，应使用 readl、writel 等 MMIO accessor，以满足架构相关的访问宽度、顺序和屏障要求。\n第三，MMIO 通常比普通内存访问昂贵。频繁读取设备寄存器会引入 PCIe Read Request 和 Completion 往返，因此高性能设备倾向于：\n将描述符和完成项放在内存队列中； 使用批处理； 减少 MMIO Read； 使用少量 posted MMIO Write 敲 doorbell。 七、为什么 MMIO Write 通常比 MMIO Read 友好 PCIe Memory Write 通常是 posted request。发送方提交写 TLP 后，不必等待协议层返回数据完成包。请求仍受到流控和错误处理约束，但 CPU 不需要像读取那样等待目标返回内容。\nMemory Read 是 non-posted request：\nRequester ── Memory Read Request ──\u0026gt; Completer Requester \u0026lt;── Completion with Data ── Completer 访问延迟包含请求路由、设备处理和 Completion 返回。因此在数据路径中反复读取 BAR 状态寄存器非常低效。\n现代设备通常让 CPU 写 doorbell，然后通过以下方式获知完成：\n设备 DMA 写回 completion queue； 设备触发 MSI/MSI-X； CPU 批量轮询主机内存中的 completion entry。 这也是 SPDK、DPDK 等用户态高性能框架大量使用内存队列和轮询，而不是不断读取设备状态寄存器的原因。\n八、DMA 是什么 DMA 即 Direct Memory Access。对 PCIe 设备而言，DMA 通常表示设备成为 PCIe Requester，主动对某个地址发起 Memory Read 或 Memory Write。\n以网卡接收数据为例：\n1. 驱动分配接收缓冲区 2. 驱动将缓冲区映射为 DMA 地址 3. 驱动把 DMA 地址填入 RX descriptor ring 4. 驱动通过 BAR doorbell 通知网卡 5. 网卡 DMA 读取 descriptor 6. 网卡 DMA 把报文写入缓冲区 7. 网卡更新 completion，并触发中断或等待轮询 数据路径是：\nNIC ── PCIe Memory Write ──\u0026gt; Host RAM CPU 不负责逐字节搬运数据，但 CPU 仍负责创建队列、管理缓冲区、同步所有权和处理完成事件。\nDMA Read 与 DMA Write 从设备视角看：\nDMA Read：设备从目标地址读取数据，例如网卡读取待发送报文； DMA Write：设备向目标地址写数据，例如网卡写入收到的报文。 从业务语义看容易产生方向歧义。例如“存储读取”表示应用从 SSD 读取数据，但 NVMe 控制器执行的是向内存 DMA Write。\n因此分析时最好明确主语：\nNVMe read command = 应用从 SSD 读取 = NVMe 控制器向目标内存执行 DMA Write 九、BAR 与 DMA 的关系 BAR 和 DMA 不是同一种机制，也不是互相替代关系。\nCPU / Driver ── MMIO through BAR ──\u0026gt; Device Device ── DMA ──\u0026gt; Host memory 典型工作流如下：\n驱动在主机内存中分配命令队列、描述符和数据缓冲区； 驱动获得这些对象的 DMA 地址； 驱动通过 BAR 寄存器配置队列基地址和长度； CPU 填写新命令； CPU 通过 BAR doorbell 通知设备； 设备 DMA 读取命令和输入数据； 设备 DMA 写回输出数据和完成项； 设备通过 MSI-X 或内存状态通知 CPU。 可以把二者分别看作：\nBAR/MMIO = 控制路径 DMA = 批量数据路径 这只是常见设计，而非协议强制。例如 CPU 可以通过设备 BAR 直接读写设备内存，设备也可以 DMA 访问主机内存中的控制结构。\nBAR 地址不能作为普通 DMA 地址使用 BAR 地址的语义是“某段主机物理 MMIO 地址路由到设备资源”；普通 DMA 地址的语义是“设备发起请求时可访问的目标地址”。两者可能都表现为 64 位整数，但归属、映射和生命周期不同。\nBAR address CPU ── Root Complex ──\u0026gt; PCIe device resource DMA address / IOVA PCIe device ── IOMMU / Root Complex ──\u0026gt; Host RAM 只有在受支持的 peer-to-peer 场景下，一个设备才可能把另一个设备的 BAR 地址作为 DMA 目标，并且还需要 PCIe 拓扑、地址路由、驱动和安全策略共同允许。\n十、DMA 为什么需要内核映射 API 应用看到的虚拟内存可能：\n由不连续物理页组成； 被换出或迁移； 不在设备 DMA mask 可达范围； 尚未建立 IOMMU 映射； 与 CPU cache 存在一致性要求； 生命周期短于设备异步操作。 因此驱动需要通过 DMA API，例如：\nvoid *cpu_addr; dma_addr_t dma_addr; cpu_addr = dma_alloc_coherent(dev, size, \u0026amp;dma_addr, GFP_KERNEL); 或者对已有内存执行流式映射：\ndma_addr = dma_map_single(dev, buffer, size, DMA_TO_DEVICE); 两者返回的 dma_addr 才是应该写入硬件描述符的地址。\nCoherent DMA 与 Streaming DMA 常见模型包括：\nCoherent DMA：CPU 和设备可持续共享，适合 descriptor ring、completion queue 等控制结构； Streaming DMA：为一次或一段时间的数据传输建立映射，适合数据 buffer。 “coherent”并不意味着完全不需要内存屏障。CPU 仍可能需要确保描述符字段在敲 doorbell 前对设备可见，并正确处理设备和 CPU 对队列所有权的切换。\n十一、IOMMU 如何改变 DMA 没有隔离时，具备总线主控能力的设备可能访问大范围主机物理内存。这既不安全，也不利于虚拟化。\nIOMMU 在设备和物理内存之间建立地址翻译与权限检查：\nRequester ID + IOVA + access type │ ▼ IOMMU page table │ translation + permission │ ▼ Host physical page IOMMU 的主要价值包括：\n将设备限制在被授权的内存范围内； 隔离不同设备、进程和虚拟机； 将离散物理页映射成连续 IOVA； 支持设备直通； 检测非法 DMA 访问。 PCIe Requester ID 通常参与选择 IOMMU domain。SR-IOV 的不同 VF 可以获得独立隔离。\nIOMMU 也会引入页表维护、IOTLB miss 和映射操作成本。高性能系统常通过大页、长期映射、批量映射和合理的 IOVA 管理降低开销。\n十二、一次 NVMe I/O 中 BAR 与 DMA 如何配合 NVMe 是理解二者关系的典型设备。\n初始化阶段 驱动通常会：\n映射 NVMe BAR； 读取控制器能力； 在主机内存中分配 Admin Submission Queue 和 Completion Queue； 将队列 DMA 地址写入控制器寄存器； 启用控制器； 创建 I/O Queue。 提交读取请求 CPU prepares NVMe command in Submission Queue │ │ command contains data target address ▼ CPU writes SQ tail doorbell through BAR │ ▼ NVMe DMA reads command from host memory │ NVMe reads flash media │ NVMe DMA writes data to target memory │ NVMe DMA writes Completion Queue entry │ NVMe triggers MSI-X or host polls completion 这里 BAR 承担少量控制交互，DMA 承担命令、完成项和实际数据传输。\n十三、什么是 PCIe Peer-to-Peer DMA 普通 DMA 的目标通常是主机内存：\nDevice A ──\u0026gt; Host RAM Peer-to-peer DMA 希望一个设备直接访问另一个设备的资源：\nDevice A ── PCIe fabric ──\u0026gt; Device B BAR memory 例如 NVMe 控制器把读取的数据写入 GPU 显存。GPU 显存通过 GPU BAR 形成 PCIe 可路由地址，NVMe 的 Memory Write TLP 最终由 GPU 接收。\nP2P 是否可行取决于多个条件：\n两个设备是否处在允许 P2P 的拓扑中； PCIe Switch 和 Root Complex 是否正确转发 peer request； ACS 设置是否强制请求绕行或隔离； IOMMU 是否支持所需映射方式； 目标设备是否暴露可 DMA 的 BAR 内存； 驱动是否能注册、固定并导出目标内存； 平台固件是否分配了足够的 MMIO aperture； 虚拟化和安全策略是否允许。 因此“设备都插在 PCIe 上”并不意味着天然支持直接互传。\n十四、GPU 显存与 BAR 的关系 GPU 拥有自己的 VRAM。CPU 若要通过 PCIe 地址访问 VRAM，需要 GPU 将某段显存映射到 BAR aperture。\nCPU MMIO address │ ▼ GPU BAR aperture │ ▼ GPU VRAM pages 早期系统中，BAR aperture 往往远小于显存容量，驱动需要动态改变窗口映射。启用 64 位 BAR 和 Resizable BAR 后，可以给 GPU 分配更大的 MMIO 窗口。\n对于 P2P DMA，关键不是 CPU 是否频繁读写该 BAR，而是 PCIe fabric 中的其他设备是否能把请求路由到 GPU 暴露的显存地址，以及 GPU 驱动能否让特定显存页在传输期间保持稳定并可访问。\n十五、从传统存储读取到 GDS 假设应用需要从 NVMe SSD 读取数据供 CUDA kernel 使用。\n传统路径 传统路径通常经过主机页缓存或用户态缓冲区：\nNVMe SSD │ DMA ▼ Host memory / page cache │ CPU coordination or copy ▼ Pinned host memory │ GPU DMA engine ▼ GPU VRAM 简化代码可能是：\nread(file, host_buffer) cudaMemcpy(gpu_buffer, host_buffer, size, HostToDevice) 这条路径存在一些成本：\n数据先进入主机内存，再传入显存； 可能发生额外内存拷贝； CPU 参与文件系统、页缓存和传输提交； 主机内存带宽被占用两次； 大规模并发 I/O 会增加 CPU 和内存子系统压力。 GPUDirect Storage 路径 GDS 的目标路径是：\nNVMe SSD │ │ PCIe DMA / peer-capable data path ▼ GPU VRAM 从应用角度，数据由存储读取到 GPU buffer，减少主机内存 bounce buffer 和 CPU 参与。\n但“直接”需要谨慎理解。GDS 是一个软硬件协同的数据路径，不只是让 NVMe 控制器随意拿到 GPU 虚拟地址。它通常涉及：\nCUDA 分配和管理 GPU memory； NVIDIA 驱动固定并导出 GPU 内存映射； nvidia-fs 等内核组件协调存储驱动与 GPU 驱动； cuFile 提供用户态文件 I/O API； 支持的文件系统、块设备或网络存储路径； DMA 映射、拓扑检查、对齐和回退策略。 十六、BAR 在 GDS 中扮演什么角色 GDS 与 BAR 的联系可以分成控制面和地址可达性两部分。\n1. NVMe 仍然通过 BAR 接收命令 即使数据目标变成 GPU 显存，NVMe 控制器仍然需要正常初始化和提交请求：\nCPU / NVMe driver │ │ MMIO writes through NVMe BAR ▼ NVMe controller doorbell Submission Queue 中包含读取命令和数据目标描述。CPU 写 doorbell 后，NVMe 才开始处理请求。\n因此 GDS 并没有消除 BAR。它改变的是数据最终 DMA 到哪里，而不是取消设备控制路径。\n2. GPU 显存需要形成 PCIe 可访问资源 为了让存储侧 DMA 能够抵达 GPU memory，GPU 驱动必须把目标显存页转换成对发起设备有效的 DMA 映射。底层会涉及 GPU 的 PCIe 地址窗口、页表和驱动导出的 P2P memory 信息。\n从概念上看：\nCUDA virtual address │ ▼ GPU driver pins VRAM pages │ ▼ PCIe/DMA-visible mappings backed by GPU BAR resources │ ▼ Storage DMA reaches GPU VRAM 不能把 CUDA 指针直接当作 BAR 地址或 NVMe DMA 地址。中间的固定、映射、权限和生命周期管理必须由驱动完成。\n3. BAR 大小会影响设备内存可见性 平台需要为 GPU BAR 分配合适的 MMIO 地址范围。64 位 MMIO aperture、Above 4G Decoding 和 Resizable BAR 等能力会影响大容量设备内存的映射条件。\n不过 GDS 能否工作不能只用“是否启用 Resizable BAR”判断。实际支持还依赖 GPU、驱动、CUDA/GDS 版本、存储驱动、文件系统、PCIe 拓扑和平台配置。\n十七、GDS 的完整控制与数据路径 可以把一次 GDS 读取拆成以下阶段。\n1. GPU 内存注册 应用准备 GPU buffer：\ncudaMalloc() -\u0026gt; CUDA virtual address GDS 相关组件注册或缓存该 buffer 的映射，使 GPU 内存页在 I/O 期间不会失效，并建立存储设备可使用的 DMA 描述。\n2. 文件和存储路径解析 cuFile 处理文件描述符，确认文件系统和设备是否支持目标路径。文件偏移最终需要映射到底层存储块或远端存储请求。\n3. 构造存储请求 内核存储栈或用户态存储框架构造 NVMe 等设备命令。数据目标不再是普通 host buffer，而是经过注册的 GPU memory DMA mapping。\n4. BAR doorbell 启动 I/O 驱动更新 Submission Queue，并写 NVMe BAR 中的 doorbell：\nCPU ── MMIO Write ──\u0026gt; NVMe BAR doorbell 5. 设备执行 DMA NVMe 控制器读取命令，从介质取得数据，并通过可用的 PCIe 路径把数据写入 GPU memory：\nNVMe ── PCIe Memory Write ──\u0026gt; GPU memory mapping ──\u0026gt; VRAM 6. 完成通知 NVMe 写回 Completion Queue，随后通过中断或轮询通知软件。应用在同步完成后才能安全地让 GPU kernel 使用数据。\n把整条链路画在一起：\nControl path Application / cuFile / driver │ ├── register GPU buffer ├── build storage request └── write NVMe BAR doorbell │ ▼ NVMe SSD │ │ Data path: DMA ▼ GPU BAR / mapping │ ▼ GPU VRAM │ ▼ CUDA kernel 十八、GDS 为什么可能更快 GDS 的收益通常来自减少中间阶段，而不是让 PCIe 链路的物理带宽凭空增加。\n1. 减少主机内存拷贝 传统路径可能同时消耗：\nNVMe -\u0026gt; Host RAM Host RAM -\u0026gt; GPU VRAM GDS 尽量将其压缩为：\nNVMe -\u0026gt; GPU VRAM 这样可以减少主机 DRAM 流量，尤其适合多 GPU、多 NVMe 并发读取。\n2. 降低 CPU 开销 CPU 不再负责大块数据复制，可以把更多周期用于：\n数据预处理调度； 模型执行； 元数据管理； 网络和存储队列管理。 3. 改善流水线并行 应用可将 I/O、解压、预处理和 GPU 计算流水化。在数据集加载、检查点恢复、向量数据库、科学计算等场景中，降低数据到达 GPU 的延迟。\n4. 降低 Host Memory 带宽争用 大型 GPU 服务器中，CPU、NIC、NVMe 和 GPU 都可能竞争主机内存带宽。绕过不必要的 host bounce buffer 能减轻内存控制器压力。\n十九、GDS 并非永远完全绕过 CPU 和主机内存 “Direct”不等于完全没有 CPU，也不保证每次 I/O 都走纯 P2P 路径。\nCPU 仍然负责：\nAPI 调用和请求提交； 文件系统元数据处理； 内存注册与映射管理； 错误处理和完成管理； 必要的同步。 某些条件下还可能使用兼容或回退路径，例如：\n文件系统或存储驱动不支持直接路径； GPU、NVMe 或拓扑不满足要求； I/O 大小、偏移或内存地址不满足对齐要求； DMA 映射建立失败； 平台 IOMMU/ACS 配置限制 P2P； 数据需要经过软件处理。 因此评估 GDS 时，应使用实际工具和指标确认数据路径，而不是仅凭 API 调用成功判断已经绕过主机内存。\n二十、PCIe 拓扑为什么重要 下面两种拓扑可能有明显差异。\n同一个 PCIe Switch 下 PCIe Switch ┌─────────┐ │ │ NVMe GPU 理论上 peer traffic 可以在 Switch 内部转发，不必上行到 Root Complex。但是否真正这样路由仍受 Switch、ACS 和平台配置影响。\n跨 Root Complex 或跨 CPU Socket GPU ── Root Complex A ── CPU interconnect ── Root Complex B ── NVMe 请求可能跨 NUMA 节点或根端口，带来更高延迟、较低带宽，甚至不支持 P2P。设备距离不仅是逻辑概念，而会直接影响可达性和性能。\n部署高性能 GDS 系统时，应关注：\nGPU 与 NVMe 的 PCIe 层级； NUMA node； Link speed 和 link width； Switch 上行带宽是否过度汇聚； ACS 和 IOMMU 状态； GPU BAR/MMIO 资源分配； 多设备并发下的共享链路瓶颈。 二十一、BAR、DMA 和 GDS 的常见误区 误区一：BAR 就是一块 DMA 内存 不是。BAR 是设备向系统暴露资源的地址窗口；DMA buffer 是设备被授权访问的目标内存。两者的地址语义和管理方式不同。\n误区二：CPU 往 BAR 写数据就是 DMA 不是。CPU 对 BAR 的访问属于 CPU 发起的 MMIO。DMA 通常由设备作为 requester 发起。\n误区三：DMA 完全不需要 CPU DMA 避免 CPU 搬运数据，但请求准备、映射、同步和完成处理仍需要软件参与。\n误区四：设备知道进程虚拟地址就能 DMA 不可以。进程虚拟地址必须经过固定和 DMA 映射，转换成设备可用且在生命周期内有效的地址。\n误区五：GPU 开启大 BAR 就等于支持 GDS 不等于。大 BAR 有利于设备内存地址可见性，但 GDS 还依赖完整的软件栈、存储路径和 PCIe 拓扑支持。\n误区六：GDS 总能让 NVMe 直接写显存 实际路径可能因平台和请求条件回退。必须结合系统配置和监控工具验证。\n误区七：P2P 一定比经过主机内存快 如果 P2P 请求需要跨 socket、绕行 Root Complex，或设备间共享窄链路，它可能没有预期收益。性能最终由拓扑、传输大小、并发度和软件开销共同决定。\n二十二、如何在 Linux 中观察 BAR 和拓扑 查看设备及 BAR 资源 lspci -s 65:00.0 -vv 输出中的 Region 类似：\nRegion 0: Memory at ... [size=16K] Region 2: Memory at ... [64-bit, prefetchable] [size=32M] 它们分别对应设备 BAR 资源。\n也可以查看：\ncat /sys/bus/pci/devices/0000:65:00.0/resource ls -l /sys/bus/pci/devices/0000:65:00.0/resource* 查看 PCIe 拓扑 lspci -t 结合设备 BDF，可以判断 NVMe 和 GPU 是否位于同一 Switch 或 Root Port 下。\n查看 NUMA 归属 cat /sys/bus/pci/devices/0000:65:00.0/numa_node 查看链路能力和当前状态 lspci -s 65:00.0 -vv | grep -E \u0026#39;LnkCap|LnkSta\u0026#39; 重点关注链路代际和宽度是否降级，例如设备支持 x16，但实际协商成 x8。\n查看 NVIDIA GPU 拓扑 nvidia-smi topo -m 它可以辅助判断 GPU、NIC、CPU 和 NUMA 之间的距离。对 NVMe 仍应结合 lspci -t 查看完整 PCIe 树。\n二十三、性能设计中的实用原则 1. 控制面和数据面分离 使用 BAR/MMIO 做少量控制，通过内存队列和 DMA 搬运大数据。不要把大块数据逐字节写入 BAR 寄存器窗口。\n2. 减少 MMIO Read 优先使用 posted write、内存 completion queue、批量轮询和 MSI-X，避免频繁跨 PCIe 往返读取状态。\n3. 批量提交与 doorbell 合并 一次填写多个描述符后再更新 doorbell，可摊薄 MMIO 和同步成本。但批量过大会增加单请求延迟，需要在吞吐和延迟之间权衡。\n4. 控制 DMA 映射生命周期 频繁 map/unmap 会增加 IOMMU 和页固定开销。稳定的长生命周期缓冲池通常更适合高 IOPS 数据路径，但必须限制 pinned memory 规模并正确回收。\n5. 尊重内存顺序 在写 doorbell 前，必须保证设备将读取的描述符已经对设备可见；处理完成项时，也要保证先观察所有权变化，再读取设备写入的数据。\n6. 先检查拓扑，再优化软件 如果 NVMe 和 GPU 跨 NUMA 节点、链路降级或共享上行过载，仅优化提交代码很难达到目标带宽。\n7. 用实际数据路径验证 GDS 同时观察：\n存储吞吐和 IOPS； GPU copy engine 与 kernel 利用率； CPU 使用率； 主机 DRAM 带宽； PCIe link throughput； GDS 直接路径与兼容路径统计。 只有这些指标一起变化，才能判断瓶颈是否真的被消除。\n二十四、总结 BAR、DMA 和 GDS 可以串成一条完整的 PCIe I/O 逻辑：\n1. PCIe 配置空间中的 BAR 描述设备资源需求 2. 固件或操作系统给 BAR 分配 MMIO 地址 3. 驱动映射 BAR，通过寄存器和 doorbell 控制设备 4. 驱动为内存建立 DMA 映射，把 DMA 地址交给设备 5. 设备作为 PCIe requester 执行 Memory Read/Write 6. IOMMU 可对 DMA 地址进行翻译和隔离 7. P2P 允许设备访问另一个设备暴露的 PCIe 地址资源 8. GDS 将 GPU 内存注册、存储请求和 P2P/DMA 能力组合起来 9. 数据尽量从存储设备直接进入 GPU VRAM 最终可以用三句话概括：\nBAR 解决 CPU 如何找到并控制设备。\nDMA 解决设备如何高效访问目标内存。\nGDS 解决存储设备如何在驱动和拓扑允许时，把数据更直接地送到 GPU 显存。\n理解这三者后，再分析 NVMe、RDMA、GPU Direct、SPDK 或用户态设备驱动时，就能清楚地区分配置空间、MMIO 控制路径、DMA 数据路径与设备间 P2P 路径，而不会把几个看起来都是“地址”的概念混为一谈。\n","permalink":"https://yangyang233333.github.io/posts/pcie-bar-dma-gds/","summary":"\u003cp\u003e在操作系统驱动、高性能网卡、NVMe SSD 和 GPU 系统中，经常会同时看到 BAR、MMIO、DMA、IOMMU、peer-to-peer DMA、GPUDirect RDMA 和 GPUDirect Storage 等概念。这些名词都与“设备怎样通过 PCIe 交换控制信息和数据”有关，但它们处在不同层次。\u003c/p\u003e\n\u003cp\u003e最简洁的理解是：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eBAR：让 CPU 能够定位并访问 PCIe 设备中的寄存器或显存窗口\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eDMA：让 PCIe 设备能够主动读取或写入系统内存\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eP2P DMA：让一个 PCIe 设备直接访问另一个 PCIe 设备暴露的地址空间\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGDS：利用 DMA、GPU 内存映射和驱动协作，让存储数据尽量直接进入 GPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e本文从 PCIe 地址空间和事务模型出发，解释 BAR 是如何分配和映射的，驱动为什么通过 BAR 下发命令，DMA 地址为什么不能简单等同于物理地址，以及 GPUDirect Storage 如何把这些机制组合成一条高性能数据路径。\u003c/p\u003e\n\u003ch2 id=\"一先建立-pcie-系统视图\"\u003e一、先建立 PCIe 系统视图\u003c/h2\u003e\n\u003cp\u003e一个典型服务器的 PCIe 拓扑如下：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                         CPU\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                          │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                Memory Controller\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                          │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                       Host RAM\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                          │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    Root Complex\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                   ┌──────┴──────┐\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                   │             │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e              PCIe Switch    PCIe Endpoint\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e              ┌────┴────┐         GPU\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e              │         │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e           NVMe SSD     NIC\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eCPU 和内存构成主机侧。Root Complex 把 CPU/内存系统连接到 PCIe fabric。NVMe、网卡和 GPU 通常是 Endpoint。PCIe Switch 用于扩展端口和转发事务。\u003c/p\u003e","title":"PCIe BAR 深入解析：从设备地址窗口到 DMA 与 GPUDirect Storage"},{"content":"Rust 语言只定义了 Future、async/await 和 Waker 等异步基础设施，并没有在标准库中提供完整的异步运行时。Tokio 补齐了这一层：它提供任务调度器、网络 I/O 驱动、定时器、异步同步原语、异步文件接口以及阻塞任务隔离机制。\n如果把 Rust 异步程序比作一座城市：\nFuture = 等待执行的工作 Executor/Scheduler = 安排工作在哪个线程运行 I/O Driver = 监听 socket 是否就绪 Timer Driver = 管理定时器何时到期 Waker = 通知某个任务可以继续 Tokio = 将上述组件组合成运行时 本文不再重复 Future::poll 和状态机的基础推导，而是聚焦 Tokio 本身：runtime 如何启动，任务如何调度，网络 I/O 为什么不会阻塞线程，以及生产代码中应该怎样处理并发、超时、共享状态、CPU 密集任务和优雅退出。\n一、Tokio 提供了什么 一个典型 Tokio 应用依赖：\n[dependencies] tokio = { version = \u0026#34;1\u0026#34;, features = [\u0026#34;full\u0026#34;] } full 适合学习和应用开发，但库作者通常应该只启用实际需要的 feature，缩短编译时间并减少依赖面。\nTokio 的主要组成包括：\n组件 作用 Runtime 组合调度器、I/O driver 和 timer driver Task 由 runtime 调度的异步任务 tokio::net TCP、UDP、Unix Socket 等异步网络接口 tokio::time sleep、interval、timeout tokio::sync channel、Mutex、RwLock、Semaphore、Notify tokio::fs 文件系统异步接口 spawn_blocking 将阻塞或 CPU 密集工作移出异步 worker select! 同时等待多个异步分支 需要先建立一个重要认识：\nTokio 不会让普通阻塞函数自动变成异步函数。只有与 runtime 协作、在等待时返回 Pending 的操作，才能释放 worker thread。\n二、#[tokio::main] 做了什么 最常见入口是：\n#[tokio::main] async fn main() { println!(\u0026#34;hello tokio\u0026#34;); } 它大致展开为：\nfn main() { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap(); runtime.block_on(async { println!(\u0026#34;hello tokio\u0026#34;); }); } enable_all() 启用 I/O 和时间驱动。block_on 让当前同步线程进入 runtime，持续推进根 Future，直到它完成。\nTokio 支持两种主要调度器：\nmulti_thread：多个 worker thread，默认用于服务端程序 current_thread：单线程运行所有异步任务 可以显式配置：\n#[tokio::main(flavor = \u0026#34;multi_thread\u0026#34;, worker_threads = 4)] async fn main() { // ... } 单线程 runtime 并不意味着一次只能处理一个连接。大量任务仍能在 I/O 等待点交错执行；只是任何长时间不让出执行权的任务都会阻塞全部任务。\n三、Task 为什么比线程轻量 tokio::spawn 创建的是异步 Task，不是操作系统线程：\nlet handle = tokio::spawn(async { 42 }); let value = handle.await.unwrap(); assert_eq!(value, 42); 每个 Task 主要保存：\nFuture 状态机； 调度状态和引用计数； Waker 所需信息； 输出或 panic 结果。 Task 在 .await 返回 Pending 后不占用线程。线程可以继续执行另一个 ready task，因此少量 worker 可以承载大量主要等待网络的任务。\ntokio::spawn 要求 Future 通常满足：\nFuture + Send + \u0026#39;static 'static 不代表任务一定存活到程序结束，而是任务不能借用可能提前失效的栈变量。常见做法是使用 async move 转移所有权：\nlet request_id = String::from(\u0026#34;req-42\u0026#34;); let handle = tokio::spawn(async move { process(request_id).await }); 如果确实需要执行非 Send Future，可以使用 LocalSet 配合 current-thread runtime，但不要把它当成规避所有权设计的默认方案。\n四、调度器怎样工作 multi-thread scheduler 的核心思想是：\n每个 worker 有本地任务队列 │ ├── 优先执行自己的 ready task ├── 必要时读取全局注入队列 └── 空闲时从其他 worker 偷任务 这种方式称为 work stealing。任务被唤醒后重新进入 ready queue，worker 再次 poll 它。\n概念执行过程：\nWorker 0 poll Task A -\u0026gt; A 等待 socket，返回 Pending -\u0026gt; Worker 0 转去 poll Task B I/O driver 发现 A 的 socket ready -\u0026gt; 调用 A 的 Waker -\u0026gt; A 回到 ready queue -\u0026gt; 某个 worker 再次 poll A Tokio 还需要避免单个总是 ready 的任务无限霸占线程。协作式调度依赖任务经常遇到 .await，Tokio 的部分资源操作也带有 cooperative budget。极端计算循环应显式让出：\nloop { do_small_piece_of_work(); tokio::task::yield_now().await; } 不过如果工作本质上是长时间 CPU 计算，更正确的方案通常是 spawn_blocking、Rayon 或独立计算服务，而不是不断 yield_now()。\n五、I/O Driver 为什么能管理大量连接 Linux 上 Tokio 通常基于 epoll，其他平台使用对应的操作系统事件机制。以 TCP 读取为例：\nTask 调用 AsyncRead::poll_read -\u0026gt; 尝试非阻塞 read -\u0026gt; 数据尚未到达，得到 WouldBlock -\u0026gt; 向 I/O driver 登记兴趣和 Waker -\u0026gt; 返回 Pending 网卡收到数据 -\u0026gt; 内核将 fd 标记为 readable -\u0026gt; epoll 通知 Tokio I/O driver -\u0026gt; driver 唤醒相关 Task -\u0026gt; Task 再次 poll_read -\u0026gt; read 成功 线程没有在 read 上睡眠等待。Task 只是暂停，worker 可以处理其他连接。\nTokio I/O 类型应使用 tokio::net，不要在 async task 中直接使用阻塞式 std::net 读写。已有标准库 socket 可切换为 nonblocking 后转换为 Tokio 类型，但必须满足 API 要求。\n六、Timer Driver 与取消 tokio::time::sleep 也不会为每个定时器创建线程：\nuse tokio::time::{sleep, Duration}; sleep(Duration::from_millis(100)).await; sleep Future 把 deadline 注册到 timer driver，然后返回 Pending。时间到达时，driver 唤醒 Task。\n许多 Tokio Future 具有取消安全边界：当 Future 被 drop，表示调用方不再等待它。但取消不是“向任意代码注入异常”，而是停止继续 poll 并释放 Future 保存的状态。\n这意味着：\ndrop 一个尚未开始写入的 Future 可能什么也没发生； drop 一个已产生外部副作用的 Future 不会自动回滚； select! 循环中必须确认被取消操作是否 cancel-safe； 业务事务需要自己定义幂等、补偿或提交边界。 七、例子一：正确并发执行两个请求 顺序写法：\nlet user = fetch_user().await?; let orders = fetch_orders().await?; 总延迟接近两次请求之和。如果两者互不依赖，可使用 join!：\nuse tokio::try_join; async fn load_page() -\u0026gt; anyhow::Result\u0026lt;Page\u0026gt; { let (user, orders) = try_join!( fetch_user(), fetch_orders(), )?; Ok(Page { user, orders }) } join! 和 try_join! 在当前 Task 中并发 poll 多个 Future，不会额外创建 Task；try_join! 遇到第一个错误会返回。\n如果需要独立任务、独立生命周期或跨线程调度，再使用 spawn：\nlet user_task = tokio::spawn(fetch_user()); let order_task = tokio::spawn(fetch_orders()); let user = user_task.await??; let orders = order_task.await??; 最佳实践：\n只为“并发”优先用 join!； 需要后台执行或独立取消时使用 spawn； 不要为了让代码看起来异步而无条件 spawn 每个函数； 始终处理 JoinError，任务可能 panic 或被 abort。 八、例子二：超时与取消 网络调用必须有时间边界：\nuse tokio::time::{timeout, Duration}; async fn load_with_timeout() -\u0026gt; anyhow::Result\u0026lt;Response\u0026gt; { let response = timeout( Duration::from_secs(2), fetch_remote_data(), ) .await .map_err(|_| anyhow::anyhow!(\u0026#34;request timed out\u0026#34;))??; Ok(response) } timeout 超时后 drop 内部 Future。是否会取消底层操作，取决于该 Future 的实现和副作用阶段。\n同时等待响应和退出信号可用 select!：\nuse tokio::signal; async fn run_service() -\u0026gt; anyhow::Result\u0026lt;()\u0026gt; { tokio::select! { result = serve() =\u0026gt; result, _ = signal::ctrl_c() =\u0026gt; { println!(\u0026#34;shutdown requested\u0026#34;); Ok(()) } } } 最佳实践：\n为外部 RPC、数据库请求和队列操作设置 deadline； 区分 timeout、连接错误和业务错误； 重试必须带退避、上限和幂等条件； 不要在无限循环中无条件重试立即失败的请求； select! 中循环使用同一个 Future 时，先确认取消安全性。 九、例子三：用 Semaphore 限制并发 一次 spawn 十万个任务虽然可能工作，但会同时占用连接、内存和下游容量。使用 Semaphore 建立背压：\nuse std::sync::Arc; use tokio::sync::Semaphore; use tokio::task::JoinSet; async fn process_all(items: Vec\u0026lt;Item\u0026gt;) -\u0026gt; anyhow::Result\u0026lt;()\u0026gt; { let limit = Arc::new(Semaphore::new(64)); let mut tasks = JoinSet::new(); for item in items { let permit = limit.clone().acquire_owned().await?; tasks.spawn(async move { let _permit = permit; process(item).await }); } while let Some(result) = tasks.join_next().await { result??; } Ok(()) } permit 在任务结束时自动释放。JoinSet 便于收集动态任务，并在对象 drop 时管理剩余任务。\n更流式的场景也可以使用 StreamExt::buffer_unordered。\n最佳实践：\n并发上限应来自下游容量，而不是 CPU 核数的机械倍数； 对数据库连接池、HTTP client、文件描述符和内存同时考虑； 有界 channel 和 Semaphore 是建立背压的常用工具； 不要只限制 task 数量，却让每个 task 创建大量子任务。 十、例子四：共享状态与 Mutex Tokio 同时提供异步 Mutex：\nuse std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; type Cache = Arc\u0026lt;Mutex\u0026lt;HashMap\u0026lt;String, String\u0026gt;\u0026gt;\u0026gt;; async fn insert(cache: Cache, key: String, value: String) { let mut guard = cache.lock().await; guard.insert(key, value); } 但“异步代码就必须使用 tokio::sync::Mutex”是误区。\n选择原则：\n临界区很短且不会跨 .await：优先 std::sync::Mutex； 必须持锁跨 .await：使用 Tokio Mutex，但重新考虑设计； 高并发共享服务：优先消息传递或分片状态； 读多写少：可评估 RwLock，不要假设一定更快。 危险写法：\nlet mut guard = state.lock().await; call_remote_service().await; guard.update(); 远程调用期间一直持锁，会把一个网络延迟放大成全局串行瓶颈。更好的方式是缩短临界区：\nlet snapshot = { let guard = state.lock().await; guard.snapshot() }; let result = call_remote_service(snapshot).await?; { let mut guard = state.lock().await; guard.apply(result); } 同时必须考虑两次加锁之间状态可能变化，必要时使用版本号或 compare-and-set 语义。\n十一、例子五：阻塞操作必须隔离 错误示例：\nasync fn handler() { std::thread::sleep(Duration::from_secs(1)); } 这会阻塞 Tokio worker thread，而不是只暂停当前 Task。同样危险的还有：\n大文件的同步读写； 阻塞式数据库客户端； 长时间 CPU 压缩、加密和解析； Mutex 竞争严重的同步调用； 在 async 中执行外部命令并同步等待。 对于有限、不可避免的阻塞函数：\nlet output = tokio::task::spawn_blocking(move || { blocking_library_call(input) }) .await??; spawn_blocking 使用专门的 blocking thread pool，避免占住异步 worker。\n但它不是无限 CPU 任务调度器。持续 CPU 密集工作应该：\n限制同时运行数量； 使用 Rayon 等计算线程池； 通过 channel 与 Tokio runtime 通信； 必要时拆成独立服务。 另一个重要事实是：已经开始运行的 spawn_blocking 闭包通常不能靠 abort 强制停止。闭包应自行检查取消标志或被设计成有限时长操作。\n十二、例子六：有界 Channel 与优雅退出 消息传递通常比共享可变状态更容易管理：\nuse tokio::sync::{mpsc, oneshot}; struct Command { key: String, reply: oneshot::Sender\u0026lt;anyhow::Result\u0026lt;String\u0026gt;\u0026gt;, } async fn manager(mut rx: mpsc::Receiver\u0026lt;Command\u0026gt;) { while let Some(command) = rx.recv().await { let result = handle(command.key).await; let _ = command.reply.send(result); } } 调用侧：\nlet (reply_tx, reply_rx) = oneshot::channel(); command_tx.send(Command { key, reply: reply_tx, }).await?; let value = reply_rx.await??; 使用有界 mpsc::channel(capacity) 可以在消费者跟不上时让生产者等待，从而形成背压。无界 channel 可能把短期流量高峰变成持续内存增长。\n服务退出时，可以使用 CancellationToken、广播 channel 或 watch channel 通知子任务，并用 JoinSet 等待它们完成：\nuse tokio_util::sync::CancellationToken; use tokio::task::JoinSet; async fn run() -\u0026gt; anyhow::Result\u0026lt;()\u0026gt; { let shutdown = CancellationToken::new(); let mut tasks = JoinSet::new(); for worker_id in 0..4 { let token = shutdown.child_token(); tasks.spawn(async move { worker(worker_id, token).await }); } tokio::signal::ctrl_c().await?; shutdown.cancel(); while let Some(result) = tasks.join_next().await { result??; } Ok(()) } 最佳实践：\n停止接收新请求； 通知后台任务退出； 为 drain 设置最大等待时间； flush 日志、指标和必要状态； 超时后明确 abort 哪些仍可安全取消的任务。 十三、select! 的常见陷阱 select! 默认在多个就绪分支之间采用公平策略；也可以使用 biased; 指定从上到下优先检查。使用 biased 模式时，必须自己保证高频分支不会饿死 shutdown 分支。\n循环中常见写法：\nloop { tokio::select! { Some(message) = rx.recv() =\u0026gt; handle(message).await, _ = shutdown.cancelled() =\u0026gt; break, } } 注意事项：\n被禁用分支的 Future 可能每轮重新创建； 某个分支赢得竞争后，其他分支 Future 会被 drop； 读取流或协议帧时要确认中途取消不会丢失数据； 不要在某分支中执行长时间无 .await 计算； 对 shutdown 分支给予足够调度机会。 十四、不要在 async 中滥用文件 I/O 普通文件不像 socket 那样总能通过 readiness API 高效异步化。Tokio 的 tokio::fs 在许多平台上会把文件操作放到 blocking pool。\n因此：\ntokio::fs 可以避免阻塞 async worker； 它不保证磁盘 I/O 变成内核原生异步； 大量小文件操作仍可能压满 blocking pool； 高吞吐存储场景应考虑批处理、专用线程池或 io_uring 方案； 不要把 tokio::fs 当作无限并发许可证。 十五、错误处理与任务管理 不要随手丢弃 JoinHandle：\nlet _ = tokio::spawn(do_work()); 这种 detached task 的错误和 panic 很容易丢失。更好的选择：\n请求范围内的并发：join! / try_join!； 动态任务集合：JoinSet； 长期后台任务：保存 handle，并接入 shutdown； 真正 fire-and-forget：至少在任务内部记录错误和关键上下文。 任务 panic 不会自动让整个进程崩溃，但会表现为 JoinError。关键后台任务意外退出时，服务通常应该触发整体 shutdown，而不是静默降级。\n十六、可观测性 异步系统仅看线程堆栈通常很难定位问题。建议使用结构化日志和 tracing：\nuse tracing::{info, instrument}; #[instrument(skip(client), fields(request_id = %request_id))] async fn fetch( client: \u0026amp;Client, request_id: \u0026amp;str, ) -\u0026gt; anyhow::Result\u0026lt;Response\u0026gt; { info!(\u0026#34;sending request\u0026#34;); client.send().await } 需要关注：\n请求总延迟和各 .await 阶段延迟； channel 深度和 Semaphore 等待时间； 活跃 task 数； blocking pool 饱和； 超时、取消和重试次数； runtime worker 是否长期满负荷； 下游连接池等待时间。 Tokio Console 可以帮助观察 Task 生命周期、poll 时间、wakeup 和资源等待，特别适合排查 task 长时间不让出、锁竞争和异步死锁。\n十七、Runtime 配置原则 不要一开始就随意调整 worker 数量。默认 multi-thread runtime 通常是合理起点。\n需要调整时，先明确瓶颈：\n网络 I/O 密集：worker 不必与连接数相等； CPU 密集：应隔离计算，而不是无限增加 async worker； 大量阻塞调用：先修复阻塞边界； tail latency 异常：检查长 poll、锁竞争和队列积压； NUMA 或超大机器：可考虑拆分 runtime 或绑定专用资源，但需要基准测试。 库代码通常不应该偷偷创建全局 runtime，也不应该在已有 runtime 内再次 block_on。库优先暴露 async API，让应用决定 runtime 配置和生命周期。\n十八、最佳实践清单 并发 独立 Future 使用 join!，独立任务使用 spawn； 使用 Semaphore、有界 channel 或流并发限制建立背压； 保存和管理重要任务的 JoinHandle； 避免无上限 spawn。 阻塞边界 async task 内不调用 thread::sleep； 阻塞库放入 spawn_blocking； 长时间 CPU 工作使用专用计算池； 不持有锁执行慢 I/O。 超时与取消 外部操作设置 deadline； 重试带指数退避、随机抖动和次数上限； 验证 select! 中操作的取消安全性； 设计幂等键或补偿机制。 共享状态 短临界区可用标准 Mutex； 避免持有 guard 跨 .await； 优先消息传递和状态所有权集中； 高并发 map 考虑分片而非单一全局锁。 生命周期 使用 CancellationToken 或 channel 广播退出； shutdown 时停止接收、等待 drain、设置超时； 不创建无法停止、无法观察的后台任务； 将 runtime 生命周期放在应用边界管理。 十九、总结 Tokio 的核心不是“把函数前加上 async”，而是一套协作式执行系统：\nFuture 在 poll 中推进 遇到 I/O 或 timer 返回 Pending I/O/Timer Driver 在资源就绪时调用 Waker Scheduler 将 Task 放回 ready queue 多个 worker 通过本地队列和 work stealing 执行任务 高质量 Tokio 程序通常具备这些特征：\nTask 经常在真正的等待点让出线程； 阻塞和 CPU 密集工作被明确隔离； 并发有上限，并对下游形成背压； 每个外部操作有超时和取消策略； 共享状态的锁范围很短； 后台任务可观察、可停止、可等待； tracing 和指标能够解释请求时间花在哪里。 理解 runtime 的执行模型后，很多“异步性能问题”会变得容易判断：程序究竟是在等待 I/O、等待锁、等待配额，还是根本阻塞了 worker thread。\n参考资料 Tokio 官方文档与教程 Tokio Runtime、Task、I/O、Time 和 Sync 模块文档 Tokio 官方源码中的 runtime scheduler 与 I/O driver Rust 标准库 Future、Context、Waker 文档 ","permalink":"https://yangyang233333.github.io/posts/rust-tokio-runtime-and-best-practices/","summary":"\u003cp\u003eRust 语言只定义了 \u003ccode\u003eFuture\u003c/code\u003e、\u003ccode\u003easync/await\u003c/code\u003e 和 \u003ccode\u003eWaker\u003c/code\u003e 等异步基础设施，并没有在标准库中提供完整的异步运行时。Tokio 补齐了这一层：它提供任务调度器、网络 I/O 驱动、定时器、异步同步原语、异步文件接口以及阻塞任务隔离机制。\u003c/p\u003e\n\u003cp\u003e如果把 Rust 异步程序比作一座城市：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eFuture            = 等待执行的工作\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eExecutor/Scheduler = 安排工作在哪个线程运行\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eI/O Driver         = 监听 socket 是否就绪\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eTimer Driver       = 管理定时器何时到期\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eWaker              = 通知某个任务可以继续\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eTokio              = 将上述组件组合成运行时\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e本文不再重复 \u003ccode\u003eFuture::poll\u003c/code\u003e 和状态机的基础推导，而是聚焦 Tokio 本身：runtime 如何启动，任务如何调度，网络 I/O 为什么不会阻塞线程，以及生产代码中应该怎样处理并发、超时、共享状态、CPU 密集任务和优雅退出。\u003c/p\u003e\n\u003ch2 id=\"一tokio-提供了什么\"\u003e一、Tokio 提供了什么\u003c/h2\u003e\n\u003cp\u003e一个典型 Tokio 应用依赖：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003edependencies\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003etokio\u003c/span\u003e = { \u003cspan style=\"color:#a6e22e\"\u003eversion\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;1\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efeatures\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;full\u0026#34;\u003c/span\u003e] }\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003efull\u003c/code\u003e 适合学习和应用开发，但库作者通常应该只启用实际需要的 feature，缩短编译时间并减少依赖面。\u003c/p\u003e\n\u003cp\u003eTokio 的主要组成包括：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e组件\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e作用\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eRuntime\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e组合调度器、I/O driver 和 timer driver\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eTask\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e由 runtime 调度的异步任务\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003etokio::net\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eTCP、UDP、Unix Socket 等异步网络接口\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003etokio::time\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003esleep、interval、timeout\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003etokio::sync\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003echannel、Mutex、RwLock、Semaphore、Notify\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003etokio::fs\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e文件系统异步接口\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003espawn_blocking\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e将阻塞或 CPU 密集工作移出异步 worker\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eselect!\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e同时等待多个异步分支\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e需要先建立一个重要认识：\u003c/p\u003e","title":"Rust Tokio 深入解析：运行时原理、并发模型与最佳实践"},{"content":"Mooncake 仓库同时存在经典 Transfer Engine 和 TENT（Transfer Engine Next）。TENT 不是简单增加一种 Transport，而是重构了 Segment 生命周期、运行时调度、拓扑选择、QoS、故障转移和插件体系。\n阅读版本：\nMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc 一、为什么需要下一代引擎 经典 TE 已经支持 RDMA、TCP、NVLink 和多种硬件，但随着后端增加，TransferEngineImpl + MultiTransport + 各 Transport 容易出现几个问题：\nSegment 生命周期分散在元数据和后端注册逻辑中； 传输选择主要依赖静态协议与局部规则； 多 rail、拥塞、故障和 QoS 难以统一调度； 不同后端各自维护进度线程和资源模型； 新硬件接入需要理解大量经典内部约定； 请求取消、deadline 和 failover 缺少统一运行时。 TENT 将这些能力上移到 Runtime 层。\n二、目录结构 TENT 核心位于：\ntent/src/runtime/ ├── transfer_engine_impl.cpp ├── segment.cpp ├── segment_manager.cpp ├── segment_registry.cpp ├── segment_tracker.cpp ├── transport_loader.cpp ├── transport_selector.cpp ├── progress_worker.cpp ├── admission_queue.cpp ├── qos_contract.cpp ├── receiver_credit.cpp ├── topology.cpp ├── control_plane.cpp └── proxy_manager.cpp 外围包括：\nmetastore：etcd / redis / HTTP rpc：控制面 RPC platform：CPU/GPU 内存探测和分配 plugins：CUDA、ROCm 等平台插件 metrics：统一指标系统 transport：各传输插件 与经典 TE 相比，职责边界更加清晰。\n三、兼容入口 公共 TransferEngine 仍然可以启用 TENT。src/transfer_engine.cpp 根据 use_tent_ 创建：\nstd::shared_ptr\u0026lt;mooncake::tent::TransferEngine\u0026gt; 经典参数会转换为 tent::Config：\nlocal_server_name -\u0026gt; local_segment_name metadata connection -\u0026gt; metadata_type + metadata_servers 这层兼容降低迁移成本，但 TENT 自己的原生 API 和配置能表达更多调度语义。\n四、SegmentManager 集中管理生命周期 经典模式中，Segment 信息分布在 TransferMetadata、注册表和各 Transport。TENT 引入：\nSegment SegmentManager SegmentRegistry SegmentTracker 可以把它们理解为：\nSegment：本地或远端地址空间对象； SegmentManager：创建、打开、注册和关闭 Segment； SegmentRegistry：维护可查找对象和注册关系； SegmentTracker：跟踪远端变化、引用和失效。 集中管理后，注销内存、远端重启、metadata 更新和正在执行的请求可以在同一生命周期模型中协调。\n五、TransportLoader 与插件化 经典 MultiTransport::installTransport() 包含大量构建宏和 if (proto == ...) 分支。TENT 使用 TransportLoader 将后端加载与引擎核心解耦。\n平台能力也通过 Device Plugin 扩展，例如 CUDA 和 ROCm 插件负责：\n识别设备内存； 查询 device/NUMA 属性； 提供平台特有注册或拷贝能力； 向拓扑系统暴露设备关系。 这样新增硬件不必把所有条件编译逻辑堆进一个中心工厂。\n六、TransportSelector TENT 的 TransportSelector 不只按 Segment protocol 字符串路由，而是结合拓扑和运行期条件选择路径。\n输入可以包括：\n源、目标 memory type 源、目标节点和设备 拓扑距离 可用 transport 链路优先级 transport hint 当前 rail 状态 对应测试包括 topology priority matrix、transport hint、selector 等，说明选择逻辑被提升为可独立验证的模块。\n理想决策可能是：\n同 GPU / 同进程 -\u0026gt; 本地直接完成 同节点 GPU -\u0026gt; SHM / GPU IPC / NVLink 跨节点 GPU -\u0026gt; RDMA / EFA / UB 持久层 -\u0026gt; GDS / NVMe-oF 故障或不支持 -\u0026gt; TCP 七、ProgressWorker TENT 用 ProgressWorker 统一推进异步操作。传统后端容易各自创建 poller，造成线程模型分散。\nProgressWorker 的职责类似事件循环：\n取得待执行 operation -\u0026gt; 提交后端请求 -\u0026gt; poll completion -\u0026gt; 推进多阶段 operation -\u0026gt; 处理 retry / cancel / timeout -\u0026gt; 完成用户 future/callback 测试覆盖 poll backoff 和 progress worker，意味着实现会在低延迟与空闲 CPU 消耗之间做权衡，而不是无限 busy polling。\n八、AdmissionQueue 与 QoS 经典 TE 主要回答“怎样传”，TENT 进一步回答“现在是否应该传、谁先传”。\nAdmissionQueue 和 QoSContract 可以支持：\n带宽上限； 请求优先级； deadline； 多租户公平性； 大请求切片； 拥塞时的 admission control； deadline promotion。 仓库测试包含 bandwidth arbitration、deadline promotion 和 QoS contract。这说明 TENT 面向的不只是 microbenchmark 峰值带宽，而是共享 AI 集群中的可预测服务质量。\n九、Receiver Credit 发送方很快并不代表接收方能无限承受请求。TENT 引入 receiver credit 管理接收端资源：\n接收端公布 credit -\u0026gt; 发送端消费 credit 提交 -\u0026gt; 请求完成后归还 -\u0026gt; credit 不足则排队或限流 它可以避免接收端 staging buffer、队列或 RPC handler 被突发流量压垮。对需要双边协议或 remote stage 的后端尤其重要。\n十、Remote Stage 与代理路径 并非所有源和目标都能直接建立单边访问。TENT 的 remote_stage_operation 和 proxy_manager 支持分阶段路径：\n源设备 -\u0026gt; 本地或远端 staging -\u0026gt; 网络 transport -\u0026gt; 目标设备 虽然 staging 会增加一次拷贝，但它提供兼容性和故障回退。统一 operation 状态机可以把多阶段传输表现为一个用户请求。\n十一、故障处理与取消 TENT 测试明显增加了：\nfailover engine failover E2E fault proxy RDMA cancel rail monitor endpoint lifecycle RPC handler isolation 这反映设计重点从“后端返回错误码”升级为：\n失败发生在哪个阶段； 是否可以切换 rail 或 transport； 已完成部分是否需要保留； cancel 如何传播到底层； callback 是否只触发一次； shutdown 是否仍能安全回收资源。 十二、Metastore 与 Control Plane TENT 把 metastore、RPC 和 control plane 明确分层：\nMetastore：Segment 和节点描述 RPC：实时协商与命令 Control Plane：资源、拓扑、选择和故障协调 Data Plane：Transport 实际搬运 支持 etcd、Redis 和 HTTP，使集群部署可以按规模和依赖选择控制面。\n十三、Metrics TENT 有独立 metrics 模块和配置加载，测试覆盖指标记录和 HTTP server。值得观测的维度包括：\n请求数、字节数和吞吐； queueing、submission 和 completion 延迟； transport/rail 分布； retry、failover 和 cancel； receiver credit； admission queue 深度； deadline miss； endpoint 与 Segment 生命周期。 对多路径引擎而言，没有这些指标就很难解释“为什么这次选了 TCP 而不是 RDMA”。\n十四、经典 TE 与 TENT 对比 维度 经典 TE TENT 后端管理 MultiTransport 中心工厂 TransportLoader / 插件化 Segment Metadata 与后端共同管理 SegmentManager/Registry/Tracker 路径选择 protocol 与固定优先级 拓扑、hint、状态驱动 进度推进 各后端自身 worker ProgressWorker 统一运行时 QoS 基础批次与负载信息 AdmissionQueue + QoSContract 故障 endpoint 级恢复 rail、transport、operation 级 failover 接收控制 后端自行处理 Receiver Credit 可观测性 分散统计 统一 Metrics 十五、如何阅读和使用两代代码 如果目标是理解 Mooncake 当前广泛使用的数据路径，应先掌握经典 TE：\nTransferEngineImpl -\u0026gt; TransferMetadata -\u0026gt; MultiTransport -\u0026gt; RDMA/TCP Transport 如果目标是开发新后端、QoS、容错或动态路由，应重点研究 TENT：\nSegmentManager -\u0026gt; TransportSelector -\u0026gt; AdmissionQueue -\u0026gt; ProgressWorker -\u0026gt; Transport plugin 两代实现目前通过公共门面共存。阅读时必须先确认配置实际启用哪一套，否则看到相同 API 名称却会追到不同执行路径。\n十六、系列总结 Mooncake Transfer Engine 的本质不是一个 RDMA wrapper，而是一个异构地址空间传输运行时：\nSegment 负责命名和发现 Memory Registration 负责建立访问能力 Transport 负责实际搬运 Batch/Operation 负责异步生命周期 Metadata/RPC 负责控制面 TENT Runtime 进一步负责选择、QoS 与容错 它之所以适合 KV Cache，不是因为理解 KV Cache 内容，而是因为能以较低控制开销搬运大量、可切片、可异步并行的内存区域，并在不同硬件环境下保持统一接口。\n参考源码 tent/include/tent/transfer_engine.h tent/src/transfer_engine.cpp tent/src/runtime/transfer_engine_impl.cpp tent/src/runtime/segment_manager.cpp tent/src/runtime/segment_registry.cpp tent/src/runtime/transport_selector.cpp tent/src/runtime/progress_worker.cpp tent/src/runtime/admission_queue.cpp tent/src/runtime/qos_contract.cpp tent/src/runtime/receiver_credit.cpp tent/src/runtime/transport_loader.cpp ","permalink":"https://yangyang233333.github.io/posts/mooncake-transfer-engine-source-reading-tent/","summary":"\u003cp\u003eMooncake 仓库同时存在经典 Transfer Engine 和 \u003cstrong\u003eTENT（Transfer Engine Next）\u003c/strong\u003e。TENT 不是简单增加一种 Transport，而是重构了 Segment 生命周期、运行时调度、拓扑选择、QoS、故障转移和插件体系。\u003c/p\u003e\n\u003cp\u003e阅读版本：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一为什么需要下一代引擎\"\u003e一、为什么需要下一代引擎\u003c/h2\u003e\n\u003cp\u003e经典 TE 已经支持 RDMA、TCP、NVLink 和多种硬件，但随着后端增加，\u003ccode\u003eTransferEngineImpl + MultiTransport + 各 Transport\u003c/code\u003e 容易出现几个问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eSegment 生命周期分散在元数据和后端注册逻辑中；\u003c/li\u003e\n\u003cli\u003e传输选择主要依赖静态协议与局部规则；\u003c/li\u003e\n\u003cli\u003e多 rail、拥塞、故障和 QoS 难以统一调度；\u003c/li\u003e\n\u003cli\u003e不同后端各自维护进度线程和资源模型；\u003c/li\u003e\n\u003cli\u003e新硬件接入需要理解大量经典内部约定；\u003c/li\u003e\n\u003cli\u003e请求取消、deadline 和 failover 缺少统一运行时。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eTENT 将这些能力上移到 Runtime 层。\u003c/p\u003e\n\u003ch2 id=\"二目录结构\"\u003e二、目录结构\u003c/h2\u003e\n\u003cp\u003eTENT 核心位于：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003etent/src/runtime/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── transfer_engine_impl.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── segment.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── segment_manager.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── segment_registry.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── segment_tracker.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── transport_loader.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── transport_selector.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── progress_worker.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── admission_queue.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── qos_contract.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── receiver_credit.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── topology.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── control_plane.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e└── proxy_manager.cpp\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e外围包括：\u003c/p\u003e","title":"Mooncake Transfer Engine 源码阅读（四）：TENT 下一代架构如何重构传输引擎"},{"content":"Transfer Engine 的公共 API 很统一，但 RDMA 和 TCP 的实现差异很大。RDMA 需要 MR、QP、WR 和 CQ；TCP 需要连接、lane、消息 framing 和接收端主动拷贝。本文沿源码比较两条路径，并分析 Batch 状态如何完成。\n阅读版本：\nMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc 一、Transport 抽象 Transport 基类统一定义：\ninstall / uninstall registerLocalMemory / unregisterLocalMemory submitTransfer getTransferStatus allocateBatchID / freeBatchID 它还定义 TransferRequest、TransferStatus 和内部 BufferEntry。\n公共抽象要求每个后端回答三个问题：\n本地内存如何准备为可传输状态； 请求怎样排队和执行； 如何查询每个 task 的最终状态和字节数。 具体连接模型不属于公共 API。\n二、RDMA 初始化 RDMA 后端主要位于：\nrdma_transport.cpp rdma_context.cpp rdma_endpoint.cpp endpoint_store.cpp worker_pool.cpp 安装阶段通常完成：\n枚举 RDMA devices / ports / GID -\u0026gt; 创建每张 HCA 的 context -\u0026gt; 建立 PD、CQ 等资源 -\u0026gt; 启动 worker / poller -\u0026gt; 发布 NIC endpoint metadata -\u0026gt; 准备 endpoint store Mooncake 支持多 HCA、多端口和 GPU 内存，因此一个逻辑请求可能被切到多个 rail 上并行发送。\n三、RDMA 内存注册 CPU 内存一般通过 verbs 注册为 MR：\naddr + length -\u0026gt; ibv_reg_mr -\u0026gt; lkey：本地 SGE 使用 -\u0026gt; rkey：远端 RDMA READ/WRITE 使用 GPU 内存则依赖 CUDA peer memory、DMA-BUF 或平台对应机制，最终也需要产生 HCA 可访问的 MR。\n每张 HCA 可能需要独立注册，因此一个 BufferEntry 可能保存多份 MR 信息。注册结果发布到 SegmentDesc 后，远端才能选择对应 rail 的 rkey。\n这解释了为何注册大块内存池比频繁注册小对象更高效：MR 注册涉及 pin page、驱动交互和页表建立，不适合位于每次 KV block 传输的热路径。\n四、RDMA 提交链 简化调用链为：\nTransferEngineImpl::submitTransfer -\u0026gt; MultiTransport::selectTransport -\u0026gt; RdmaTransport::submitTransfer -\u0026gt; 验证本地与远端注册区间 -\u0026gt; 取得或创建 RdmaEndpoint -\u0026gt; 将 request 切成 Slice -\u0026gt; WorkerPool 选择 rail / worker -\u0026gt; 构造 ibv_send_wr 与 ibv_sge -\u0026gt; ibv_post_send -\u0026gt; CQ poller 获得 completion -\u0026gt; 更新 task / batch 状态 切片的原因包括：\n单个 WR 或 SGE 的长度限制； 跨越不同 MR 区间； 多 NIC striping； 拥塞和 worker 队列负载； 重试与 failover 粒度。 源码还处理“按顺序推进 slice”的场景。它用 continuation/trampoline 避免同步失败或同步完成导致递归层层增长，体现了异步状态机的工程细节。\n五、Endpoint 生命周期 RdmaEndpoint 代表与远端某条 rail 的连接状态，负责 QP 建立、提交和错误处理。EndpointStore 缓存 endpoint，避免每次传输重新握手。\n典型状态问题包括：\n首次连接尚未完成 远端重启导致旧 QP 失效 CQ 返回 transport error 某条 rail 不可用 多个线程同时请求重连 shutdown 时仍有连接任务 源码和测试包含 endpoint reestablish、state、async event drain、GID probe 等场景，说明 TE 把 RDMA 故障恢复视为核心能力，而不是只实现 happy path benchmark。\nEndpoint cache 必须与 Segment metadata 版本协同：远端重启后，即使名称相同，旧 endpoint 和旧 rkey 都可能无效。\n六、WorkerPool 与多轨并行 WorkerPool 把提交任务分配给不同 worker 和 NIC rail。设计目标是：\n避免所有请求竞争单个提交锁； 利用多张 HCA 聚合带宽； 将 CQ polling 与提交分散到 CPU core； 统计 NIC load； 在 rail 故障时停止或迁移流量。 上层提交的是一个 batch，底层可能变成：\nTask 0 ├── Slice 0 -\u0026gt; HCA 0 / QP A ├── Slice 1 -\u0026gt; HCA 1 / QP B └── Slice 2 -\u0026gt; HCA 0 / QP C 只有所有 slice 都完成，task 才能标记 COMPLETED。任何不可恢复错误都要合并到 task 和 batch 状态。\n七、Completion 如何回到 Batch 提交成功只表示请求进入后端，不表示数据已经到达。\n完成链为：\nCQE -\u0026gt; 根据 wr_id 找回 Slice/Task 上下文 -\u0026gt; 检查 wc.status -\u0026gt; 累加 transferred_bytes -\u0026gt; 减少 pending slice 计数 -\u0026gt; 最后一个 slice 完成 -\u0026gt; 设置 task COMPLETED 或 FAILED -\u0026gt; 必要时触发 notify / continuation getTransferStatus(batch_id, task_id) 查询的正是这个聚合状态。\n释放 BatchID 前必须确保所有任务已结束，否则后端完成回调可能写入已释放状态。API 把 allocate/free 显式交给调用者，也是为了让生命周期边界清楚。\n八、RDMA READ 与 WRITE 从调用者角度：\nWRITE：本地内存写到目标 Segment； READ：从目标 Segment 读到本地内存。 RDMA 单边操作的发起方掌握本地 lkey 和远端地址/rkey。远端 CPU 不需要为每次 payload 进入数据路径，但必须事先：\n注册 MR； 发布 rkey； 保持内存有效； 处理连接和元数据更新。 所以“绕过远端 CPU”是指数据搬运阶段，不是整个系统完全没有远端控制面。\n九、TCP 后端 TCP 后端遵守同一接口，但数据路径不同：\nsubmitTransfer -\u0026gt; 找到或建立 TCP session -\u0026gt; 将请求分配到 lane -\u0026gt; 发送控制头和 payload / 请求 -\u0026gt; 对端线程接收 -\u0026gt; memcpy 到注册目标区间 -\u0026gt; 返回完成响应 -\u0026gt; 本地更新 task 状态 TcpTransport::submitTransfer()、submitTransferTask() 和 submitTransferTaskGroup() 体现了任务分组与 lane 调度。\nTCP 的优势是部署简单、兼容普通以太网；代价是：\n更多 CPU 协议栈开销； 通常需要 CPU memcpy； 延迟和尾延迟高于配置良好的 RDMA； GPU buffer 可能需要 staging。 但 TCP 是很重要的可靠回退路径，也适用于不具备 RDMA 的开发环境。\n十、为什么统一 API 仍然有价值 RDMA 和 TCP 后端内部完全不同，但上层仍然使用：\nSegment ID + offset local address + length READ / WRITE BatchID 统一抽象使 Mooncake Store 可以：\n在开发环境使用 TCP； 在生产集群切换 RDMA； 同时安装多种协议； 按 Segment 或 Buffer 选择后端； 保持 KV Cache 管理逻辑不变。 十一、其他后端如何嵌入 仓库中的其他 Transport 延续相同框架：\nNVLink / HIP / NCCL：节点内或 GPU 间传输； NVMe-oF：把持久存储包装成可读写 Segment，内部使用 cuFile； CXL：共享内存窗口； EFA/CXI/UB/Barex：不同云厂商或硬件互连； Device transport：由 GPU 侧发起或参与提交。 它们需要实现相同生命周期，但可以有完全不同的 endpoint、队列和完成模型。\n十二、排障时应观察什么 一次请求卡住时，可以按层定位：\n请求是否选对 Transport -\u0026gt; 本地地址是否注册 -\u0026gt; 远端 SegmentDesc 是否新鲜 -\u0026gt; endpoint 是否连接成功 -\u0026gt; 请求是否进入 worker queue -\u0026gt; post_send / socket send 是否成功 -\u0026gt; completion 是否到达 -\u0026gt; task pending count 是否归零 不要只看最终 FAILED。RDMA 问题常来自 GID、MTU、rkey、GPU MR、PCIe 拓扑或远端重启；TCP 问题则常见连接、lane 和接收端地址校验。\n下一篇将阅读 TENT：为什么 Mooncake 在已有 MultiTransport 的基础上又引入 Runtime、SegmentManager、TransportSelector、ProgressWorker、AdmissionQueue 和 QoS contract。\n参考源码 include/transport/transport.h src/transport/rdma_transport/rdma_transport.cpp src/transport/rdma_transport/rdma_endpoint.cpp src/transport/rdma_transport/rdma_context.cpp src/transport/rdma_transport/worker_pool.cpp src/transport/rdma_transport/endpoint_store.cpp src/transport/tcp_transport/tcp_transport.cpp ","permalink":"https://yangyang233333.github.io/posts/mooncake-transfer-engine-source-reading-rdma-tcp/","summary":"\u003cp\u003eTransfer Engine 的公共 API 很统一，但 RDMA 和 TCP 的实现差异很大。RDMA 需要 MR、QP、WR 和 CQ；TCP 需要连接、lane、消息 framing 和接收端主动拷贝。本文沿源码比较两条路径，并分析 Batch 状态如何完成。\u003c/p\u003e\n\u003cp\u003e阅读版本：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一transport-抽象\"\u003e一、Transport 抽象\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eTransport\u003c/code\u003e 基类统一定义：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003einstall / uninstall\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eregisterLocalMemory / unregisterLocalMemory\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esubmitTransfer\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egetTransferStatus\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eallocateBatchID / freeBatchID\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e它还定义 \u003ccode\u003eTransferRequest\u003c/code\u003e、\u003ccode\u003eTransferStatus\u003c/code\u003e 和内部 \u003ccode\u003eBufferEntry\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e公共抽象要求每个后端回答三个问题：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e本地内存如何准备为可传输状态；\u003c/li\u003e\n\u003cli\u003e请求怎样排队和执行；\u003c/li\u003e\n\u003cli\u003e如何查询每个 task 的最终状态和字节数。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e具体连接模型不属于公共 API。\u003c/p\u003e\n\u003ch2 id=\"二rdma-初始化\"\u003e二、RDMA 初始化\u003c/h2\u003e\n\u003cp\u003eRDMA 后端主要位于：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003erdma_transport.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003erdma_context.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003erdma_endpoint.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eendpoint_store.cpp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eworker_pool.cpp\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e安装阶段通常完成：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e枚举 RDMA devices / ports / GID\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 创建每张 HCA 的 context\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 建立 PD、CQ 等资源\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 启动 worker / poller\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 发布 NIC endpoint metadata\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 准备 endpoint store\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMooncake 支持多 HCA、多端口和 GPU 内存，因此一个逻辑请求可能被切到多个 rail 上并行发送。\u003c/p\u003e","title":"Mooncake Transfer Engine 源码阅读（三）：RDMA、TCP 与请求完成状态机"},{"content":"Mooncake Transfer Engine 的核心抽象不是“远端指针”，而是 Segment + BufferDesc + Metadata。应用注册本地内存后，TE 将地址范围、设备位置和传输协议发布为 Segment 描述，其他节点才能定位并建立连接。\n阅读版本：\nMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc 一、为什么不能直接发送指针 一个进程中的地址：\n0x7f12... 在另一个进程中通常没有意义。即使两台机器都使用相同数值，它们也不指向同一块物理内存。\n高性能传输还需要额外信息：\n这是 CPU DRAM 还是 GPU VRAM； 对应哪张 GPU； 是否注册成 RDMA MR； rkey 和网卡 endpoint 是什么； 是否能用 NVLink、GPU IPC 或 CXL； 节点当前是否存活； 地址区间是否已经注销。 因此 TE 把地址空间包装成 Segment，并通过元数据服务交换描述。\n二、SegmentDesc TransferMetadata::SegmentDesc 是远端发现的中心结构，主要承载：\nSegment ID Segment 名称 协议或协议集合 本机 RPC / endpoint 信息 BufferDesc 列表 设备与拓扑信息 后端扩展元数据 可以把一个 Segment 理解成某个 TE 实例公开的可传输地址空间：\nSegment: decode-node-7 ├── Buffer A: CPU metadata pool, protocol=rdma ├── Buffer B: GPU KV pool, protocol=rdma └── Buffer C: GPU KV pool, protocol=hip 同一块 GPU buffer 可以被多个后端注册，从而同时支持：\n同机：HIP / CUDA IPC 快路径 跨机：RDMA 路径 当前 MultiTransport::selectTransport() 正是根据 Buffer 覆盖范围和可达性进行选择。\n三、BufferDesc BufferDesc 描述一段已注册区域，关键语义包括：\n起始地址或后端 offset； 长度； memory location； protocol； transport-specific key 或 handle； 可能的设备标识。 内存位置由 memory_location 模块解析。典型值表示 CPU、CUDA GPU、ROCm、Ascend 等设备。\n注册接口为：\nregisterLocalMemory(addr, length, location, remote_accessible, update_metadata) 其中：\naddr 和 length 定义地址范围； location 告诉后端如何注册； remote_accessible 决定是否公开给远端； update_metadata 控制是否立即更新 Segment 描述。 批量注册接口可以在多个区域注册完成后统一更新元数据，避免每块 buffer 都触发一次远端写入。\n四、注册调用链 经典实现的主链为：\nTransferEngine::registerLocalMemory -\u0026gt; TransferEngineImpl::registerLocalMemory -\u0026gt; 检查地址重叠和 location -\u0026gt; MultiTransport / 各已安装 Transport 注册 -\u0026gt; Transport::registerLocalMemory -\u0026gt; 产生 BufferDesc 或后端注册信息 -\u0026gt; TransferMetadata 更新本地 Segment -\u0026gt; metadata store 发布 不同后端的注册行为不同：\n后端 注册可能执行的动作 RDMA ibv_reg_mr 或 GPU peer memory MR，记录 lkey/rkey TCP 登记可访问区间，主要用于统一校验和元数据 NVLink / HIP 建立或导出 GPU IPC handle NVMe-oF 建立文件、offset 或 cuFile 相关描述 CXL 记录共享地址窗口和 base offset 因此 registerLocalMemory 不是简单把指针塞进 map。它是为所有适用 Transport 准备数据面访问能力。\n五、地址重叠检查 TransferEngine::checkOverlap() 和内部注册表用于阻止不一致的重叠区域。\n如果：\nBuffer A: [0x1000, 0x3000) Buffer B: [0x2000, 0x4000) 它们在不同协议、不同设备位置或不同生命周期下可能产生歧义：给定一个 offset 时究竟选择哪个注册项？注销其中一项是否会破坏另一项？\n当前 multi-protocol 设计允许同一区域按不同协议注册，但需要在描述和选择逻辑中明确归属，而不是无约束地重复注册。\n六、元数据插件 TransferMetadata 并不把 etcd 写死在核心代码里。元数据实现通过插件和连接字符串选择，仓库包含或支持：\netcd redis http p2p handshake 元数据层提供的核心能力包括：\n添加、更新和删除本地 Segment； 按名称或 ID 查询远端 Segment； 更新 Buffer 列表； 发布拓扑、RPC 和 endpoint 数据； 缓存 SegmentDesc； 失效或同步缓存。 这是一条控制面路径。真正的大块 KV Cache 不会写入 etcd 或 Redis，元数据服务只保存描述和定位信息。\n七、openSegment 与缓存 openSegment(name) 大致执行：\n查本地 segment cache -\u0026gt; 未命中则访问 metadata store -\u0026gt; 解析 SegmentDesc -\u0026gt; 验证状态 -\u0026gt; 建立 SegmentHandle / 引用 -\u0026gt; 后端按需创建 endpoint 远端描述通常会缓存，减少每个 I/O 都访问元数据服务。syncSegmentCache() 用于显式同步，连接异常或远端重启后也需要使旧描述失效。\n缓存带来典型分布式问题：\n远端注销内存后，本地仍持有旧 rkey； 远端进程重启，Segment 名称不变但 endpoint 已变化； 本地正在提交请求时描述被刷新； 多线程同时 open 同一 Segment。 因此 RDMA endpoint 代码还实现了重新建立连接和错误恢复，不能把 metadata cache 当作永久真相。\n八、注销流程 unregisterLocalMemory(addr) 的正确顺序不能只是删除元数据：\n阻止新请求命中该区域 -\u0026gt; 等待或拒绝仍在使用的请求 -\u0026gt; 从各 Transport 注销 MR / IPC handle -\u0026gt; 更新 SegmentDesc -\u0026gt; 发布新元数据 -\u0026gt; 释放本地注册项 如果先释放 MR，再让远端继续使用旧 rkey，可能触发远端访问错误；如果只删除元数据但旧 endpoint 仍缓存，问题同样存在。\n经典实现依赖上层在生命周期边界正确协调。TENT 则进一步引入 SegmentManager、SegmentRegistry 和跟踪机制，把资源生命周期集中管理。\n九、RPC communicator 与 notify 元数据服务适合保存相对稳定的描述，但不适合承担所有实时控制消息。TE 还启动 RPC communicator，用于：\n节点间握手； notify； 活性探测； 某些 endpoint 协商； P2P metadata 模式。 这形成两条控制路径：\nMetadata Store：持久或可缓存的 Segment 描述 RPC：实时点对点控制消息 数据本身则走 RDMA、TCP、NVLink 等 Transport。\n十、locality 与协议选择 TE 通过节点名称、地址和拓扑判断目标是否同机。对 multi-protocol Segment，选择逻辑会检查请求 offset 落在哪个 Buffer，并按优先级选择。\n当前实现中可见类似优先关系：\nGPU IPC 类后端 \u0026gt; CXL \u0026gt; RDMA \u0026gt; TCP 但 GPU IPC 只能用于同主机可达 GPU。跨主机时即使 Buffer 同时标记 HIP 和 RDMA，也必须跳过 HIP。\n这种选择不是通用最优调度器，而是一套明确、可预测的规则。更复杂的链路质量、拥塞、故障和 QoS 决策，是 TENT 重构的重要目标。\n十一、Segment 设计的价值 Segment 把三类信息绑定在一起：\n身份：我是谁，怎样被发现 地址：我公开哪些 Buffer 能力：这些 Buffer 能通过哪些 Transport 访问 它让上层只需要表达：\n向 segment X 的 offset Y 写入 N 字节 而不必直接管理：\n远端网卡、QP、rkey、GPU IPC handle、TCP session、文件描述符…… 下一篇将选择 RDMA 和 TCP 两个后端深入调用链：请求怎样被切片到不同 NIC，如何进入 worker queue，完成事件怎样回写 Batch 状态，以及出错后 endpoint 如何恢复。\n参考源码 include/transfer_metadata.h src/transfer_metadata.cpp src/transfer_metadata_plugin.cpp src/transfer_engine_impl.cpp src/memory_location.cpp src/multi_transport.cpp src/transport/rdma_transport/endpoint_store.cpp ","permalink":"https://yangyang233333.github.io/posts/mooncake-transfer-engine-source-reading-segment-metadata/","summary":"\u003cp\u003eMooncake Transfer Engine 的核心抽象不是“远端指针”，而是 \u003cstrong\u003eSegment + BufferDesc + Metadata\u003c/strong\u003e。应用注册本地内存后，TE 将地址范围、设备位置和传输协议发布为 Segment 描述，其他节点才能定位并建立连接。\u003c/p\u003e\n\u003cp\u003e阅读版本：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMooncake commit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一为什么不能直接发送指针\"\u003e一、为什么不能直接发送指针\u003c/h2\u003e\n\u003cp\u003e一个进程中的地址：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e0x7f12...\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e在另一个进程中通常没有意义。即使两台机器都使用相同数值，它们也不指向同一块物理内存。\u003c/p\u003e\n\u003cp\u003e高性能传输还需要额外信息：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e这是 CPU DRAM 还是 GPU VRAM；\u003c/li\u003e\n\u003cli\u003e对应哪张 GPU；\u003c/li\u003e\n\u003cli\u003e是否注册成 RDMA MR；\u003c/li\u003e\n\u003cli\u003erkey 和网卡 endpoint 是什么；\u003c/li\u003e\n\u003cli\u003e是否能用 NVLink、GPU IPC 或 CXL；\u003c/li\u003e\n\u003cli\u003e节点当前是否存活；\u003c/li\u003e\n\u003cli\u003e地址区间是否已经注销。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e因此 TE 把地址空间包装成 Segment，并通过元数据服务交换描述。\u003c/p\u003e\n\u003ch2 id=\"二segmentdesc\"\u003e二、\u003ccode\u003eSegmentDesc\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eTransferMetadata::SegmentDesc\u003c/code\u003e 是远端发现的中心结构，主要承载：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eSegment ID\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eSegment 名称\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e协议或协议集合\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e本机 RPC / endpoint 信息\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eBufferDesc 列表\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e设备与拓扑信息\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e后端扩展元数据\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e可以把一个 Segment 理解成某个 TE 实例公开的可传输地址空间：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eSegment: decode-node-7\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── Buffer A: CPU metadata pool, protocol=rdma\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── Buffer B: GPU KV pool, protocol=rdma\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e└── Buffer C: GPU KV pool, protocol=hip\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e同一块 GPU buffer 可以被多个后端注册，从而同时支持：\u003c/p\u003e","title":"Mooncake Transfer Engine 源码阅读（二）：Segment、内存注册与元数据服务"},{"content":"Mooncake Transfer Engine（简称 TE）是 Mooncake 数据平面的基础组件。它不负责决定 KV Cache 应该放在哪里，而是提供统一接口，把一段本地内存搬到远端 Segment，或者从远端 Segment 读取到本地内存。\n本文基于 Mooncake 官方仓库：\ncommit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc commit date: 2026-08-21 当前仓库同时保留经典 Transfer Engine 和下一代 TENT。前三篇先阅读经典实现，第四篇再分析 TENT 如何重构控制面、调度和传输后端。\n一、源码布局 核心目录为 mooncake-transfer-engine/：\n路径 作用 include/transfer_engine.h 对外 C++ API include/transfer_engine_impl.h 经典实现内部接口 include/transport/transport.h Transport 抽象、请求和状态 include/transfer_metadata.h Segment、Buffer 与元数据接口 src/transfer_engine.cpp 公共 API 转发层，同时兼容经典 TE 与 TENT src/transfer_engine_impl.cpp 初始化、内存注册、Segment 与批次管理 src/multi_transport.cpp 安装和选择具体 Transport src/transport/ RDMA、TCP、NVLink、NVMe-oF、EFA 等后端 tent/ Transfer Engine Next 实现 TE 的经典架构可以概括为：\n应用 / Mooncake Store │ ▼ TransferEngine │ ▼ TransferEngineImpl ┌────┼──────────────┐ ▼ ▼ ▼ Metadata MultiTransport Batch 生命周期 │ ┌──────┼───────────────┐ ▼ ▼ ▼ ▼ RDMA TCP NVLink NVMe-oF ... 二、公共 API 是一层门面 TransferEngine 类本身很薄，大多数函数转发给 TransferEngineImpl：\ninit openSegment / closeSegment registerLocalMemory / unregisterLocalMemory allocateBatchID / freeBatchID submitTransfer getTransferStatus installTransport 这层门面还有一个重要职责：根据 use_tent_ 把同一套兼容 API 转发到经典实现或 TENT。\n例如 init()：\n经典模式 -\u0026gt; impl_-\u0026gt;init(...) TENT 模式 -\u0026gt; 构造 tent::Config -\u0026gt; tent::TransferEngine 因此从应用视角看，迁移到 TENT 不一定要一次性改写全部调用代码，但部分经典接口在 TENT 下会成为空操作或采用不同语义，例如手动 installTransport() 不再是主要配置方式。\n三、初始化过程 经典 TransferEngineImpl::init() 的主要工作为：\n解析 metadata connection string -\u0026gt; 创建 TransferMetadata -\u0026gt; 确定本地 segment/server name -\u0026gt; 探测本机拓扑、GPU 和 HCA -\u0026gt; 启动 RPC communicator -\u0026gt; 创建 MultiTransport -\u0026gt; 注册本地 Segment 描述 -\u0026gt; 按配置自动安装可用 Transport metadata connection string 支持显式指定后端类型，例如：\netcd redis http 也支持点对点 handshake 模式。连接字符串在 parseConnectionStringInternal() 中拆为协议和地址，再由元数据插件创建具体实现。\n本地 server name 不是简单日志标签。它同时是 Segment 名称和远端发现键，其他进程通过它查询：\nSegment ID； RPC 地址； 支持协议； 已注册 Buffer； 设备拓扑与网络端点。 四、核心传输请求 对外请求类型来自 Transport::TransferRequest，其核心字段表达：\n操作类型：READ / WRITE 本地地址：source 或 destination 目标 Segment ID 目标 offset 长度 这里的 offset 值得注意：经典 TE 的一些内存 Segment 使用远端虚拟地址语义，而 CXL、NVMe-oF 等后端可能使用不同地址解释。MultiTransport::selectTransport() 会结合 SegmentDesc 和 BufferDesc 判断请求落在哪个注册区间。\n典型写请求可表示为：\nlocal_ptr + length --WRITE--\u0026gt; target_segment_id + target_offset 读请求则反向搬运：\ntarget_segment_id + target_offset --READ--\u0026gt; local_ptr + length TE 只搬运字节，不理解 KV Cache 的 token、layer 或 tensor 语义。上层必须把逻辑对象映射为地址范围。\n五、BatchID 不是网络请求 ID 应用先调用：\nBatchID id = engine.allocateBatchID(requests.size()); engine.submitTransfer(id, requests); BatchID 对应一组传输任务的本地状态容器。每个 task 有独立状态，批次也可以汇总状态。\n请求状态通常经历：\nWAITING -\u0026gt; PENDING -\u0026gt; COMPLETED 或 FAILED / CANCELED 不同 Transport 内部的队列和状态实现不同，但公共 API 通过 getTransferStatus() 和 getBatchTransferStatus() 统一暴露。\n批次设计的价值包括：\n一次提交多个 KV Cache slice； 分摊 API 和锁开销； 允许后端按 NIC、QP 或 lane 并行调度； 统一跟踪完成与错误； 支持完成后发送 notify。 六、SegmentHandle 与 SegmentID 应用通常通过名称打开远端 Segment：\nSegmentHandle handle = engine.openSegment(\u0026#34;decode-node-7\u0026#34;); openSegment() 查询元数据，将名称解析为 SegmentDesc，并维护本地缓存或引用。请求真正携带的是 Segment ID。\n区分两者很重要：\nSegment 名称面向部署和服务发现； Segment ID 是运行期标识； SegmentHandle 是调用侧管理对象； BufferDesc 才描述可访问地址区间。 closeSegment() 释放本地引用或缓存，不等于删除远端 Segment。删除本地 Segment 使用单独的 removeLocalSegment()。\n七、MultiTransport 的职责 MultiTransport 不是一种传输协议，而是后端工厂和路由器。\ninstallTransport(proto) 根据构建宏创建具体对象，例如：\nrdma tcp nvlink nvmeof cxl efa cxi hip musa maca 随后调用：\ntransport-\u0026gt;install(local_server_name, metadata, topology); 具体后端在 install 阶段建立设备上下文、worker、listener、endpoint cache 或连接资源。\nselectTransport() 根据目标 Segment 的协议选择后端。当前源码还支持 multi-protocol Segment：同一个 Segment 中不同 Buffer 可以归属于不同协议，例如 GPU KV pool 同时注册到 HIP 和 RDMA。\n路由器会结合：\n目标 offset 落在哪个 Buffer； Buffer 的 protocol； 是否同主机、GPU IPC 是否可达； 固定协议优先级； 禁用某种后端的环境配置。 例如同节点优先 GPU IPC，跨节点自动跳过 HIP，回退 RDMA。\n八、一次调用的完整骨架 应用创建 TransferEngine -\u0026gt; init(metadata, local_name, address) -\u0026gt; registerLocalMemory(local_buffer) -\u0026gt; openSegment(remote_name) -\u0026gt; allocateBatchID(N) -\u0026gt; 构造 N 个 TransferRequest -\u0026gt; submitTransfer(batch_id, requests) -\u0026gt; TransferEngineImpl -\u0026gt; MultiTransport::selectTransport -\u0026gt; Transport::submitTransfer -\u0026gt; 后端队列 / 网络操作 -\u0026gt; getTransferStatus -\u0026gt; freeBatchID -\u0026gt; closeSegment -\u0026gt; unregisterLocalMemory 传输前必须注册本地内存，因为高性能后端需要预先建立 MR、GPU IPC handle、DMA-BUF 或其他设备映射。TCP 虽不一定需要 RDMA MR，也遵循统一注册接口以保持元数据一致。\n九、通知机制 submitTransferWithNotify() 在传输完成后向远端发送通知。TE 还提供：\nsendNotifyByID sendNotifyByName getNotifies 数据搬运和“数据已经可消费”的控制消息是两件事。上层不能只根据本地提交成功就假设远端业务已经开始使用；notify 为生产者—消费者协议提供轻量控制面。\n十、这层抽象的边界 Transfer Engine 负责：\n注册可传输内存； 发布和发现 Segment； 选择传输后端； 提交异步读写； 跟踪状态与通知。 它不负责：\nKV Cache 的 key、淘汰和副本策略； 推理请求调度； tensor layout 设计； 自动保证上层对象一致性； 在所有环境都实现 GPU 零拷贝。 下一篇将深入 TransferMetadata、SegmentDesc 和 BufferDesc，解释一块内存如何从本地指针变成集群中可发现、可路由的远端地址空间。\n参考源码 include/transfer_engine.h include/transfer_engine_impl.h include/transport/transport.h src/transfer_engine.cpp src/transfer_engine_impl.cpp src/multi_transport.cpp ","permalink":"https://yangyang233333.github.io/posts/mooncake-transfer-engine-source-reading-architecture/","summary":"\u003cp\u003eMooncake Transfer Engine（简称 TE）是 Mooncake 数据平面的基础组件。它不负责决定 KV Cache 应该放在哪里，而是提供统一接口，把一段本地内存搬到远端 Segment，或者从远端 Segment 读取到本地内存。\u003c/p\u003e\n\u003cp\u003e本文基于 Mooncake 官方仓库：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit: 777cc7782417b6e554cf7c2d53210d0d8f89f5cc\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit date: 2026-08-21\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e当前仓库同时保留经典 Transfer Engine 和下一代 TENT。前三篇先阅读经典实现，第四篇再分析 TENT 如何重构控制面、调度和传输后端。\u003c/p\u003e\n\u003ch2 id=\"一源码布局\"\u003e一、源码布局\u003c/h2\u003e\n\u003cp\u003e核心目录为 \u003ccode\u003emooncake-transfer-engine/\u003c/code\u003e：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e路径\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e作用\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003einclude/transfer_engine.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e对外 C++ API\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003einclude/transfer_engine_impl.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e经典实现内部接口\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003einclude/transport/transport.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eTransport 抽象、请求和状态\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003einclude/transfer_metadata.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eSegment、Buffer 与元数据接口\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esrc/transfer_engine.cpp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e公共 API 转发层，同时兼容经典 TE 与 TENT\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esrc/transfer_engine_impl.cpp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e初始化、内存注册、Segment 与批次管理\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esrc/multi_transport.cpp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e安装和选择具体 Transport\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esrc/transport/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eRDMA、TCP、NVLink、NVMe-oF、EFA 等后端\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003etent/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eTransfer Engine Next 实现\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eTE 的经典架构可以概括为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e应用 / Mooncake Store\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eTransferEngine\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eTransferEngineImpl\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ┌────┼──────────────┐\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ▼    ▼              ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMetadata  MultiTransport  Batch 生命周期\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ┌──────┼───────────────┐\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ▼      ▼       ▼       ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  RDMA   TCP    NVLink   NVMe-oF ...\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"二公共-api-是一层门面\"\u003e二、公共 API 是一层门面\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eTransferEngine\u003c/code\u003e 类本身很薄，大多数函数转发给 \u003ccode\u003eTransferEngineImpl\u003c/code\u003e：\u003c/p\u003e","title":"Mooncake Transfer Engine 源码阅读（一）：统一传输 API 与整体架构"},{"content":"前两篇分别介绍了 nvidia-fs 的模块结构，以及 GPU virtual address 到 peer DMA address 的映射。本文沿一次 cuFileRead 对应的内核路径，分析文件 I/O 如何提交、完成和清理，并介绍 batch、稀疏文件、RDMA 与 /proc 诊断接口。\n阅读版本：\ncommit: 328d1d8cce1175c013720985c30e123e9a35242c GDS_VERSION: 2.29.4 一、I/O 入口 nvfs_ioctl() 接收：\nNVFS_IOCTL_READ NVFS_IOCTL_WRITE NVFS_IOCTL_BATCH_IO 单次读写共用两阶段结构：\nnvfs_io_init(op, ioargs) -\u0026gt; 验证并构造 nvfs_io nvfs_io_start_op(nvfsio) -\u0026gt; 向目标文件提交真正 I/O 分成两步的意义在于：参数验证、对象引用和资源分配都应在进入异步 I/O 前完成。一旦请求提交，完成回调可能很快发生，初始化不完整会造成竞态。\n二、nvfs_io_init 做了什么 nvfs_io_init() 是用户参数到内核 I/O 对象的转换层，主要工作包括：\n根据文件描述符取得目标 struct file； 检查读写权限； 查找已经注册的 GPU buffer/mgroup； 验证 GPU buffer offset、文件 offset 和长度； 建立影子 page 对应的 iov 或迭代器； 初始化 kiocb、完成函数和统计字段； 为同步、异步和特殊文件系统路径设置标志。 源码还会检查文件系统类型、direct I/O 条件和文件权限。写操作比读操作更复杂，因为它可能涉及文件扩展、页缓存一致性及磁盘空间预分配。\n一个重要边界是：nvidia-fs 处理的是已经由用户态打开的文件描述符。路径解析、权限主体和 open flag 仍走标准 Linux VFS；模块不会绕过文件系统安全模型去访问裸文件。\n三、提交到 Linux 文件 I/O nvfs_io_start_op() 根据内核版本和文件能力选择相应读写入口。代码使用 kiocb 与 iterator 风格接口，使请求能够异步完成。\n概念路径如下：\nnvfs_io_start_op -\u0026gt; rw_verify_area / permission checks -\u0026gt; file_start_write（写路径） -\u0026gt; call_read_iter 或 call_write_iter -\u0026gt; 文件系统 direct-I/O -\u0026gt; bio / request -\u0026gt; nvfs-dma 提供 GPU DMA addresses 如果底层立即完成，返回值就是已处理字节数；如果返回异步排队状态，最终由 nvfs_io_complete() 收尾。\n驱动封装了不同 Linux 内核版本的 API 差异，相关兼容代码集中在 nvfs-kernel-interface.c/.h 和 configure 生成的特性宏中。这也是该仓库需要 DKMS 构建而不是提供一个永远不变的二进制模块的重要原因。\n四、同步与异步完成 nvfs_io_complete() 是完成路径中心，负责：\n记录结果与实际字节数； 更新 read/write 成功、错误、延迟和带宽统计； 处理稀疏读元数据； 释放文件和 GPU mapping 引用； 唤醒等待方或写入完成栅栏； 最终调用 nvfs_io_free()。 源码通过 nvfs_ioctl_metapage 在共享元数据页中维护：\nend_fence_val result state sparse_data 这让用户态可以观察异步操作状态，而不必让 GPU 数据本身承担控制信息。\n完成路径必须正确处理短读、短写、错误和被终止状态。不能把“回调被调用”等同于“全部请求字节成功完成”。统计代码也分别记录请求数、成功数、错误数、MiB、平均延迟和状态错误。\n五、I/O 生命周期状态机 一次 I/O 同时引用：\n目标文件； nvfs_io 对象； GPU buffer/mgroup； 可能存在的 peer DMA mapping； 元数据或 end-fence page。 因此异常路径不能随意 kfree。源码通过 active operation 计数和 GPU mapping 状态协调以下事件：\nI/O 正常完成 用户注销 GPU buffer 进程关闭 /dev/nvidia-fs GPU 驱动触发 free callback 模块开始卸载 nvfs_io_ret() 统一解释同步返回和异步排队结果，nvfs_io_free() 统一归还引用。nvfs_io_terminate_requested() 则让正在执行的路径感知 mapping 已进入终止阶段。\n这里体现了一条内核驱动通用原则：资源释放的真正时点由最后一个引用决定，而不是由第一个“我要删除”请求决定。\n六、Batch I/O 启用 NVFS_BATCH_SUPPORT 时，NVFS_IOCTL_BATCH_IO 进入 nvfs-batch.c：\nnvfs_io_batch_init -\u0026gt; 复制并验证多个 I/O 描述 -\u0026gt; 为每项调用 nvfs_io_init -\u0026gt; 创建 batch 状态 nvfs_io_batch_submit -\u0026gt; 逐项提交 -\u0026gt; 汇总完成和错误 -\u0026gt; 更新 batch 统计与延迟 批处理并不意味着把多个文件请求强行合并成一个 block request。它主要减少用户态到内核态的控制开销，并以统一对象跟踪一组子 I/O。\n失败处理必须区分：\nbatch 初始化阶段失败，一个请求都没有提交； 部分子请求已经提交，后续项失败； 所有请求都提交，但某些异步完成返回错误。 因此 batch 对象需要自己的完成计数和生命周期，不能在 ioctl 返回后立即释放。\n七、稀疏文件读取 源码定义 nvfs_io_sparse_data 和最多若干 hole region。稀疏文件的 hole 不对应真实磁盘块，但读取语义要求返回零。\n普通 CPU buffer 可以由内核清零；GPU direct path 则必须明确告诉上层哪些区域是洞，或者通过受支持路径完成相应处理。\n相关流程使用：\nnvfs_io_map_sparse_data nvfs_io_unmap_sparse_data nvfs_io_hole 元数据记录起始文件 offset、hole 数量和每段页范围。统计接口还单独输出 sparse read 次数、I/O 数、hole 数和页数。\n这说明“存储直接 DMA 到 GPU”不等于可以忽略文件语义。稀疏区、EOF、短读和文件扩展仍需要上层与文件系统协同处理。\n八、写路径与文件一致性 写路径会检查目标文件权限，并在必要时处理文件增长。源码中的 nvfs_need_fallocate() 反映了某些写入场景需要提前建立空间映射，避免 direct I/O 过程中进入不适合的元数据分配路径。\n同时，写路径还统计 page-cache 相关结果。GDS 主要追求 direct path，但文件可能已存在缓存页，文件系统必须维持缓存与磁盘内容一致性。\n因此部署中常见的 O_DIRECT、文件系统 mount mode 和对齐要求不是纯性能建议，而是为了让 I/O 语义落在驱动明确支持的路径上。\n九、RDMA 支持 nvfs-rdma.c 管理写入 GPU mapping 对象的 RDMA 注册信息，对应 ioctl 包括：\nSET_RDMA_REG_INFO GET_RDMA_REG_INFO CLEAR_RDMA_REG_INFO 结构中可以保存 queue pair、LID、rkey 等信息，用于支持 NFS over RDMA 或具备 PeerDirect 能力的分布式存储路径。\n这部分再次说明，GDS 不只有本地 NVMe 模式：\n本地块设备：block request -\u0026gt; peer DMA mapping 远程存储：RDMA NIC -\u0026gt; 已注册 GPU memory / peer direct path 二者共享 GPU buffer 生命周期管理，但设备和传输协议不同。\n十、/proc 可观测性 模块创建多组诊断节点。最常用的是：\ncat /proc/driver/nvidia-fs/version cat /proc/driver/nvidia-fs/peer_affinity cat /proc/driver/nvidia-fs/peer_distance cat /proc/fs/nvfs/stats /proc/fs/nvfs/stats 会输出：\nGDS 和 NVFS 驱动版本； Mellanox PeerDirect 支持状态； read/write 和 peer I/O 统计开关； active process 与 shadow buffer； batch 数量和平均提交延迟； 读写次数、MiB、带宽和平均延迟； sparse read； mmap、BAR1 mapping 和 callback； CPU/GPU page 混合、SG 扩展、DMA mapping 等错误； 当前 read/write/batch active ops。 向 stats 节点写入可触发统计重置。实际排障时应先保留现场数据，再决定是否清零。\n十一、如何判断是否真的走 direct path 仅看到应用调用 cuFileRead 并不够。建议组合检查：\nlsmod | grep nvidia_fs cat /proc/driver/nvidia-fs/version cat /proc/fs/nvfs/stats lspci -tv 重点观察：\nnvidia_fs 是否加载且版本匹配； read/write 数量和 MiB 是否随测试增长； DMA mapping、mixed CPU/GPU page 等错误是否增长； peer affinity 是否符合 GPU 与 NVMe/NIC 的物理拓扑； libcufile 是否发生兼容模式回退； 文件系统、驱动和 mount 参数是否在当前版本支持矩阵内。 nvidia-fs 统计只能证明内核侧发生了什么，完整判断仍应结合 libcufile 日志、设备性能计数和系统拓扑。\n十二、三篇源码阅读总结 从整个仓库看，nvidia-fs 的核心设计可以压缩成四句话：\n用字符设备和 ioctl 接收 libcufile 控制请求； 用 NVIDIA P2P API pin GPU pages，并创建 Linux 可携带的影子 pages； 在存储 DMA mapping 阶段把影子 page 转回 peer-specific GPU DMA address； 用状态机、引用计数和完成回调保证异步 I/O 与 GPU 内存释放不冲突。 所以，GDS 的“直接”主要发生在数据面：存储设备与 GPU 显存之间减少 CPU bounce buffer。控制面仍然经过用户态库、系统调用、VFS、文件系统和设备驱动。\n这也解释了为什么 nvidia-fs 代码量不算巨大，却横跨 GPU 内存、Linux VM、VFS、block layer、PCIe、RDMA 和异步生命周期。它不是一个完整存储系统，而是一块高度耦合的内核适配层。\n参考资料 NVIDIA gds-nvidia-fs 官方仓库，commit 328d1d8cce1175c013720985c30e123e9a35242c src/nvfs-core.c、src/nvfs-core.h src/nvfs-batch.c src/nvfs-rdma.c src/nvfs-proc.c src/nvfs-stat.c src/nvfs-kernel-interface.c ","permalink":"https://yangyang233333.github.io/posts/nvidia-fs-source-code-reading-io/","summary":"\u003cp\u003e前两篇分别介绍了 \u003ccode\u003envidia-fs\u003c/code\u003e 的模块结构，以及 GPU virtual address 到 peer DMA address 的映射。本文沿一次 \u003ccode\u003ecuFileRead\u003c/code\u003e 对应的内核路径，分析文件 I/O 如何提交、完成和清理，并介绍 batch、稀疏文件、RDMA 与 \u003ccode\u003e/proc\u003c/code\u003e 诊断接口。\u003c/p\u003e\n\u003cp\u003e阅读版本：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit: 328d1d8cce1175c013720985c30e123e9a35242c\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGDS_VERSION: 2.29.4\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一io-入口\"\u003e一、I/O 入口\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003envfs_ioctl()\u003c/code\u003e 接收：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNVFS_IOCTL_READ\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNVFS_IOCTL_WRITE\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNVFS_IOCTL_BATCH_IO\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e单次读写共用两阶段结构：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003envfs_io_init(op, ioargs)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 验证并构造 nvfs_io\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003envfs_io_start_op(nvfsio)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 向目标文件提交真正 I/O\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e分成两步的意义在于：参数验证、对象引用和资源分配都应在进入异步 I/O 前完成。一旦请求提交，完成回调可能很快发生，初始化不完整会造成竞态。\u003c/p\u003e\n\u003ch2 id=\"二nvfs_io_init-做了什么\"\u003e二、\u003ccode\u003envfs_io_init\u003c/code\u003e 做了什么\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003envfs_io_init()\u003c/code\u003e 是用户参数到内核 I/O 对象的转换层，主要工作包括：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e根据文件描述符取得目标 \u003ccode\u003estruct file\u003c/code\u003e；\u003c/li\u003e\n\u003cli\u003e检查读写权限；\u003c/li\u003e\n\u003cli\u003e查找已经注册的 GPU buffer/mgroup；\u003c/li\u003e\n\u003cli\u003e验证 GPU buffer offset、文件 offset 和长度；\u003c/li\u003e\n\u003cli\u003e建立影子 page 对应的 iov 或迭代器；\u003c/li\u003e\n\u003cli\u003e初始化 \u003ccode\u003ekiocb\u003c/code\u003e、完成函数和统计字段；\u003c/li\u003e\n\u003cli\u003e为同步、异步和特殊文件系统路径设置标志。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e源码还会检查文件系统类型、direct I/O 条件和文件权限。写操作比读操作更复杂，因为它可能涉及文件扩展、页缓存一致性及磁盘空间预分配。\u003c/p\u003e","title":"nvidia-fs 源码阅读（三）：文件 I/O、批处理、完成路径与诊断"},{"content":"上一篇从 nvfs_init()、字符设备和 ioctl 看到了 nvidia-fs 的外部形态。本文进入 GDS direct path 的核心：怎样把用户分配的 GPU virtual address 变成存储设备能够 DMA 的地址，同时又让 Linux 文件 I/O 栈能够携带它。\n阅读版本仍为 NVIDIA gds-nvidia-fs：\ncommit: 328d1d8cce1175c013720985c30e123e9a35242c GDS_VERSION: 2.29.4 一、问题本质 普通块 I/O 最终围绕 bio_vec、struct page、scatterlist 和 DMA mapping 展开。但 cudaMalloc 得到的是 GPU 虚拟地址，对 Linux 页缓存和块层来说，它并不是普通 CPU 内存页。\n驱动需要解决三次“翻译”：\nGPU virtual address -\u0026gt; NVIDIA P2P page table 中的 GPU physical pages -\u0026gt; Linux I/O 栈可携带的影子 struct page -\u0026gt; 某个存储 PCIe 设备可使用的 DMA address 三个地址空间不能混为一谈：\nGPU virtual address 是应用看到的地址； P2P page table 描述 GPU 内存页； DMA address 与发起 DMA 的 peer device 有关，同一段 GPU 内存面对不同设备可能得到不同映射。 二、入口：NVFS_IOCTL_MAP 用户态注册 GPU buffer 后，nvfs_ioctl() 进入 NVFS_IOCTL_MAP 分支：\nnvfs_ioctl -\u0026gt; nvfs_map -\u0026gt; nvfs_map_gpu_info -\u0026gt; nvfs_pin_gpu_pages nvfs_map() 先检查地址、长度和 ABI 参数，再创建描述对象。源码把 GPU 页大小定义为 64 KiB：\n#define GPU_PAGE_SHIFT 16 #define GPU_PAGE_SIZE ((u64)1 \u0026lt;\u0026lt; GPU_PAGE_SHIFT) 驱动仍需处理系统 PAGE_SIZE，因此一个 GPU page 可能对应多个内核基础页单位。对齐、offset 和长度计算贯穿后续路径。\n三、核心对象 nvfs_gpu_args 每个已注册 GPU buffer 都由 nvfs_gpu_args 一类对象维护。虽然字段会随版本演化，但职责稳定地包括：\n用户 GPU 虚拟地址和长度； NVIDIA P2P page table； 影子 page 组成的 memory group； 针对不同 peer PCI 设备的 DMA mapping 缓存； 完成栅栏和元数据页； 当前状态、引用与正在执行的 I/O； GPU PCI BDF、UUID 等身份信息； RDMA 注册附加信息。 映射对象不能只以虚拟地址为键。进程地址空间、GPU 上下文、peer device 和生命周期都影响它是否仍然有效，因此代码使用锁、哈希结构、引用计数和状态机共同管理。\n四、向 NVIDIA 驱动请求 P2P 页表 nvfs_pin_gpu_pages() 是注册流程的中心。它调用 NVIDIA GPU 驱动导出的 P2P 接口，为指定 GPU virtual range 获得 page table，并注册 free callback。\n概念过程为：\nGPU VA + size -\u0026gt; nvidia_p2p_get_pages(..., free_callback) -\u0026gt; struct nvidia_p2p_page_table -\u0026gt; pages[] 描述 GPU 物理页 free callback 非常重要。GPU context 被销毁、显存被释放或驱动回收映射时，NVIDIA 驱动可以异步通知 nvidia-fs：这些页不能再用于新 DMA。\n源码中的 nvfs_get_pages_free_callback() 不会把释放当成普通同步函数处理，而是进入终止状态机。这是因为 callback 到达时可能仍有 I/O 正在飞行，直接释放 page table 会造成 use-after-free 或设备访问无效地址。\n五、为什么需要“影子页” Linux 的文件和块 I/O 习惯传递 struct page *。GPU 显存并没有天然对应的普通系统内存 struct page，所以 nvfs-mmap.c 创建一组影子 page，也可理解为占位 page 或 carrier page。\n影子页的作用不是保存数据，而是：\n让 GPU buffer 能进入 Linux 的 iov/bio/request 表示； 在 page 私有元数据中关联回 GPU memory group； 当存储驱动准备 DMA mapping 时识别“这是 GPU page”； 依据 page offset 找回对应 GPU 物理页。 因此数据不会先写入影子页再复制到显存。影子页只是控制结构，真实 DMA 目标仍是 GPU 内存。\n可以把它理解为：\nLinux 看到：struct page + offset + length nvidia-fs 看到：这个 page 属于某个 GPU mgroup DMA 层得到：对应 GPU page 的 peer DMA address nvfs_mmap() 把这些页映射到用户态约定区域，使 libcufile 可以构造文件 I/O 所需的内存视图。vm_operations_struct 负责 VMA 生命周期和 fault 行为，避免把它当作普通匿名内存。\n六、memory group 如何连接两套世界 源码中的 nvfs_mgroup 是影子页到 GPU 注册对象之间的桥梁。每个 I/O 页可以反查所属 group，再定位：\nGPU page table； GPU page index； 页内 offset； 当前映射状态； 稀疏文件元数据区； 终止与引用信息。 这使块层不需要理解 CUDA 地址空间。块层仍处理 page 和 request，只有在 DMA 映射阶段由 nvfs 扩展识别特殊页。\n这也是 nvidia-fs 与存储驱动需要协作的根本原因：标准 dma_map_page() 面向普通系统内存，而 GPU peer memory 需要走 NVIDIA P2P mapping。\n七、peer DMA mapping 获得 GPU physical pages 后，还不能直接把地址交给 NVMe。PCIe DMA 地址以具体 peer device 为上下文。\nnvfs_get_p2p_dma_mapping() 和 nvfs_get_dma() 负责这部分工作，概念路径为：\n存储设备 struct pci_dev + nvfs_gpu_args + NVIDIA P2P page table │ ▼ nvidia_p2p_dma_map_pages │ ▼ nvidia_p2p_dma_mapping │ ▼ 按 GPU page index 取得 dma_address 映射结果会按 peer device 缓存。这样同一 GPU buffer 多次对同一个 NVMe 控制器发起 I/O 时，无需每次重新建立完整 peer mapping。\n释放时则必须调用相应的 unmap API，并确保没有 request 仍引用该地址。\n八、从 bio_vec 到 scatterlist nvfs-dma.c 是存储数据真正指向 GPU 的关键文件。它提供两套适配：\n较早接口的 nvfs_blk_rq_map_sg； 新内核 iterator 风格的 nvfs_blk_rq_dma_map_iter_start/next。 主要步骤为：\n遍历 request 中的 bio_vec -\u0026gt; 判断 page 是否属于 nvfs -\u0026gt; 反查 mgroup 和 GPU page index -\u0026gt; 获取该 peer device 的 P2P DMA mapping -\u0026gt; 计算 dma address + page offset -\u0026gt; 填充或迭代 scatter-gather segment -\u0026gt; 合并物理连续且满足边界约束的 segment nvfs_validate_gpu_request() 会防止一个 request 混入不支持的页面组合。nvfs_get_gpu_page_info() 提取 GPU 地址和长度，nvfs_check_bvec_contiguity()、nvfs_coalesce_gpu_pages() 尝试合并连续 GPU 页，降低 SG entry 数量。\n合并不是越多越好，还必须受以下条件约束：\nGPU page 的物理连续性； DMA segment 最大长度； boundary mask； offset 与 I/O 长度； request 队列和设备能力。 九、为什么要分析 PCIe 拓扑 nvfs-pci.c 保存 GPU 和 peer device 的 PCI 路径，计算距离、公共上游桥、链路速率和宽度，并检查 ACS。\n原因是“能够 DMA”不代表“路径同样好”。例如：\nNVMe 与 GPU 在同一 PCIe switch 通常比：\nNVMe -\u0026gt; CPU Root Complex A -\u0026gt; 互连 -\u0026gt; Root Complex B -\u0026gt; GPU 具有更短路径和更少跨根端口流量。\n驱动维护 GPU—peer rank matrix，并通过 /proc/driver/nvidia-fs/peer_affinity、peer_distance 等接口暴露结果或统计。这些信息可以帮助上层为 GPU 选择更合适的 NVMe 或网卡。\nACS 也会影响 peer-to-peer 流量能否按预期经过 PCIe 层级。源码显式遍历开启 ACS 的桥，说明 GDS 性能问题不能只从文件系统参数排查，还要看硬件拓扑和固件配置。\n十、释放路径为什么复杂 GPU buffer 的正常注销大致为：\n阻止新 I/O -\u0026gt; 等待 active I/O 归零 -\u0026gt; 清理各 peer DMA mapping -\u0026gt; 释放 P2P page table -\u0026gt; 撤销用户映射与影子 pages -\u0026gt; 释放 mgroup / gpu_info 但实际还存在 GPU 驱动 free callback、进程异常退出和模块卸载等入口。nvfs_free_gpu_info() 的 from_dma 参数、延迟释放统计和状态转换，都是为避免重复释放和并发引用。\n源码阅读时应始终追问两个问题：\n当前对象是否还允许新 I/O 获取引用？ 最后一个异步 I/O 完成后，谁负责真正释放？ 如果忽略这两个问题，只看 nvidia_p2p_get_pages() 和 dma_map_pages()，会错过驱动实现最困难的部分。\n十一、完整映射链总结 应用 GPU VA -\u0026gt; NVFS_IOCTL_MAP -\u0026gt; nvfs_pin_gpu_pages -\u0026gt; NVIDIA P2P page table -\u0026gt; nvfs-mmap 创建影子 pages / mgroup -\u0026gt; 文件 I/O 使用影子 pages -\u0026gt; request 进入存储驱动 -\u0026gt; nvfs-dma 识别特殊 pages -\u0026gt; 按 peer PCI device 建立 P2P DMA mapping -\u0026gt; scatterlist / iterator 返回 GPU DMA addresses -\u0026gt; 设备直接 DMA 到 GPU 显存 这条链说明，GDS direct path 不是简单地把 cudaMalloc 指针传给 read()。它需要 GPU 驱动、Linux page 抽象、块层和存储驱动共同接受一套受控的地址转换协议。\n下一篇将沿 NVFS_IOCTL_READ/WRITE 继续：nvfs_io_init() 如何构造请求，怎样进入 Linux 文件操作，异步完成如何回写状态，以及 batch I/O、稀疏文件与 /proc/fs/nvfs/stats 如何工作。\n参考资料 NVIDIA gds-nvidia-fs 官方仓库，commit 328d1d8cce1175c013720985c30e123e9a35242c src/nvfs-core.c、src/nvfs-core.h src/nvfs-mmap.c、src/nvfs-mmap.h src/nvfs-dma.c、src/nvfs-dma.h src/nvfs-p2p.h src/nvfs-pci.c ","permalink":"https://yangyang233333.github.io/posts/nvidia-fs-source-code-reading-memory-dma/","summary":"\u003cp\u003e上一篇从 \u003ccode\u003envfs_init()\u003c/code\u003e、字符设备和 ioctl 看到了 \u003ccode\u003envidia-fs\u003c/code\u003e 的外部形态。本文进入 GDS direct path 的核心：怎样把用户分配的 GPU virtual address 变成存储设备能够 DMA 的地址，同时又让 Linux 文件 I/O 栈能够携带它。\u003c/p\u003e\n\u003cp\u003e阅读版本仍为 NVIDIA \u003ccode\u003egds-nvidia-fs\u003c/code\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit: 328d1d8cce1175c013720985c30e123e9a35242c\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGDS_VERSION: 2.29.4\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一问题本质\"\u003e一、问题本质\u003c/h2\u003e\n\u003cp\u003e普通块 I/O 最终围绕 \u003ccode\u003ebio_vec\u003c/code\u003e、\u003ccode\u003estruct page\u003c/code\u003e、scatterlist 和 DMA mapping 展开。但 \u003ccode\u003ecudaMalloc\u003c/code\u003e 得到的是 GPU 虚拟地址，对 Linux 页缓存和块层来说，它并不是普通 CPU 内存页。\u003c/p\u003e\n\u003cp\u003e驱动需要解决三次“翻译”：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU virtual address\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; NVIDIA P2P page table 中的 GPU physical pages\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; Linux I/O 栈可携带的影子 struct page\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 某个存储 PCIe 设备可使用的 DMA address\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e三个地址空间不能混为一谈：\u003c/p\u003e","title":"nvidia-fs 源码阅读（二）：GPU 内存注册、影子页与 DMA 映射"},{"content":"nvidia-fs 是 NVIDIA GPUDirect Storage（GDS）的 Linux 内核模块。它不是一种文件系统，而是连接 libcufile、NVIDIA GPU 驱动、Linux 文件 I/O 和支持 GPUDirect 的存储驱动的一层内核协调组件。\n这组文章阅读 NVIDIA 官方 gds-nvidia-fs 仓库，采用的版本为：\ncommit: 328d1d8cce1175c013720985c30e123e9a35242c GDS_VERSION: 2.29.4 commit date: 2026-06-01 系列分为三篇：\n模块初始化、设备接口与整体架构； GPU 内存注册、页表与 DMA 映射； 文件 I/O、批量请求、完成路径与可观测性。 本文先回答一个问题：加载 nvidia_fs.ko 后，内核里究竟多了什么？\n一、源码目录 核心代码都位于 src/：\n文件 职责 nvfs-core.c/.h 字符设备、ioctl、GPU 内存注册和文件 I/O 主流程 nvfs-mod.c 与 NVMe、RDMA 等外部模块动态注册 DMA 回调 nvfs-mmap.c/.h GPU 页对应的影子 struct page 和 mmap 管理 nvfs-dma.c/.h block request 到 GPU DMA 地址的映射 nvfs-batch.c/.h 批量 I/O 提交与完成 nvfs-rdma.c/.h RDMA 注册信息管理 nvfs-pci.c/.h GPU 与存储设备的 PCIe 拓扑、距离和亲和性 nvfs-proc.c、nvfs-stat.c /proc 配置、统计和诊断接口 nvfs-kernel-interface.c 不同 Linux 内核版本的兼容封装 主线集中在 nvfs-core.c，但真正的数据直达能力是多个文件共同完成的。\n二、整体架构 源码展示的路径可以抽象为：\n应用程序 │ cuFile API ▼ libcufile.so（用户态，未包含在本仓库） │ open / ioctl / mmap ▼ /dev/nvidia-fs │ ├── nvfs-core：注册 GPU buffer、构造文件 I/O ├── nvfs-mmap：创建代表 GPU 内存的影子 page ├── nvfs-dma：把 block request 映射到 GPU DMA 地址 ├── nvfs-pci：选择和统计 GPU—存储设备路径 └── nvfs-proc/stat：配置与诊断 │ ├── NVIDIA GPU 驱动的 P2P API └── NVMe / RDMA / 文件系统扩展接口 这里最关键的设计不是“在内核里重新实现一个文件系统”，而是让现有 Linux I/O 栈能够携带一种特殊页面：它看起来是 struct page，实际背后对应 GPU 显存。\n三、模块入口 nvfs_init 模块入口位于 nvfs-core.c：\nmodule_init(nvfs_init); module_exit(nvfs_exit); nvfs_init() 的工作可以归纳为五步：\n1. 检查运行环境和模块参数 2. 注册字符设备主设备号 3. 创建 nvidia-fs class 与设备节点 4. 初始化 /proc、统计、PCI 与 DMA 子系统 5. 探测并连接支持 nvfs 扩展的外部驱动 设备节点由 register_chrdev、class_create 和 device_create 这一组典型字符设备 API 建立，用户态 libcufile 随后可通过 /dev/nvidia-fs 与模块通信。\n设备权限由 nvfs_devnode() 设置。这说明 nvidia-fs 对用户态暴露的核心形态是字符设备控制面，而不是一个可挂载文件系统。\n初始化还会调用 probe_module_list()。该函数位于 nvfs-mod.c，通过 __symbol_get() 动态查找外部模块导出的注册与注销函数，然后把 nvfs_dma_rw_ops 或新版 iterator ops 注册进去。\n这样设计的优点是：\nnvidia-fs 不需要静态依赖每一种存储驱动； NVMe、RDMA 或厂商文件系统可以按约定接入； 模块加载顺序变化时可以重新探测； 卸载时能够成对注销回调，避免留下悬空函数指针。 换句话说，nvidia-fs 同时扮演消费者和服务者：向 GPU 驱动消费 P2P 页表能力，又向存储侧提供 GPU 页面识别及 DMA 映射能力。\n四、字符设备接口 核心 file_operations 包含：\nopen -\u0026gt; nvfs_open release -\u0026gt; nvfs_close unlocked_ioctl -\u0026gt; nvfs_ioctl compat_ioctl -\u0026gt; nvfs_ioctl（按构建条件） mmap -\u0026gt; nvfs_mmap nvfs_open() 会为进程建立与文件实例相关的状态，并增加活跃操作计数；nvfs_close() 负责释放该进程尚未清理的 GPU 映射。\n真正的控制中心是 nvfs_ioctl()。公开命令定义在 nvfs-core.h：\nNVFS_IOCTL_REMOVE NVFS_IOCTL_READ NVFS_IOCTL_MAP NVFS_IOCTL_WRITE NVFS_IOCTL_SET_RDMA_REG_INFO NVFS_IOCTL_GET_RDMA_REG_INFO NVFS_IOCTL_CLEAR_RDMA_REG_INFO NVFS_IOCTL_BATCH_IO（按构建配置） 它们大致分为三组：\n内存控制：MAP 和 REMOVE； 文件 I/O：READ、WRITE 和 BATCH_IO； RDMA 元数据：设置、查询和清理注册信息。 用户态参数先统一复制到 nvfs_ioctl_param_union，再按命令解释为 map、I/O、batch 或 RDMA 参数。这种 union ABI 可以保持设备接口集中，但也意味着用户态库和内核模块必须严格匹配结构布局与版本。\n五、一次典型调用如何进入内核 以应用调用 cuFileBufRegister 和 cuFileRead 为例，概念调用链为：\ncuFileBufRegister -\u0026gt; libcufile 打开 /dev/nvidia-fs -\u0026gt; NVFS_IOCTL_MAP -\u0026gt; nvfs_map -\u0026gt; nvfs_map_gpu_info -\u0026gt; nvfs_pin_gpu_pages -\u0026gt; 建立 GPU page table 与影子 page cuFileRead -\u0026gt; NVFS_IOCTL_READ -\u0026gt; nvfs_io_init -\u0026gt; nvfs_io_start_op -\u0026gt; Linux 文件异步读入口 -\u0026gt; block / filesystem 路径识别影子 page -\u0026gt; nvfs DMA 映射回调得到 GPU DMA 地址 -\u0026gt; 存储设备向 GPU 显存传输 -\u0026gt; nvfs_io_complete 需要注意，公开仓库不包含 libcufile 的实现。因此我们能确认内核 ABI 和执行路径，但不能仅凭这个仓库还原 cuFile 用户态的全部策略，例如兼容模式选择、分块和某些回退逻辑。\n六、状态机与并发安全 GPU buffer 的生命周期不是一个简单布尔值。nvfs-core.c 使用状态转换和原子操作协调：\n正常 I/O； GPU 驱动触发 free callback； 用户主动注销； 进程退出； 模块卸载； 尚未完成的异步 I/O。 nvfs_transit_state()、nvfs_io_terminate_requested()、nvfs_io_terminate() 和 nvfs_free_gpu_info() 是理解并发释放的关键函数。\n难点在于 GPU 页可能仍被设备 DMA 使用。释放流程不能只删除哈希表条目，还要：\n阻止新 I/O 获取该映射； 通知或等待正在执行的 I/O； 释放 peer DMA mapping； 归还 NVIDIA P2P page table； 清理影子 page 和元数据页； 最后释放 nvfs_gpu_args。 这种“注销”和“异步完成”竞态，是整个驱动里比 ioctl 分发更值得关注的部分。\n七、模块退出 nvfs_exit() 基本按初始化的逆序执行：\n设置 shutdown 标志 -\u0026gt; 阻止新请求 -\u0026gt; 等待或终止活跃操作 -\u0026gt; 注销外部 DMA 回调 -\u0026gt; 清理 proc/stat/PCI 子系统 -\u0026gt; 销毁设备和 class -\u0026gt; 注销字符设备 动态符号通过 __symbol_put() 归还。注册函数和注销函数必须成对存在，probe_module_list() 对不完整的符号对直接拒绝接入，避免模块卸载时出现不可恢复的不一致状态。\n八、这一层究竟负责什么 读完入口代码后，可以更准确地定义 nvidia-fs：\n它是一层 GPU 内存到 Linux 文件 I/O 的适配与协调驱动，通过字符设备提供控制面，通过影子 page 嵌入现有 I/O 栈，并通过动态注册接口让存储驱动获取正确的 GPU DMA 地址。\n它不负责：\n实现 cuFile 的完整用户态 API； 替代 ext4、XFS、NFS 或并行文件系统； 自己驱动 NVMe 控制器； 绕过 Linux 内核的全部控制路径。 它真正减少的是数据经过 CPU 内存 bounce buffer 的必要性，CPU 仍然参与系统调用、I/O 提交和完成处理。\n下一篇将进入最核心的数据结构：nvfs_gpu_args、nvfs_mgroup、NVIDIA P2P page table，以及为什么驱动需要为 GPU 显存创建“影子 struct page”。\n参考资料 NVIDIA gds-nvidia-fs 官方仓库，commit 328d1d8cce1175c013720985c30e123e9a35242c README.md src/nvfs-core.c src/nvfs-core.h src/nvfs-mod.c ","permalink":"https://yangyang233333.github.io/posts/nvidia-fs-source-code-reading-architecture/","summary":"\u003cp\u003e\u003ccode\u003envidia-fs\u003c/code\u003e 是 NVIDIA GPUDirect Storage（GDS）的 Linux 内核模块。它不是一种文件系统，而是连接 \u003ccode\u003elibcufile\u003c/code\u003e、NVIDIA GPU 驱动、Linux 文件 I/O 和支持 GPUDirect 的存储驱动的一层内核协调组件。\u003c/p\u003e\n\u003cp\u003e这组文章阅读 NVIDIA 官方 \u003ccode\u003egds-nvidia-fs\u003c/code\u003e 仓库，采用的版本为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit: 328d1d8cce1175c013720985c30e123e9a35242c\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGDS_VERSION: 2.29.4\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit date: 2026-06-01\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e系列分为三篇：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e模块初始化、设备接口与整体架构；\u003c/li\u003e\n\u003cli\u003eGPU 内存注册、页表与 DMA 映射；\u003c/li\u003e\n\u003cli\u003e文件 I/O、批量请求、完成路径与可观测性。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e本文先回答一个问题：加载 \u003ccode\u003envidia_fs.ko\u003c/code\u003e 后，内核里究竟多了什么？\u003c/p\u003e\n\u003ch2 id=\"一源码目录\"\u003e一、源码目录\u003c/h2\u003e\n\u003cp\u003e核心代码都位于 \u003ccode\u003esrc/\u003c/code\u003e：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e文件\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e职责\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-core.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e字符设备、ioctl、GPU 内存注册和文件 I/O 主流程\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-mod.c\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e与 NVMe、RDMA 等外部模块动态注册 DMA 回调\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-mmap.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eGPU 页对应的影子 \u003ccode\u003estruct page\u003c/code\u003e 和 mmap 管理\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-dma.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eblock request 到 GPU DMA 地址的映射\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-batch.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e批量 I/O 提交与完成\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-rdma.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eRDMA 注册信息管理\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-pci.c/.h\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eGPU 与存储设备的 PCIe 拓扑、距离和亲和性\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-proc.c\u003c/code\u003e、\u003ccode\u003envfs-stat.c\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e/proc\u003c/code\u003e 配置、统计和诊断接口\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003envfs-kernel-interface.c\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e不同 Linux 内核版本的兼容封装\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e主线集中在 \u003ccode\u003envfs-core.c\u003c/code\u003e，但真正的数据直达能力是多个文件共同完成的。\u003c/p\u003e","title":"nvidia-fs 源码阅读（一）：模块初始化、设备接口与整体架构"},{"content":"GDS、GIDS 和 uGDS 的名字非常接近，也都在讨论 GPU 与存储之间的数据通路，因此很容易被理解成同一项技术的三个版本。\n实际上，它们解决的是三个不同层面的问题：\nGDS 关注数据路径：怎样让存储数据不经 CPU 内存中转，直接进入 GPU 显存； GIDS 关注控制路径：怎样让 GPU Kernel 自己决定并发起存储请求，减少 GPU 与 CPU 往返； uGDS 关注软件栈：怎样让 CPU 在用户态直接管理 NVMe 队列，绕过内核 NVMe 驱动与文件系统。 可以先用一句话概括：\nGDS 是“数据直达 GPU”，GIDS 是“GPU 主动要数据”，uGDS 是“用户态 CPU 直接驱动 NVMe 把数据送到 GPU”。\n本文从请求由谁发起、数据经过哪里、NVMe 命令由谁构造、是否保留文件系统语义等维度，分析三者的区别与联系。更深入的原理可以继续阅读本系列的三篇文章：\nNVIDIA GPUDirect Storage（GDS）详解：让存储数据绕过 CPU 直达 GPU NVIDIA GPU-Initiated Data Storage（GIDS）详解：让 GPU Kernel 主动访问存储 uGDS 原理解析：在用户态打通 NVMe SSD 与 GPU 显存 一、先区分数据路径与控制路径 理解这三项技术的关键，是不要把“数据经过哪里”和“谁发起 I/O”混为一谈。\n数据路径 数据路径描述有效载荷如何移动。例如：\n传统路径：SSD → CPU 内存 → GPU 显存 直接路径：SSD ──────────→ GPU 显存 GDS、GIDS 和 uGDS 都希望建立 SSD 与 GPU 显存之间的 DMA 路径，减少 CPU bounce buffer。但“数据不经过 CPU 内存”并不代表“CPU 没有参与”。\n控制路径 控制路径描述谁决定读取什么数据，以及谁生成和提交 I/O 请求：\nCPU 发起：Application CPU Thread → I/O Stack → SSD GPU 发起：GPU Kernel → Device-side Queue → I/O Service → SSD 经典 GDS 和 uGDS 仍由 CPU 侧代码发起 I/O；GIDS 的核心目标则是让 GPU Kernel 成为请求生产者。\n软件栈路径 软件栈路径描述请求是否经过文件系统、内核 NVMe 驱动和通用块层：\nGDS： Application → cuFile → 文件系统/内核存储栈 → NVMe uGDS： Application → libugds → 用户态 NVMe SQ/CQ → NVMe uGDS 的主要差异就在这一层。它不仅优化 SSD 到 GPU 的数据路径，还把 NVMe 命令构造、提交队列和完成队列管理移到了用户态。\n二、GDS：优化存储到 GPU 的数据路径 NVIDIA GPUDirect Storage，简称 GDS，是一套面向 CUDA 应用的直接存储访问技术。应用在 CPU 上调用 cuFileRead、cuFileWrite 等接口，把文件数据读入 GPU 缓冲区或从 GPU 缓冲区写回文件。\n典型路径如下：\nCPU Application │ cuFileRead ▼ cuFile / nvidia-fs / 文件系统 │ ▼ NVMe 或并行文件系统 ──DMA──\u0026gt; GPU VRAM GDS 改变的是数据搬运路径：\n数据可以绕过 CPU 内存中的中转缓冲区； CPU 内存带宽和 Cache 压力降低； 应用不再需要手动维护 host staging buffer； 存储读取可以与 GPU 计算形成流水线。 但 GDS 没有改变请求发起者。通常仍然是 CPU 线程计算文件偏移、调用 cuFile API，并等待或轮询完成。\n因此，GDS 最适合以下情况：\n数据以文件形式管理； 请求可以由 CPU 提前规划和批量提交； 需要兼容成熟文件系统、并行文件系统和存储语义； 希望降低数据复制成本，但不想自己接管 NVMe 控制器。 详细原理、API 与部署方式参见：NVIDIA GPUDirect Storage（GDS）详解。\n三、GIDS：优化 GPU 与存储之间的控制路径 GPU-Initiated Data Storage，简称 GIDS，关注的问题是：如果下一次访问的数据只有 GPU Kernel 在运行时才知道，为什么还要把索引交给 CPU，再由 CPU 发起 I/O？\n经典 CPU 发起流程可能是：\nGPU Kernel 生成索引 │ synchronize ▼ CPU 读取索引并构造 I/O │ ▼ 数据进入 GPU │ launch ▼ 下一个 GPU Kernel GIDS 希望把它变成：\nGPU Kernel │ 生成 I/O 请求 ▼ Device-side Request Queue │ ▼ 存储服务层 ──DMA──\u0026gt; GPU VRAM │ ▼ GPU Kernel 继续消费数据 它改变的是请求生产者和控制路径：\nGPU 线程能够产生存储请求； 减少 Kernel、CPU 线程和存储服务之间的同步往返； 支持图遍历、稀疏计算、向量检索等数据依赖型访问； 更适合运行期间才知道访问地址的细粒度 I/O。 GIDS 不等于“异步 GDS”。异步 GDS 仍是 CPU 调用异步 API，只是调用后不阻塞；GIDS 则是请求本身由 GPU Kernel 产生。\nGIDS 也不是简单地让每个 GPU 线程直接操作 NVMe doorbell。GPU 可能同时产生海量小请求，需要设备侧队列、请求合并、缓存、背压、完成通知和错误处理。真正的系统通常会在 GPU 请求与底层存储之间设置软件或硬件服务层。\n因此，GIDS 更适合：\n图计算中的指针追踪和邻接表加载； 向量数据库中的数据依赖型候选扩展； 超大 Embedding 表与稀疏模型； GPU 主导的 out-of-core 算法； 无法由 CPU 提前预测的细粒度访问。 详细架构与工程挑战参见：NVIDIA GPU-Initiated Data Storage（GIDS）详解。\n四、uGDS：优化 CPU 到 NVMe 的软件栈路径 uGDS 是 ScaleX-IO 开源的用户态 GPU Direct Storage 库。它同样让 NVMe SSD 通过 PCIe P2P DMA 直接读写 GPU 显存，但请求仍由 CPU 侧应用发起。\n它与经典 GDS 最重要的不同是：uGDS 绕过了内核 NVMe 驱动和文件系统，由用户态库直接管理 NVMe SQ/CQ。\nCPU Application │ uGDSRead / uGDSWrite ▼ libugds.so │ 构造 NVMe Command │ 更新 SQ Tail │ 写 Doorbell │ 轮询 CQ ▼ NVMe SSD ──PCIe P2P DMA──\u0026gt; GPU VRAM uGDS 仍然需要一个小型内核模块完成特权控制面工作，例如：\n映射 NVMe PCI BAR； 固定 GPU 页面并获取 DMA 地址； 建立 DMA-buf 映射； 注册 MSI-X 与 eventfd 中断。 但完成初始化后，稳态读写不必逐次经过系统调用、文件系统、块层和内核 NVMe 快路径。\n这种设计能够降低固定软件延迟，尤其适合 4KB 小 I/O、专用裸设备和可控硬件拓扑，但代价也很明显：\n应用面对的是块偏移，而不是普通文件； NVMe 控制器通常需要由 uGDS 独占； 数据布局、元数据、隔离和恢复需要上层负责； PCIe P2P、IOMMU、ACS、Large BAR 等平台条件必须满足； 用户态系统需要自行处理队列、错误、超时和生命周期。 uGDS 当前同时支持 CUDA 与 AMD HIP/ROCm 后端，还能导出 DMA-buf 给 RDMA 等设备使用。\n详细源码分析参见：uGDS 原理解析：在用户态打通 NVMe SSD 与 GPU 显存。\n五、三者的核心区别 对比维度 GDS GIDS uGDS 核心目标 数据绕过 CPU 内存 GPU Kernel 主动产生 I/O 绕过内核 NVMe 与文件系统 请求发起者 CPU 应用线程 GPU Kernel CPU 应用线程 数据终点/起点 GPU 显存 GPU 显存 GPU 显存 数据是否必须经 CPU 内存 通常不需要 通常不需要 不需要 NVMe 命令主要由谁管理 内核与 GDS 软件栈 取决于具体实现 用户态 CPU 库 是否经过文件系统 可以，且常见 取决于实现 不经过 对外接口层次 文件 I/O API 设备侧请求 API 块设备偏移 API CPU 是否参与控制 是 尽量减少 是 是否属于 NVIDIA 专有技术方向 是 NVIDIA 推动的技术方向 否，BSD 开源项目 GPU 支持 NVIDIA CUDA 主要面向 NVIDIA GPU NVIDIA CUDA、AMD ROCm 主要优势 成熟文件语义与生态 消除细粒度 CPU 控制往返 极短用户态 NVMe 路径 主要代价 仍有 CPU 控制开销 系统复杂、缓存与调度困难 裸设备、独占与运维复杂 这张表中最值得记住的是：uGDS 并不是 GIDS。\nuGDS 名字里的 u 指的是 user space。它让 CPU 在用户态发起 NVMe I/O；GIDS 则让 GPU 发起 I/O。两者优化的是不同方向。\n六、用三条路径理解三者 GDS：CPU 发起，内核存储栈执行，数据直达 GPU 控制路径：CPU → cuFile → Kernel Storage Stack → NVMe 数据路径：NVMe ───────────────────────────→ GPU 关键词是：CPU 发起、文件语义、直接数据路径。\nGIDS：GPU 发起，服务层调度，数据返回 GPU 控制路径：GPU Kernel → Device Queue → I/O Service → NVMe 数据路径：NVMe ───────────────────────────────→ GPU 关键词是：GPU 发起、动态访问、减少控制往返。\nuGDS：CPU 发起，用户态直接驱动 NVMe，数据直达 GPU 控制路径：CPU → libugds → NVMe SQ/CQ 数据路径：NVMe ─────────────────→ GPU 关键词是：CPU 发起、用户态 NVMe、裸块设备。\n七、它们是替代关系还是组合关系 三者并不完全处于同一层，因此不能简单地说谁“替代”谁。\nGDS 与 uGDS：同类数据目标，不同软件栈选择 二者都可由 CPU 发起 SSD 与 GPU 显存之间的直接传输，但工程定位不同：\nGDS 更强调文件系统、CUDA 生态和通用部署； uGDS 更强调用户态 NVMe、裸设备和极低固定延迟。 如果系统需要读取普通文件、共享存储或并行文件系统，GDS 更自然；如果 SSD 是专用缓存盘，上层能自行管理对象到 LBA 的映射，uGDS 更有发挥空间。\nGIDS 可以构建在不同数据面之上 GIDS 描述的是 GPU 发起控制模型，不强制规定底层一定使用哪一种存储数据面。理论上，设备侧请求经过聚合和调度后，可以落到：\nGDS 或类似的文件数据路径； 用户态 NVMe 数据路径； GPU 文件系统或专用存储服务； 带缓存的分层存储运行时。 因此可以把 GIDS 看成更靠近应用和 GPU Kernel 的控制面，而 GDS、uGDS 更靠近具体数据搬运与存储栈。\nCPU 仍可能是 GPU I/O 的协处理器 即使采用 GPU 发起模式，也不意味着 CPU 必须完全消失。CPU 很擅长：\n合并大量细粒度请求； 管理 NVMe 队列与完成项； 处理异常和复杂控制流； 执行缓存替换与元数据操作。 一种实际架构是 GPU 产生请求，CPU 用户态服务线程负责批处理和提交，SSD 再直接 DMA 到 GPU。这同时吸收了 GIDS 的请求模型和 uGDS 的用户态 NVMe 路径。\n八、性能对比不能只看带宽 选择技术时，不能只比较顺序读带宽。至少需要观察四类指标。\n1. 数据带宽 大块顺序 I/O 主要受 SSD、PCIe 链路和 GPU DMA 能力限制。只要能建立直接路径，三种架构都可能接近硬件上限。\n2. 单次 I/O 延迟 uGDS 通过用户态队列和 busy polling 减少软件路径，可能在小 I/O 上取得明显优势。GDS 的延迟还包含文件系统与内核存储栈成本。\n3. 请求生成与控制往返 当访问地址由 GPU 动态产生时，GIDS 的优势不一定表现为单条 NVMe 延迟更低，而是减少 GPU—CPU 同步、Kernel relaunch 和控制信息搬运。\n4. 端到端计算吞吐 最重要的指标通常是业务吞吐：训练 step time、推理 token throughput、图遍历速度或向量查询 QPS。存储微基准更快，不代表计算流水线一定更快；预取距离、缓存命中率、批量大小和计算重叠都会决定最终结果。\n九、怎样选择 选择 GDS，如果你需要 使用普通文件或成熟并行文件系统； 在 CUDA 生态中获得生产级直接存储路径； 由 CPU 提前规划和批量提交 I/O； 保留现有权限、文件布局和运维方式； 避免自行接管裸 NVMe 控制器。 研究或采用 GIDS，如果你需要 GPU Kernel 运行期间才知道下一次访问位置； 处理图、稀疏模型、Embedding 或向量检索等动态访问； 减少高频 GPU—CPU 控制往返； 构建 GPU 主导的 out-of-core 执行模型； 能够接受设备侧队列、缓存和调度系统的复杂度。 选择 uGDS，如果你需要 在专用 NVMe 上追求低延迟或高小 I/O 吞吐； 上层已经有对象索引、缓存目录或元数据服务； 能够使用裸设备并管理 LBA 布局； 希望同时支持 CUDA 与 ROCm； 能控制 PCIe 拓扑、IOMMU、CPU 绑核和设备归属。 十、一个大模型 KV Cache 分层示例 假设推理系统把冷 KV Cache 保存到本地 NVMe，热数据保留在 GPU：\n使用 GDS CPU 调度器判断需要恢复哪些 KV block，通过文件偏移调用 cuFile，把数据读回 GPU。优点是能使用文件与成熟存储生态，系统集成简单。\n使用 uGDS CPU 调度器维护 KV block 到 SSD LBA 的映射，通过用户态 NVMe 队列直接把数据 DMA 到 GPU。它可能降低小块读取延迟，但需要专用 SSD 和自定义元数据层。\n使用 GIDS 如果 GPU 侧推理 Kernel 在执行中才能确定下一批稀疏 KV block，Kernel 可以直接产生缺页或读取请求。后端服务将请求合并后，再通过某种直接数据路径取回。这里的底层数据面既可能类似 GDS，也可能使用 uGDS 式用户态 NVMe。\n这说明三者的关系可以表达为：\nGIDS：谁产生请求、怎样减少控制往返 │ ├── GDS 数据面：文件系统 + 直接 GPU DMA │ └── uGDS 数据面：用户态 NVMe + 直接 GPU DMA 这是一种架构层次上的理解，并不代表现有产品已经把任意两者直接拼装成统一 API。\n十一、常见误解 误解一：数据绕过 CPU，就等于 CPU 不参与 错误。GDS 与 uGDS 的数据不经过 CPU 内存，但请求仍由 CPU 生成和提交。数据路径与控制路径必须分别讨论。\n误解二：uGDS 是 GIDS 的开源实现 错误。uGDS 的 u 是 user space，不是 GPU initiated。它的 I/O 由 CPU 用户态代码发起。\n误解三：GIDS 一定比 GDS 快 不一定。对于大块、连续、可预取的文件读取，CPU 批量提交 GDS 已经很高效。GIDS 的主要收益出现在细粒度、动态、数据依赖型请求。\n误解四：uGDS 只是把 cuFile 改了名字 错误。虽然 API 风格接近，但 uGDS 直接管理 NVMe SQ/CQ，并绕过文件系统与内核 NVMe 驱动，部署和语义差异很大。\n误解五：直接 DMA 自动保证数据一致性 错误。NVMe、RDMA 和 GPU Kernel 访问同一块显存时，应用仍需用 I/O completion、CUDA/HIP stream 同步和 RDMA CQ 建立正确的生产者—消费者顺序。\n十二、总结 GDS、GIDS 和 uGDS 分别优化 GPU 存储系统的不同维度：\nGDS 优化数据路径：CPU 发起文件 I/O，存储数据绕过 CPU 内存直达 GPU； GIDS 优化控制路径：GPU Kernel 产生存储请求，减少 GPU 与 CPU 的细粒度往返； uGDS 优化软件栈路径：CPU 在用户态直接管理 NVMe 队列，绕过文件系统和内核 NVMe 快路径。 如果只记住一张图，可以记住：\n请求发起者 存储软件栈 数据路径 GDS CPU 文件系统/内核/GDS SSD → GPU GIDS GPU 取决于具体实现 SSD → GPU uGDS CPU 用户态 用户态 NVMe SSD → GPU 它们不是简单的版本递进关系，而是三个可以独立比较、也可能在未来系统中组合的设计轴：数据怎样移动、请求由谁产生、存储命令在哪里执行。\n系列文章 NVIDIA GPUDirect Storage（GDS）详解：让存储数据绕过 CPU 直达 GPU NVIDIA GPU-Initiated Data Storage（GIDS）详解：让 GPU Kernel 主动访问存储 uGDS 原理解析：在用户态打通 NVMe SSD 与 GPU 显存 ","permalink":"https://yangyang233333.github.io/posts/gds-gids-ugds-comparison/","summary":"\u003cp\u003eGDS、GIDS 和 uGDS 的名字非常接近，也都在讨论 GPU 与存储之间的数据通路，因此很容易被理解成同一项技术的三个版本。\u003c/p\u003e\n\u003cp\u003e实际上，它们解决的是三个不同层面的问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eGDS\u003c/strong\u003e 关注数据路径：怎样让存储数据不经 CPU 内存中转，直接进入 GPU 显存；\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGIDS\u003c/strong\u003e 关注控制路径：怎样让 GPU Kernel 自己决定并发起存储请求，减少 GPU 与 CPU 往返；\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003euGDS\u003c/strong\u003e 关注软件栈：怎样让 CPU 在用户态直接管理 NVMe 队列，绕过内核 NVMe 驱动与文件系统。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e可以先用一句话概括：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eGDS 是“数据直达 GPU”，GIDS 是“GPU 主动要数据”，uGDS 是“用户态 CPU 直接驱动 NVMe 把数据送到 GPU”。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e本文从请求由谁发起、数据经过哪里、NVMe 命令由谁构造、是否保留文件系统语义等维度，分析三者的区别与联系。更深入的原理可以继续阅读本系列的三篇文章：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/nvidia-gpudirect-storage-gds/\"\u003eNVIDIA GPUDirect Storage（GDS）详解：让存储数据绕过 CPU 直达 GPU\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/nvidia-gpu-initiated-data-storage-gids/\"\u003eNVIDIA GPU-Initiated Data Storage（GIDS）详解：让 GPU Kernel 主动访问存储\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ugds-userspace-gpu-direct-storage/\"\u003euGDS 原理解析：在用户态打通 NVMe SSD 与 GPU 显存\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"一先区分数据路径与控制路径\"\u003e一、先区分数据路径与控制路径\u003c/h2\u003e\n\u003cp\u003e理解这三项技术的关键，是不要把“数据经过哪里”和“谁发起 I/O”混为一谈。\u003c/p\u003e\n\u003ch3 id=\"数据路径\"\u003e数据路径\u003c/h3\u003e\n\u003cp\u003e数据路径描述有效载荷如何移动。例如：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e传统路径：SSD → CPU 内存 → GPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e直接路径：SSD ──────────→ GPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eGDS、GIDS 和 uGDS 都希望建立 SSD 与 GPU 显存之间的 DMA 路径，减少 CPU bounce buffer。但“数据不经过 CPU 内存”并不代表“CPU 没有参与”。\u003c/p\u003e","title":"GDS、GIDS 与 uGDS 有什么区别：从数据直达、GPU 发起到用户态 NVMe"},{"content":"大模型推理正在把存储重新推到系统性能的核心位置。模型权重、KV Cache、Embedding 和训练检查点都可能在 SSD、主存与 GPU 显存之间频繁搬运。传统路径通常要经过文件系统、内核块层、页缓存或用户态中转缓冲区，不但增加 CPU 开销，也让小块 I/O 延迟变得难以控制。\nuGDS 是 ScaleX-IO 开源的一套用户态 GPU Direct Storage 开发库。它的核心思路很直接：\n让 CPU 在用户态构造 NVMe 命令并管理提交队列与完成队列，由 SSD 通过 PCIe P2P DMA 直接读写 GPU 显存，从数据路径中绕开内核 NVMe 驱动和文件系统。\n本文结合 uGDS 当前源码，解释它为什么快、一次 I/O 如何执行、内核模块与用户态库如何分工，以及使用这类“绕过内核”的存储栈时必须承担哪些工程责任。\n一、uGDS 想解决什么问题 先看一条常见的数据读取路径：\nNVMe SSD │ ▼ 内核 NVMe 驱动 → 块层 → 文件系统 │ ▼ 主机内存缓冲区 │ ▼ GPU 显存 即使系统支持 Direct I/O，应用仍然通常要进入内核，由内核驱动准备 NVMe 命令、完成 DMA 映射并处理中断或轮询。若数据还要经过主机内存，再复制到 GPU，路径会更长。\nNVIDIA GPUDirect Storage 已经能够缩短这条路径，让存储设备直接访问 GPU 内存。但 uGDS 继续向下推进了一步：不仅绕过主机内存，还把 NVMe 快路径放到用户态。\n它希望消除的主要成本包括：\n文件系统和通用块层带来的软件栈开销； 系统调用与用户态、内核态切换； 通用内核驱动为了兼容性引入的锁和调度路径； 小 I/O 场景中尤其明显的固定延迟； CPU 参与数据复制带来的带宽和缓存污染。 因此，uGDS 并不是一个新的文件系统，也不是一个普通的异步文件 I/O 库。它更接近一套面向 GPU 工作负载的用户态 NVMe 驱动与 P2P DMA 运行时。\n二、整体架构：控制面留在内核，数据面搬到用户态 uGDS 的架构可以简化为：\n┌────────────────────────────────────┐ │ Application │ │ uGDSRead / uGDSWrite / Batch API │ └────────────────┬───────────────────┘ │ ┌────────────────▼───────────────────┐ │ libugds.so │ │ GPU Buffer 注册 │ │ NVMe SQ/CQ 管理 │ │ NVMe 命令构造与完成轮询 │ └────────────────┬───────────────────┘ │ ioctl + mmap ┌────────────────▼───────────────────┐ │ ugds_drv.ko │ │ PCI BAR 映射 │ │ GPU 页固定与 DMA 地址导出 │ │ MSI-X / eventfd 中断注册 │ └────────────────┬───────────────────┘ │ PCIe P2P DMA │ ┌────────▼────────┐ │ NVMe SSD ↔ GPU │ └─────────────────┘ 这里有一个容易误解的地方：用户态 I/O 并不意味着完全不需要内核。\n应用没有权限自行完成 PCI 设备 BAR 映射、锁定 GPU 页面、获得 DMA 总线地址或配置 MSI-X。uGDS 仍然需要一个小型内核模块处理这些特权操作，但稳态 I/O 不再逐次进入内核。\n这种设计把系统拆成两个部分：\n控制面：由 ugds_drv.ko 建立设备映射、内存映射和中断通道； 数据面：由 libugds.so 在用户态提交 NVMe 命令、敲 doorbell 并等待完成。 完成初始化后，一次普通读写不需要经过文件系统、内核 NVMe 驱动和块层。\n三、一次 uGDS I/O 是怎样执行的 uGDS 暴露的接口风格接近 cuFile。基本使用流程如下：\nuGDSDriverOpen(); int fd = open(\u0026#34;/dev/ugds_drv0\u0026#34;, O_RDWR); uGDSDescr_t desc = { .type = UGDS_HANDLE_TYPE_OPAQUE_FD, .handle.fd = fd, }; uGDSHandle_t handle; uGDSHandleRegister(\u0026amp;handle, \u0026amp;desc); void* gpu_buffer; cudaMalloc(\u0026amp;gpu_buffer, 4096); uGDSBufRegister(gpu_buffer, 4096, 0); uGDSRead(handle, gpu_buffer, 4096, 0, 0); uGDSWrite(handle, gpu_buffer, 4096, 0, 0); 这几步背后分别发生了什么？\n1. 打开驱动并初始化全局状态 uGDSDriverOpen() 建立用户态库的全局状态。库内部维护设备句柄、已注册缓冲区、队列和并发保护结构。\n2. 注册 NVMe 设备句柄 应用打开 /dev/ugds_drv0，然后调用 uGDSHandleRegister()。注册过程会映射 NVMe 控制器的 PCI BAR，并初始化控制器管理队列与 I/O 队列。\nNVMe 的队列本质上是位于内存中的环形数组：\nSQ，Submission Queue，保存待执行命令； CQ，Completion Queue，保存设备写回的完成项。 用户态库获得 BAR 中的 doorbell 寄存器映射后，就能直接通知控制器“SQ 中出现了新命令”。\n3. 注册 GPU 缓冲区 uGDSBufRegister() 不是简单地记住一个指针。GPU 虚拟地址不能直接交给 NVMe 控制器，必须先转换为设备可用于 DMA 的总线地址。\n注册阶段大致完成：\n确认缓冲区所属后端与地址范围； 固定 GPU 内存，防止映射在 I/O 中途失效； 获取 GPU 页对应的 DMA 地址； 建立虚拟地址到 DMA 页列表的映射； 把结果保存到用户态缓冲区注册表。 这也是为什么缓冲区注册通常应该被复用，而不是每次 I/O 前注册、结束后立即注销。注册属于慢路径，数据传输才是快路径。\n4. 构造 NVMe 命令 调用 uGDSRead() 时，uGDS 根据 SSD 偏移、长度和 GPU 缓冲区偏移构造 NVMe Read 命令。命令中的 PRP 等数据指针最终指向 GPU 显存对应的 DMA 地址，而不是主机内存。\n数据方向为：\nuGDSRead: SSD ──DMA──\u0026gt; GPU VRAM uGDSWrite: GPU VRAM ──DMA──\u0026gt; SSD CPU 只负责准备描述符、更新 SQ tail、写 doorbell，以及检查 CQ。真正的数据不会流经 CPU Cache。\n5. 等待完成 默认模式下，uGDS 使用 busy polling 检查 CQ，并在循环中使用 _mm_pause() 降低自旋对流水线和超线程的干扰。\n为了减少一个控制器上的队列争用，库支持多个 I/O queue pair，并以轮询方式选择队列。同步 API 会一直等到对应 CQE 出现，再返回传输结果。\n四、为什么用户态 NVMe 能降低延迟 uGDS 的性能优势主要不是来自某条神奇指令，而是来自路径缩短和专用化。\n1. 去掉通用软件栈 传统内核路径需要兼顾大量设备、文件系统、进程和调度策略。uGDS 面向明确的 NVMe 到 GPU 场景，可以直接操作队列，减少中间层。\n2. 避免每次 I/O 的系统调用 队列、doorbell 和 DMA 映射准备好以后，命令提交与完成处理都在当前进程中完成，不必为每个请求进入内核。\n3. 轮询比中断更适合低延迟 中断可以节省 CPU，但会引入中断投递、调度和唤醒延迟。对于持续高负载或 4KB 小 I/O，专用 CPU 核轮询 CQ 往往能得到更稳定的尾延迟。\n4. 数据直接到达最终位置 SSD 直接 DMA 到 GPU 显存，省去了主机内存中转和额外复制，也减少了内存带宽消耗。\n项目 README 给出的测试结果中，在 A100 40GB 与 Samsung 990 PRO 的环境下，uGDS 相比 NVIDIA GDS 展示了更高的带宽和明显更低的 4KB 延迟。需要注意，这些数字来自项目方的特定硬件、拓扑和测试方法，不能直接外推到所有机器；PCIe 拓扑、SSD 型号、GPU BAR、NUMA 位置、IOMMU 配置和队列深度都会影响结果。\n五、轮询模式与中断模式 Busy polling 的代价是会持续占用 CPU。为了兼顾大块 I/O 和低 CPU 利用率，uGDS 还提供可选的中断模式：\nexport UGDS_INTERRUPT_MODE=1 内核模块支持将 NVMe MSI-X vector 绑定到 eventfd。用户态可以等待 eventfd，收到通知后再处理 CQ。\n两种模式没有绝对优劣：\n模式 优点 代价 适合场景 Busy polling 延迟低、抖动小 占用 CPU 核 高频小 I/O、追求尾延迟 MSI-X + eventfd CPU 占用低 唤醒路径更长 大块 I/O、吞吐优先、请求稀疏 在生产系统中，常见做法是把轮询线程固定到独立 CPU 核，并确保它与 GPU、NVMe 位于合适的 NUMA 节点。\n六、CUDA 与 ROCm 双后端如何统一 uGDS 同时支持 NVIDIA CUDA 与 AMD HIP/ROCm，但两者获取 GPU DMA 映射的机制并不完全相同。\nCUDA 路径 CUDA 后端要求 NVIDIA 开放内核模块，并通过 NVIDIA P2P 页面固定和映射能力，让 NVMe 控制器访问 GPU 内存。项目也支持将合适的 CUDA 缓冲区导出为 DMA-buf。\nHIP/ROCm 路径 AMD 后端以 Linux DMA-buf 为核心：\n用户态通过 HSA Runtime 导出 GPU 内存的 DMA-buf； 内核模块使用标准 DMA-buf attach、pin 和 map API； 获得 NVMe 控制器可访问的 DMA 地址； 用户态继续使用相同的 uGDS I/O 接口。 这层抽象让上层 API 保持一致，但部署时必须保证内核模块和用户态库启用了相同后端。双后端环境中，还应使用显式后端参数注册缓冲区，避免仅凭指针自动判断带来的歧义。\n七、DMA-buf 与 RDMA：让一块显存连接更多设备 uGDS 不只服务 NVMe。注册的 GPU 缓冲区还可以通过 uGDSExportDmabuf() 导出 DMA-buf 文件描述符，交给支持 DMA-buf 的其他子系统，例如 RDMA 或 DPDK。\n这意味着一块 GPU 缓冲区可能同时处于以下数据路径中：\nNVMe SSD ─┐ ├── PCIe / DMA-buf ── GPU VRAM RDMA NIC ─┘ 它为分布式推理中的“SSD 缓存层 + 网络缓存层 + GPU 计算层”提供了很有价值的组合空间。不过，零拷贝不会自动解决同步问题。\n可以把访问者分成两类：\nProducer：向显存写数据，例如 NVMe Read、RDMA Recv、GPU Kernel Write； Consumer：从显存读数据，例如 NVMe Write、RDMA Send、GPU Kernel Read。 两个 Consumer 可以并发读取同一区域，但 Producer 与任何其他访问者发生重叠时，都可能形成数据竞争。应用必须通过 NVMe completion、RDMA CQ 或 CUDA/HIP stream barrier 明确建立先后关系。\n例如，SSD 把一批 KV Cache 读入 GPU 后，不能仅凭“命令已经提交”就启动依赖这些数据的 Kernel，而要等到对应 I/O 完成；同样，GPU 刚写完的缓冲区要落盘，也需要先同步相关 stream。\n八、同步、异步与批量 API uGDS 提供的不只是同步 uGDSRead() 和 uGDSWrite()。源码中还包含异步、批量和多 stream 相关实现与测试。\n这些 API 的价值在于把三类工作重叠起来：\n时间 ──────────────────────────────────────────\u0026gt; CPU: 构造下一批命令 ───── 构造下一批命令 SSD: DMA Batch A ─────── DMA Batch B GPU: Compute A ─────── Compute B 同步接口简单，但每个调用都要等完成；批量接口可以一次提交多条命令，摊薄 doorbell 和软件开销；异步接口则允许存储 I/O 与 GPU Kernel 重叠。\n对大模型系统而言，这种重叠通常比单纯追求某个微基准峰值更重要。只有把预取、计算和回写组织成流水线，额外的存储带宽才能真正转化为 token throughput 或训练吞吐。\n九、uGDS 的工程边界与风险 用户态存储栈获得性能的同时，也接管了原本由内核负责的大量工作。\n1. 它操作的是块设备，不是普通文件 示例打开的是 /dev/ugds_drv0。I/O 使用设备偏移，不会替你解析目录、inode、extent 或文件增长。应用若需要“按文件读取”，必须自己管理数据布局和元数据，或者由上层缓存系统提供这层抽象。\n2. NVMe 设备不能同时由内核驱动使用 同一控制器通常不能一边交给常规内核 NVMe 驱动挂载文件系统，一边由 uGDS 直接接管。部署前必须明确设备归属，并防止误挂载或其他进程访问。\n3. 错误处理与隔离更困难 用户态驱动需要正确处理队列深度、超时、控制器错误、并发访问和进程异常。普通文件系统提供的权限、崩溃恢复和一致性语义，在裸块设备路径中都不是免费获得的。\n4. 硬件拓扑决定 P2P 是否成立 GPU 与 NVMe 最好位于同一 PCIe Root Complex。某些 PCIe Switch、ACS 配置、IOMMU 模式或虚拟化环境会阻断或绕行 P2P DMA。如果平台不支持，数据可能无法直达，甚至无法建立映射。\n5. GPU 内存必须正确注册和同步 缓冲区注销前必须确保没有在途 I/O，也不能仍被 RDMA MR 引用。越过这些生命周期约束，轻则返回错误，重则可能造成 DMA 访问已失效内存。\n6. 当前项目仍在快速演进 截至 2026 年 8 月 20 日，uGDS 的公开路线图仍包含更多 cuFile API、更多 NVMe 能力与进一步性能优化。适合评估和集成的团队应固定版本、补充故障测试，并在目标硬件上验证，而不是只根据 README 的峰值数字做容量规划。\n十、安装与部署前需要检查什么 uGDS 对环境要求较高。以 CUDA 后端为例，至少需要：\nLinux 内核头文件与正在运行的内核匹配； NVIDIA 开放内核模块，而不是闭源内核模块； CUDA 12 或更新版本； CMake 3.18 或更新版本； 支持 C++17 的编译器。 AMD 后端还依赖较新的 ROCm、HSA DMA-buf 导出能力、Large BAR，以及内核中的 PCI P2PDMA 和 DMA-buf move notify 配置。\n真正部署前，建议按下面的顺序验证：\n用 lspci -tv 确认 GPU 与 NVMe 的 PCIe 拓扑； 检查 IOMMU、ACS、Resizable BAR 或 Large BAR 配置； 确认目标 NVMe 没有挂载文件系统且未被内核业务使用； 构建并加载与 GPU 后端匹配的 ugds_drv.ko； 先运行项目功能测试，再运行性能测试； 分别测试 4KB、小队列深度、大块顺序 I/O 和真实业务流水线； 监控 CPU 核占用、PCIe 带宽、SSD 温度与持续写入降速。 十一、它适合哪些场景 uGDS 最适合同时具备以下特征的系统：\n数据最终消费位置就是 GPU 显存； 数据布局可控，能够使用裸设备或专用分区； 对 4KB 延迟、尾延迟或 CPU 开销敏感； 愿意为固定硬件平台做拓扑和驱动调优； 上层已经有缓存目录、对象索引或元数据服务。 典型场景包括：\n大模型 KV Cache 的 SSD 分层与预取； 模型权重按需加载； GPU 数据处理流水线； 向量检索或图计算的数据分页； GPU、NVMe 与 RDMA NIC 之间的零拷贝数据通路。 如果应用主要读写普通文件、强调通用性与运维简单，或者硬件 P2P 条件不可控，那么成熟文件系统加 GDS、io_uring 或常规 Direct I/O 可能是更稳妥的方案。\n十二、总结 uGDS 的关键价值不只是“SSD 可以直接把数据写进显存”，而是把 NVMe 命令提交与完成处理也搬到了用户态：\n内核模块只负责设备映射、GPU 页固定和中断等特权控制面； 用户态库直接管理 NVMe SQ/CQ 并敲 doorbell； NVMe 控制器通过 PCIe P2P DMA 直接访问 GPU 显存； CUDA、ROCm、DMA-buf 和 RDMA 被统一到同一套缓冲区生命周期中； 应用用更多 CPU 核、部署约束和工程复杂度，换取更短的数据路径与更可控的延迟。 从系统设计角度看，uGDS 很好地展示了一条趋势：当 GPU 成为主要计算中心后，存储系统不再只是“把数据读进内存”，而需要围绕 GPU 的地址空间、执行流和 PCIe 拓扑重新设计。\n系列导航 GDS、GIDS 与 uGDS 有什么区别：从数据直达、GPU 发起到用户态 NVMe NVIDIA GPUDirect Storage（GDS）详解 NVIDIA GPU-Initiated Data Storage（GIDS）详解 uGDS 原理解析 参考资料 ScaleX-IO/uGDS CoPilotIO: CPU as a Co-pilot for GPU I/O to Free GPU Compute NVIDIA GPUDirect Storage Linux PCI Peer-to-Peer DMA Support Linux DMA-BUF ","permalink":"https://yangyang233333.github.io/posts/ugds-userspace-gpu-direct-storage/","summary":"\u003cp\u003e大模型推理正在把存储重新推到系统性能的核心位置。模型权重、KV Cache、Embedding 和训练检查点都可能在 SSD、主存与 GPU 显存之间频繁搬运。传统路径通常要经过文件系统、内核块层、页缓存或用户态中转缓冲区，不但增加 CPU 开销，也让小块 I/O 延迟变得难以控制。\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/ScaleX-IO/uGDS\"\u003euGDS\u003c/a\u003e 是 ScaleX-IO 开源的一套用户态 GPU Direct Storage 开发库。它的核心思路很直接：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e让 CPU 在用户态构造 NVMe 命令并管理提交队列与完成队列，由 SSD 通过 PCIe P2P DMA 直接读写 GPU 显存，从数据路径中绕开内核 NVMe 驱动和文件系统。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e本文结合 uGDS 当前源码，解释它为什么快、一次 I/O 如何执行、内核模块与用户态库如何分工，以及使用这类“绕过内核”的存储栈时必须承担哪些工程责任。\u003c/p\u003e\n\u003ch2 id=\"一ugds-想解决什么问题\"\u003e一、uGDS 想解决什么问题\u003c/h2\u003e\n\u003cp\u003e先看一条常见的数据读取路径：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNVMe SSD\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e内核 NVMe 驱动 → 块层 → 文件系统\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e主机内存缓冲区\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e即使系统支持 Direct I/O，应用仍然通常要进入内核，由内核驱动准备 NVMe 命令、完成 DMA 映射并处理中断或轮询。若数据还要经过主机内存，再复制到 GPU，路径会更长。\u003c/p\u003e","title":"uGDS 原理解析：在用户态打通 NVMe SSD 与 GPU 显存"},{"content":"GPUDirect Storage（GDS）已经能够让存储数据绕过 CPU 内存，直接进入 GPU 显存。但经典 GDS 仍有一个重要特征：I/O 请求由 CPU 侧应用代码提交。\n当 GPU kernel 在执行过程中才知道下一份数据位于哪里时，控制权必须在 GPU 与 CPU 之间往返。对于图计算、向量检索、稀疏模型和超大规模数据分析，这种细粒度同步可能比数据传输本身更昂贵。\nNVIDIA GPU-Initiated Data Storage，简称 GIDS，试图进一步消除这层控制瓶颈：让 GPU kernel 直接发起存储 I/O，使 GPU 不仅是数据的接收者，也是 I/O 请求的生产者。\n本文介绍 GIDS 的动机、架构、工作流程、适用场景，以及它和 GDS 的本质区别。\n一、为什么有了 GDS 还需要 GIDS GDS 优化了数据路径：\nCPU 发起请求 │ ▼ 存储 ─────────────► GPU 显存 数据不经过 CPU 内存 这已经消除了大量主机内存中转，但控制路径仍然经过 CPU。经典流程可能是：\nGPU 执行 kernel； GPU 产生下一批要访问的数据索引； kernel 结束或与 CPU 同步； CPU 读取结果并构造文件 I/O； CPU 调用 cuFile； 数据进入 GPU； CPU 再次启动 kernel。 如果每次请求都是大块、批量且可提前预测，这种方式通常没有问题。但如果 GPU 在计算过程中产生数以万计甚至更多的动态访问，频繁的 GPU—CPU 往返会带来：\nkernel launch 和同步开销； CPU 请求生成开销； 设备到主机的控制信息传输； CPU 线程调度和队列竞争； GPU 等待数据时的空闲。 GIDS 的目标就是把这段 I/O 控制逻辑下沉到 GPU。\n二、GIDS 是什么 GIDS 的核心定义是：\n允许运行中的 GPU kernel 通过设备侧 API 主动生成和提交存储请求，并在 GPU 侧获取完成结果。\n它把传统的“CPU 驱动 GPU 和存储”模式，转变为 GPU 可以自主驱动部分数据访问的模式。\n理想化的数据与控制路径如下：\nGPU kernel │ 生成 I/O 请求 ▼ 设备侧存储接口 │ ▼ NVMe / 文件系统 / 远程存储 │ DMA ▼ GPU 显存 │ └── GPU kernel 继续处理 与 GDS 一样，数据目标仍然是 GPU 显存，数据路径也尽可能避免 CPU 内存。但不同的是，请求不再必须由 CPU 对每个 I/O 逐一提交。\n三、GIDS 改变了什么 理解 GIDS，可以把 GPU 存储 I/O 分为三个层面：\n数据面：数据从存储传到 GPU； 控制面：谁决定读什么、何时读； 执行面：谁提交请求、检查完成并消费数据。 GDS 主要优化数据面，而 GIDS 进一步改变控制面与执行面。\n层面 传统 I/O GDS GIDS 数据经过 CPU 内存 是 通常否 通常否 CPU 决定并提交每个 I/O 是 是 不一定 GPU kernel 可直接请求存储 否 否 是 GPU 可基于中间结果继续取数 需要 CPU 协助 需要 CPU 协助 可以设备侧闭环 因此，GIDS 不是 GDS 的另一个名字，也不只是异步 GDS。它代表的是更加自主的 GPU I/O 执行模型。\n四、GIDS 的典型工作流程 一个 GPU 发起存储访问的程序可以抽象为以下过程：\nCPU 完成初始化，打开文件或数据集； CPU 建立 GPU 可访问的存储上下文、映射和请求队列； CPU 启动 GPU kernel； GPU 线程根据计算结果生成读取请求； 设备侧运行时将请求送入存储栈； 存储数据通过 DMA 写入 GPU 缓冲区； GPU 检查完成状态并消费数据； kernel 继续生成后续请求，不必为每批 I/O 返回 CPU。 可以把它理解为 GPU 侧的生产者—消费者系统：\nGPU 工作线程 │ 产生地址、偏移、长度 ▼ GPU 请求队列 │ ▼ 存储服务与设备 │ ▼ GPU 完成队列 │ ▼ GPU 工作线程继续执行 CPU 并没有从系统中完全消失。它仍然负责程序启动、资源创建、权限、文件生命周期、异常恢复和全局协调。GIDS 移除的是高频、细粒度 I/O 请求中的 CPU 代理角色。\n五、设备侧 API 的设计挑战 把存储 API 放进 GPU kernel 并不是简单地把 read() 编译成设备函数。GPU 的执行模型与 CPU 有根本差异。\n1. 海量并发线程 一个 kernel 可能同时运行数十万个线程。如果每个线程独立发起小 I/O，请求数量会迅速超过存储设备和软件队列的承载能力。\n因此 GIDS 运行时通常需要：\n合并相邻请求； 以 warp、thread block 或 cooperative group 为单位协作； 限制 outstanding I/O 数量； 对请求进行排队和背压； 避免热点数据被重复读取。 2. GPU 不能像 CPU 一样阻塞 CPU 线程可以睡眠等待 I/O，GPU 中大量线程若直接自旋，会浪费执行资源。设备侧 API 需要设计轮询、让出执行、异步通知或协作等待机制。\n3. 文件语义复杂 POSIX 文件 API 包含权限、目录、文件描述符、页缓存和一致性语义。将完整 POSIX 模型暴露给 GPU 既复杂又低效。\n因此 GIDS 更适合使用受限且面向数据路径的接口，例如：\n已打开对象的偏移读取； 预注册的数据区域； 固定大小 block 或 page； key 到数据块的映射； 简化的异步请求和完成队列。 4. 错误处理 存储可能返回短读、超时、介质错误或远端失败。GPU 侧代码必须能够识别错误，并决定重试、跳过、记录还是通知 CPU。\n六、GIDS 的关键收益 1. 减少 GPU—CPU 控制往返 GIDS 最大的收益不是再次缩短数据路径，因为 GDS 已经能做到直接 DMA；它真正减少的是请求生成和完成处理中的控制往返。\n2. 支持数据依赖型访问 GPU 可以根据当前计算结果立即决定下一次读取：\n读取节点 A │ ▼ GPU 计算得到邻居 B、C │ ├── 读取 B └── 读取 C 这类访问很难由 CPU 提前完整预测，却非常适合 GPU 自主发起。\n3. 提高细粒度 I/O 的扩展性 如果请求生成本身高度并行，GPU 可以直接构造请求，避免先压缩成一批索引传给 CPU，再由 CPU 展开为存储操作。\n4. 构建存储支持的超量数据模型 GPU 显存容量有限，而图、向量索引、embedding 表和科学数据可能达到 TB 甚至 PB 级。GIDS 让应用更容易把存储作为 GPU 可按需访问的后备层。\n七、GIDS 的典型应用场景 1. 图计算 图遍历通常具有强数据依赖：只有访问当前顶点后，才能知道下一批邻居。GPU 线程可以直接按顶点或边的索引读取存储中的分片，减少每层遍历与 CPU 的同步。\n2. 向量数据库与近似最近邻搜索 图结构的 ANN 索引会根据当前距离结果继续探索新的节点。索引无法完全驻留显存时，GPU 可按搜索路径请求存储中的向量和邻接表。\n3. 超大 embedding 表 推荐系统中的 embedding 表可能远大于 GPU 显存。查表键由 GPU 上的 batch 动态产生，GIDS 可以让设备端直接发起缺失 embedding 的读取。\n4. 稀疏计算 稀疏矩阵、稀疏张量和自适应网格只访问数据集的一部分，访问位置由计算结果动态决定，适合设备侧按需取数。\n5. out-of-core 分析 数据库扫描、过滤、join 和科学数据分析可让 GPU 根据谓词或中间结果继续读取相关分区，而不是由 CPU 预先加载全部数据。\n八、GIDS 与缓存的关系 直接访问存储并不意味着每次都应读取设备。存储延迟远高于 HBM，因此高效的 GIDS 系统通常需要多级缓存：\nGPU 寄存器 / Shared Memory │ ▼ GPU HBM │ ▼ 主机内存或扩展内存 │ ▼ NVMe / 远程存储 GIDS 更像是在最下层缓存未命中时，为 GPU 提供一种自主补数能力。系统仍然需要：\n数据预取； 热点缓存； 请求合并； page 或 block 粒度选择； 数据淘汰； 多 GPU 之间的缓存一致性或分片策略。 如果完全忽略局部性，让每个 GPU 线程随机读取很小的数据块，存储延迟和 IOPS 很可能压垮系统。\n九、GIDS 与 GDS 的区别 对比项 GDS GIDS 全称 GPUDirect Storage GPU-Initiated Data Storage 优化重点 存储到 GPU 的数据路径 GPU 自主生成和提交存储请求 I/O 发起位置 CPU 主机代码 GPU kernel 数据是否经过 CPU 内存 通常不经过 通常不经过 是否需要逐批返回 CPU 通常需要 CPU 组织请求 可减少或避免 访问模式 大块、批量、可提前规划 细粒度、动态、数据依赖 编程接口 主机侧 cuFile 设备侧 API 与请求队列 软件复杂度 相对成熟 更复杂、更前沿 对存储 IOPS 要求 以吞吐为主 可能同时强调吞吐和 IOPS 最简洁的区分方式是：\nGDS = CPU 发起 + GPU 直收 GIDS = GPU 发起 + GPU 直收 十、GIDS 不等于这些技术 1. GIDS 不等于异步 GDS 异步 GDS 仍然是 CPU 侧程序调用 API，只是请求与 CUDA Stream 协同并异步完成。GIDS 的请求生成逻辑位于 GPU kernel。\n2. GIDS 不等于统一内存 CUDA Unified Memory 提供统一虚拟地址和按页迁移，重点是 CPU/GPU 内存管理。GIDS 面向的是文件、块设备或对象数据的设备侧存储 I/O。\n3. GIDS 不等于 GPU Direct RDMA GPUDirect RDMA 让网卡等 PCIe 设备直接访问 GPU 显存。它可以成为远程 GIDS 数据路径的一部分，但本身不定义 GPU kernel 如何生成文件或对象存储请求。\n4. GIDS 不等于传统 GPU 文件系统 某些 GPU 文件系统或研究系统允许 kernel 使用类似文件的 API，但实现可能依赖 CPU 代理线程。只有当请求控制路径真正由 GPU 发起并尽量减少 CPU 逐请求参与时，才符合 GIDS 的核心目标。\n十一、GIDS 面临的工程挑战 1. 存储延迟远高于 GPU 指令延迟 GPU 擅长隐藏数百个周期的内存延迟，但 NVMe 和远程存储延迟高出多个数量级。运行时必须提供足够并发，或者让等待 I/O 的工作与其他计算交错。\n2. 小 I/O 会降低效率 GPU 自然产生细粒度访问，但 SSD 和文件系统更喜欢较大的连续请求。请求聚合是 GIDS 性能的关键。\n3. 一致性与写入更加复杂 读取通常比写入容易。多个 GPU 线程并发修改文件或共享对象时，需要定义原子性、顺序、可见性和崩溃恢复语义。\n4. 安全与隔离 GPU kernel 不应绕过文件权限、进程隔离和地址校验。设备侧请求必须被限制在 CPU 预先授权的文件、对象或地址范围内。\n5. 可观测性 传统工具主要观察 CPU 系统调用。GIDS 请求在 GPU 侧生成后，需要新的 tracing、profiling 和错误诊断能力，才能定位队列拥塞、缓存未命中和设备热点。\n十二、如何选择 GDS 还是 GIDS 优先选择 GDS 的情况：\nI/O 可以由 CPU 提前知道； 数据以大块或 batch 形式读取； 需要成熟的软件栈和广泛文件系统支持； 主要瓶颈是 CPU 内存中转和带宽； 希望较小改造现有 CUDA 应用。 考虑 GIDS 的情况：\n下一次访问依赖 GPU kernel 的中间结果； GPU—CPU 控制同步已经成为瓶颈； 数据规模远大于显存，且访问稀疏、动态； 应用可以使用设备侧专用数据接口； 存储系统能承受高度并发请求，并支持请求聚合。 很多系统并不是二选一，而是组合使用：CPU 通过 GDS 预取大块数据，GPU 在计算过程中通过 GIDS 处理不可预测的缓存未命中。\n十三、总结 GIDS 把 GPU 存储架构从“GPU 被动接收数据”推进到“GPU 主动获取数据”。它继承了 GDS 直接数据路径的思想，又进一步减少 CPU 在细粒度 I/O 控制路径中的参与。\n它最适合解决以下问题：\n数据访问由 GPU 中间结果动态决定； 工作集远大于 GPU 显存； GPU 与 CPU 的频繁同步限制扩展性； 图、向量、稀疏和超量数据应用需要按需读取。 不过，GIDS 也比 GDS 更依赖请求聚合、缓存、背压和设备侧运行时。它不是让 GPU 对存储进行无限制随机读取，而是为 GPU 主导的数据系统提供一种新的 I/O 控制模型。\n一句话总结：\nGDS 让数据绕过 CPU，GIDS 进一步让请求也绕过 CPU。\n系列导航 GDS、GIDS 与 uGDS 有什么区别：从数据直达、GPU 发起到用户态 NVMe NVIDIA GPUDirect Storage（GDS）详解 NVIDIA GPU-Initiated Data Storage（GIDS）详解 uGDS 原理解析 参考资料 NVIDIA GPUDirect Storage Documentation NVIDIA CUDA Documentation NVIDIA 关于 GPU-Initiated Networking 与 GPU-Initiated Storage 的技术资料 GPU-initiated storage access、GPUfs、BaM 等相关系统研究论文与技术资料 ","permalink":"https://yangyang233333.github.io/posts/nvidia-gpu-initiated-data-storage-gids/","summary":"\u003cp\u003eGPUDirect Storage（GDS）已经能够让存储数据绕过 CPU 内存，直接进入 GPU 显存。但经典 GDS 仍有一个重要特征：I/O 请求由 CPU 侧应用代码提交。\u003c/p\u003e\n\u003cp\u003e当 GPU kernel 在执行过程中才知道下一份数据位于哪里时，控制权必须在 GPU 与 CPU 之间往返。对于图计算、向量检索、稀疏模型和超大规模数据分析，这种细粒度同步可能比数据传输本身更昂贵。\u003c/p\u003e\n\u003cp\u003eNVIDIA GPU-Initiated Data Storage，简称 \u003cstrong\u003eGIDS\u003c/strong\u003e，试图进一步消除这层控制瓶颈：让 GPU kernel 直接发起存储 I/O，使 GPU 不仅是数据的接收者，也是 I/O 请求的生产者。\u003c/p\u003e\n\u003cp\u003e本文介绍 GIDS 的动机、架构、工作流程、适用场景，以及它和 GDS 的本质区别。\u003c/p\u003e\n\u003ch2 id=\"一为什么有了-gds-还需要-gids\"\u003e一、为什么有了 GDS 还需要 GIDS\u003c/h2\u003e\n\u003cp\u003eGDS 优化了数据路径：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eCPU 发起请求\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e存储 ─────────────► GPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       数据不经过 CPU 内存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这已经消除了大量主机内存中转，但控制路径仍然经过 CPU。经典流程可能是：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eGPU 执行 kernel；\u003c/li\u003e\n\u003cli\u003eGPU 产生下一批要访问的数据索引；\u003c/li\u003e\n\u003cli\u003ekernel 结束或与 CPU 同步；\u003c/li\u003e\n\u003cli\u003eCPU 读取结果并构造文件 I/O；\u003c/li\u003e\n\u003cli\u003eCPU 调用 cuFile；\u003c/li\u003e\n\u003cli\u003e数据进入 GPU；\u003c/li\u003e\n\u003cli\u003eCPU 再次启动 kernel。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e如果每次请求都是大块、批量且可提前预测，这种方式通常没有问题。但如果 GPU 在计算过程中产生数以万计甚至更多的动态访问，频繁的 GPU—CPU 往返会带来：\u003c/p\u003e","title":"NVIDIA GPU-Initiated Data Storage（GIDS）详解：让 GPU Kernel 主动访问存储"},{"content":"在 AI 训练、科学计算和数据分析系统中，GPU 的算力越来越强，但数据从存储设备进入 GPU 的路径却可能成为瓶颈。传统 I/O 通常要先把数据读入 CPU 内存，再复制到 GPU 显存，不仅增加内存带宽消耗，还让 CPU 承担大量数据搬运工作。\nNVIDIA GPUDirect Storage，简称 GDS，解决的正是这个问题：它在存储设备与 GPU 显存之间建立更直接的数据路径，使应用能够通过 cuFile API 将文件数据读入 GPU 缓冲区，减少 CPU bounce buffer 和不必要的数据复制。\n本文介绍 GDS 的工作原理、软件栈、典型使用方式、适用场景以及它与 GPU-Initiated Data Storage（GIDS）的关系。\n一、传统 GPU I/O 为什么效率不高 传统文件读取到 GPU 的路径大致如下：\nNVMe / 文件系统 │ ▼ CPU 内存缓冲区 │ cudaMemcpy ▼ GPU 显存 应用通常先调用 read、pread 或异步 I/O 接口，把文件内容读入主机内存，然后再调用 CUDA memcpy 将数据复制到 GPU。\n这条路径存在几个问题：\n同一份数据先经过 CPU 内存，再进入 GPU 显存； 存储流量和 GPU 传输流量竞争 CPU 内存带宽； CPU 需要提交、管理和完成数据搬运； 大规模 GPU 系统中，CPU 和内存通道容易成为共享瓶颈； 应用需要维护主机端 staging buffer，并处理双重缓冲。 当 GPU 计算越来越快、单机挂载更多 NVMe 或更高速的并行文件系统后，这些额外开销会更加明显。\n二、GDS 是什么 GDS 是 NVIDIA GPUDirect 技术家族面向存储 I/O 的组成部分。它允许兼容的存储和文件系统通过 DMA 路径与 GPU 显存交换数据，而不必总是经过 CPU 内存中的中转缓冲区。\n典型数据路径变为：\nNVMe / 文件系统 │ DMA ▼ GPU 显存 需要注意，“绕过 CPU”主要是指数据路径。在经典 GDS 模型中，CPU 仍然负责：\n运行应用控制逻辑； 调用 cuFileRead、cuFileWrite 等 API； 向存储栈提交 I/O； 处理文件描述符、错误和完成状态。 因此 GDS 的核心可以概括为：\nCPU 发起 I/O，但数据尽可能不经过 CPU 内存，而是直接进入或离开 GPU 显存。\n这也是它与 GIDS 最关键的区别。\n三、GDS 的软件栈 GDS 并不是只安装一个 CUDA 库就能使用，它依赖 GPU、驱动、文件系统和存储设备共同提供一条可用的数据路径。\n从应用到硬件可以抽象为：\nGPU 应用 │ ▼ cuFile API / libcufile │ ▼ NVIDIA 驱动与 nvidia-fs │ ▼ 兼容文件系统 / 块设备 │ ▼ NVMe、本地阵列或并行存储 1. cuFile API cuFile 是 GDS 面向应用的主要 API。应用注册文件句柄和 GPU 缓冲区后，可调用同步或异步接口读写数据。\n常见调用过程为：\n使用普通文件 API 打开文件； 将文件描述符注册为 CUfileHandle_t； 分配 GPU 缓冲区； 视需要注册 GPU 缓冲区； 调用 cuFileRead 或 cuFileWrite； 注销缓冲区和文件句柄。 简化后的伪代码如下：\nint file_descriptor = open(path, O_RDONLY | O_DIRECT); CUfileDescr_t descriptor = {}; descriptor.handle.fd = file_descriptor; descriptor.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD; CUfileHandle_t handle; cuFileHandleRegister(\u0026amp;handle, \u0026amp;descriptor); void* device_buffer = nullptr; cudaMalloc(\u0026amp;device_buffer, size); cuFileBufRegister(device_buffer, size, 0); ssize_t bytes_read = cuFileRead( handle, device_buffer, size, file_offset, 0 ); 实际程序还需要完整检查 CUDA、文件系统和 cuFile 的返回值，并满足设备、内存地址、文件偏移和 I/O 大小的对齐要求。\n2. 内核模块与驱动 GDS 使用 NVIDIA 驱动提供 GPU 内存映射和 DMA 能力。nvidia-fs 模块在经典架构中负责连接 GPU 内存与文件系统、块设备路径。\n不同驱动、CUDA 和 Linux 内核组合支持的能力可能不同，因此部署时应以当前 NVIDIA GDS 文档中的支持矩阵为准，而不能只检查 GPU 型号。\n3. 文件系统与存储 GDS 是否真正走直接路径，取决于文件系统和存储后端是否受支持。常见部署包括：\n本地 NVMe； 软件定义的本地存储； 支持 GDS 的并行文件系统； 支持 RDMA 和 GPU Direct 路径的远程存储系统。 如果某条路径不满足条件，libcufile 可能使用兼容模式或回退路径。程序仍可能正常读取，但性能特征会与真正的 GDS direct path 不同。\n四、GDS 如何提升性能 1. 减少数据复制 传统路径通常包含“存储到主机内存”和“主机内存到 GPU”两个阶段。GDS 尽可能将其合并为存储与 GPU 之间的一次 DMA 数据传输。\n2. 降低 CPU 内存带宽压力 CPU 内存带宽是整机共享资源。数据集越大、GPU 越多，主机内存中转造成的压力越明显。GDS 把部分数据流量移出 CPU 内存路径，可以让 CPU 和内存资源留给数据预处理、网络协议或其他任务。\n3. 降低 CPU 开销 GDS 不代表 CPU 完全不参与，但可以减少 CPU 执行 memcpy、管理 staging buffer 和驱动额外复制的开销。\n4. 改善流水线并行 结合 CUDA Stream、异步 cuFile API 和双缓冲，应用可以构建如下流水线：\n读取批次 N+1 ───────────────┐ │ 并行 计算批次 N ────────────────┘ 数据读取与 GPU 计算重叠后，端到端吞吐量往往比单次 I/O 延迟更重要。\n五、同步与异步 GDS 经典 cuFileRead 和 cuFileWrite 是主机线程发起的同步调用。应用也可以使用批量或异步能力，将请求与 CUDA Stream 配合。\n异步 GDS 的价值主要在于：\n减少主机线程阻塞； 批量提交多个 I/O； 让 I/O 与 kernel 执行重叠； 在多个 GPU 或多个文件分片之间建立流水线。 但即使使用异步接口，请求通常仍由 CPU 侧代码准备和提交。异步 GDS 不等于 GIDS：前者改变等待与调度方式，后者进一步让 GPU kernel 自己发起存储请求。\n六、GDS 的典型应用场景 1. AI 训练数据加载 大规模训练需要持续读取样本、特征、embedding 或 checkpoint。对于已经预处理好、可直接供 GPU 消费的数据，GDS 可以减少 CPU staging 开销。\n如果训练管线包含大量 CPU 解码、随机增强或复杂解析，瓶颈可能仍在 CPU 预处理而非数据复制。此时需要先测量，再判断 GDS 能带来多少收益。\n2. 推理与向量检索 当模型权重、KV 数据、向量索引或 embedding 无法全部驻留显存时，系统需要按需从高速存储加载数据。GDS 能为 GPU 缓冲区提供更直接的数据入口。\n3. 科学计算 地震分析、气象模拟、计算流体力学和基因分析往往需要在大型文件与 GPU 数组之间传输数据，适合使用 GDS 减少主机内存中转。\n4. Checkpoint 训练过程既要将参数写入存储，也要在故障恢复时快速读取。GDS 同时支持读写，可用于改善 checkpoint 保存和恢复路径。\n七、部署与诊断 部署 GDS 时不应只看应用是否成功返回，还要确认实际数据路径。\n常用检查思路包括：\n查看 NVIDIA 驱动、CUDA Toolkit 和 GDS 组件版本； 使用 nvidia-smi 检查 GPU 和驱动状态； 使用 gdscheck 检查系统配置； 使用 gdsio 测量 GPU 缓冲区 I/O； 检查挂载参数、IOMMU、PCIe 拓扑和设备亲和性； 查看 cuFile 日志，确认是否发生兼容模式或 POSIX 回退； 将 GDS 与普通 pread + cudaMemcpy 的端到端吞吐量对比。 PCIe 拓扑尤其重要。即使软件支持 GDS，如果 NVMe 和 GPU 跨越多个 PCIe Root Complex 或 NUMA 节点，实际路径也可能绕行，影响带宽与延迟。\n八、GDS 的限制 GDS 并不是所有场景下都一定更快。\n小而离散的 I/O 可能被提交和元数据开销主导； 数据需要 CPU 解压、解析或增强时，仍离不开主机处理； 不兼容的文件系统可能触发回退； 不合理的文件布局和随机访问会限制存储吞吐； GPU 与存储设备的 PCIe 拓扑会影响结果； 多进程、多 GPU 并发可能让瓶颈转移到文件系统或 NVMe 队列。 因此，评估 GDS 时应该测量完整工作负载，而不仅仅看理想条件下的大块顺序读取带宽。\n九、GDS 与 GIDS 的区别 两者都希望缩短存储到 GPU 的路径，但改变的是不同层次。\n对比项 GDS GIDS 全称 GPUDirect Storage GPU-Initiated Data Storage 请求发起者 CPU 主机代码 GPU kernel / GPU 线程 数据目标 GPU 显存 GPU 显存 主要 API 主机侧 cuFile API 设备侧存储 API CPU 数据中转 尽可能避免 尽可能避免 CPU 控制路径 仍然存在 从细粒度 I/O 控制路径中移除 适合模式 大块、批量、可预知的 I/O 依赖 GPU 计算结果的细粒度动态 I/O 成熟度 已广泛用于生产环境 更前沿，依赖新软件与硬件支持 一句话总结：\nGDS 是“CPU 下命令，数据直达 GPU”；GIDS 是“GPU 自己下命令，数据也直达 GPU”。\n十、总结 GDS 的价值不是简单地把文件读得更快，而是重构 GPU 应用的数据通路：\n减少 CPU 内存中转和数据复制； 降低 CPU 与主机内存带宽压力； 支持存储 I/O 与 GPU 计算并行； 为 AI、HPC 和 GPU 数据分析提供更可扩展的 I/O 基础。 如果应用的 I/O 可以由 CPU 预先规划，并且主要以较大块、批量方式传输，GDS 通常是更成熟、直接的选择。若 GPU 需要根据 kernel 内部计算结果动态访问海量存储对象，则应继续关注 GIDS。\n系列导航 GDS、GIDS 与 uGDS 有什么区别：从数据直达、GPU 发起到用户态 NVMe NVIDIA GPUDirect Storage（GDS）详解 NVIDIA GPU-Initiated Data Storage（GIDS）详解 uGDS 原理解析 参考资料 NVIDIA GPUDirect Storage Overview Guide NVIDIA GPUDirect Storage Design Guide NVIDIA GPUDirect Storage cuFile API Reference Guide NVIDIA GPUDirect Storage Best Practices Guide ","permalink":"https://yangyang233333.github.io/posts/nvidia-gpudirect-storage-gds/","summary":"\u003cp\u003e在 AI 训练、科学计算和数据分析系统中，GPU 的算力越来越强，但数据从存储设备进入 GPU 的路径却可能成为瓶颈。传统 I/O 通常要先把数据读入 CPU 内存，再复制到 GPU 显存，不仅增加内存带宽消耗，还让 CPU 承担大量数据搬运工作。\u003c/p\u003e\n\u003cp\u003eNVIDIA GPUDirect Storage，简称 \u003cstrong\u003eGDS\u003c/strong\u003e，解决的正是这个问题：它在存储设备与 GPU 显存之间建立更直接的数据路径，使应用能够通过 \u003ccode\u003ecuFile\u003c/code\u003e API 将文件数据读入 GPU 缓冲区，减少 CPU bounce buffer 和不必要的数据复制。\u003c/p\u003e\n\u003cp\u003e本文介绍 GDS 的工作原理、软件栈、典型使用方式、适用场景以及它与 GPU-Initiated Data Storage（GIDS）的关系。\u003c/p\u003e\n\u003ch2 id=\"一传统-gpu-io-为什么效率不高\"\u003e一、传统 GPU I/O 为什么效率不高\u003c/h2\u003e\n\u003cp\u003e传统文件读取到 GPU 的路径大致如下：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNVMe / 文件系统\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eCPU 内存缓冲区\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       │ cudaMemcpy\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eGPU 显存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e应用通常先调用 \u003ccode\u003eread\u003c/code\u003e、\u003ccode\u003epread\u003c/code\u003e 或异步 I/O 接口，把文件内容读入主机内存，然后再调用 CUDA memcpy 将数据复制到 GPU。\u003c/p\u003e\n\u003cp\u003e这条路径存在几个问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e同一份数据先经过 CPU 内存，再进入 GPU 显存；\u003c/li\u003e\n\u003cli\u003e存储流量和 GPU 传输流量竞争 CPU 内存带宽；\u003c/li\u003e\n\u003cli\u003eCPU 需要提交、管理和完成数据搬运；\u003c/li\u003e\n\u003cli\u003e大规模 GPU 系统中，CPU 和内存通道容易成为共享瓶颈；\u003c/li\u003e\n\u003cli\u003e应用需要维护主机端 staging buffer，并处理双重缓冲。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e当 GPU 计算越来越快、单机挂载更多 NVMe 或更高速的并行文件系统后，这些额外开销会更加明显。\u003c/p\u003e","title":"NVIDIA GPUDirect Storage（GDS）详解：让存储数据绕过 CPU 直达 GPU"},{"content":"MVCC（Multi-Version Concurrency Control，多版本并发控制）是现代数据库实现高并发事务的重要机制。它的核心思想并不复杂：数据被修改时，不立即丢弃旧值，而是保留多个版本；事务读取数据时，根据自己的快照选择可见版本。\n因此，读事务可以继续访问旧版本，写事务则创建新版本。普通读取通常不需要等待并发写入完成，写入也不必等待读事务释放共享锁。\n本文从 MVCC 要解决的问题出发，依次说明版本存储、事务快照、可见性判断、写冲突控制和旧版本回收，并比较 InnoDB 与 PostgreSQL 的典型实现。\n一、为什么需要 MVCC 假设数据库中存在一条记录：\n账户 A 的余额 = 100 事务 T1 正在读取这条记录，与此同时，事务 T2 希望把余额修改为 80。\n如果所有读取都依赖共享锁，那么 T2 可能必须等待 T1 结束；如果 T2 已经持有排他锁，T1 又可能必须等待 T2。锁能够保证正确性，但大量读写互相等待会降低系统并发能力，并增加死锁概率。\nMVCC 使用另一种思路处理普通读取：\n新版本：balance = 80，由 T2 创建 ↓ 旧版本：balance = 100 如果 T1 的事务快照不允许它看到 T2 的修改，T1 就读取旧版本 100；在 T2 提交后启动的新事务，则可以读取新版本 80。\n于是，同一时刻可以出现：\n旧事务看到 100 新事务看到 80 这不是数据不一致，而是两个事务观察到了数据库在不同逻辑时刻的一致状态。\n二、MVCC 的五个组成部分 一个完整的 MVCC 实现通常包含五个部分：\nMVCC = 多版本存储 + 事务快照 + 可见性判断 + 写冲突控制 + 旧版本回收 只保存历史版本还不够。数据库还必须知道版本由谁创建、事务能够看到哪些版本、两个写事务冲突时如何处理，以及历史版本何时可以安全删除。\n1. 多版本存储 每次更新都会产生一个逻辑上的新版本：\nV3：balance = 80 创建事务 = T30 ↓ V2：balance = 120 创建事务 = T20 ↓ V1：balance = 100 创建事务 = T10 数据库可以把多个版本直接保存在表中，也可以只在数据页保存当前版本，再通过 Undo 记录重建历史版本。\n2. 事务标识 数据库通常为事务分配单调递增的事务 ID、提交序列号或逻辑时间戳。行版本需要记录足够的元数据，例如：\n哪个事务创建了该版本； 哪个事务删除或替代了该版本； 创建事务是否已经提交； 如何找到前一个版本。 这些元数据是判断版本可见性的基础。\n3. 事务快照 事务执行一致性读取时，数据库会创建一个快照。快照描述某个逻辑时刻的事务状态，例如：\n创建快照时哪些事务仍然活跃； 哪些事务已经提交； 当前事务自己的标识； 快照能够观察到的事务 ID 边界。 在不同系统中，它可能被称为 Read View、Snapshot 或 Consistent View。\n4. 可见性判断 数据库把行版本的事务元数据与当前快照进行比较，决定该版本是否可见。如果最新版本不可见，就沿版本链寻找更旧的版本。\n5. 版本回收 历史版本不能永久保留。当数据库确认没有任何活跃事务还可能读取某个旧版本时，就可以通过 Purge、VACUUM 或类似机制回收空间。\n三、事务快照如何判断版本是否可见 一种常见的 Read View 可以抽象为：\ncreator_id：创建快照的事务 ID min_active：当前最小活跃事务 ID max_id：下一个待分配事务 ID active_ids：创建快照时未提交的事务集合 对于某个由 version_id 创建的数据版本，可见性判断可以简化为：\nversion_id == creator_id 当前事务自己的修改，通常可见 version_id \u0026lt; min_active 创建快照前通常已经提交，可见 version_id \u0026gt;= max_id 创建快照后才产生，不可见 version_id 位于 active_ids 中 创建快照时仍未提交，不可见 其他情况 创建快照时通常已经提交，可见 查找过程可以表示为：\nfunction findVisibleVersion(row, snapshot): version = row.latestVersion while version != null: if isVisible(version, snapshot): return version version = version.previousVersion return null 真实实现还会处理事务提交状态、删除标记、当前事务自身操作和异常恢复等情况，但核心始终是：\n使用事务快照筛选版本，而不是无条件读取最新值。\n四、一次 UPDATE 如何产生新版本 假设事务 T20 执行：\nUPDATE account SET balance = 80 WHERE id = 1; 数据库通常需要执行以下步骤：\n定位目标记录； 获取必要的行锁，检查写写冲突； 保存旧值，或创建能够重建旧值的版本信息； 生成 balance = 80 的新版本； 把新版本与事务 T20 关联； 写入 WAL、Redo Log 等恢复日志； 提交事务并更新事务状态。 逻辑版本链变为：\n当前版本：balance = 80，创建者 T20 ↓ 历史版本：balance = 100，创建者 T10 如果另一个事务的快照不能看到 T20，就继续读取历史版本。\n这里必须强调：MVCC 没有消灭锁。 两个事务同时修改同一行时，仍然需要行锁、乐观冲突检测或其他机制决定谁先提交。MVCC 主要降低的是普通读取与写入之间的阻塞。\n五、快照读和当前读 理解 MVCC 时，很容易误以为所有查询都会读取历史版本。实际上，数据库通常区分快照读和当前读。\n快照读 普通查询一般属于快照读：\nSELECT balance FROM account WHERE id = 1; 数据库根据事务快照选择可见版本，通常不需要给目标行添加阻塞式共享锁。\n当前读 下面这些操作需要基于最新的可用版本执行：\nSELECT * FROM account WHERE id = 1 FOR UPDATE; UPDATE account SET balance = 80 WHERE id = 1; DELETE FROM account WHERE id = 1; 它们通常需要读取较新的已提交版本，并使用行锁、范围锁或冲突检测处理并发修改。\n因此可以概括为：\n快照读：根据快照读取合适的历史版本 当前读：读取当前可操作版本，并参与锁与冲突控制 六、隔离级别如何影响快照 MVCC 的行为与事务隔离级别密切相关。\nRead Committed：每条语句看到新的已提交状态 在常见的 Read Committed 实现中，每条查询语句都获取新的快照：\nT1 第一次查询 → 100 T2 修改为 200 并提交 T1 第二次查询 → 200 它可以避免脏读，但同一事务重复读取时可能得到不同结果。\nRepeatable Read：事务内复用快照 在常见的 Repeatable Read 实现中，事务内的快照读复用同一个快照：\nT1 第一次查询 → 100 T2 修改为 200 并提交 T1 第二次查询 → 仍然是 100 因此同一事务中的重复快照读能够保持一致。\n不过，隔离级别的具体语义由数据库实现决定。MVCC 本身也不能自动解决所有序列化异常。例如两个事务分别读取不同记录，再基于读取结果更新另一条记录，可能发生写偏差。\n要实现 Serializable，数据库通常还需要：\n谓词锁或范围锁； 序列化快照隔离； 读写依赖检测； 冲突事务回滚与重试。 七、InnoDB 如何实现 MVCC InnoDB 采用“当前记录加 Undo 历史”的实现方式。聚簇索引记录中包含隐藏的事务信息，概念上包括：\nDB_TRX_ID：最后修改该记录的事务 ID DB_ROLL_PTR：指向对应的 Undo 记录 更新一行时，InnoDB 会把恢复旧值所需的信息写入 Undo Log，再修改数据页中的当前记录，并通过回滚指针把当前版本连接到历史版本。\n数据页中的当前版本 ↓ DB_ROLL_PTR Undo 中的上一版本 ↓ 更旧的 Undo 版本 一致性读取发现当前版本不可见时，会沿着 Undo 版本链回溯，重建对当前 Read View 可见的旧版本。\nUndo Log 因此承担两类职责：\n事务失败时撤销修改； 为 MVCC 一致性读取提供历史版本。 当所有可能读取旧版本的事务都已经结束后，Purge 线程才能清理不再需要的 Undo 和删除记录。\n八、PostgreSQL 如何实现 MVCC PostgreSQL 采用多元组版本方式。更新一行时，通常会创建新的行元组，旧元组暂时保留在表中。\n每个元组都带有事务可见性信息，概念上包括：\nxmin：创建该版本的事务 xmax：删除或替代该版本的事务 更新后的状态可以表示为：\n旧元组：xmin=T10, xmax=T20, balance=100 新元组：xmin=T20, xmax=0, balance=80 查询根据快照以及 T10、T20 的提交状态，判断哪个元组对当前事务可见。\n旧元组不会立即删除，因为老事务可能仍然需要它。等旧版本不再对任何事务可见后，VACUUM 才能回收死亡元组占用的空间。\n两种实现的差异可以简化为：\nInnoDB：当前版本主要在数据页，历史版本通过 Undo 重建 PostgreSQL：新旧行版本可以同时存在于表的数据页中 它们的数据组织不同，但都依赖事务快照、版本元数据和垃圾回收。\n九、为什么长事务会伤害 MVCC 数据库删除旧版本之前，必须确认最老的活跃快照也不再需要它。如果一个事务长时间不提交，它持有的旧快照就会阻止版本回收。\n可能造成的后果包括：\nUndo 空间持续增长； PostgreSQL 表和索引膨胀； Purge 或 VACUUM 无法推进； 版本链变长，查询需要回溯更多历史记录； 事务 ID 回收压力增加； 存储空间和查询延迟上升。 因此，生产系统应避免在事务中执行长时间计算、等待网络请求或人工操作，也应监控长事务和最老活跃快照。\n十、一个完整的并发示例 初始值为：\nx = 100 事务执行顺序如下：\n1. T1 开始并建立快照 2. T1 查询 x，得到 100 3. T2 开始 4. T2 将 x 更新为 200 5. T2 提交 6. T1 再次查询 x 此时版本链为：\nV2：x = 200，由 T2 创建 ↓ V1：x = 100，历史版本 如果 T1 在 Repeatable Read 下复用原快照：\n检查 V2 → 对 T1 不可见 检查 V1 → 对 T1 可见 返回 100 而在 T2 提交后启动的事务 T3 可以看到 V2：\nT1 看到 100 T3 看到 200 与此同时，如果 T1 试图更新这条记录，它就不能仅凭旧快照直接覆盖数据，而需要进入数据库的当前读和写冲突处理流程。\n十一、MVCC 的优点与代价 优点 普通读取通常不会阻塞写入； 写入通常不会阻塞普通快照读； 可以提供一致性快照； 适合读多写多的高并发事务系统； 降低共享锁使用频率和读写锁竞争。 代价 需要额外空间保存历史版本； 可见性判断增加读取成本； 版本链过长会影响性能； 需要复杂的垃圾回收机制； 长事务会阻碍旧版本清理； 写写冲突仍需要锁或冲突检测。 总结 MVCC 的本质不是“完全无锁”，而是把普通读取从“等待最新值”改为“读取对当前快照可见的版本”。\n一个数据库要实现 MVCC，至少需要完成以下工作：\n为事务分配 ID、时间戳或提交序列号； 更新时生成新版本并保留历史版本； 为读取建立一致性事务快照； 根据快照执行版本可见性判断； 使用锁或冲突检测解决并发写入； 在安全边界推进后回收旧版本。 最终可以用一句话概括：\nMVCC 通过“版本链 + 事务快照 + 可见性判断”提高读写并发能力，再通过锁、冲突检测和版本回收保证完整的事务语义与长期运行效率。\n","permalink":"https://yangyang233333.github.io/posts/database-mvcc-principles-and-implementation/","summary":"\u003cp\u003eMVCC（Multi-Version Concurrency Control，多版本并发控制）是现代数据库实现高并发事务的重要机制。它的核心思想并不复杂：\u003cstrong\u003e数据被修改时，不立即丢弃旧值，而是保留多个版本；事务读取数据时，根据自己的快照选择可见版本。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e因此，读事务可以继续访问旧版本，写事务则创建新版本。普通读取通常不需要等待并发写入完成，写入也不必等待读事务释放共享锁。\u003c/p\u003e\n\u003cp\u003e本文从 MVCC 要解决的问题出发，依次说明版本存储、事务快照、可见性判断、写冲突控制和旧版本回收，并比较 InnoDB 与 PostgreSQL 的典型实现。\u003c/p\u003e\n\u003ch2 id=\"一为什么需要-mvcc\"\u003e一、为什么需要 MVCC\u003c/h2\u003e\n\u003cp\u003e假设数据库中存在一条记录：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e账户 A 的余额 = 100\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e事务 T1 正在读取这条记录，与此同时，事务 T2 希望把余额修改为 \u003ccode\u003e80\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e如果所有读取都依赖共享锁，那么 T2 可能必须等待 T1 结束；如果 T2 已经持有排他锁，T1 又可能必须等待 T2。锁能够保证正确性，但大量读写互相等待会降低系统并发能力，并增加死锁概率。\u003c/p\u003e\n\u003cp\u003eMVCC 使用另一种思路处理普通读取：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e新版本：balance = 80，由 T2 创建\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                 ↓\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e旧版本：balance = 100\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果 T1 的事务快照不允许它看到 T2 的修改，T1 就读取旧版本 \u003ccode\u003e100\u003c/code\u003e；在 T2 提交后启动的新事务，则可以读取新版本 \u003ccode\u003e80\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e于是，同一时刻可以出现：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e旧事务看到 100\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e新事务看到 80\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这不是数据不一致，而是两个事务观察到了数据库在不同逻辑时刻的一致状态。\u003c/p\u003e\n\u003ch2 id=\"二mvcc-的五个组成部分\"\u003e二、MVCC 的五个组成部分\u003c/h2\u003e\n\u003cp\u003e一个完整的 MVCC 实现通常包含五个部分：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMVCC = 多版本存储\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     + 事务快照\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     + 可见性判断\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     + 写冲突控制\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     + 旧版本回收\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e只保存历史版本还不够。数据库还必须知道版本由谁创建、事务能够看到哪些版本、两个写事务冲突时如何处理，以及历史版本何时可以安全删除。\u003c/p\u003e","title":"数据库 MVCC 原理与实现：版本链、事务快照和可见性判断"},{"content":"3FS 是面向 AI 训练和推理负载设计的分布式文件系统。它没有把 FoundationDB 当成普通的持久化 KV 使用，而是围绕 FoundationDB 的有序键空间、乐观事务、冲突检测和 Versionstamp，构建了一套强一致的文件系统元数据服务。\n本文从源码出发，梳理 3FS 如何接入 FoundationDB，如何编码 inode 和目录项，以及 create、rename、remove、list 等文件系统操作怎样映射为 FoundationDB 事务。\n本文分析的源码位于 3FS 仓库中的以下目录：\nsrc/fdb/ FoundationDB C API 封装 src/common/kv/ 通用 KV 和事务接口 src/meta/store/ inode、目录项和元数据操作 src/meta/components/ ID 分配、服务分布和 GC 等组件 src/meta/service/ Meta Service RPC 入口 一、3FS 中 FoundationDB 的定位 3FS 将数据面和元数据面分开：\n文件的 chunk 数据由 Storage Service 保存。 文件系统元数据由 Meta Service 管理，并持久化到 FoundationDB。 FoundationDB 中保存的主要内容包括：\ninode； directory entry，也就是目录项； 文件打开会话； RPC 幂等记录； Meta Server 分布信息； 用户、配置和其他全局状态。 整体调用链可以简化为：\nFUSE / Client │ RPC ▼ MetaOperator │ OperationDriver：事务、提交、重试、幂等 │ MetaStore Operation：open / rename / remove / list │ Inode / DirEntry / FileSession：键和值的编码 │ IKVEngine / IReadWriteTransaction │ FDBKVEngine / FDBTransaction │ FoundationDB C API 这里最重要的一点是：上层元数据代码不直接依赖 FoundationDB C API。3FS 先抽象出通用 KV 接口，再由 FDB 实现这些接口。这既隔离了数据库细节，也允许单元测试使用内存 KV。\n二、FoundationDB 接入层 1. FDBContext：管理数据库运行环境 FDBContext 负责 FoundationDB 客户端的全局初始化和销毁，主要工作包括：\n选择 FoundationDB API Version； 配置 external client 和多版本客户端； 调用 fdb_setup_network()； 启动独立的 fdb_net 线程执行 fdb_run_network()； 根据 cluster file 创建数据库连接； 进程退出时停止网络线程。 因此，3FS 的 coroutine 并没有替代 FoundationDB 的网络模型。FDB 网络线程负责推进 FDBFuture，3FS coroutine 则异步等待 Future 完成，再把结果转换为自己的 CoTryTask。\n2. HybridKvEngine：统一 FDB 和内存 KV 3FS 定义了三个核心接口：\nIKVEngine IReadOnlyTransaction IReadWriteTransaction 生产环境通常使用 FDBKVEngine，测试时则可以使用 MemKVEngine。两者通过 HybridKvEngine 统一创建。\n当开启 FoundationDB multiple client 配置时，HybridKvEngine 会创建多个数据库 handle，并在创建事务时随机选择一个：\nMeta request │ ▼ HybridKvEngine::pick() │ ├── FDB client 0 ├── FDB client 1 └── FDB client N 这不是把数据分片到多个 FDB 集群，而是让同一个 FDB 集群可以利用更多客户端网络线程和连接资源。\n3. FDBTransaction：把 C API 转换为 coroutine FDBTransaction 对外提供的主要接口包括：\nsnapshotGet(key) get(key) snapshotGetRange(begin, end) getRange(begin, end) addReadConflict(key) addReadConflictRange(begin, end) set(key, value) clear(key) setVersionstampedValue(...) commit() reset() 这些接口最终分别调用 fdb_transaction_get、fdb_transaction_get_range、fdb_transaction_set、fdb_transaction_commit 等 FoundationDB C API。\n上层 MetaStore 因此只需要面对 coroutine 和统一的 Result，不需要直接处理 FDBFuture、回调和错误码。\n三、元数据如何映射到有序键空间 3FS 没有使用 FoundationDB Directory Layer，而是自行定义固定长度的键前缀。\n主要前缀如下：\n前缀 含义 INOD inode DENT directory entry INOS inode/file session IDEM 请求幂等记录 META Meta Server 分布信息 USER 用户信息 SING 全局单键 CONF 配置 固定前缀把不同类型的数据隔离到不同子空间，同时保留 FoundationDB 的字典序排列能力。\n1. Inode inode 的键可以抽象为：\nINOD + encoded_inode_id 值是序列化后的 Inode 对象，包含：\nINOD/\u0026lt;inode-id\u0026gt; -\u0026gt; { type, acl, timestamps, nlink, file-layout | directory-info | symlink-info, ... } 每个 inode 可以通过 inode ID 做一次精确 KV 查询。文件、目录和符号链接共用同一套 inode 键空间，通过 inode 中的类型和 variant 数据区分。\n2. Directory Entry 目录项的键可以抽象为：\nDENT + parent_inode_id + filename 假设 inode 100 是一个目录，它的键空间可能是：\nDENT/\u0026lt;inode-100\u0026gt;/a.txt -\u0026gt; inode-101 DENT/\u0026lt;inode-100\u0026gt;/b.txt -\u0026gt; inode-102 DENT/\u0026lt;inode-100\u0026gt;/dir -\u0026gt; inode-103 同一个目录下的全部目录项会连续排列。因此列目录只需扫描：\n[DENT/\u0026lt;parent\u0026gt;/, prefixEnd(DENT/\u0026lt;parent\u0026gt;/)) 这种设计同时支持：\n根据 parent + filename 精确查找； 按目录执行范围扫描； 使用 prev + limit 做分页； 读取一条记录判断目录是否为空； 让不同文件名的冲突落到不同键上。 这正是文件系统目录结构与 FoundationDB 有序 KV 最自然的结合点。\n3. FileSession 文件打开会话的键可以抽象为：\nINOS + inode_id + session_uuid 把 inode ID 放在 session UUID 前面，可以通过范围查询列出某个 inode 的所有打开会话，用于 close、prune session 和 GC 等操作。\n4. 幂等记录 幂等记录的键可以抽象为：\nIDEM + request_uuid + client_uuid 值中保存：\nclient ID； request ID； 执行时间； 第一次请求的返回结果。 代码特意将 request ID 放在 client ID 前面，使不同请求更均匀地散布在键空间中，避免大量客户端请求集中到相邻键范围。\n四、Snapshot Read 与显式冲突控制 FoundationDB 的普通读和 Snapshot Read 有一个关键区别：\n读取方式 是否加入读冲突集合 典型用途 get() / getRange() 是 读取后依据结果写入 snapshotGet() / snapshotGetRange() 否 stat、list、路径遍历 如果路径解析过程中读取的每一个祖先 inode 和目录项都进入读冲突集合，那么任意一个祖先目录发生变化，都可能导致当前写事务提交失败。\n3FS 的优化方式是：\n使用 snapshot read 解析路径 │ ▼ 完成权限检查和对象定位 │ ▼ 只为真正影响正确性的键添加 read conflict │ ▼ 写入并提交 例如创建文件时，路径遍历可以使用 Snapshot Read，但提交前会显式保护：\nparent inode； 目标 parent + filename 对应的 dentry 键。 如果另一个事务同时修改父目录，或者创建了同名目录项，本事务就会在 commit 阶段发生冲突。\n这种做法将“读取数据”和“参与并发控制”拆开，使事务冲突范围保持在最小必要集合。\n五、一次 Meta Operation 如何运行 所有元数据操作都由 OperationDriver 驱动。可以将它的核心逻辑简化为：\nwhile (true) { result = operation.run(txn); if (result.ok()) { txn.commit(); return result; } if (!retryable(result)) { return result; } txn.reset(); backoff(); } 实际实现还会处理：\n请求 deadline； Meta Service readonly 模式； FoundationDB GRV cache； commit_unknown_result； 幂等记录； operation 的 retry 回调； 指标、日志和 trace。 这里有一个非常重要的原则：\n事务失败后，3FS 重新执行完整的 Meta Operation，而不是只重新调用 commit。\n事务 reset 后，原来的读版本和读取结果已经不能继续使用。路径解析、权限判断和目录项状态都必须基于最新数据库版本重新执行。\n六、典型文件系统操作 1. 创建文件 创建一个新文件时，同一个 FoundationDB 事务中大致完成：\n解析父目录； 检查访问权限； 分配 inode ID； 为 parent inode 添加读冲突； 为目标 dentry 添加读冲突； 写入新 dentry； 写入新 inode； 写入 file session； 提交事务。 假设两个客户端同时创建 /dir/a：\nT1 read conflict: DENT/dir/a T2 read conflict: DENT/dir/a T1 commit success T2 commit -\u0026gt; not_committed T2 reset and retry T2 sees a already exists 整个过程不需要额外的分布式锁。FoundationDB 的乐观事务负责识别并发写入，3FS 的重试逻辑负责重新执行操作。\n2. Rename rename 是 FoundationDB 事务能力最有价值的场景之一。跨目录 rename 需要同时修改多个逻辑对象：\n源目录的 dentry； 目标目录的 dentry； 源 parent inode； 目标 parent inode； 被移动 inode； 必要时被覆盖的目标 inode。 3FS 在一个事务中完成：\n检查源路径和目标路径 │ ├── 检查目标目录是否为空 ├── 删除源 dentry ├── 必要时删除或更新目标 inode ├── 创建目标 dentry └── 更新 inode 和父目录关系 │ ▼ commit 事务提交要么全部生效，要么全部不生效。因此跨目录 rename 不需要自行实现日志、两阶段提交或跨节点锁协议。\n3. 删除空目录 删除目录前必须保证目录为空。3FS 对目标目录的 dentry 前缀执行范围读取，并限制最多读取一条：\n[DENT/\u0026lt;directory\u0026gt;/, prefixEnd(DENT/\u0026lt;directory\u0026gt;/)) 如果范围为空，则继续删除该目录对应的 dentry 和 inode。\n因为这里使用的是非 Snapshot Range Read，读取范围会进入事务的 read conflict ranges。假设检查完成后，另一个事务向目录插入新文件，那么删除事务在 commit 时会发生冲突，不会把一个已经变成非空的目录删除掉。\n4. 递归删除 递归删除可能涉及大量 inode 和 dentry，不适合放进一个 FoundationDB 事务：\n事务可能持续过久； 读写集合可能过大； 与前台元数据操作冲突的概率会快速上升。 3FS 因此不会试图用一个超大事务删除整棵目录树，而是先原子地移动或标记待删除对象，再交给 GC Manager 分批清理。\n这体现了使用 FoundationDB 时的一个重要边界：事务适合短小、确定的元数据原子操作，不适合承载无限扩张的后台任务。\n5. List 列目录主要是只读操作，3FS 使用 Snapshot Range Read：\nsnapshot resolve directory inode │ ▼ snapshot range scan DENT/\u0026lt;directory\u0026gt;/ │ ▼ 按需并发加载每个 entry 对应的 inode 这样即使目录很大，list 也不会因为读取大量 dentry 而给其他写事务制造读写冲突。\n七、事务重试与 Maybe Committed FDBRetryStrategy 统一处理 FoundationDB 事务错误。处理过程大致是：\n判断错误是否来自 FoundationDB 事务； 判断错误是否可重试； 根据 operation 配置判断是否允许重试 maybe-committed； 调用 FoundationDB onError() 或 reset 事务； 使用带随机抖动的退避； 重新执行完整 operation。 FoundationDB 事务提交时可能遇到 commit_unknown_result：客户端不知道事务是否已经提交成功。\n如果客户端直接重试，一个 remove、create 或其他非天然幂等操作可能被执行两次。3FS 使用幂等记录解决这个问题。\n需要幂等保护的操作按以下顺序执行：\n读取 IDEM 记录 │ ├── 已存在：返回第一次执行结果 │ └── 不存在 │ ▼ 执行业务修改 │ ▼ 写入 IDEM 结果 │ ▼ 同一个 FDB commit 业务修改和幂等结果在同一个事务中提交，因此不会出现以下中间状态：\n业务修改已经提交，但幂等记录尚未保存 如果第一次事务实际已经提交，只是客户端收到了未知结果，那么第二次执行会读到幂等记录，并直接返回第一次的结果。\n八、Versionstamp 的用途 FoundationDB Versionstamp 是与事务提交绑定的 10 字节版本值：\n前 8 字节来自数据库提交版本； 后 2 字节用于区分同一事务中的顺序。 3FS 使用 Versionstamp 保存全局元数据版本，以及 Meta Server 的状态版本。它的优势是：\n版本和事务原子绑定； 全局单调递增； 不需要读取全局计数器再加一； 不会让所有事务竞争同一个计数器键。 Meta Server 可以据此判断服务注册信息或元数据映射是否发生变化。\n如果改用普通全局计数器：\nget(version) set(version + 1) 所有更新都会在同一个键上产生冲突，形成明显热点。Versionstamp 更符合 FoundationDB 的设计方式。\n九、Inode ID 如何避免热点 inode ID 分配同样存在全局热点问题。如果每创建一个文件都更新一次 FoundationDB 计数器，高并发 create 会全部竞争同一个键。\n3FS 使用分段分配：\nFoundationDB 分配高位号段 │ ▼ Meta Server 在本地生成低 12 位 │ └── 一个 FDB 分配结果生成 4096 个 inode ID 生成 4096 个 inode ID 才需要再次访问 FoundationDB，大幅降低全局分配键的更新频率。\n这个设计体现了另一个重要原则：\nFoundationDB 适合协调粗粒度的全局号段，不适合让每个前台请求都更新同一个全局计数器。\n十、为什么这种设计适合 FoundationDB 1. 目录结构天然适合有序 KV DENT + parent_inode + filename 同时支持精确查找、范围扫描、分页、判空和细粒度冲突控制，不需要维护额外的目录索引。\n2. 用事务替代分布式锁 create、rename、unlink 等操作主要依赖读写冲突和事务重试，而不是显式分布式锁。服务节点宕机时也不需要处理锁租约和锁所有者恢复。\n3. Snapshot Read 控制冲突放大 路径解析可能读取大量祖先对象。3FS 先用 Snapshot Read 获得数据，再只为真正参与写入判断的键添加冲突，从而降低无关事务互相 abort 的概率。\n4. 幂等结果与业务修改原子提交 这使 Meta Service 能正确处理 RPC 重试、网络超时和 FoundationDB 的未知提交结果。\n5. 主动限制事务边界 前台操作保持短小，而递归删除和 GC 被拆成后台批次。3FS 并没有因为 FoundationDB 支持事务，就把任意规模的工作都塞进一个事务。\n十一、总结 3FS 使用 FoundationDB 的核心方式可以概括为：\n将文件系统命名空间编码成有序 KV，把一次 POSIX 元数据变更封装为一个短小的 Serializable 事务；通过 Snapshot Read、显式最小冲突范围、事务重试、幂等记录和 Versionstamp，在不引入分布式锁的情况下实现强一致元数据服务。\n它并不是简单地把 inode 保存到 FoundationDB，而是围绕 FoundationDB 的事务模型重新设计了：\ninode 和 dentry 的键布局； 路径解析方式； 并发控制范围； commit unknown 的处理； inode ID 分配； Meta Server 状态版本； 大规模删除和 GC 的事务边界。 从这个角度看，3FS 的 Meta Service 是一个很典型的 FoundationDB 上层系统：FoundationDB 提供强一致、有序 KV 和乐观事务，3FS 则负责把文件系统语义准确地翻译成键、范围和冲突集合。\n","permalink":"https://yangyang233333.github.io/posts/3fs-foundationdb-metadata-design/","summary":"\u003cp\u003e3FS 是面向 AI 训练和推理负载设计的分布式文件系统。它没有把 FoundationDB 当成普通的持久化 KV 使用，而是围绕 FoundationDB 的有序键空间、乐观事务、冲突检测和 Versionstamp，构建了一套强一致的文件系统元数据服务。\u003c/p\u003e\n\u003cp\u003e本文从源码出发，梳理 3FS 如何接入 FoundationDB，如何编码 inode 和目录项，以及 create、rename、remove、list 等文件系统操作怎样映射为 FoundationDB 事务。\u003c/p\u003e\n\u003cp\u003e本文分析的源码位于 3FS 仓库中的以下目录：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esrc/fdb/             FoundationDB C API 封装\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esrc/common/kv/       通用 KV 和事务接口\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esrc/meta/store/      inode、目录项和元数据操作\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esrc/meta/components/ ID 分配、服务分布和 GC 等组件\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esrc/meta/service/    Meta Service RPC 入口\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"一3fs-中-foundationdb-的定位\"\u003e一、3FS 中 FoundationDB 的定位\u003c/h2\u003e\n\u003cp\u003e3FS 将数据面和元数据面分开：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e文件的 chunk 数据由 Storage Service 保存。\u003c/li\u003e\n\u003cli\u003e文件系统元数据由 Meta Service 管理，并持久化到 FoundationDB。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eFoundationDB 中保存的主要内容包括：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003einode；\u003c/li\u003e\n\u003cli\u003edirectory entry，也就是目录项；\u003c/li\u003e\n\u003cli\u003e文件打开会话；\u003c/li\u003e\n\u003cli\u003eRPC 幂等记录；\u003c/li\u003e\n\u003cli\u003eMeta Server 分布信息；\u003c/li\u003e\n\u003cli\u003e用户、配置和其他全局状态。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e整体调用链可以简化为：\u003c/p\u003e","title":"3FS 如何使用 FoundationDB：元数据模型、事务与并发控制"},{"content":"FoundationDB 支持跨任意 key range 的 ACID 事务，并向应用提供严格可串行化语义。但它既没有让每个 Storage Server 参与经典的两阶段提交，也不是传统单机数据库中“共享缓冲池 + Undo/Redo”的事务实现。\n它的核心方案可以概括为：\n全局版本号 + MVCC 快照读 + 客户端暂存修改 + Resolver 乐观冲突检测 + 多副本 TLog 持久化。\n本文结合 FoundationDB 官方源码，沿着一笔事务的完整路径，解释它如何从读取、提交、冲突检测，一直走到日志持久化和 Storage Server 应用。\n本文分析的源码版本为：\ncommit: eab21ffe07c20575d5bac9ab0745375b8f7f8357 date: 2026-08-12 源码中的具体行号会随版本变化，阅读时应以函数和类型名为主要定位依据。\n一、整体架构 FoundationDB 的事务涉及以下核心角色：\n角色 主要职责 Client 保存事务 mutations、读冲突范围和写冲突范围 GRV Proxy 分配 Read Version Commit Proxy 批量接收事务、分配 Commit Version、组织提交流水线 Resolver 检测乐观事务冲突 TLog 复制并持久化已提交 mutations Storage Server 提供版本化读取，异步应用 TLog mutations Sequencer 为事务系统提供版本推进基础 完整流程可以简化为：\nClient │ ├── 获取 Read Version │ ├── 从 Storage Server 读取该版本 │ ├── 在客户端积累 mutations 和 conflict ranges │ └── 提交 │ ▼ Commit Proxy │ ├── 组成 Commit Batch ├── 分配 Commit Version └── 按 key range 发给 Resolver │ ▼ Resolver │ ├── 检查 read conflict ranges ├── 登记 write conflict ranges └── 返回 committed / conflict / too old │ ▼ Commit Proxy │ ├── 丢弃冲突事务 ├── 为 mutation 添加 Storage Server tag └── 将成功事务写入 TLog │ ▼ TLog │ ├── 按冗余策略复制和持久化 └── 返回 durable │ ▼ Commit Proxy │ └── 向 Client 返回 Commit Version │ ▼ Storage Server ├── 异步读取自己的 tagged mutations ├── 应用到内存中的版本化数据 └── 后台持久化到 Storage Engine 最关键的解耦是：\n事务提交完成 不等于 Storage Server 已经将数据写入本地文件 客户端收到成功时，事务已经可靠进入复制的 TLog；Storage Server 可以在之后异步追赶。\n二、第一阶段：获得 Read Version FoundationDB 事务不会随意读取各个 Storage Server 的“当前值”。事务首先从 GRV Proxy 获取一个全局逻辑读版本：\nRead Version = RV 客户端相关逻辑主要位于：\nfdbclient/NativeAPI.actor.cpp 服务端 GRV 处理位于：\nfdbserver/grvproxy/GrvProxyServer.cpp GRV 是 Get Read Version 的缩写。获得版本后，事务对不同 Storage Server 的读取都携带相同的版本：\nStorage Server A：读取 version 100 Storage Server B：读取 version 100 Storage Server C：读取 version 100 这样，即使数据分布在多个节点上，事务看到的仍然是数据库在同一个逻辑版本上的一致快照。\nRead Version 是递增的数据库逻辑版本，而不是 Unix 时间戳。源码中版本通常使用 Version 表示，本质上是一个整数类型。\n事务提交结构中的 read_snapshot 保存了这个版本：\nstruct CommitTransactionRef { Version read_snapshot; VectorRef\u0026lt;MutationRef\u0026gt; mutations; VectorRef\u0026lt;KeyRangeRef\u0026gt; read_conflict_ranges; VectorRef\u0026lt;KeyRangeRef\u0026gt; write_conflict_ranges; // ... }; 定义位于：\nfdbclient/include/fdbclient/CommitTransaction.h read_snapshot 不只是读取数据时使用，也是 Resolver 判断事务是否与后续写入冲突的时间起点。\n三、第二阶段：修改首先保存在客户端 FoundationDB 客户端事务主要积累三类信息：\n1. mutations 2. read conflict ranges 3. write conflict ranges 1. Mutations 例如客户端执行：\ntr[b\u0026#34;account/A\u0026#34;] = b\u0026#34;80\u0026#34; tr.clear(b\u0026#34;temporary/key\u0026#34;) tr.add(b\u0026#34;counter\u0026#34;, encode_int64(1)) 这些操作会被整理成 MutationRef，常见 mutation 类型包括：\nSetValue ClearRange AddValue And Or Xor AppendIfFits CompareAndClear 重要的是，执行一次 set 并不意味着客户端立即将数据写入 Storage Server。提交前，它更像是在事务对象中追加一条指令：\n待提交 mutation：SET account/A = 80 如果事务最终取消或冲突，这些未发布的 mutations 可以直接丢弃。这也是 FoundationDB 通常不需要传统事务 Undo Log 的原因。\n2. Read Conflict Range 事务普通读取一个 key 时，会登记相应的读冲突范围。概念上，读取：\nvalue = tr[b\u0026#34;account/A\u0026#34;] 会形成：\n[\u0026#34;account/A\u0026#34;, keyAfter(\u0026#34;account/A\u0026#34;)) 它表达的是：\n如果从我的 Read Version 到提交之间，有其他成功事务写过这个范围，那么我的读取前提已经失效，事务不能提交。\n范围读取也会登记对应范围：\nrows = tr.get_range(b\u0026#34;user/0000\u0026#34;, b\u0026#34;user/9999\u0026#34;) 可以形成：\nread conflict range = [\u0026#34;user/0000\u0026#34;, \u0026#34;user/9999\u0026#34;) 3. Write Conflict Range 写入一个 key 时，客户端会登记写冲突范围：\nmutation: SET account/A = 80 write conflict range: [account/A, keyAfter(account/A)) 范围删除则会登记整个删除范围：\ntr.clear_range(b\u0026#34;tmp/\u0026#34;, b\u0026#34;tmp0\u0026#34;) write conflict range = [\u0026#34;tmp/\u0026#34;, \u0026#34;tmp0\u0026#34;) 它表示：\n如果本事务提交，它会使依赖该范围旧状态的并发事务冲突。\n四、FDB 为什么提交冲突范围，而不是事务代码？ FoundationDB 不会把应用的事务函数发送到服务器重新执行。\n例如应用代码可能是：\nbalance = decode(await tr[b\u0026#34;account/A\u0026#34;]) tr[b\u0026#34;account/A\u0026#34;] = encode(balance - 20) 服务器并不知道“读取余额再减 20”这段业务逻辑。客户端发送的是已经整理好的确定性事务描述：\nRead Version: 100 Read Conflict Range: account/A Write Conflict Range: account/A Mutation: SET account/A = 80 提交请求类型是：\nstruct CommitTransactionRequest : TimedRequest { Arena arena; SpanContext spanContext; CommitTransactionRef transaction; ReplyPromise\u0026lt;CommitID\u0026gt; reply; uint32_t flags; Optional\u0026lt;UID\u0026gt; debugID; Optional\u0026lt;ClientTrCommitCostEstimation\u0026gt; commitCostEstimation; Optional\u0026lt;TagSet\u0026gt; tagSet; IdempotencyIdRef idempotencyId; }; 定义位于：\nfdbclient/include/fdbclient/CommitProxyInterface.h 这种设计把业务计算留在客户端，把服务端提交过程收敛为两个问题：\n1. 事务读取的前提是否仍然成立？ 2. 如果成立，如何将确定的 mutations 可靠地纳入全局历史？ 五、第三阶段：客户端发起提交 原生事务 API 的提交入口位于：\nfdbclient/NativeAPI.actor.cpp 公开入口是：\nFuture\u0026lt;Void\u0026gt; Transaction::commit() { return commitAndWatch(this); } 随后进入：\nTransaction::commitMutations() 客户端将当前事务的读版本、mutations 和冲突范围组装为 CommitTransactionRequest，再进入：\ntryCommit(...) tryCommit 的主要工作包括：\n找到当前数据库代际的 Commit Proxy； 发送提交请求； 等待 CommitID； 区分成功、冲突或提交结果未知； 保存提交版本和事务批次编号。 Commit Proxy 的回复类型是：\nstruct CommitID { Version version; uint16_t txnBatchId; Optional\u0026lt;Value\u0026gt; metadataVersion; Optional\u0026lt;Standalone\u0026lt;VectorRef\u0026lt;int\u0026gt;\u0026gt;\u0026gt; conflictingKRIndices; }; 其中最关键的是：\nVersion version; 通常可以理解为：\nversion != invalidVersion：事务提交成功 version == invalidVersion：事务发生冲突 六、Commit Proxy 为什么批量处理事务？ Commit Proxy 不会为每个客户端事务单独完成一遍冲突检测和日志持久化，而是将多个请求组成 Commit Batch：\nT1 ─┐ T2 ─┤ T3 ─┼── Commit Batch ── Resolver ── TLog T4 ─┤ T5 ─┘ 主体实现位于：\nfdbserver/commitproxy/CommitProxyServer.cpp 批处理可以：\n减少 Resolver RPC 次数； 合并 TLog 消息和持久化操作； 摊薄同步写入成本； 统一决定同一批事务的顺序； 提高网络和 mutation 编码效率。 一个 batch 会获得 Commit Version：\ncommitVersion = V 同一版本可以包含多个事务，所以 CommitID 还带有：\nuint16_t txnBatchId; 事务的逻辑提交位置可以理解为：\n(commitVersion, txnBatchId) 七、第四阶段：Commit Proxy 将事务发送给 Resolver Resolver 接收的请求类型是：\nstruct ResolveTransactionBatchRequest : TimedRequest { Version prevVersion; Version version; Version lastReceivedVersion; VectorRef\u0026lt;CommitTransactionRef\u0026gt; transactions; // ... }; 定义位于：\nfdbserver/core/include/fdbserver/core/ResolverInterface.h Commit Proxy 会按照 conflict range 所在的 key 空间，将事务发送给相关 Resolver。\n假设集群有多个 Resolver：\nResolver 0：负责范围 A Resolver 1：负责范围 B Resolver 2：负责范围 C 如果事务同时访问范围 A 和 C，它可能需要经过 Resolver 0 与 Resolver 2：\n最终结果 = Resolver 0 结果 AND Resolver 2 结果 任何一个 Resolver 判断有冲突，整个事务都不能提交。\n八、Resolver 如何检测冲突？ Resolver 的主处理函数位于：\nfdbserver/resolver/Resolver.cpp 入口是：\nFuture\u0026lt;Void\u0026gt; resolveBatch( Reference\u0026lt;Resolver\u0026gt; self, ResolveTransactionBatchRequest req) 处理时会创建 ConflictBatch：\nConflictBatch conflictBatch( self-\u0026gt;conflictSet, \u0026amp;reply.conflictingKeyRangeMap, \u0026amp;reply.arena); 然后加入各事务的读写冲突范围，最后执行：\nconflictBatch.detectConflicts( req.version, newOldestVersion, commitList, \u0026amp;tooOldList); 事务主要有三种结果：\nTransactionCommitted TransactionConflict TransactionTooOld 核心冲突规则 对于事务 T：\nRead Version = RV Read Conflict Ranges = R Write Conflict Ranges = W 如果存在一个已经提交的事务 U，满足：\nU.commitVersion \u0026gt; T.readVersion 并且：\noverlap(U.writeConflictRanges, T.readConflictRanges) 那么 T 冲突。\n写成公式是：\nconflict(T) = 存在事务 U： U.commitVersion \u0026gt; T.readVersion 并且 U.writeRanges 与 T.readRanges 相交 它判断的重点不是“两个事务是否都写了同一个 key”，而是：\n其他事务是否修改了本事务曾经依赖的读取结果。\n九、转账事务如何发生冲突？ 假设初始数据为：\nA = 100 两个事务都获得：\nRead Version = 100 事务 T1：\n读取 A = 100 写入 A = 80 事务 T2：\n读取 A = 100 写入 A = 70 它们提交的信息分别是：\nT1: read snapshot = 100 read conflict = A write conflict = A mutation = SET A=80 T2: read snapshot = 100 read conflict = A write conflict = A mutation = SET A=70 假设 T1 先在版本 101 提交，Resolver 已知：\nA 最近被写入的版本 = 101 检查 T2 时发现：\nT2 read version = 100 A last write version = 101 101 \u0026gt; 100 因此 T2 的读取前提已经过期：\nT2 = TransactionConflict 客户端通常在收到 not_committed 后重新执行整个事务，而不是只重新发送原来的 SET A=70。重新执行才能基于最新余额重新计算正确结果。\n十、为什么纯写事务可能不冲突？ 考虑两个事务不读取旧值，直接写入：\nT1: SET A = 80 T2: SET A = 70 如果它们没有显式添加读冲突范围，两者可能都成功，并按照提交版本形成顺序：\nversion 101: SET A=80 version 102: SET A=70 最终结果是：\nA = 70 这并不违反串行化，因为两个事务都没有声明自己的写入依赖 A 的旧值。串行执行 T1、T2 也会得到相同结果。\n如果业务需要 compare-and-set 或 read-modify-write 语义，就必须先执行普通读取，或者显式添加相应的读冲突范围。\n因此 FoundationDB 一个非常重要的概念是：\n事务冲突语义来自 conflict ranges，而不只是 mutations。\n十一、Atomic Operation 为什么能减少冲突？ 普通计数器递增可能写成：\nvalue = await tr[b\u0026#34;counter\u0026#34;] tr[b\u0026#34;counter\u0026#34;] = encode(decode(value) + 1) 由于读取会产生读冲突范围，多个事务并发执行时通常只有一个成功，其他事务需要重试。\n使用原子 mutation：\ntr.add(b\u0026#34;counter\u0026#34;, encode_int64(1)) 客户端无需先读旧值，可以只提交：\nMutation: ADD counter, 1 Write Conflict Range: counter 多个加法操作可以按提交版本依次应用：\nversion 101: ADD counter, 1 version 102: ADD counter, 1 version 103: ADD counter, 1 所以 Atomic Operation 的价值不仅是减少一次读取，还包括：\n避免不必要的读冲突； 提高热点计数器吞吐； 将可交换操作交给数据库按提交顺序应用。 十二、为什么会出现 TransactionTooOld？ Resolver 不可能永久保存数据库从诞生以来的全部写冲突历史。它只维护一个有限版本窗口中的 conflict set。\n假设：\nResolver 最早保留版本 = 1000 事务 Read Version = 500 Resolver 已经无法确定版本 (500, 1000] 之间是否有事务写过当前事务的读取范围。此时不能冒险允许提交，只能返回：\nTransactionTooOld 源码中会将对应事务标记为：\nreply.committed[index] = ConflictBatchStatus::TransactionTooOld; 这也是 FDB 要求事务保持短小的原因之一。长事务会带来：\nMVCC 历史版本过期； Resolver 冲突历史过期； 冲突概率增大； 失败后的重试成本增加。 十三、同一个 Commit Batch 内也需要冲突检测 Resolver 不仅检查当前 batch 与历史成功事务之间的冲突，还要处理同一个 batch 内部的事务关系。\n例如：\nT1 读取 A，写入 B T2 读取 B，写入 C 如果逻辑顺序中 T1 在 T2 前面，那么 T2 读取的 B 已经被 T1 修改，不能再基于旧快照无条件提交。\nConflictBatch 会同时处理：\n历史提交产生的冲突 + 当前批次内部产生的冲突 通过检测的事务会进入 commitList，并被标记为：\nConflictBatchStatus::TransactionCommitted 因此 Resolver 不只是维护一张简单的“key 最近写入版本表”，还需要构造批次内部确定的提交结果。\n十四、Commit Proxy 如何合并多个 Resolver 的结果？ 一个事务可能被多个 Resolver 检查。Commit Proxy 收到全部回复后，会合并结果：\nT1: Resolver A = committed Resolver B = committed 最终 = committed T2: Resolver A = committed Resolver B = conflict 最终 = conflict 只有最终状态为 TransactionCommitted 的事务，其 mutations 才会进入后续日志编码。\nCommit Proxy 随后还要根据 key 到 shard 的映射，为 mutation 添加对应的 Storage Server tag。\n十五、Tag 如何把日志分发给 Storage Server？ TLog 不需要让每个 Storage Server 读取整个数据库的全部 mutation。Commit Proxy 根据 key range 的副本分布，为 mutation 添加 tag：\nMutation: SET account/A = 80 Tags: Storage Server 1 Storage Server 4 Storage Server 7 Storage Server 从 TLog 读取时，只获取带有自己 tag 的消息：\nStorage Server 1：读取 SS1 tag 对应的 mutations Storage Server 4：读取 SS4 tag 对应的 mutations 这样既保留了统一的事务日志顺序，又避免所有 Storage Server 接收全部写入。\n十六、第五阶段：将成功事务写入 TLog Commit Proxy 完成冲突判定和 mutation 路由后，会调用日志系统：\npProxyCommitData-\u0026gt;logSystem-\u0026gt;push( versionSet, self-\u0026gt;toCommit, span.context, self-\u0026gt;debugID, tpcvMap); 相关位置：\nfdbserver/commitproxy/CommitProxyServer.cpp fdbserver/logsystem/LogSystem.cpp LogSystem::push() 会按照日志系统的复制配置，将消息发送到 TLog。核心请求类型是：\nstruct TLogCommitRequest : TimedRequest { Version prevVersion; Version version; Version knownCommittedVersion; Version minKnownCommittedVersion; Version seqPrevVersion; StringRef messages; ReplyPromise\u0026lt;TLogCommitReply\u0026gt; reply; uint16_t tLogCount; std::vector\u0026lt;uint16_t\u0026gt; tLogLocIds; }; 定义位于：\nfdbserver/core/include/fdbserver/core/TLogInterface.h 其中：\n字段 作用 prevVersion 前一个相关版本 version 当前提交版本 knownCommittedVersion 已知的提交进度 messages 编码后的 tagged mutations tLogCount 相关 TLog 数量 tLogLocIds 日志位置或副本标识 十七、什么时候才算事务提交成功？ Resolver 返回无冲突时，事务还没有真正完成提交。\nResolver 只证明：\n该事务可以进入串行化历史 Commit Proxy 还必须等待成功事务的 mutations 按集群冗余策略写入 TLog。满足持久化条件后，才向客户端返回带有效版本的 CommitID。\n因此：\n冲突检查完成 ≠ 事务提交完成 更准确的提交边界是：\n成功事务的 mutations 已经可靠进入复制日志系统 此时 Storage Server 可以尚未写入本地数据文件：\nClient 收到 commit success ↓ TLog 已可靠持久化 ↓ Storage Server 可能仍在追赶 这正是 TLog 类似分布式 Redo Log 的地方。\n十八、TLog 如何完成本地持久化？ TLog 的主要实现位于：\nfdbserver/tlog/TLogServer.cpp 收到 TLogCommitRequest 后，TLog 将消息加入持久化队列，并调用类似：\nself-\u0026gt;persistentQueue-\u0026gt;commit(); 只有日志达到相应持久化进度，才返回：\nstruct TLogCommitReply { Version version; }; 单个 TLog 节点故障不必然导致事务丢失，因为 LogSystem 会根据集群的冗余模式，将日志放入多个故障域。\n概念上，一个三副本日志系统可能是：\nTLog A：zone 1 TLog B：zone 2 TLog C：zone 3 具体成功条件由日志复制策略决定，而不是简单固定为“所有 TLog 都返回成功”。\n十九、第六阶段：Storage Server 异步读取日志 Storage Server 通常不参与普通事务的冲突判定，也不是客户端提交 RPC 中的 prepare participant。\n它持续从 TLog peek 与自己 tag 对应的消息。主要更新逻辑位于：\nfdbserver/storageserver/storageserver.cpp 获取 mutation 后，会按版本应用：\nupdater.applyMutation( data, msg, ver, false); Storage Server 维护多个重要版本进度：\nversion/currentVersion：已经从 TLog 接收并应用到内存的版本 durableVersion：已经持久化到本地 Storage Engine 的版本 所以系统中可能正常出现：\nTLog durable version = 1200 Storage Server version = 1198 Storage durableVersion = 1180 这些进度不相等并不意味着数据不一致，而是说明写入处于流水线中的不同阶段。\n二十、Storage Server 如何回答指定版本的读取？ 假设客户端获得：\nRead Version = 1195 负责该 key 的 Storage Server 必须至少追赶到版本 1195，才能正确回答请求。\n如果它当前只应用到：\nversion = 1190 就需要等待从 TLog 获取后续 mutations：\nwait until version \u0026gt;= 1195 然后从版本化数据中返回版本 1195 的快照。\n因此读取正确性依赖两个条件：\n1. 客户端请求携带明确的 Read Version 2. Storage Server 回答前已经应用到该版本 Storage Server 不会用版本 1190 的旧状态假装回答版本 1195 的请求。\n二十一、Storage Server 如何写入本地存储？ Storage Server 的后台持久化循环是：\nFuture\u0026lt;Void\u0026gt; updateStorage(StorageServer* data) 位于：\nfdbserver/storageserver/storageserver.cpp 它会将内存中的版本化 mutations 批量提交到本地 Storage Engine，然后推进 durableVersion：\n从 TLog 获取 mutation ↓ 应用到内存版本 ↓ 批量提交本地 Storage Engine ↓ 推进 durableVersion ↓ 允许回收旧状态和对应日志 客户端事务提交不需要同步等待这一过程。若 Storage Server 在本地持久化前崩溃，可以从自己的 durableVersion 之后继续读取 TLog，恢复缺失的 mutations。\n二十二、MVCC 如何提供历史版本？ Storage Server 保存版本化状态，使客户端可以按 Read Version 读取。\n假设提交历史是：\nversion 100: A = 100 version 105: A = 80 version 110: A = 60 不同事务会看到：\nRead Version A 的值 102 100 107 80 115 60 FoundationDB 不需要为每个版本完整复制一份数据库。Storage Server 使用当前数据和有限的版本化变更，构造目标版本的读取结果。\n当系统确认没有事务再需要某些旧版本时，可以推进最老可见版本并清理历史状态。这也是事务无法无限运行的另一个原因。\n二十三、FDB 如何保证严格可串行化？ FoundationDB 主要通过四个机制建立严格可串行化语义。\n1. 全局提交顺序 每个成功事务获得一个逻辑提交位置：\n(commitVersion, txnBatchId) 成功事务因此可以放入一个确定的全局顺序。\n2. 一致性快照读 事务中的普通读取基于同一个 Read Version。\n3. Resolver 冲突检测 如果事务读取的内容在 Read Version 之后被修改，事务不能提交。\n4. 成功回复前日志持久化 只有 mutation 达到 TLog 持久化要求，客户端才收到提交成功。\n因此，一个成功事务可以被逻辑地串行化在自己的 Commit Version 上。\n二十四、一笔转账的完整路径 初始状态：\nversion 100: A = 100 B = 50 应用事务：\n@fdb.transactional def transfer(tr): a = decode(tr[b\u0026#34;A\u0026#34;].wait()) b = decode(tr[b\u0026#34;B\u0026#34;].wait()) tr[b\u0026#34;A\u0026#34;] = encode(a - 20) tr[b\u0026#34;B\u0026#34;] = encode(b + 20) 客户端阶段 事务获取：\nRead Version = 100 读取：\nA@100 = 100 B@100 = 50 本地事务状态变为：\nmutations: SET A = 80 SET B = 70 read conflict ranges: A B write conflict ranges: A B Commit Proxy 阶段 Commit Proxy 将事务放入 batch，并为 batch 分配：\nCommit Version = 110 Resolver 阶段 Resolver 检查：\n在版本 (100, 110] 内，是否有成功事务写过 A 或 B？ 如果没有：\nTransactionCommitted 如果有人在版本 105 写过 A：\nTransactionConflict TLog 阶段 通过冲突检测后写入：\nversion 110: SET A = 80 SET B = 70 TLog 达到持久化要求后，客户端收到：\nCommit Version = 110 Storage Server 阶段 负责 A、B 的 Storage Server 异步获取并应用：\nA = 80 B = 70 即使 Storage Server 在应用前崩溃，版本 110 的 mutations 仍然可以从 TLog 恢复。\n二十五、为什么 commit_unknown_result 特别危险？ 客户端发送事务后，可能发生：\n事务已经成功写入 TLog 但成功回复在网络中丢失 客户端此时无法确定事务到底成功还是失败，只能得到：\ncommit_unknown_result 这与明确的 not_committed 不同：\nnot_committed： 确定事务没有提交，可以重新执行。 commit_unknown_result： 事务可能已经提交，直接重试可能重复产生业务效果。 例如转账已经成功，但客户端因超时再次执行一次，就可能重复扣款。\n提交请求中包含：\nIdempotencyIdRef idempotencyId; 新版 FoundationDB 可以利用相关机制改进幂等提交处理，但应用仍应理解“结果未知”与“确定失败”的差异，并为关键业务设计幂等标识或结果确认流程。\n二十六、为什么它不是经典两阶段提交？ FDB 的流程容易被误认为 2PC：\nResolver 检查 TLog 提交 但经典两阶段提交通常是：\nCoordinator ├── Participant A: prepare ├── Participant B: prepare └── Participant C: prepare 全部 prepare 后再发送 commit FoundationDB 普通事务中：\nStorage Server 不是 prepare participant； Resolver 只检查冲突，不保存每笔业务的待定数据； TLog 持久化已经确定的 mutation 流； Storage Server 之后按版本异步应用； 不需要为每个事务在所有 shard 上维护 prepare 状态。 因此更准确的描述是：\n事务经过集中排序和冲突检测后，确定的 mutations 被原子地纳入复制日志。\n二十七、这种事务方案的优势和代价 优势 跨 shard 事务不需要所有 Storage Server 参与 2PC； 客户端提交内容确定，适合编码为 mutation 日志； 冲突检测、日志持久化与数据存储彼此解耦； Commit Batch 可以提高 Resolver 和 TLog 吞吐； Storage Server 可以异步持久化，缩短提交关键路径； 全局版本统一了 MVCC、冲突判断和故障恢复； 节点故障不会改变已经确定的事务提交顺序。 代价 所有事务依赖全局版本体系； Resolver 承担集中式冲突检测压力； 热 key 上的 read-modify-write 容易频繁冲突； 事务时长受 MVCC 和 conflict set 历史窗口限制； commit_unknown_result 要求应用考虑幂等性； 大事务会增加 Client、Commit Proxy、Resolver 和 TLog 压力； 涉及大量 key range 的事务可能需要多个 Resolver 共同检查。 二十八、总结 FoundationDB 的事务主路径可以压缩为五步：\n1. GRV Proxy 为事务分配 Read Version 2. 客户端按该版本读取，并积累 mutations 与 conflict ranges 3. Commit Proxy 组成批次、分配 Commit Version，并发送给 Resolver 4. Resolver 检查 Read Version 之后是否存在相交写入 5. 成功事务写入多副本 TLog，持久化后回复客户端 随后由 Storage Server 异步完成：\n6. 从 TLog 获取自己的 tagged mutations 7. 按版本应用到 MVCC 内存状态 8. 后台写入本地 Storage Engine 整个方案最核心的职责划分是：\nClient： 保存未提交修改和事务依赖 GRV Proxy： 给出一致性快照版本 Resolver： 判断读取前提是否仍然成立 Commit Proxy： 决定批次、版本和全局提交顺序 TLog： 保证已经提交的 mutations 不丢失 Storage Server：异步生成可查询、可持久化的数据状态 因此，FoundationDB 既不是传统“数据页 + Undo/Redo”事务方案，也不是标准参与者式 2PC，而是：\n以版本化 mutation 流为核心，通过乐观冲突检测和复制日志实现严格可串行化事务。\n参考源码 fdbclient/NativeAPI.actor.cpp：客户端读版本、事务组装和提交流程 fdbclient/include/fdbclient/CommitTransaction.h：CommitTransactionRef 等事务数据结构 fdbclient/include/fdbclient/CommitProxyInterface.h：CommitTransactionRequest 与 CommitID fdbserver/grvproxy/GrvProxyServer.cpp：Read Version 分配 fdbserver/commitproxy/CommitProxyServer.cpp：Commit Batch、Resolver 汇总与日志提交 fdbserver/resolver/Resolver.cpp：Resolver 主流程 fdbserver/core/include/fdbserver/core/ConflictBatch.h：批量冲突检测 fdbserver/logsystem/LogSystem.cpp：日志复制与 push() fdbserver/tlog/TLogServer.cpp：TLog 消息持久化 fdbserver/storageserver/storageserver.cpp：mutation 获取、MVCC 应用和本地持久化 ","permalink":"https://yangyang233333.github.io/posts/foundationdb-transaction-implementation/","summary":"\u003cp\u003eFoundationDB 支持跨任意 key range 的 ACID 事务，并向应用提供严格可串行化语义。但它既没有让每个 Storage Server 参与经典的两阶段提交，也不是传统单机数据库中“共享缓冲池 + Undo/Redo”的事务实现。\u003c/p\u003e\n\u003cp\u003e它的核心方案可以概括为：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e全局版本号 + MVCC 快照读 + 客户端暂存修改 + Resolver 乐观冲突检测 + 多副本 TLog 持久化。\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e本文结合 FoundationDB 官方源码，沿着一笔事务的完整路径，解释它如何从读取、提交、冲突检测，一直走到日志持久化和 Storage Server 应用。\u003c/p\u003e\n\u003cp\u003e本文分析的源码版本为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecommit: eab21ffe07c20575d5bac9ab0745375b8f7f8357\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edate:   2026-08-12\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e源码中的具体行号会随版本变化，阅读时应以函数和类型名为主要定位依据。\u003c/p\u003e\n\u003ch2 id=\"一整体架构\"\u003e一、整体架构\u003c/h2\u003e\n\u003cp\u003eFoundationDB 的事务涉及以下核心角色：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e角色\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e主要职责\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eClient\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e保存事务 mutations、读冲突范围和写冲突范围\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eGRV Proxy\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e分配 Read Version\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCommit Proxy\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e批量接收事务、分配 Commit Version、组织提交流水线\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eResolver\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e检测乐观事务冲突\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eTLog\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e复制并持久化已提交 mutations\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eStorage Server\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e提供版本化读取，异步应用 TLog mutations\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSequencer\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e为事务系统提供版本推进基础\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e完整流程可以简化为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eClient\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ├── 获取 Read Version\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ├── 从 Storage Server 读取该版本\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ├── 在客户端积累 mutations 和 conflict ranges\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  └── 提交\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   Commit Proxy\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 组成 Commit Batch\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 分配 Commit Version\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 按 key range 发给 Resolver\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e     Resolver\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 检查 read conflict ranges\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 登记 write conflict ranges\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 返回 committed / conflict / too old\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   Commit Proxy\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 丢弃冲突事务\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 为 mutation 添加 Storage Server tag\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 将成功事务写入 TLog\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       TLog\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 按冗余策略复制和持久化\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 返回 durable\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   Commit Proxy\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 向 Client 返回 Commit Version\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        │\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ▼\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e Storage Server\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 异步读取自己的 tagged mutations\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ├── 应用到内存中的版本化数据\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        └── 后台持久化到 Storage Engine\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e最关键的解耦是：\u003c/p\u003e","title":"FoundationDB 事务如何实现？从源码看 GRV、Resolver、TLog 与 Storage Server"},{"content":"传统数据库通过 Undo Log 和 Redo Log 保证事务的原子性与持久性：Undo 撤销未提交的修改，Redo 恢复已经提交但尚未写入数据文件的修改。那么，在采用分布式事务架构的 FoundationDB 中，是否也存在这两类日志？\n简短回答是：\nFoundationDB 有承担类似 Redo 职责的 TLog，但通常没有传统意义上的 Undo Log。\n这并不意味着 FoundationDB 不支持事务回滚或 MVCC，而是因为它对未提交数据、提交日志和历史版本的组织方式与传统数据库不同。\n一、先回顾传统数据库为什么需要 Undo 和 Redo 传统数据库通常允许事务直接修改缓冲池中的共享数据页。数据页何时写入磁盘，与事务何时提交并不完全同步。\n因此会出现两种状态。\n1. 事务未提交，数据页却已经落盘 例如事务把账户余额从 100 改成 80，但随后事务失败：\nBEGIN; UPDATE account SET balance = 80 WHERE id = \u0026#39;A\u0026#39;; ROLLBACK; 如果包含 80 的脏页已经写入磁盘，数据库就必须知道原值是 100，才能撤销这次修改。这是 Undo Log 的主要职责。\n2. 事务已经提交，数据页却尚未落盘 另一个事务已经执行 COMMIT，但修改可能仍然只存在于内存中的脏页里。如果服务器此时断电，已提交的数据就会丢失。\n因此数据库先持久化 Redo Log，再返回提交成功。重启后，即使数据页没有及时落盘，也可以通过 Redo 重新应用修改。\n可以把二者概括为：\nUndo：事务不应该生效，但修改可能已经进入数据文件。 Redo：事务应该生效，但修改可能还没有进入数据文件。 二、FoundationDB 的事务提交流程 FoundationDB 并不是把客户端事务中的每次 set 或 clear 立即写入 Storage Server。事务提交前，这些操作首先保存在客户端的事务对象中。\n一次提交可以简化为：\n客户端积累读写集合和 mutations ↓ 向事务系统发起提交 ↓ Commit Proxy 分配提交版本并组织提交 ↓ Resolver 检查读写冲突 ↓ mutations 写入多个 TLog 副本 ↓ 满足持久化与复制条件 ↓ 向客户端返回提交成功 ↓ Storage Server 异步获取并应用 mutations 这里最重要的区别是：\n在事务成功提交以前，它的修改不会作为正式的数据库版本发布给 Storage Server 和其他事务。\n如果事务因为冲突、超时、进程故障或用户主动取消而失败，FoundationDB 通常只需要丢弃客户端尚未提交的 mutations，而不需要从共享数据页中恢复旧值。\n三、TLog 就是 FoundationDB 的 Redo Log 吗？ FoundationDB 的 Transaction Log，简称 TLog，承担了与 Redo Log 非常相似的职责。\n假设事务在版本 100 提交：\nversion 100 SET account/A = 80 当事务返回提交成功时，这条 mutation 已经按照集群的冗余策略写入 TLog，但相关 Storage Server 可能还没有将它应用到本地存储引擎。\n如果 Storage Server 此时重启，它可以根据自己最后持久化的版本，继续从日志系统获取缺失的 mutations：\nStorage Server 最后持久化版本：95 重新获取并应用： version 96 version 97 version 98 version 99 version 100 这与传统 Redo 的思想一致：\n传统数据库： Redo 已持久化，数据页可以稍后刷盘。 FoundationDB： TLog 已持久化，Storage Server 可以稍后应用 mutation。 因此可以近似理解为：\nFDB TLog ≈ 分布式复制的 Redo Log 但二者并不完全等价。传统 Redo Log 通常是单个存储引擎内部的数据页恢复日志；TLog 则是 FoundationDB 分布式事务系统的一部分，同时承担以下职责：\n持久化已经提交的 mutations； 将事务系统与 Storage Server 的异步应用过程解耦； 在节点故障后重新提供尚未持久化到 Storage Server 的数据； 通过多副本和故障域策略满足集群级持久性要求； 参与整个数据库代际切换和故障恢复过程。 所以 TLog 不只是“某个节点的本地 WAL”，而是分布式提交路径上的核心组件。\n四、FoundationDB 为什么通常不需要传统 Undo Log？ 传统 Undo 存在的前提是：\n未提交事务可以修改共享数据页，甚至把修改写入磁盘。 FoundationDB 避免了这个前提。事务提交以前，修改主要保存在客户端事务对象中，不会作为一个正式版本进入 Storage Server。\n如果提交失败：\n丢弃客户端 mutations 而不是：\n找到所有被修改的数据 根据旧值逐项恢复 例如客户端执行：\ntransaction[\u0026#34;account/A\u0026#34;] = \u0026#34;80\u0026#34; 这次赋值在提交前只是事务中的一个 mutation。若冲突检测失败，数据库并没有一个已经对外可见的 account/A = 80 需要撤销。\n从设计思路上看，它接近一种 未提交修改不进入正式存储状态 的策略：\n传统数据库： 先修改共享缓冲页，失败后通过 Undo 恢复。 FoundationDB： 提交前不发布修改，失败后直接丢弃。 这也是 FoundationDB 不需要为分布式事务维护传统 Undo Log 的根本原因。\n五、没有 Undo，事务还能回滚吗？ 可以，但要区分提交前和提交后。\n1. 提交前回滚 事务尚未提交时，可以取消、重置或丢弃事务对象。因为 mutations 还没有成为数据库正式版本，所以不需要执行反向写入。\n这相当于：\n取消一份尚未生效的修改计划 而不是：\n撤销一批已经写入数据库的数据 2. 提交后撤销 事务一旦成功提交，就已经成为全局版本历史的一部分，不能再对原事务执行传统意义上的 ROLLBACK。\n如果业务需要撤销，只能再提交一个补偿事务：\nversion 100：account/A = 80 version 101：account/A = 100 版本 101 没有删除版本 100 曾经发生过的事实，而是产生了一个新的数据库状态。\n这一点与关系型数据库相同：COMMIT 成功后，也只能通过新的反向事务补偿，不能重新回滚已经提交的事务。\n六、FoundationDB 的 MVCC 是否依赖 Undo？ FoundationDB 支持 MVCC，但它不是典型的 InnoDB Undo 版本链模式。\n在 InnoDB 中，一条记录的当前版本可以通过回滚指针找到 Undo 中的旧版本：\n当前记录：80 ↓ roll pointer 旧版本：100 ↓ 更旧版本 FoundationDB 的事务开始读取时，会获取一个 Read Version。之后，事务请求 Storage Server 读取该版本对应的数据快照：\n事务读取版本：100 Storage Server： 返回 key 在 version 100 时的值 Storage Server 按提交版本接收和应用 mutations，并在有限的历史窗口中维护服务旧版本读取所需的状态。概念上可以表示为：\nversion 100：account/A = 100 version 101：account/A = 80 version 102：account/A = 60 读取版本 100 的事务看到 100，读取版本 102 的事务看到 60。\n因此：\nFoundationDB 有 MVCC 历史版本，但这些历史版本不能简单称为 Undo Log。\n二者都能帮助读取旧数据，但目的和组织方式不同：\n机制 主要用途 传统 Undo Log 撤销未提交修改，并可能为 MVCC 构造旧版本 FDB 版本化状态 为指定 Read Version 提供一致性快照 FoundationDB 对旧版本的保留是有限的。事务运行过久，可能因为所需版本已经超出可读取窗口而失败。因此，FDB 应用通常应保持事务短小，并在出现可重试错误时重新执行事务。\n七、Storage Engine 自己会不会有 WAL？ 这里必须区分两个层次。\n1. FoundationDB 分布式事务层 这一层包含：\nCommit Proxy； Resolver； TLog； Storage Server； 全局提交版本； mutation 分发与恢复。 在这一层，TLog 最接近 Redo，而传统 Undo 通常不是必要组件。\n2. Storage Server 本地存储引擎 Storage Server 最终还要把数据写入本地存储引擎。不同引擎可能采用不同的持久化方式，例如：\nWrite-Ahead Log； Copy-on-Write； 原子页面提交； 检查点； 版本化 B-tree； 引擎内部的恢复日志。 这些机制属于本地 Storage Engine 的实现细节。即使某个引擎内部使用 WAL，也不能把它与 FoundationDB 的 TLog 混为一谈。\n可以把两层关系理解为：\nFoundationDB TLog 负责集群级已提交 mutation 的持久化与分发 Storage Engine 本地日志 负责单个 Storage Server 本地文件状态的一致性 它们解决的问题层次不同。\n八、故障恢复时具体会发生什么？ 假设数据库中存在三个事务：\nT1：已提交，Storage Server 尚未应用 T2：正在客户端执行，尚未提交 T3：已提交，Storage Server 已经持久化 此时集群发生故障。\n对 T1 T1 已经写入 TLog 并满足提交条件，因此它不能丢失。恢复系统需要确认已提交边界，并让 Storage Server 重新获取、应用相关 mutations。\n效果类似于 Redo：\n重新应用 T1 对 T2 T2 尚未成功提交，它的 mutations 仍属于客户端事务状态。客户端连接中断后，这些修改直接消失，不需要数据库执行 Undo。\n丢弃 T2 对 T3 T3 已经被 Storage Server 持久化，不需要再次产生业务效果。系统通过版本和持久化进度判断从哪里继续处理，而不是重新运行原始应用逻辑。\n恢复完成后：\nT1：保留 T2：不生效 T3：保留 这与传统数据库崩溃恢复想要达到的最终结果一致，只是实现路径不同。\n九、TLog 重放不是重新执行事务代码 需要特别注意，恢复 mutations 不等于重新运行用户事务。\n假设原事务逻辑是：\n读取余额 余额减 20 写入新余额 FoundationDB 不会在恢复时再次执行“读取后减 20”，否则每次重放都可能再次扣款。\nTLog 保存的是事务提交后确定的 mutations，并带有对应的提交版本。Storage Server 按版本应用这些确定的变更。\n可以将其理解为：\n不安全的重放： balance = balance - 20 确定性的 mutation： 在提交版本 100 写入事务已经确定的结果 版本化和持久化进度也使 Storage Server 能判断哪些 mutations 已经处理、哪些仍然缺失。\n十、与传统数据库的对照 能力 传统数据库 FoundationDB 提交前修改位置 共享缓冲池中的数据页 主要在客户端事务对象中 未提交数据是否可能进入正式存储 可能 通常不会成为正式版本 撤销未提交事务 Undo Log 丢弃未提交 mutations 已提交修改持久化 Redo Log / WAL 多副本 TLog 后台应用修改 刷新脏页 Storage Server 获取并应用 mutations MVCC 读取 常通过 Undo 版本链构造旧版本 按 Read Version 读取版本化状态 提交后撤销 新建补偿事务 新建补偿事务 恢复进度标识 LSN、页面版本等 全局提交版本和持久化版本 这个对照表里最关键的一行是：\n传统 Undo：修改已经进入共享状态，所以需要反向恢复。 FDB：修改尚未发布，所以提交失败时直接丢弃。 十一、这种设计带来了什么？ 1. 简化未提交事务的恢复 提交失败不需要在多个 Storage Server 上协调反向操作。事务对正式数据库状态要么以某个版本提交，要么根本没有出现。\n2. 提交路径与存储路径解耦 事务不必等待所有相关 Storage Server 完成本地持久化后才能返回成功。只要 mutations 在事务日志系统中满足持久化要求，Storage Server 就可以异步追赶。\n3. 适合分布式故障恢复 TLog 不是单机日志，而是具备复制和故障域约束的分布式组件。节点故障时，系统可以从存活副本确认已提交历史并恢复数据流。\n4. 对事务时长提出限制 FoundationDB 不会无限保留任意旧版本。长事务不仅更容易产生冲突，也可能超出 MVCC 版本窗口。应用应尽量缩短事务，并通过重试循环处理可重试错误。\n十二、总结 回答“FoundationDB 中有没有 Undo 和 Redo”，不能只看组件名称，而要看它们解决的问题。\nFoundationDB 中：\nTLog ≈ 分布式、复制的 Redo Log MVCC 历史版本 ≠ 传统 Undo Log 未提交 mutations 直接丢弃，因此通常不需要传统 Undo 它与传统数据库的核心区别是：\n传统数据库： 允许未提交修改进入共享数据页，失败后再通过 Undo 恢复。 FoundationDB： 提交前不发布修改，失败后直接丢弃事务状态。 而对于已经提交的事务：\n传统数据库依靠 Redo 保证不丢失； FoundationDB 依靠多副本 TLog 保证 mutations 可恢复。 所以，从事务语义看，两者都要实现“失败的事务不生效、成功的事务不丢失”；从内部实现看，FoundationDB 用版本化 mutations、TLog 和 Storage Server 的异步应用机制，替代了经典的 Undo/Redo 组合。\n参考资料 FoundationDB Transaction Processing FoundationDB Key-Value Store Architecture FoundationDB Recovery Internals FoundationDB Configuration ","permalink":"https://yangyang233333.github.io/posts/foundationdb-undo-redo/","summary":"\u003cp\u003e传统数据库通过 Undo Log 和 Redo Log 保证事务的原子性与持久性：Undo 撤销未提交的修改，Redo 恢复已经提交但尚未写入数据文件的修改。那么，在采用分布式事务架构的 FoundationDB 中，是否也存在这两类日志？\u003c/p\u003e\n\u003cp\u003e简短回答是：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eFoundationDB 有承担类似 Redo 职责的 TLog，但通常没有传统意义上的 Undo Log。\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e这并不意味着 FoundationDB 不支持事务回滚或 MVCC，而是因为它对未提交数据、提交日志和历史版本的组织方式与传统数据库不同。\u003c/p\u003e\n\u003ch2 id=\"一先回顾传统数据库为什么需要-undo-和-redo\"\u003e一、先回顾传统数据库为什么需要 Undo 和 Redo\u003c/h2\u003e\n\u003cp\u003e传统数据库通常允许事务直接修改缓冲池中的共享数据页。数据页何时写入磁盘，与事务何时提交并不完全同步。\u003c/p\u003e\n\u003cp\u003e因此会出现两种状态。\u003c/p\u003e\n\u003ch3 id=\"1-事务未提交数据页却已经落盘\"\u003e1. 事务未提交，数据页却已经落盘\u003c/h3\u003e\n\u003cp\u003e例如事务把账户余额从 \u003ccode\u003e100\u003c/code\u003e 改成 \u003ccode\u003e80\u003c/code\u003e，但随后事务失败：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eBEGIN\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eUPDATE\u003c/span\u003e account\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eSET\u003c/span\u003e balance \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eWHERE\u003c/span\u003e id \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;A\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eROLLBACK\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果包含 \u003ccode\u003e80\u003c/code\u003e 的脏页已经写入磁盘，数据库就必须知道原值是 \u003ccode\u003e100\u003c/code\u003e，才能撤销这次修改。这是 Undo Log 的主要职责。\u003c/p\u003e\n\u003ch3 id=\"2-事务已经提交数据页却尚未落盘\"\u003e2. 事务已经提交，数据页却尚未落盘\u003c/h3\u003e\n\u003cp\u003e另一个事务已经执行 \u003ccode\u003eCOMMIT\u003c/code\u003e，但修改可能仍然只存在于内存中的脏页里。如果服务器此时断电，已提交的数据就会丢失。\u003c/p\u003e\n\u003cp\u003e因此数据库先持久化 Redo Log，再返回提交成功。重启后，即使数据页没有及时落盘，也可以通过 Redo 重新应用修改。\u003c/p\u003e\n\u003cp\u003e可以把二者概括为：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eUndo：事务不应该生效，但修改可能已经进入数据文件。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eRedo：事务应该生效，但修改可能还没有进入数据文件。\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"二foundationdb-的事务提交流程\"\u003e二、FoundationDB 的事务提交流程\u003c/h2\u003e\n\u003cp\u003eFoundationDB 并不是把客户端事务中的每次 \u003ccode\u003eset\u003c/code\u003e 或 \u003ccode\u003eclear\u003c/code\u003e 立即写入 Storage Server。事务提交前，这些操作首先保存在客户端的事务对象中。\u003c/p\u003e","title":"FoundationDB 中有 Undo 和 Redo 吗？从 TLog、MVCC 到故障恢复"},{"content":"选哈希算法时常有两个问题绑在一起：碰撞概率有多小、算得有多快。这篇把碰撞概率背后的数学（生日界）讲清楚，再用它算一算 256-bit 的 SHA-256/BLAKE3 与 128-bit 的 XXH128 各自的碰撞概率，最后附上一组本机实测速度数据。\n本文所有实测数据均来自随机生成的字节串，不含任何业务或私有数据。\n一、背景：碰撞概率的两种含义 对固定长度输出的哈希，\u0026ldquo;碰撞概率\u0026quot;必须分两种场景谈，否则会得出互相矛盾的结论：\n随机碰撞：没有攻击者，数据是正常/随机的。这时只要输出在取值空间里均匀分布，碰撞概率就纯粹由输出位数决定，与具体算法无关。 抗恶意碰撞：有攻击者知道算法、故意构造两个哈希相同的输入。这里才真正区分加密哈希（SHA-256、BLAKE3、BLAKE2）和非加密哈希（xxHash、CityHash、Murmur）。 第一种是数学问题，用生日界就能算。第二种是密码学性质，非加密哈希直接不提供保证。\n二、生日问题：直觉的陷阱 一个房间里要多少人，才有超过 50% 的概率存在两人同一天生日？答案是 23 人——远比直觉小。原因在于碰撞看的不是\u0026quot;某人和我同天\u0026rdquo;，而是\u0026quot;任意两人之间\u0026quot;的配对数：n 个人有 n(n-1)/2 ≈ n²/2 对，概率随 n² 增长。\n哈希碰撞是同一个问题：把\u0026quot;人\u0026quot;换成\u0026quot;哈希输入\u0026quot;，把\u0026quot;365 天\u0026quot;换成\u0026quot;哈希空间大小 N\u0026quot;。对 128-bit 哈希，N = 2^128。\n三、生日界公式的推导 设哈希空间大小为 N，独立均匀地放入 n 个值。直接算\u0026quot;至少一次碰撞\u0026quot;要用容斥，很麻烦，所以反过来算\u0026quot;全部不同\u0026quot;的概率：\n第 1 个：随便放 → N/N 第 2 个：不能撞前 1 个 → (N-1)/N ... 第 n 个：不能撞前 n-1 个 → (N-n+1)/N 全部不同的概率是连乘：\nP(无碰撞) = ∏_{k=0}^{n-1} (1 - k/N) 于是至少一次碰撞：\nP(碰撞) = 1 - ∏_{k=0}^{n-1} (1 - k/N) 这是精确解，但连乘不好用。用近似 1 - x ≈ e^(-x)（x 很小时成立）把每一项换掉：\n∏ (1 - k/N) ≈ ∏ e^(-k/N) = exp( -(1/N) · Σ_{k=0}^{n-1} k ) 指数上是等差数列求和 Σ k = n(n-1)/2 ≈ n²/2，代回得到最常用的生日界公式：\nP(碰撞) ≈ 1 - exp( -n² / (2N) ) 当 n²/(2N) ≪ 1 时再简化一次（e^(-x) ≈ 1 - x）：\nP(碰撞) ≈ n² / (2N) 这个形式最好用：碰撞概率随输入条数的平方增长——n 翻倍，概率变 4 倍。\n50% 碰撞点为什么是 √N 令 P = 0.5：\nexp(-n²/(2N)) = 0.5 n²/(2N) = ln 2 n = √(2 ln2 · N) ≈ 1.177 · √N 所以 50% 碰撞点 ≈ √N = 2^(位数/2)。\n生日问题：N=365 → n ≈ 1.177·√365 ≈ 23，对上了。 128-bit：N=2^128 → n ≈ 2^64 ≈ 1.8×10^19。 256-bit：N=2^256 → n ≈ 2^128。 这就是密码学里\u0026quot;n-bit 哈希的抗碰撞强度只有 2^(n/2)\u0026ldquo;的由来——不是 2^n，因为生日攻击把开销从 N 降到了 √N。\n四、SHA-256（256-bit）的随机碰撞概率 N = 2^256 ≈ 1.16×10^77，代入 P ≈ n²/(2N)：\n哈希条数 n 至少一次碰撞概率(约) 1×10^9 (十亿) ~4×10^-60 1×10^12 (万亿) ~4×10^-54 1×10^15 ~4×10^-48 1×10^18 ~4×10^-42 2^128 ≈ 3.4×10^38 ~50% 要哈希约 2^128 ≈ 3.4×10^38 个不同值才有 50% 概率撞一次。这个数字超出任何现实规模——即便调动全球算力持续运行，也无法接近。所以 256-bit 的随机碰撞可以当作永远不会发生。\n五、XXH128（128-bit）的随机碰撞概率 N = 2^128 ≈ 3.4×10^38，代入 P ≈ n²/(2N)：\n哈希条数 n 至少一次碰撞概率(约) 1×10^6 (百万) ~1.5×10^-27 1×10^9 (十亿) ~1.5×10^-21 1×10^12 (万亿) ~1.5×10^-15 1×10^15 ~1.5×10^-9 2^64 ≈ 1.8×10^19 ~50% 要哈希约 2^64 ≈ 1.8×10^19 个不同值才有 50% 概率撞一次。换个体感：每秒哈希 10 亿条、不停跑 100 年（约 3×10^18 条），碰撞概率仍在 ~10^-3 以下。对去重、分片、缓存 key 这类场景，128-bit 的随机碰撞实际可以忽略。\nxxHash 官方用 SMHasher 测试套件验证过其分布质量（雪崩效应、无明显偏置），所以\u0026quot;均匀分布\u0026quot;这个前提在实践中站得住。\n但 XXH128 不抗恶意碰撞 这是它与位数无关的根本短板：XXH128 是非加密哈希，不提供密码学抗碰撞保证。攻击者若知道算法（开源、种子公开），能以远低于 2^64 的代价主动构造出哈希相同的两个输入。因此：\n不能用于数字签名、内容寻址防伪、完整性校验、防 hash-flooding 的 DoS 防护等安全用途； 这些场景必须用加密哈希（SHA-256 / BLAKE3 / BLAKE2），它们的抗碰撞代价是 2^(n/2) 且没有已知捷径。 六、实测速度对比 在同一台机器上测了一组，数据集为随机字节串、按不同长度分档，每档 200 万次调用取中位数。测试环境：Intel Xeon Silver 4310（支持 sha_ni / avx2 / avx512f），加密哈希用 Rust（release + LTO + target-cpu=native），XXH128 用官方 C 版 xxHash v0.8.2（-O3，AVX2 路径）。\n单次哈希耗时中位数（纳秒），值越小越快：\n长度 SHA-256 BLAKE3 BLAKE2b-256 SHA3-256 XXH128(AVX2) 16 B 113 118 234 567 28 64 B 177 111 232 556 32 256 B 341 321 430 1058 52 1024 B 1024 1121 1591 4071 76 4096 B 3761 1370 6234 15660 220 吞吐量（MB/s），值越大越快（4096 B 档）：\n算法 吞吐 XXH128 (AVX2) ~14600 BLAKE3 ~2700 SHA-256 ~1026 BLAKE2b-256 ~621 SHA3-256 ~247 XXH128 的 SIMD 路径差异（AVX2 vs SSE2） 用 XXH_VECTOR 宏强制钉死向量路径，单次耗时中位数（ns）：\n长度 scalar SSE2 AVX2 AVX512 16 B 28 28 28 28 64 B 32 32 32 32 256 B 66 54 52 56 1024 B 140 94 76 78 4096 B 474 288 220 204 要点：小输入（≤64 B）走的是标量短路径，四条路径完全一样，SIMD 加速无效；分水岭在 ~256 B 以上，数据越大 AVX2 相对 SSE2/scalar 优势越明显；AVX2 与 AVX512 基本打平，而 AVX512 在部分型号上会触发降频，通常 AVX2 更划算。\n七、结论 随机碰撞概率只看位数：256-bit 需 2^128 条、128-bit 需 2^64 条才有 50% 概率碰撞，两者在现实规模下都可视为不发生。 是否抗恶意碰撞才是选型关键：加密哈希（SHA-256/BLAKE3/BLAKE2）抗攻击，非加密哈希（XXH128）不抗。 选型建议： 需要 32 B 输出 + 抗攻击 + 快 → BLAKE3（大块数据吞吐领先，且支持多线程）。 要生态最稳、机器有 SHA-NI → SHA-256。 纯内部散列（哈希表、分片、去重），不怕攻击、只求极致速度 → XXH128，但绝不可用于安全场景。 核心记忆：碰撞概率 ≈ n²/(2N)，50% 碰撞点 ≈ √N = 2^(位数/2)。看的是两两配对数，所以概率随 n² 涨，撞上只需 √N 而非 N。\n","permalink":"https://yangyang233333.github.io/posts/hash-collision-birthday-bound/","summary":"\u003cp\u003e选哈希算法时常有两个问题绑在一起：\u003cstrong\u003e碰撞概率有多小\u003c/strong\u003e、\u003cstrong\u003e算得有多快\u003c/strong\u003e。这篇把碰撞概率背后的数学（生日界）讲清楚，再用它算一算 256-bit 的 SHA-256/BLAKE3 与 128-bit 的 XXH128 各自的碰撞概率，最后附上一组本机实测速度数据。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e本文所有实测数据均来自随机生成的字节串，不含任何业务或私有数据。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"一背景碰撞概率的两种含义\"\u003e一、背景：碰撞概率的两种含义\u003c/h2\u003e\n\u003cp\u003e对固定长度输出的哈希，\u0026ldquo;碰撞概率\u0026quot;必须分两种场景谈，否则会得出互相矛盾的结论：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e随机碰撞\u003c/strong\u003e：没有攻击者，数据是正常/随机的。这时只要输出在取值空间里均匀分布，碰撞概率就纯粹由\u003cstrong\u003e输出位数\u003c/strong\u003e决定，与具体算法无关。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e抗恶意碰撞\u003c/strong\u003e：有攻击者知道算法、故意构造两个哈希相同的输入。这里才真正区分\u003cstrong\u003e加密哈希\u003c/strong\u003e（SHA-256、BLAKE3、BLAKE2）和\u003cstrong\u003e非加密哈希\u003c/strong\u003e（xxHash、CityHash、Murmur）。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e第一种是数学问题，用生日界就能算。第二种是密码学性质，非加密哈希直接不提供保证。\u003c/p\u003e\n\u003ch2 id=\"二生日问题直觉的陷阱\"\u003e二、生日问题：直觉的陷阱\u003c/h2\u003e\n\u003cp\u003e一个房间里要多少人，才有超过 50% 的概率存在两人同一天生日？答案是 \u003cstrong\u003e23 人\u003c/strong\u003e——远比直觉小。原因在于碰撞看的不是\u0026quot;某人和我同天\u0026rdquo;，而是\u0026quot;任意两人之间\u0026quot;的配对数：\u003ccode\u003en\u003c/code\u003e 个人有 \u003ccode\u003en(n-1)/2 ≈ n²/2\u003c/code\u003e 对，概率随 \u003ccode\u003en²\u003c/code\u003e 增长。\u003c/p\u003e\n\u003cp\u003e哈希碰撞是同一个问题：把\u0026quot;人\u0026quot;换成\u0026quot;哈希输入\u0026quot;，把\u0026quot;365 天\u0026quot;换成\u0026quot;哈希空间大小 \u003ccode\u003eN\u003c/code\u003e\u0026quot;。对 128-bit 哈希，\u003ccode\u003eN = 2^128\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"三生日界公式的推导\"\u003e三、生日界公式的推导\u003c/h2\u003e\n\u003cp\u003e设哈希空间大小为 \u003ccode\u003eN\u003c/code\u003e，独立均匀地放入 \u003ccode\u003en\u003c/code\u003e 个值。直接算\u0026quot;至少一次碰撞\u0026quot;要用容斥，很麻烦，所以反过来算\u0026quot;全部不同\u0026quot;的概率：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e第 1 个：随便放         → N/N\n第 2 个：不能撞前 1 个  → (N-1)/N\n...\n第 n 个：不能撞前 n-1 个 → (N-n+1)/N\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e全部不同的概率是连乘：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eP(无碰撞) = ∏_{k=0}^{n-1} (1 - k/N)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e于是至少一次碰撞：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eP(碰撞) = 1 - ∏_{k=0}^{n-1} (1 - k/N)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e这是精确解，但连乘不好用。用近似 \u003ccode\u003e1 - x ≈ e^(-x)\u003c/code\u003e（\u003ccode\u003ex\u003c/code\u003e 很小时成立）把每一项换掉：\u003c/p\u003e","title":"哈希碰撞概率与生日界：从公式到 SHA-256 / XXH128 实测"}]