多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深度强化学习云工作流调度:DDPG优先级调度实战解析

深度强化学习云工作流调度:DDPG优先级调度实战解析 简介一套基于深度强化学习的云工作流调度完整Python工程面向对云工作流调度与智能优化算法感兴趣的开发者、毕业设计及课程设计学生。资源围绕数据预处理、模型构建、训练与评估完整流程展开覆盖任务分配、资源利用率优化等核心问题结合详细注释与项目说明可帮助学习者快速理解DRL在云工作流调度中的落地方法。压缩包共134个文件大小11.2MB以Python脚本、pth模型权重、npy数据文件为主体另含xlsx/xls表格、png可视化图、json配置、pkl序列化对象及Markdown说明文档其中py脚本支撑数据生成与训练评估pth保存训练好的模型参数npy为预处理后的工作流与任务数据png便于观察训练趋势与调度效果目录结构清晰方便按模块阅读与二次开发。附带的模型权重与训练日志可支撑对照实验适合初学者从零搭建环境并复现也供有经验的开发者参考调优。当前已有52人浏览或学习使用。1. 深度强化学习凭什么接管云工作流调度一个完整毕设项目的拆解把云工作流调度交给深度强化学习很多人的第一反应是“杀鸡用牛刀”。但真跑过实验的人会有相反的经验云工作流调度这个经典组合优化问题静态启发式算法在动态异构环境下频繁翻车而深度强化学习能在训练中直接学到“下一步任务分给哪台虚拟机”的决策倾向。这份项目源码正好是一个完整闭环数据、模型、训练、评估、详细注释和项目说明全部在压缩包里。特别适合两类人——正在做深度强化学习方向毕设或课设的学生以及想找个非游戏环境练手 DRL 工程的开发者。我把它完整拆了一遍下面按“建模 → 选型 → 训练 → 避坑 → 验证”的顺序讲。2. 问题建模与数据准备DAG、状态向量和奖励函数是调度问题的地基想把深度强化学习用在云工作流调度上第一步不是写网络而是把调度问题翻译成强化学习能理解的语言。这一步做不好后面所有训练曲线都是在自欺欺人。整个翻译过程可以拆成三个问题工作流长什么样、调度器每步看到什么、每步决策后怎么给奖励。2.1 工作流调度的本质DAG、依赖约束与 makespan工作流本质上是一张有向无环图DAG节点是任务边是任务之间的依赖关系。一个任务只有等它所有前驱任务执行完毕才有资格被调度。调度器要做的是决定每个任务放到哪台虚拟机、什么时候开始执行在满足依赖约束的前提下让整体完成时间最短这个整体完成时间就是 makespan。听起来像经典调度问题但云环境让它难了不少。虚拟机是异构的同一段代码在不同规格的 VM 上执行时间差好几倍任务之间还有数据传输开销运行过程中 VM 性能还会波动。HEFT 这类静态启发式算法在理想模型下表现很好可一旦实际执行时间偏离预估按静态排序做出的调度就各种白等。更关键的是启发式算法不会从历史调度经验里学习同样的环境波动第二次出现时它还是按老规则走。把这个问题变成强化学习能解的 MDP思路是这样状态是当前工作流的就绪任务、任务预估执行时间、各 VM 负载动作是这一轮把哪些就绪任务派到哪里奖励是这一轮调度决策带来的 makespan 变化。深度强化学习的价值不在于替代 HEFT而是通过大量交互学到“看着工作流当前状态就知道该偏向谁”的策略。2.2 状态空间与动作空间把调度决策翻译成向量状态设计是这一步最花时间的环节。常见做法是拼三块信息工作流特征、就绪任务信息、集群状态。工作流特征包括每个任务在每类虚拟机上的预估执行时间矩阵和任务依赖深度就绪任务信息是当前所有可调度任务的预估时长分布集群状态是每台 VM 当前的负载、排队任务数和累计利用率。这三块拼成一个定长向量输入网络前做归一化。归一化不是可选项时间数值动辄几百上千不归一化会让梯度计算吃尽苦头。动作空间的选择直接决定算法路线。如果只做“任务分配给哪台 VM”这种离散决策动作空间大小是任务数乘 VM 数工作流一大就组合爆炸。更巧妙的做法是让网络输出连续的优先级分数Actor 网络为所有任务输出一个 0 到 1 之间的优先级调度器每轮只对当前就绪任务按优先级排序优先派给最早空闲的 VM。这样动作维度从“任务×VM”降成“任务数”而且连续动作天然适合 DDPG 这类算法。奖励函数是个容易翻车的地方。直接用负 makespan 当奖励数值量级太大训练初期梯度会被噪声淹没。我一般会先用 HEFT 跑一遍同一工作流拿到参考 makespan然后让奖励等于当前 makespan 和 HEFT makespan 的比值取负值。这样奖励被压到 1 附近不同规模工作流之间还能横向对比。这个设计在毕设答辩时也容易讲清楚。2.3 数据读取与预处理工作流文件如何变成张量工作流数据集通常以文件形式存放每行描述一个任务的预估执行时间和它的前驱任务列表。不同项目的数据格式不完全一样但核心字段跑不出这几个任务 ID、预估执行时长、前驱列表、任务所在层数。读取这文件的代码逻辑和整个调度流程强绑定写清楚它后续所有数据预处理都有抓手。def load_workflow(dag_file): tasks {} with open(dag_file, r) as f: for line in f: row [float(x) for x in line.strip().split(,)] tid int(row[0]) tasks[tid] { runtime: row[1], # 归一化前的预估执行时间 parents: [int(x) for x in row[2:]] if len(row) 2 else [] } return tasks这段代码把每一行读成三个字段任务 ID、执行时间、前驱列表。前驱列表为空说明这是入口任务可以立即进入就绪队列。读取之后要顺手做两件事计算每个任务的依赖深度再按深度分层方便后面判断任务什么时候进入就绪状态。很多初学者把 DAG 读进来就直接丢给网络结果状态里全是原始时间数值训练起来 reward 纹丝不动其实就是少了归一化这一步。构建状态向量时就绪任务信息要填充成定长向量因为网络输入维度不能变。常见做法是设置一个最大任务数不够的位置补 0超出的部分截断。VM 负载向量同样做归一化用当前排队任务量除以最大队列长度。这份资源里的数据目录就包含了多组不同规模的工作流文件拿到后先跑一遍这段读取代码确认数据能正确解析成 tasks 字典再往下走。3. 选 DDPG 不选 DQN连续优先级输出与 Actor-Critic 实现算法选型是这种项目里最容易被问“为什么”的地方。很多初学者一上来就选 DQN因为资料多、教程熟但云工作流调度场景里 DQN 有它绕不过去的坎。这一章把选型逻辑和网络实现讲透读完你就能自己回答“为什么不用 DQN”这类问题。3.1 动作空间决定算法路线DQN 的别扭与 DDPG 的顺手DQN 只能输出离散动作。要把调度问题塞进 DQN动作要么是“当前任务选哪台 VM”要么是“下一轮调度哪个任务”。前者的问题是动作数量等于 VM 数乘以任务数工作流一复杂动作空间就爆炸后者的问题是输出层长度要等于任务数不同工作流任务数还不一样网络结构没法统一。DDPG 走的是另一条路Actor 网络直接输出一个连续向量每个维度对应一个任务的优先级。调度器拿到这个向量后把当前就绪任务按优先级降序排列依次分配到最早空闲的虚拟机上。这个“输出优先级、再排序、再分配”的模式既保留了连续动作空间的平滑性又绕开了离散动作空间过大的问题在相关工作里是最常见的落地范式。PPO 也能做类似事情但实现复杂度明显高于 DDPG对毕设项目来说 DDPG 的收敛速度调起来更直觉。这套逻辑在项目说明里应该有明确交代。因为训练框架不是核心矛盾下面代码统一按 PyTorch 写法展开思想在 TensorFlow 或 Keras 里一模一样。3.2 Actor-Critic 网络结构优先级输出与价值评估import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, state_dim, action_dim, hidden256): super().__init__() self.fc1 nn.Linear(state_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.fc3 nn.Linear(hidden, action_dim) def forward(self, s): x F.relu(self.fc1(s)) x F.relu(self.fc2(x)) return torch.sigmoid(self.fc3(x)) # 输出 0~1 的优先级分数 class Critic(nn.Module): def __init__(self, state_dim, action_dim, hidden256): super().__init__() self.fc1 nn.Linear(state_dim action_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.fc3 nn.Linear(hidden, 1) def forward(self, s, a): x torch.cat([s, a], dim-1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x) # 输出当前状态动作对的 Q 值Actor 的输出层用 sigmoid 激活把优先级压缩到 0 到 1 之间保证调度器拿到的排序分数全是正的且可比。隐藏层 256 维对这个规模的问题是够用的太小学不到任务间依赖关系太大容易在小数据集上过拟合。Critic 的输入是状态和动作拼在一起的向量输出一个标量 Q 值衡量“当前状态下采取这组优先级到底好不好”。代码里dim-1指定拼接维度实际含义是把 64 条批量数据在最后一维上对齐拼起来这是最常见也最不容易出错的写法。网络结构里有个细节容易忽略Actor 输出的动作维度要固定为任务总数而不是就绪任务数。还没就绪的任务在解码时直接忽略就行这样网络结构不用跟着工作流变化而改。调度器解码时只看就绪任务里谁的优先级分最高只把分高的任务派出去。3.3 超参数表与“为什么 Critic 学习率要更大”DDPG 对超参数比 DQN 敏感得多参数不对训练曲线就是一条横线。下面这组数值是我在类似调度项目里的起步配置能覆盖大多数中小规模工作流场景。这份资源的项目说明里写了作者最终收敛用的参数直接对照着抄是最高效的路径。参数初始值参数含义与调整方向actor_lr1e-4Actor 学习率调大容易震荡调小收敛慢critic_lr1e-3Critic 学习率比 Actor 大 5~10 倍是常见做法gamma0.99折扣因子调度是长序列决策取接近 1 合理tau0.005Target 网络软更新系数越小越稳replay_size100000经验池容量调度一 episode 步数多池子要大batch_size64采样批量太小梯度噪声大太大更新慢noise_sigma0.2探索噪声标准差训练后期要衰减为什么 Critic 学习率要比 Actor 大因为 Critic 承担了“老师”的角色它必须先学起来才能给 Actor 提供有意义的梯度方向。Critic 如果学不动Actor 收到的梯度信号就是错的越练越偏。实践中经常遇到 Actor loss 在降但整体调度效果变差的情况十有八九是 Critic 还没收敛Actor 就开始瞎优化。Target 网络的软更新是 DDPG 区别于 DQN 的一个关键点。DQN 里目标网络是周期性硬拷贝DDPG 里每个 step 都做一次target tau * current (1 - tau) * target。tau 取 0.005 意味着 target 网络缓慢追踪在线网络这样 Q 值的监督信号不会剧烈跳动。这个机制在毕设答辩里几乎是必问项能用自己的话讲清楚软更新和硬拷贝的差别面试官或评委就会觉得你是真跑过代码的人。4. 训练主循环与评估口径从 episode 曲线到真实调度效果的落差模型定义好之后真正花时间的是训练循环和评估。这一章把 DDPG 的更新流程、TensorBoard 日志里该看哪几条曲线、以及最终评估用什么指标讲清楚。很多项目代码能跑通但说不出“训练到什么程度算好”问题就出在评估口径不明确。4.1 训练主循环采样、更新、软同步、噪声训练循环的骨架不长但每一步都有讲究。下面的框架函数浓缩了 DDPG 的核心流程用带噪声的动作探索环境、存经验池、从经验池采样更新 Critic、再更新 Actor、最后软更新两个 Target 网络。def update(actor, critic, target_actor, target_critic, replay, opt_a, opt_c): s, a, r, s2, done replay.sample(batch_size) with torch.no_grad(): target_q r gamma * (1 - done) * target_critic(s2, target_actor(s2)) q critic(s, a) loss_c F.mse_loss(q, target_q) opt_c.zero_grad() loss_c.backward() nn.utils.clip_grad_norm_(critic.parameters(), 1.0) opt_c.step() opt_a.zero_grad() actor_loss -critic(s, actor(s)).mean() actor_loss.backward() opt_a.step() for tp, p in zip(target_actor.parameters(), actor.parameters()): tp.data.copy_(tau * p.data (1 - tau) * tp.data) for tp, p in zip(target_critic.parameters(), critic.parameters()): tp.data.copy_(tau * p.data (1 - tau) * tp.data)这段代码里值得细看的是target_q的计算。先算出 target Actor 在下一状态的动作再让 target Critic 给它打分最后乘上折扣因子加上即时奖励。(1 - done)的作用是终止状态不再往后估算未来收益这个 mask 忘掉的话训练末期 Q 值会严重偏高。梯度裁剪放在倒数第二行clip_grad_norm_把 Critic 的梯度模长限制在 1.0这是防止 loss 爆炸最直接的手段。Actor 的更新公式直观理解很舒服critic(s, actor(s))是“用当前策略做出的动作能拿多高的 Q 值”取负号做梯度下降等价于让 Actor 不断提高自己动作在 Critic 眼里的评分。软更新的循环手动遍历参数做插值实际工程里也可以用 PyTorch 的torch.optim.swa_utils或者直接写工具函数但手写这个循环最透明调参时一眼能看懂。每轮 episode 结束建议顺手跑一次纯确定性策略评估也就是不加噪声让 Actor 直接输出优先级算一次 makespan 存到日志里。训练过程中的 reward 曲线波动大往往看不出趋势但每轮结束的 makespan 评估曲线能清楚反映策略到底有没有变好。4.2 观察 TensorBoard 日志events.out.tfevents 里该看哪几件事资源压缩包里能看到一组events.out.tfevents.*文件这是 TensorBoard 的标准事件文件。PyTorch 和 TensorFlow 训练时都会产生这种格式的日志文件名里带的时间戳、主机名和进程号是自动生成的一台机器上跑多次实验就会留下多个这样的文件。它们记录的是训练过程中的标量数据比如每轮奖励、每轮 makespan、各种 loss。用 TensorBoard 打开日志后重点看四条曲线。第一条是 episode reward发展趋势应该向上但毛刺多很正常调度问题每轮工作流随机生成奖励天然波动大。第二条是每轮评估 makespan这条曲线比 reward 更有说服力应该缓慢下降并最终稳定在一个区间。第三条是 Critic loss理想情况是前期波动后期收敛到某个小值附近如果它一路走高或者突然竖直拉升就是第 5 章的梯度爆炸问题。第四条是 Actor loss它本身意义有限但能配合 Critic loss 判断训练是否进入良性循环。有件事得提醒多条曲线混在一起看不出实验对比是因为训练时日志路径没分开。TensorBoard 按目录划分 run同一个目录下的同名 tag 会叠在同一条曲线上。正确做法是每次实验写进带时间戳的独立子目录这份资源里多个事件文件其实也说明作者反复调过多轮参数保留这些日志本身就是项目说明的一部分。4.3 评估指标与基线对比不只盯着 makespan 看毕设答辩和实际落地最看重的是评估评估指标选不对结果白跑。常用的评估指标按重要性排大致是下面这四类指标含义使用场景makespan最后一个任务完成的时间最直观、最核心的指标SLRmakespan 除以关键路径长度消除工作流规模差异跨数据集可比资源利用率总执行时间 / (VM 数 × makespan)判断有没有 VM 长期空闲相对加速比基线算法 makespan / 本算法 makespan和 HEFT 等对比时最常用评估方法上有一个容易翻车的点只用一组测试工作流跑一次就下结论。调度结果受工作流随机性和初始化种子影响很大正确做法是用多组不同规模的工作流每组跑 10 次以上不同随机种子取均值和标准差。对比基线的优先级是 HEFT 和随机调度HEFT 是静态调度的事实标准随机调度是下界参考。如果结果比随机调度还差不用怀疑一定是训练出了问题回去查状态归一化或者奖励设计。SLR 这个指标值得多说一句。不同规模的工作流 makespan 数值天差地别但 SLR 消除了关键路径长度的影响让不同数据集之间可以直接比较。论文里常看到“SLR1.2”这种表述意思是调度结果比关键路径理论下界多花了 20% 的时间这个数越接近 1 说明调度越接近最优。5. 避坑笔记奖励不涨、Loss 爆炸、日志覆盖与评估翻车这一章全部是实际跑调度项目时踩过的坑每条都按“现象 → 原因 → 解决”的套路写。DDPG 本身调参就带点玄学调度场景又把状态空间和奖励函数拉得更复杂这些坑我不信有人能全部绕开。血泪经验建议逐条对照排查。5.1 训练几百个 episode奖励纹丝不动现象reward 曲线是一条水平线波动范围始终在同一个量级几百轮训练毫无上升趋势。看 TensorBoard 里的 makespan 也是老样子甚至比随机调度还差。原因九成情况是奖励尺度没归一化。makespan 动辄上千直接拿负 makespan 当奖励TD 误差量级很大梯度在反向传播时数值不稳定策略网络学不到任何有用信号。另一个常见原因是奖励里只有最终 makespan 一个信号中间每一步奖励都是 0相当于延迟奖励过长训练信号稀疏到没法学。解决把奖励压到 1 附近的量级并且尽量拆成密集奖励。我常用的做法是先用 HEFT 跑一遍同一工作流拿到参考 makespan然后把每一步奖励设成当前累积 makespan 与 HEFT makespan 比值的负增量。这样每一步都能看到“这步决策让整体耗时变长还是变短”训练信号密集且尺度适中。step_reward - (current_makespan - last_makespan) / heft_makespan5.2 Critic Loss 指数级上涨曲线一路冲上天现象训练到某个阶段Critic loss 从几千跳到几十万TensorBoard 曲线竖着涨后续训练彻底崩掉。伴随现象是 Q 值狂飙Actor 输出的动作变成极端值 0 或 1。原因核心是更新步长太大。Critic 学习率偏大、没做梯度裁剪、target 网络更新过快三个因素叠加导致 Q 值估计发散。本质上就是监督信号的方差太大训练过程失去了稳定性。解决先给 Critic 梯度加个保险回到 4.1 代码里那行clip_grad_norm_这个动作能兜住大部分发散场景。再把 Critic 学习率降到 1e-3 以下tau 降到 0.005 以下。我踩过最狠一次是 Critic 学习开到 3e-3loss 直接爆到几百万幸亏加了梯度裁剪才没浪费一整晚训练时间。5.3 多个 events.out.tfevents 文件互相干扰曲线糊成一团现象TensorBoard 打开后奖励曲线里混着多个实验的数据同一时间点出现两条不同走向的线对不上号。点名批评一下我自己的坏习惯懒得分目录多个实验日志全写到同一个目录。原因TensorBoard 按目录划分 run同一个目录内相同 tag 会合并成一条曲线。不同实验间隔几小时甚至几天日志文件时间戳不同但目录没变曲线就被自动叠在一起对比效果完全丢失。解决每次跑实验前用代码生成带时间戳和随机种子的子目录。log_dir fruns/{time.strftime(%Y%m%d-%H%M%S)}/seed_{seed} writer SummaryWriter(log_dir)这套逻辑加在训练脚本开头一行就行。日志目录清晰之后TensorBoard 里每次实验自动分成独立 run对比实验效果一目了然调参效率能高一倍。5.4 训练时 reward 一路走高换成测试集立马现原形现象训练过程中 makespan 逐步逼近 HEFT看起来策略已经收敛。但把工作流换成没见过的测试集结果和随机调度差不多甚至更差。原因两个可能。一是评估时没关探索噪声动作加上了随机扰动策略表现被噪声拖垮。二是测试集工作流的规模、任务数、依赖深度和训练集分布不一致策略过拟合到了训练工作流的特定模式上泛化能力几乎为零。解决评估时必须关掉噪声直接用 Actor 的确定性输出。测试集和训练集要显式拆分如果项目里只有一份数据至少按工作流 ID 做分层采样保证测试集覆盖不同规模的工作流。泛化能力差的另一个信号是奖励函数里用了过多全局信息模型记住了“这个数据集长什么样”而不是“调度规则是什么”。5.5 状态向量里混入未来信息训练指标虚高但落地就崩现象训练时 SLR 低到 0.95看起来比 HEFT 还猛但换到真实执行环境或者下一批任务就崩。反复检查网络结构和超参数都找不到问题模型表现像是黑匣子。原因状态向量里包含了“泄漏信息”。我当时把整个工作流的全局信息包括还没到达的前驱任务、未来才会就绪的任务特征全部塞进了状态向量。调度器在训练时相当于“开了天眼”网络学到了利用未来信息作弊评估时一旦这些未来信息不可用策略立刻失效。解决状态向量里只能放当前时刻已知的信息。可用的包括当前就绪任务及其实估时长、已分配任务的预估剩余执行时间、各 VM 当前负载水平。会泄漏未来的信息源是尚未就绪任务的执行时间、整个工作流的关键路径长度、未来才到达的 VM 负载变化。判断标准很简单——执行时刻 T 的决策T 之后才产生的信息一律不能进状态。6. 把策略从“网络输出”落到真实调度优先级解码与仿真验证模型训练完最容易被忽略的是最后一步把网络的连续优先级输出翻译成实际可执行的调度序列。这一步写不好网络输出只停留在“看起来合理”的层面没法在仿真环境里验证更没法画甘特图给答辩老师看。def schedule_from_priorities(tasks, vm_avail, priorities): order [] ready [t for t in tasks if not tasks[t][parents]] while ready: ready.sort(keylambda t: priorities[t], reverseTrue) tid ready.pop(0) vm min(vm_avail, keyvm_avail.get) start max(vm_avail[vm], latest_end(tasks[tid])) finish start tasks[tid][runtime] vm_avail[vm] finish order.append((tid, vm, start, finish)) for t in tasks: if tasks[t].get(scheduled): continue if all(p in [x[0] for x in order] for p in tasks[t][parents]): ready.append(t) return order这段代码把优先级向量解码成真正的调度方案每次从就绪队列里挑优先级最高的任务分配给最早空闲的 VM任务完成后把新就绪的任务加入队列。这里有个经典边界问题多个任务同时就绪时如果它们的优先级分接近排序结果受浮点精度影响可能不稳定但对最终 makespan 影响不大。真正要小心的是“任务依赖未满足就加入就绪队列”的逻辑错误判断条件all(p in ...)确保所有前驱都已执行完毕才能进入就绪状态。解码器写好之后用一个测试工作流在训练好的 Actor 上跑一遍把生成的调度结果用matplotlib的barh画成甘特图再和 HEFT 的甘特图并排放。对比两张图能直观看到自己的策略是不是把长任务提前了还是经常把 VM 空着等待。甘特图和资源利用曲线是答辩时最有说服力的材料比训练曲线真实得多。从那以后我拿到任何 DRL 调度项目第一步一定是先跑通这个解码器再去看训练曲线。曲线会骗人调度序列不会。希望帮到你。本文还有配套的精品资源点击获取
返回列表