Towards Data Science

GPU Time-Slicing for Concurrent LLM Agents on Kubernetes

8.5内容质量

TL;DR · AI 摘要

在 Kubernetes 上共享 GPU 会导致尾部延迟显著增加,尤其对低延迟任务影响更大,但调度器不会报告这些问题。

核心要点

  • 共享 GPU 时,Kubernetes 会报告所有 Pod 为 Running,但尾部延迟可能增加 66%。
  • 小的低延迟任务在共享 GPU 时,其 p99 延迟从 3.68 ms 增加到 6.10 ms(≈1.66×)。
  • 使用 GTX 1080 和 CUDA 时间切片即可实现此实验,无需高端硬件如 H100。

结构提纲

按章节快速跳转。

  1. Kubernetes 上共享 GPU 会导致尾部延迟显著增加,但调度器不会报告这些问题。

  2. 使用 GTX 1080NVIDIA Kubernetes Device Plugin 实现两个不同工作负载的 GPU 共享。

  3. 共享 GPU 时,小的低延迟任务的 p99 延迟显著增加,而吞吐量变化不大。

  4. 尾部延迟和抖动显著增加,尤其对小的低延迟任务影响更大。

  5. Kubernetes 调度器无法准确反映 GPU 共享的性能影响,需关注尾部延迟。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • GPU 时间切片共享
    • 实验设置
      • GTX 1080 GPU
      • CUDA 时间切片
      • NVIDIA Kubernetes Device Plugin
    • 关键发现
      • 尾部延迟增加
      • 吞吐量变化小
      • 小任务受影响更大
    • 结论
      • 调度器无法准确反映性能影响
      • 需关注尾部延迟

金句 / Highlights

值得收藏与分享的关键句。

  • 共享 GPU 时,Kubernetes 会报告所有 Pod 为 Running,但尾部延迟可能增加 66%。

    第 2 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 小的低延迟任务在共享 GPU 时,其 p99 延迟从 3.68 ms 增加到 6.10 ms(≈1.66×)。

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 使用 GTX 1080 和 CUDA 时间切片即可实现此实验,无需高端硬件如 H100。

    第 5 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#Kubernetes#GPU#LLM#调度器#延迟优化
打开原文

Kubernetes 上用于并发 LLM 代理的 GPU 时间片分配 | Towards Data Science

Agentic AI

Kubernetes 上用于并发 LLM 代理的 GPU 时间片分配

停止依赖 Pod 的状态。一个端到端的框架,用于衡量 Kubernetes 中 AI 代理之间 GPU 共享的隐藏微架构成本和内存竞争。

Anubhab Banerjee

2026 年 6 月 14 日

22 分钟阅读

分享

架构概述

玩具代理在 Python 中一个接一个地运行。生产代理则争夺同一个 GPU —— 在一张共享的 GPU 卡上,一个对延迟敏感的代理的 p99 延迟悄无声息地恶化了 66%,而每个 Pod 仍然报告为健康。这里就是这场争夺的实际代价,精确到 p99,而不是模糊地描述。

这是“生产级代理推理”系列的第二部分。每一部分都会从代理 LLM 管道中移除一种冗余工作。第一部分消除了冗余的预填充。第二部分(本部分)处理冗余等待 —— 如何通过时间片分配让多个微代理共享一个 GPU。第三部分通过自定义 CUDA Top-K 内核将 RAG 检索保留在 GPU 上。第四部分在代理交接时持久化代理状态,使得下一个代理永远不需要面对冷启动问题。

关键要点

  • 共享 GPU 并非免费,调度器也不会告诉你。当两个代理共享一个时间片分配的 GPU 时,Kubernetes 高兴地将两个 Pod 都标记为 Running。损害隐藏在延迟的尾部。
  • 中位数会说谎,尾部才讲真相。在我的运行中(只有两个代理),两者的 p50 几乎没有变化。但那个小而对延迟敏感的代理的 p99 从 3.68 毫秒跃升至 6.10 毫秒(约为 1.66 倍),其抖动(p99/p50)也从 1.02 上升至 1.70。
  • 对延迟敏感的代理首先退化。小而抖动的工作负载受到的损害远大于大而稳定的工作负载,即使两者“都获得了 GPU”。
  • 通量几乎没有变化,这就是整个陷阱。一个平均速率的通量代理仅下降了几个百分点 —— 因此,一个监控平均值的仪表板会认为这是成功,而你的尾部敏感代理却在 50 次中悄悄错过了一次截止时间。
  • 它运行在一块 150 美元的 GPU 上。以下所有内容都是在一块五年前的 GTX 1080 上测量的,使用的是原装 NVIDIA Kubernetes 设备插件和 CUDA 时间片分配。没有 H100,没有 MIG,也没有魔法。这是有意为之,不是每个人都能负担得起 H100 —— 有些人仍然在使用旧硬件。说实话,在 H100 上运行代理 AI 生产并不需要任何魔法;但在一块 150 美元的 GPU 上,它确实需要。

TL;DR:我将两个非常不同的代理工作负载 —— 一个小型、对延迟敏感的 FFT 工作者和一个大型、变压器风格的 GEMM 工作者 —— 分别放入不同的 Kubernetes Pod 中,每个 Pod 礼貌地请求 nvidia.com/gpu: "1",然后让 NVIDIA 设备插件的 CUDA 时间片分配将它们都分配到一块物理 GTX 1080 上。然后我用 CUDA 事件对每一次迭代进行计时,汇总成 p50/p95/p99,计算退化因子(共享尾部 / 独占尾部),并将其与 DCGM GPU 利用率计数器进行交叉验证。结果:中位数和通量几乎没有变化,但尾部延迟和抖动却急剧上升 —— 对于小型、对延迟敏感的代理最糟糕。Kubernetes 说“两个健康的 Pod”。硅芯片说“你们中有一个在队列中挨饿”。Kubernetes 报告“两个健康的 Pod”。硅芯片报告的是内存总线上的街头斗殴,而 p99 尾部告诉你谁付出了代价。

GitHub 仓库:https://github.com/AnubhabBanerjee/Kube-Timeslice-Profiler

(在我们开始之前,快速坦白一下:我来自5G/6G RAN工程背景。事实证明,这正是AI RAN目前所面对的问题。在边缘服务器上,运营商正试图将对延迟敏感的基带处理与重型LLM推理放在同一GPU上。一旦AI工作负载开始消耗对延迟敏感应用的内存带宽,就会变成一个调度噩梦——这正是我写这篇帖子的原因。)

架构思维模型——阅读时请保持这个模型在脑海中。

两个Pod → 每个Pod请求nvidia.com/gpu: 1 → 设备插件愉快地表示“当然,这里有4个GPU”(实际上只有一个)→ CUDA对这一个真实GPU进行时间片切分 → 每个人轮流使用 → 尾部承担费用。

以下所有内容只是对这句话中某一部分的评论。

1. 一个坦白:“运行”是Kubernetes中最昂贵的幻觉

就像这个系列的前一篇文章一样,让我们从一个戏剧性的对话开始,然后慢慢深入更无聊、技术性更强的内容。

你:“Kubernetes,请运行我的两个代理。” Kubernetes:“完成了。两个Pod都处于Running状态。✅” 你:“在同一个GPU上吗?” Kubernetes:“是的。每个都请求了nvidia.com/gpu: 1,所以我给了每个一个GPU。” 你:“但我只拥有一个GPU。” Kubernetes:“没错。我给了每个一个GPU。” 🫡 你:“等等,什么?怎么做到的?它们不可能同时拥有——” Kubernetes:“嘘。别担心。看看它们多绿。” 你的Grafana仪表板:“一切看起来都很好,兄弟。🟢” 同时…… 你的物理GPU:(在上下文切换中尖叫) 你的p99延迟:(在角落里悄悄翻倍)

也许实际上并没有那么戏剧化,但你明白我的意思,对吧?调度器对“健康”的理解是Pod是活着的,有一个进程在运行。它对你的延迟敏感代理是否每秒被挤出GPU四十次没有意见。Pod阶段显示为Running。代理什么也没说,因为,实际上没有人问它。

这直接来源于第一部分的结尾。在SwarmKV的帖子中,我有两个代理读取一个文档,并吹嘘过预填充一次并将KV缓存分发出去。然后,在注意事项中,我承认了尴尬的部分:每个分支的实际GPU工作仍然在全局互斥锁后面运行。编排分发出去了;计算排成一行。两个代理,两个回合。五十个代理,五十个回合。我手动实现了一个锁,并就此结束。

这在演示中是可行的。但在生产环境中,这意味着“代理群”意味着十几个小型专用模型——一个路由器、一个摘要器、一个安全检查器、一个检索器、一堆工具调用者——同时都在运行,都想要同一个加速器。你不可能给每个都买一个H100(除非你的名字是Jensen Huang)。你将它们打包到一个共享的GPU上,并希望调度器能解决这个问题。

因此,我想回答一个直白的问题:当两个代理共享一个GPU时,每个代理实际上要支付什么?集群中的任何东西会告诉我吗?

剧透:它真的需要毫秒级的时间,几乎完全落在小而快的代理上,而且不,集群中的任何东西都不会告诉你。因此,我开发了一个工具来做到这一点。

2. 两个性格相反的代理

这篇文章背后的仓库运行了两个容器化的PyTorch工作者,它们代表了几乎所有代理群中可以找到的两种类型:

  • 一个小型、敏感、延迟敏感的代理(fft_worker.py)。它运行一个连续的大规模二维复数FFT循环。可以将其视为路由器/护栏/工具调用类——这些代理必须立即回答,否则整个世界都会崩溃。
  • 一个大型、稳定、计算密集型的代理(matmul_worker.py)。它运行一个连续的大规模方阵乘法流——这是变压器前向传递的核心GEMM。这才是真正进行模型思考的重量级代理。

对于每个代理来说,它们的整个工作负载都非常简单。FFT代理预先分配了一个4096×4096的复数张量,并对其进行处理:

code
# ----- 预分配张量 -----
# 单次分配可以将cuFFT计划创建和分配器流量排除在GPU上每个迭代的``elapsed_time``窗口之外。
# ``complex64``匹配典型的PHY IQ数据宽度;仅实数FFT会低估与GEMM租户相关的DRAM竞争的内存流量。
data = torch.randn(MATRIX_SIZE, MATRIX_SIZE, device=device, dtype=torch.complex64)
# 第一次启动会支付JIT/计划成本;五次迭代是一个小的固定数量——正式的稳态修剪仍然发生在``generate_results`` §1.4。
# 丢弃的``fft2``调用可以预加载指令和常量缓存,使得定时迭代看到的是可重复的SM占用率,而不是驱动程序的一次性尖峰。
for _ in range(5):
    # 将结果赋值给``_``会立即丢弃输出张量句柄;我们只需要设备上``data``的内核执行副作用。
    torch.fft.fft2(data)
# 最终的同步确保没有预热内核与第一次定时迭代的事件对重叠——这对于CUDA事件定时的有效性至关重要 §3。
sync()

GEMM代理预先分配了两个FP32矩阵,并永远地将它们相乘:

code
# 矩阵乘法需要两个操作数驻留在设备上;一次分配可以将分配器和页面交换排除在每次迭代的定时cuBLAS路径之外。
# FP32是Pascal类GPU(没有Tensor Cores)上默认的训练/推理数据类型;这与README中“1080上的GEMM”叙述一致。
A = torch.randn(MATRIX_SIZE, MATRIX_SIZE, device=device)
B = torch.randn(MATRIX_SIZE, MATRIX_SIZE, device=device)
# cuBLAS自动调优可以在第一次启动时选择不同的算法;预热迭代在``KTS_APP``行之前吸收这种非确定性。
# 五次重复与FFT代理相同,以确保论文中的跨租户比较不会将不同的预热深度与硅干扰效应混淆。
for _ in range(5):
    # 结果被丢弃;峰值内存保持平稳,因为输出张量在每次迭代中被释放,定时循环不会分配任何新内容。
    torch.matmul(A, B)
# 同步关闭预热窗口,确保第一个``_ev_start.record``不会与同一默认CUDA流语义上的预热内核重叠。
sync()

重点从来不是构建一个聪明的模型——而是构建两个具有相反行为的GPU公民,并观察它们共享一个房间。一个大约在3.6毫秒内完成并立即想要再次运行;另一个大约需要20毫秒,只想继续运算。现在将它们放在同一个GPU上,并提出唯一有趣的问题:谁先眨眼?

两个代理都通过环境变量进行配置,因此一个Pod规范可以在不重新构建镜像的情况下重新调整它们:

code
# ----- 配置(可通过环境变量覆盖,以便 pod 规格根据实验进行调整)-----
# ``ITERATIONS`` 的默认值与 FFT 工作者匹配,因此在 YAML 中不使用环境变量覆盖时,DF 的分子/分母使用可比较的样本数量。
# 增加迭代次数会延长共享 GPU 的 ``kubectl wait`` 时间;降低迭代次数会使 p99 尾部的方差增加,这些方差用于 ``results.md`` 中的争用故事叙述。
ITERATIONS = int(os.environ.get("ITERATIONS", 800))
# ``MATRIX_SIZE`` 主导每次迭代的 FLOPs;环境变量覆盖允许你在 MatMul 与 FFT 共享 8 GB 显存时降低显存使用。
# 时间切片不会分割内存——两个 pod 的峰值分配必须适合一张物理卡,否则较慢的 OOMKill 路径将使实验无效。
MATRIX_SIZE = int(os.environ.get("MATRIX_SIZE", 4096))
# ``SLEEP_MS`` 的默认值略高于 FFT 的 100 毫秒,因此两个租户很少同时唤醒,从而更真实地分散调度器的量子。
# 与 FFT 相同的注意事项:睡眠时间是在测量迭代之间,不包含在 ``latency_ms_device`` 中——只有 GPU 的矩阵乘法时间包含在样本列表中。
SLEEP_MS = int(os.environ.get("SLEEP_MS", 150))

我想到了这个时候,你可能已经意识到这里没有任何领域特定的内容。这些数值恰好来自于一个信号处理工作负载,与矩阵乘法相邻,但如果你替换为自己的两个代理——一个轻量且有截止时间,一个重量且稳定——这个故事依然成立。这是一篇关于两个工作负载在同一个加速器上碰撞的文章,而不是关于任何特定应用的文章。

不被幻象误导的计时

有一种经典的方法可以对 GPU 进行基准测试并得到一个漂亮但完全错误的数字:测量 Python 启动内核所需的时间。CUDA 是异步的,因此 torch.matmul(A, B) 几乎立即返回,而 GPU 仍在努力工作。测量这个时间,你会满意地认为你的矩阵乘法只用了 50 微秒,然后你开始困惑为什么生产环境这么慢。

工作者不会这么做。他们将每个操作包裹在 CUDA 事件中,并强制执行 torch.cuda.synchronize(),这样时钟会在内核实际在 SM 上完成之后停止:

code
# 在 ``record`` 之前立即开始 epoch,以尽量减少“启动意图”和队列提交之间的间隔,用于联合对齐研究。
epoch_ns_start = time.time_ns()
_ev_start.record()
_ = torch.fft.fft2(data)
_ev_end.record()
torch.cuda.synchronize()
epoch_ns_end = time.time_ns()
latency_ms_device = float(_ev_start.elapsed_time(_ev_end))

elapsed_time 读取 GPU 自己的时间线——亚微秒级分辨率,没有主机侧抖动。那个 synchronize() 的区别在于测量“GPU 工作了多长时间”和“Python 花了多少时间来请求”。然后每次迭代都会生成一行结构化的日志并立即刷新,因此 Kubernetes 日志流可以立即看到它:

code
print(
        f"KTS_APP,v1,FFT,{i},{epoch_ns_start},{epoch_ns_end},{latency_ms_device:.6f},{phase_optional}"
)
# ``flush`` 强制容器的标准输出通过 CRI 进行行缓冲,以便在下一次睡眠之前——没有它,tail -f 可能会批量处理行并打乱联合顺序。
sys.stdout.flush()

原始硅执行时间输入;结构化的日志输出。下游解析器将这些聚合为精确的百分位数,创建一个严格的测量合同,去除所有主机侧的噪声。

3. 两个 pod 如何最终共享一个 GPU(解释)

对于刚接触 K8s 的人来说,这将是感觉像魔法的一部分。对于其他人,你可以安全地跳过这一部分,继续阅读下一部分。

默认情况下,Kubernetes 将 nvidia.com/gpu 视为一个整体、不可分割的资源:一个 GPU,一个请求者,不共享。NVIDIA 设备插件的“时间切片”功能改变了这一会计方式。你只需提供一个 ConfigMap,其内容大致为:“假装每个物理 GPU 是多个”:

code
apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config
  namespace: nvidia-device-plugin
data:
  any: |-
    version: v1
    flags:
      migStrategy: "none"
      failOnInitError: true
    sharing:
      timeSlicing:
        failRequestsGreaterThanOne: true
        renameByDefault: false
        resources:
        - name: nvidia.com/gpu
          replicas: 4

replicas: 4 是 Kubernetes 的表达方式,意思是“对调度器说谎四次”。设置之后,一个物理的 GTX 1080 会向 API 报告四个可分配的 nvidia.com/gpu 插槽。四个 Pod 各自请求“1”个 GPU,都可以被调度,且运行得非常顺利。

重点在这里,用加粗强调,因为整个文章都依赖于这一点:这并没有对硬件进行物理上的分区。这不是 MIG。没有内存隔离,也没有计算隔离。这四个“GPU”是同一块硅芯片,Pod 之间通过 CUDA 的时间切片轮流使用它——就像一个咖啡师在四个队伍之间来回奔跑,为每个队伍服务。更多的可调度插槽,但完全没有任何隔离。

这个实验包括三个 Kubernetes Jobs:每个代理单独运行(基准测试),以及两者同时运行。后者是整个实验的关键部分——两个 Jobs,各自天真地请求一个 GPU,并故意落在同一张卡上:

code
containers:
  - name: worker
  image: localhost/kts-worker:v1
  imagePullPolicy: Never
  resources:
    limits:
      nvidia.com/gpu: "1"
    requests:
      nvidia.com/gpu: "1"

两个 Pod 都不知道对方的存在。它们也没有请求共享。调度器将它们放在同一个“房间”中,因为,就它所知,有四个“房间”。基准测试会告诉你每个代理在独占 GPU 时的运行速度;共享运行则告诉你它们在共用 GPU 时的表现。两者之间的差距就是整个故事。

4. 硬件环境,一句话概括

以下所有内容均在一台七年前的 NVIDIA GTX 1080(8 GB,Pascal)上运行,该卡运行于单节点 K3s 上,使用的是标准的 NVIDIA 设备插件和 CUDA 时间切片。没有 H100,没有 MIG,也没有数据中心机架——只有这张卡,一半的读者可能还把它放在自己的桌下。

我故意使用这台“古董”设备。在 H100 上,糟糕的调度并不会神奇地消失;它只是以更高的时钟速度执行其瓶颈。如果你的代理在一张价值 150 美元的卡上争夺内存总线,投入 3 万美元去解决这个问题并不会阻止交通堵塞,只是让事故更加昂贵。将 H100 用于调度错误并不会解决资源争用问题,只是让你在更少的毫秒内执行糟糕的架构。缓存驱逐的物理规律并不关心你的硅芯片是哪一年制造的。

(对于任何想要复现此实验的人,驱动程序、containerd 和工具包的版本在仓库中都已固定;它们是故意设置为无聊的版本,你不需要它们来理解这个故事。)

5. 证据(即,数字)

现在,整个故事用一张图片来概括:

四块面板,一个笑点。在每个延迟图表中,中位数(每组左侧的柱形图)几乎没有任何变化。吞吐量(底部的图表)只分别下降了7.3%和1.4%——这种数字你向上汇报时,很可能会收到一个竖起大拇指的emoji。而左上角图表的右上角却显示,小型代理的p99延迟增加了66%。同样的仪表板,同样的运行容器,同样的枯燥吞吐量图表——而你两个代理中的一个现在偶尔、不可预测地比昨天慢了66%。欢迎来到GPU共享的世界。

实际的数字,让所有人都不用眯着眼看柱形图:

| 指标 | 独占 | 共享 | 变化 | |------|------|------|------| | FFT | | | | | (延迟敏感) p50 | 3.598 ms | 3.593 ms | 不显著 | | p95 | 3.645 ms | 5.868 ms | 1.61倍 | | p99 | 3.679 ms | 6.101 ms | 1.66倍 | | 抖动 (p99/p50) | 1.02 | 1.70 | 尾部显著增加 | | GEMM | | | | | (计算密集型) p50 | 20.677 ms | 20.669 ms | 20.896 ms | 24.505 ms | 1.17倍 | | 20.985 ms | 24.690 ms | 1.18倍 | | 1.01 | 1.20 | 略有增加 | | 吞吐量 (迭代/秒) | 278.1 | 257.9 | -7.3% | | 49.1 | 48.3 | -1.4% |

仔细读两遍FFT的行。中位数没有变化。如果你盯着p50的仪表板,你可能会觉得什么都没发生,签完字就去吃午饭了。但每100次FFT调用中,有一次现在需要66%更长的时间,而正常迭代和糟糕迭代之间的差距几乎翻了一倍。你并没有平均意义上地减慢代理,而是让它偶尔、不可预测地变慢。这更糟糕,因为现在它变成了一个不可靠的代理,而且在周五下午没人能复现这个问题。

这是关键的不对称性,而且这不是巧合:小的、延迟敏感的代理最先和最严重地退化。大的GEMM就像推土机一样——它拿到自己的量子,然后全力推进。而小的FFT则在执行过程中不断被干扰,被推离SM,被告知要等待下一次轮到它。当两个工作负载共享一条线时,需要快速执行的那个反而遭受了影响。这在电信领域有巨大的影响:如果这种情况持续发生,通话开始掉线,甚至紧急服务号码也可能停止工作。让这个想法在你脑海中沉淀一下!

为了在任何两个代理之间进行比较,该工具计算了一个退化因子(DF)= shared_p99 / baseline_p99。DF = 1.0意味着共享没有影响。数值越高,影响越大。对于此次运行,FFT的DF是1.66,GEMM的DF是1.18。这个1.66就是整个结果压缩成一个数字,你可以把它放在幻灯片上向你的经理展示。

还有这个部分,应该被禁止:吞吐量几乎没有变化。如果你的服务水平目标(SLO)是以平均吞吐量来写的话,你看到“FFT下降7%,GEMM下降1%”就会宣布胜利。但与此同时,你那个对尾部敏感的代理却在每50次中悄悄错过一个截止时间。平均值是竞争藏身的地方。平均值是一个善良的人,他会忽略你最糟糕的时刻。而p99则是那个记得一切的朋友。

一个合理性检查,然后我们继续。分析器还会每100毫秒抓取一次DCGM GPU利用率计数器,并将它们与每个迭代合并。在共享窗口中,FFT工作者的SM和DRAM活动急剧上升(其执行周期现在与GEMM对同一内存系统的猛烈访问重叠);而在独占窗口中,它们没有。因此,竞争在两个完全独立的层次上显现出来——应用延迟和硬件计数器——这正是你知道这是真实情况而非计时器伪影的方式。

6. 这是关于代理群,而不是任何单一工作负载

人们很容易将第 5 节标记为“一个 FFT 和一个 matmul 在 GPU 上展开了一场战斗,这并不让任何写过 CUDA 内核的人感到意外”,但这完全偏离了重点。这两个工作者只是方便且可衡量的代表,它们体现了当真正的代理群组被部署在共享硬件上时立即出现的模式:

  • 轻量级、以截止期限为导向的代理 —— 路由器、防护机制、分类器、工具调用者、小型快速模型。它们单个成本低廉,持续运行,整个流程都依赖于它们。(FFT 工作者是这种类型的一个具体例子。)
  • 重型、稳定的代理 —— 大型 Transformer 前向传播、以 GEMM 为瓶颈的模型调用,这些在计算中占主导地位。(GEMM 工作者是这种类型的一个具体例子。)

将任意两个具有上述特征的代理放在一个时间切片的 GPU 上,你将得到我所测量到的完全相同的结果:中位数几乎不会变化,但小而关键于延迟的代理却会占据尾部。代理执行什么任务并不重要;重要的是它们在 SM 上的行为 —— 一个需要快速且频繁地完成,另一个只想持续运行。时间切片分配的是轮次,而不是截止期限。因此,那个生死取决于截止期限的代理,当它的轮次不断被中断时,它将受到严重影响。

这就是贯穿整个系列的系统主题。第一部分是关于不要在代理之间重复工作(共享 KV 缓存)。这一部分则是关于不要欺骗自己,关于共享 GPU 对这些代理所付出的代价。时间切片为你提供了容量 —— 单张卡上更多的可调度插槽 —— 但不会提供任何隔离。如果你只关注平均值,那么你最敏感于截止期限的代理将最先在 p99 中无声地崩溃,而每个 Pod 仍会持续显示“Running”。

7. “那……我实际上该如何运行它?”

流水线被刻意设计得枯燥,因为在系统工程中,“令人兴奋”通常意味着生产环境已经起火了。它是一个线性构建 → 集群 → 日志 → 指标图,从仓库根目录驱动:

  • run.py 使用 Podman 构建工作者镜像,将其导入 K3s 的 containerd,创建命名空间,可选地启动一个 DCGM 抓取线程,应用 Jobs,等待,然后将日志收集到 logs/run-<UTC>/ 中。
  • 工作者会发出你上面看到的每轮迭代的 KTS_APP 行。
  • generate_results.py 解析日志,去除预热部分,计算 p50/p95/p99,吞吐量代理,退化因子,以及 DCGM 的连接,然后将数据写入 data/summary.{csv,json},图表和 docs/results.md。

如果节点上已经安装了 K3s、NVIDIA 驱动、容器工具包、设备插件和 nvidia RuntimeClass,那么整个过程只需要三个命令:

code
# 1. 安装时间切片的 ConfigMap 并重新加载设备插件
kubectl apply -f time-slicing-config.yaml

# 2. 构建工作者镜像并运行完整的基准测试(构建、导入、Jobs、日志)
python3 run.py

# 3. 将日志转换为摘要、图表和结果页面
python3 generate_results.py

仓库链接?嗯,你可以在文章顶部附近找到它。恭喜你一路坚持到这里 —— 我几乎没想到会有人做到!

  • 它是两个代理,而不是五十个。配置中暴露了四个逻辑插槽;高亮显示的运行情况将一个FFT工作者与一个GEMM工作者配对。这是最小的有趣竞争情况,为了清晰度而被选中。填满所有四个插槽(完全竞争矩阵)是未来的路线图,但不在这些数据中。我没有报告五十个代理的结果,因为我没有测量五十个代理。
  • 吞吐量是一个平均速率的代理。1000 / 平均延迟是一个迭代速率,而不是在真实到达过程下的请求处理吞吐量。它在“平均值掩盖了尾部”的观点中发挥作用,但没有更复杂的含义。
  • 工作负载是合成的。一个循环的FFT和一个循环的矩阵乘法是轻量、延迟敏感代理和重量级推理代理的诚实替代品,但它们并不是在真实流量下运行的完整模型。干扰的形状具有普遍性,但绝对的毫秒数不具备。
  • DCGM活动是一个低幅度的代理。工作者通过睡眠来调节自己的节奏,因此GPU经常处于空闲状态,SM/DRAM的平均值看起来很小。将它们视为研究内部的相对信号——它们支持延迟的故事,但并不声称达到完全饱和。
  • 时间切片并不是唯一的共享模式。正如第7节所述,本研究有意测量默认路径——这是大多数人开启GPU共享时立即获得的路径。与MPS和MIG的正面交锋是另一篇文章的内容。
  • 一个GPU类别,一个运行被高亮显示。数据来自单个Pascal GTX 1080 GPU。更新的GPU上下文切换更快,绝对尾部的数值也会减少;但方向——小的延迟敏感代理首先退化——是持久的结果。

这些内容并没有改变结论。它只是让我保持诚实,说明其范围——而一旦基准测试文章隐藏了其限制条件,它的数据就不再有任何价值。

9. 总结(以及第三部分的设置)

Kubernetes的时间切片是一种美妙的幻觉。它告诉你的调度器一个GPU是四个,允许四个Pod报告Running状态,然后悄悄地将它们锁在一间房间里争夺内存总线。对于吞吐量受限、截止时间宽松的工作,这种幻觉是无害且真正有用的。但对于代理群中延迟敏感的成员,这种幻觉隐藏了你最不希望看到的地方:p99。

解决方案不是禁止GPU共享——除非你有无限的预算,否则你必须共享硬件。解决方案是停止将绿色的YAML对勾作为微架构现实的替代。测量尾部,归因退化,并以实际的硅限制为依据进行调度。Kube-TimeSlice-Profiler是朝着正确方向迈出的一步:它将“GPU今天似乎很慢”的模糊感觉转化为可衡量的退化因子,并附有证据。

如果你是初学者,只是想知道为什么“两个Pod都处于Running状态”并不意味着“两个代理都感到满意”:恭喜你,你现在对GPU共享的理解已经超过了绿色对勾。继续对你的平均值保持怀疑吧,你已经准备好了!

接下来的内容:PCIe的耻辱之旅

我们刚刚成功地让两个代理在不欺骗自己关于延迟尾部的情况下争夺一个GPU。但RAG流水线中还埋藏着另一个无声的税:PCIe的通勤。

目前,每当代理需要检索上下文时,它会暂停,离开加速器,穿越PCIe总线回到Python,运行一次向量搜索,然后再费力地返回。

在第 3 部分,我们将彻底消除这种通勤过程。我们将构建一个自定义的 CUDA Top-K 内核,以确保整个检索循环完全在 GPU 硬件上运行——无需 Python 的往返通信,也无需主机端的延迟。使用相同的预算 GPU,坚持“停止浪费硬件”的理念。

第 3 部分,我们再见。

免责声明:本文中的插图是使用 AI(Claude Opus 4.8)生成的。这些插图是示意性的,而非照片,图像中可见的任何标签都是风格化的,而非权威性的——请参阅文章正文和代码本身以获取精确的功能名称、指标值和架构细节。

作者

查看 Anubhab Banerjee 的所有文章

,

深入探讨

Kubernetes

Mlops

系统架构

分享本文

  • 在 Facebook 上分享
  • 在 LinkedIn 上分享
  • 在 X 上分享

Towards Data Science 是一个社区出版物。提交你的见解,以触达我们的全球受众,并通过 TDS 作者支付计划获得报酬。

将 href 更新为你的实际提交 URL

为 TDS 写作

✦ 结束 CTA ✦