接了个内部系统的技术复盘,把这一年多跟DSec(DeepSeek Elastic Compute)打交道的经历好好捋了一遍。简单说,DSec是我们团队自研的一套弹性计算平台,核心目标就一件事:给AI训练、微调和在线推理任务提供一个统一、弹性、不浪费的算力底座。这个方向不算新,但真把一个平台从设计到落地完整走一遭,中间踩的坑和想明白的事情,比读十篇架构文档都值钱。
先说结论,上线稳定运行后,GPU平均利用率从原来的35%左右提到了70%以上,作业排队时间从小时级压到分钟级,推理服务在流量突增时能做到分钟级完成扩容,训练任务的调度耗时控制在秒级。这个数据在我们内部算是一个比较大的改善。但比数据更重要的,是这一套设计背后的取舍逻辑——什么东西用K8s原生方案,什么东西必须自己造,哪些环节要做得重一些,哪些环节能省就省。这篇文章就是把这些决策过程、实现细节、调优记录和排查实录完整写下来。
这篇内容适合正在搭AI基础设施的团队,特别是对K8s有一定了解、但卡在GPU资源管理和弹性伸缩细节上的同学。也适合准备做平台型项目的个人开发者参考。如果你只是想知道DSec能干什么,一句话版本就是:它能把一堆零散的GPU资源池化,按需切分、动态分配,让训练任务和推理服务共享一套底座并且互不干扰。
1. 项目定位与核心需求拆解
1.1 旧有的算力管理方式到底卡在哪
DSec这个项目不是凭空冒出来的,它是对一套旧方案的彻底重构。之前团队的算力管理方式说起来很简单,就是“静态分配加人工协调”。训练任务提申请,管理员在集群里找空闲的GPU,然后手动把节点绑给任务;推理服务则是固定申请一批卡,长期占着。这种方式在集群小、任务少的时候还能凑合,但集群规模一上来,问题就非常明显。
第一个问题,资源利用率低得吓人。固定分配的结果是,很多卡在夜间和低峰期完全闲置,但高峰期又有任务排不进去。我们统计过,那时候整集群的GPU平均利用率只有35%上下,一半以上的算力都是被“占着但没用”的状态。第二个问题,训练和推理之间天然存在资源冲突。推理服务要求稳定的可用性,必须预留容量;训练任务则是突发性强、周期长,两者混在一起就容易出现互相抢资源的情况。第三个问题,没有弹性能力。大促或者活动流量上来的时候,推理服务没办法快速扩容,只能靠人肉盯监控然后手动调整。
旧方案的这些痛点,说到底就是三个关键词:静态、隔离、无策略。资源是物理分割的,任务是一个一个排队的,扩缩容基本靠人工判断。这个背景下,团队开始讨论要不要引入一套真正的弹性计算平台。
1.2 方案选型:为什么做在K8s之上而不是另起炉灶
方案选型的过程其实挺有意思。当时大家提了好几个选项:自己从零写一套资源调度系统,基于K8s定制扩展控制器,或者直接在物理机上做脚本化的容器管理。也认真考虑过是否完全基于K8s原生组件,不写一行额外代码。
我们最后的决策是:保留K8s作为基础设施层,在它之上实现自定义调度器和一套弹性控制面。理由有三条。第一,K8s在容器编排、生命周期管理、服务发现和故障自愈这些方面已经足够成熟,没有必要重新造轮子;但原生的默认调度器在细粒度GPU管理上确实不够用,比如它不感知显存大小、不会做拓扑亲和性调度,也不支持训练任务常见的Gang调度语义,这个层面必须自己做。第二,训练任务和推理任务的是复杂的、长周期的workload,原生HPA(Horizontal Pod Autoscaler)只针对无状态Web服务的简单扩缩容,对AI推理场景里的模型预热、流量灰度、GPU切分这些需求完全覆盖不到。第三,团队有资源,也有精力去维护额外的一层控制面,而且这一层控制面可以成为团队的长期技术积累。
说实话,这个决策也有风险的。自研控制面意味着要维护更多代码,而且一旦K8s版本升级,很多扩展接口都有兼容性成本。但从结果看,收益是远大于成本的。
提示:如果你的业务场景只是少量GPU跑跑Jupyter或者固定几个推理服务,K8s原生方案就够用了。真正需要DSec这类平台的,是集群规模大、任务类型杂、资源利用率压力大的团队。
2. 架构设计与核心模块拆解
2.1 模块划分与职责边界
DSec的架构在设计上分成了五个核心模块,每个模块的职责边界很清晰,配合关系比较简单:
- 资源抽象层:负责把物理GPU池化,抽象成带有显存、算力、拓扑标签的资源对象。这一层屏蔽了底层GPU型号差异,让上层不需要关心节点插的是A卡、B卡还是C卡。
- 调度引擎:负责任务到资源的映射,包含队列管理、优先级排序、Gang调度、拓扑亲和性和抢占逻辑。
- 任务管理模块:负责任务生命周期管理,包括提交、取消、重启、日志收集和状态回传。
- 弹性控制面:负责推理服务的自动扩缩容,以及训练任务的弹性容错和动态调整。
- 数据与缓存层:负责权重文件、数据集的缓存和加速分发,避免任务启动时反复从远端拉数据。
这五个模块之间通过事件和RPC通信。调度引擎是核心,其他模块都围绕它来转。任务管理模块把用户提交的作业解析成内部数据结构,交给调度引擎做决策;调度引擎决策完成后,通过K8s API创建Pod;弹性控制面监听监控数据,反馈给调度引擎做扩缩容决策。
2.2 调度策略的设计取舍
调度器是整个DSec中最有嚼头的模块。我们花了最多时间打磨三个策略:排队策略、Gang调度和抢占策略。
排队策略上,我们没有用K8s原生的PriorityClass加DefaultScheduler的简单队列,而是实现了自己的优先级队列。每个提交的任务自带优先级和资源需求,调度器按照加权公平算法计算得分,然后依次调度。这个队列的设计要考虑几个维度:队列深度、等待时长、资源缺口大小、任务所属业务线的配额权重。一开始我们的实现只按优先级排序,结果低优先级任务等了三四个小时都排不上,后来加了一个等待时间衰减机制,任务等待越久权重越高,自然平衡了优先级的饥饿问题。
Gang调度是训练任务特有的需求。一个分布式训练任务往往需要一次性申请多张卡,比如一个8卡的训练任务,如果调度器只调度到6张卡,任务根本无法启动,还白白占着6张卡。所以我们实现了All-or-Nothing语义:任务必须拿到全部所需资源才会被调度器下发,否则继续留在队列里等待。这个策略看起来简单,但实现上非常需要细心,尤其是资源预留和锁定的问题时序很容易出错。标准做法是先做资源可行性判断,把所有候选节点算出来,然后做资源预留,再绑定到节点。如果过程中任何一步失败,要做事务性回滚,不能留下资源残留。
抢占策略这块,我们的原则是“最小代价抢占”。训练任务和推理服务混部的时候,如果高优先级任务进来了但资源不够,调度器会选择抢占低优先级的训练任务,而不是动推理服务。因为训练任务有检查点机制,被抢占后可以从最近的checkpoint恢复,损失可控;推理服务一旦被抢占,直接影响线上流量。抢占时还需要考虑反亲和性,避免同一个训练任务的多个worker被抢得七零八落。
2.3 数据与缓存层的设计
数据流是平台容易被低估的模块。AI任务启动的第一件事往往是加载数据集和模型权重。如果每个任务都从头拉数据,再快的网络也扛不住。
DSec的数据层设计了一套二级缓存机制:第一级是本地节点磁盘热缓存,第二级是集群内的分布式缓存服务。调度器在做节点选择的时候,会把数据缓存命中率作为一个调度权重——优先把任务调度到已经缓存了所需数据集的节点上。这个设计直接解决了重复拉数据的开销,尤其在大模型推理场景,效果非常明显。
缓存淘汰策略上,我们用的是LRU变种,加了尺寸权重的概念。因为模型文件动辄几GB甚至几十GB,小文件把大文件挤掉是常见的问题。所以我们按“访问频率×文件大小”计算权重,既保留高频的小文件数据,也保留低频但体积巨大的模型权重。这个策略上线后,推理服务的冷启动时间从平均120秒降到了8秒左右,代价只是后端存储多占了不到两倍的冗余空间。
3. 弹性伸缩与GPU资源管理实现机制
3.1 弹性伸缩的指标设计:不是越多越好
做弹性控制面的时候,我们花了大量时间讨论用什么指标来触发扩缩容。最容易想到的方案就是组合多种指标,比如CPU使用率、GPU利用率、请求QPS、队列长度,然后设置多个阈值。但实践证明,指标越多,出问题的概率越大。指标抖动、阈值冲突、不同指标给出相反信号的时候,整个扩缩容逻辑会变得极其难调试。
后面我们收敛设计方案,把指标分成三类:容量指标(GPU利用率、显存占用率)、负载指标(推理QPS、训练Step吞吐)和饱和度指标(请求排队长度、等待时间)。扩缩容触发以负载指标和饱和度指标为主,容量指标只做辅助判断。
具体的扩容公式用的是经过平滑的线性模型:
目标副本数 = ceil(当前副本数 * (当前指标值 / 目标指标值))这个公式本身很朴素,关键在平滑。我们用的是5分钟滑动窗口加指数加权平均,每秒采样一次,但最终进入决策的是一个平滑后的趋势值。这样避免了一瞬间的流量毛刺触发扩容,然后流量恢复又触发缩容的震荡。
冷却时间也是重点。扩容冷却时间设为1分钟,缩容冷却时间设为5分钟。为什么扩容比缩容激进?因为扩容是应对风险,宁可多扩一点;缩容是节约成本,要确认流量是真的下去了再缩。这个不对称设计后面在实战中证明是必要的。另外还有一个细节,缩容时每次最多减少当前副本数的20%,防止一台一台连续删实例导致可用性波动。
注意:弹性伸缩最怕的不是决策慢,而是决策反复。一次误扩容可能只是浪费钱,但一次缩容误杀就是线上事故。所以冷却时间和平滑窗口宁可调大,不要调小。
3.2 GPU资源切分:整卡、切片还是时间片
GPU资源池化最核心的问题,是一张卡能分成多少份。我们的实践是分成三个层级:
第一层是整卡分配,适合大模型训练和重负载推理,独占整张卡,性能和隔离性最好。第二层是显存切片,基于底层硬件和驱动能力,一张卡切分成若干独立的小块,每块有自己的显存和算力配额。这个模式适合中小模型的推理服务,缺点是一张卡最多只能切固定的份数,不够灵活。第三层是时间片轮转,多个任务共享一张卡的计算单元,通过时间片分时复用,适合轻量级的批量推理和试验型任务。
三个层级决定的策略是:调度器先尝试整卡分配,如果任务的显存需求小于单卡的某个阈值(比如单卡显存的四分之一),才会尝试切片;切片也不满足,才考虑时间片。这个优先级顺序是为了保证高优先级任务的计算性能稳定,减少共享带来的干扰。
这里有个容易踩的坑:切片模式下的容错恢复比整卡复杂。整卡模式下如果GPU故障,只需要把Pod重新调度到另一台节点;但切片模式下,故障卡上其他切片的任务也会受影响,而且由于硬件资源视图是逻辑的,排障时很难定位到具体物理位置。所以我们额外做了一层“物理卡健康检查”,每个节点上的DaemonSet会周期上报每张物理卡的运行状态,一旦发现硬件故障,立即冻结该卡上的所有Pod调度,而不是等到任务失败才处理。
3.3 推理服务扩缩容的特殊处理
训练任务的弹性是“排队+等待”,推理服务的弹性是“实时+平滑”,两者的处理逻辑完全不同。推理服务的扩缩容我们做了三个额外的机制。
模型预热是最关键的一个。一个大模型推理实例从创建到真正能处理请求,中间要经历加载权重、构建计算图、缓存预热。如果不做预热就放流量,前几分钟的请求会大面积超时。实现方式是在Pod启动后,通过健康检查接口挡住真实流量,等预热完成后才摘掉屏蔽标记。预热完成的判断标准不是进程启动了,而是模型已经加载、首次推理延迟已经降到正常水平的90%以下。
最小实例保障是第二条线。不管流量多低,每个推理服务至少保留2到3个实例。这个设计是为了应对流量突然回切的情况,避免服务被缩容到只剩1个实例,然后一个弹性的浪涌流量直接把唯一实例打挂。训练服务也类似,每个训练任务至少保留一个主worker做检查点保存。
流量渐进式灰度是第三个机制。扩容出来的新实例不会一瞬间承接全部流量,而是按10%、30%、50%、100%的比例逐步接入。每一步接入后观察一小段时间的错误率和P95延迟,如果指标恶化就停止接入,并把新实例回收重扩。这个和发布系统的灰度逻辑很像,但套用到弹性伸缩上,能避免新实例还没完全就绪就被压垮的尴尬局面。
4. 关键参数、配置与实际调优记录
4.1 初始配置和压测场景设计
DSec上线前,我们做了一轮规模不算小的压测,目的不是测极限性能,而是找到一套适合内部业务的默认参数。压测场景分三块:训练任务密集提交、推理服务突发流量、训练推理混部场景。
训练任务提交压测,模拟100个训练任务同时提交,每个任务申请4到8卡,资源总量为128卡,验证排队公平性、调度延迟和Gang调度表现。推理服务压测,模拟在线流量从低峰到高峰再到低峰的完整曲线,峰值流量是平时的5倍,验证扩缩容的响应速度和稳定性。混部压测,训练任务和推理服务同时提交,高优先级推理任务抢占低优先级训练任务,验证抢占不伤推理服务。
压测工具方面,训练任务用一个内部脚本模拟器来提交(实际跑的是假的权重加载流程),推理流量用一套压测工具打真实请求,并记录QPS、延迟分布、错误率等指标。观察窗口持续48小时,因为弹性系统往往是长时间运行才能暴露出问题。
4.2 参数调优过程:从第一版到稳定版
第一版的参数设置其实挺保守的。调度队列的轮询间隔是5秒,扩缩容冷却时间是扩容3分钟缩容10分钟,弹性触发的目标利用率是60%。跑下来问题很明显:调度太慢,一个紧急任务提交后要等小半分钟才被调度到;扩容太保守,流量突增后的几分钟内出现明显延迟上升。
第一轮调整,调度轮询间隔从5秒改成1秒,调度延迟从平均10秒降到2秒以内。代价是调度器CPU占用率略微升高,但完全可接受。第二轮调整,扩缩容的触发目标从60%上调到75%,让实例在真正忙起来之前就被扩容到位。同时把扩容冷却时间从3分钟压到1分钟,缩容冷却时间保持10分钟。
第三轮调整针对混部场景。测试中发现,抢占过程中低优先级训练任务的检查点保存和资源释放顺序存在问题,导致高优先级任务等了20多秒才拿到资源。我们在抢占流程里加了并行驱逐机制:先预选目标节点,并行发驱逐信号给低优先级任务,同时提前分配新的加密等待队列,把资源释放和分配重叠起来。优化后抢占耗时降到3秒以内。
表格里是最终稳定运行的参数配置:
| 参数项 | 初始值 | 稳定值 | 调整理由 |
|---|---|---|---|
| 调度轮询间隔 | 5秒 | 1秒 | 降低任务调度延迟 |
| 扩容冷却时间 | 3分钟 | 1分钟 | 提高突发流量响应速度 |
| 缩容冷却时间 | 10分钟 | 5分钟 | 平衡成本与稳定性 |
| 弹性目标利用率 | 60% | 75% | 减少过度预留浪费 |
| 指标平滑窗口 | 60秒 | 300秒 | 抑制毛刺导致的抖动 |
| 缩容单次最大比例 | 无限制 | 20% | 防止缩容导致可用性波动 |
| 数据缓存权重 | 纯LRU | 加权LRU | 保护大文件不被频繁淘汰 |
4.3 上线后的对比数据
经过前后约两个月的压测、调优和灰度,我们记录了上线前后的关键对比数据。调度延迟从分钟级降到秒级、作业排队时间从平均2.3小时降到20分钟以内、GPU平均利用率从35%提升到72%。冷启动时间这个指标对我们感触最深,因为它是用户直接感知得到的体验指标,从原来的分钟级压缩到秒级之后,业务反馈“快得不像同一个平台”。
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 作业平均排队时间 | 约2.3小时 | 20分钟内 | 提升85% |
| GPU平均利用率 | 35% | 72% | 提升105% |
| 任务调度延迟 | 分钟级 | 2秒以内 | 降低90%以上 |
| 推理服务冷启动时间 | 约120秒 | 约8秒 | 降低93% |
| 抢占耗时 | 约20秒 | 约3秒 | 降低85% |
| 推理服务突发扩容耗时 | 10分钟级 | 3分钟以内 | 提升70% |
必须说的是,这些数据不是极限调优的结果,是在保证稳定性前提下的自然收益。后续如果继续优化调度算法和缓存策略,这个数字还有进一步提升空间。
5. 踩坑记录与问题排查实录
5.1 扩缩容震荡问题:副本数反复横跳
DSec灰度期间遇到的最典型问题就是扩缩容震荡。某个推理服务在流量稍微波动的时候,副本数在10到20之间反复横跳,每5分钟一次,看起来非常不稳定,还造成了资源浪费。排查后发现根因是监控指标的抖动被扩缩容逻辑直接放大了。当时用的是单点的1分钟平均采样,指标在阈值附近来回波动,导致系统先扩后缩、反复横跳。
解决方案分两层。第一层是引入5分钟滑动窗口加指数加权平均,让指标曲线变得平滑。第二层是设置了一个死区区间,当前指标在目标值的90%到110%之间时,不触发任何扩缩容操作。这个死区本质上就是一个“非敏感区域”,只有指标显著偏离目标才做决策。这两层加上不对称冷却时间,扩缩容震荡问题基本消失了。
5.2 冷启动时间太长:权重加载路径的优化
有个模型服务的冷启动时间一开始是150秒,完全不可接受。排查后发现瓶颈不在镜像拉取,而是模型权重的加载速度。DSec的调度器虽然在数据缓存层做了亲和性设计,但权重文件从分布式缓存读到本地,然后加载进GPU显存的链路是串行的,每一步都有不小的开销。
优化手段有三个。第一,权重文件按照层次拆分成独立分片,支持并行加载,而不是整个文件一次性读进来。第二,在节点本地增加持久化的模型缓存目录,同一模型的多次启动直接在本地复用。第三,优化了GPU显存分配的策略,让加载过程对显存的请求尽量连续,减少碎片化的分配和释放。做完这三项优化,冷启动时间从150秒降到了8秒左右,效果非常显著。
5.3 慢任务占住资源不释放:僵尸任务检测
平台运行一段时间后发现一个隐蔽的问题:有少量训练任务提交成功后,训练进程由于代码bug卡住,既没有正常退出,也没有持续资源消耗。它们像一个“僵尸”一样占着GPU资源不动。如果没人发现,这些任务会无限期占用资源,直接影响集群的有效利用率。
排查思路是结合任务状态和资源使用情况做综合分析。正常情况下,训练任务即使训练缓慢,也会有日志输出、指标上报或显存写入这样的“活性迹象”。如果一个任务超过30分钟没有日志输出、没有资源使用变化、没有网络通信量,会被标记为疑似僵尸任务,进入隔离观察区。观察10分钟后仍然没有变化,自动终止并通知用户。
第一版实现触发了一些误杀,比如跑长任务间歇性保存模型时正好处于休眠期。后来加了“用户指定任务类型”的开关,禁用了误杀标记。总体上这套僵尸检测机制为平台收回了大约6%的浪费资源,是一个性价比很高的优化。
写在最后:一些真实的个人体会
DSec这套平台折腾下来,我最大的感受是,做这类基础设施项目,最难的往往不是技术本身,而是怎么让业务方信任这个平台,并愿意把真实任务迁移过来。
迁移的意愿取决于两件事:第一,平台能不能保证稳定性,训练跑到一半被杀死是不能接受的事情,所以要有完善的检查点机制和优雅驱逐流程。第二,平台能不能让用户看到收益,当业务方看到排队时间从2小时降到20分钟,冷启动从两分钟变成8秒时,迁移阻力就会小很多。所以我们做了很详细的任务运行报告,把资源利用率、调度等待、弹性表现等数据定期同步给业务方,让他们直观感受到DSec的价值。
另外一个比较重要的经验是,弹性和利用率之间要主动做出取舍。一开始我们想把GPU利用率冲到90%以上,实际发现这个目标会让调度变得过于激进,调度器为了填满资源会把一些高优先级任务拆到碎片上,反而影响了训练性能。最终我们维持在70%到75%的区间,留出一定buffer来应对突发的调度需求。这个区间是经过反复测试才确定的,如果一开始就咬着高利用率不放,后面可能需要花更多时间处理稳定性问题。
最后,团队工程文化的积累也很重要。调度器、弹性控制面、数据缓存这些模块都不是一次性做完的,而是经过灰度、压测、线上故障逐步沉淀下来的。每个模块都积累了完整的故障预案和调优记录。这也是我愿意把过程写下来的原因。如果你也在搭类似的平台,希望这些经验能帮你避开一些我们已经踩过的坑。