☰
QPLMTS算法解析:基于Q学习与优先级列表的DAG任务调度
2026/10/22 2:58:51 网站建设 项目流程

简介:边缘计算场景下的任务调度是资源优化领域的热门课题。基于QPLMTS算法的任务调度器源码包,面向需要完成课程设计或期末大作业的高校学生,尤其是算法、边缘计算方向,提供了一套无需修改即可运行的完整项目;资源从任务图构造、调度算法执行到结果输出均有实现,可直接用于实验演示或基线对比。压缩包共8个文件,涵盖C语言核心实现、Python辅助脚本、头文件、README说明及daggen任务生成工具,整体约37KB,结构紧凑、模块划分清晰,便于快速定位调度核心逻辑。项目在导师指导下完成并获97分高分,目前已有121人学习下载;作为经过实际验证的完整课设,既可以充当课程设计模板,也能作为二次开发的起点,帮助读者缩短前期搭建成本,集中理解边缘环境下的任务卸载与资源分配建模思路。

1. QPLMTS 在解决什么问题:边缘节点任务调度的三个瓶颈

边缘计算场景下的任务调度器,难点从来不在单个任务跑多快,而在几十个带前后依赖的子任务,怎么分给算力参差不齐的边缘节点。QPLMTS 算法把 Q 学习与优先级列表结合,用 C 生成 DAG 任务图,用 Python 完成调度决策与结果统计,是一条能完整跑通的链路。这份源码包里 daggen 负责图生成,QPL.py 承载调度主流程,C 与 Python 分工清晰;作为课程设计或期末大作业交付,图生成、算法实现、实验对比三个环节齐备,属于拿到手能直接改的工程结构。适合做边缘计算调度方向入门、需要可控对比基线的从业者,也适合想在 HEFT、Min-Min 之外再找一条可调参改进路线的同学。

2. DAG 任务图生成:daggen_commons 的结构、参数与文件格式

2.1 daggen_commons.h 的核心结构:节点、边与定长数组设计

DAG 描述的是子任务之间的偏序关系:前驱没跑完,后继就只能等数据传过来。daggen_commons.h 暴露的结构就是把这种关系平铺成数组,调度器遍历时不用反复查边表,直接扫 preds 和 succs 就行。课程设计的代码量不大,但这里设计得好不好,直接影响后面 QPL.py 写起来顺不顺。

#define MAX_N 256 // 单张 DAG 的最大节点数,超出需要改宏 #define MAX_E 1024 // 最大边数,密集图场景记得同步加大 typedef struct { int id; // 任务编号,从 0 开始 double computation; // 计算量,单位 MI(百万指令) int n_preds; // 前驱数量 int preds[MAX_N]; // 前驱任务编号数组 int n_succs; // 后继数量 int succs[MAX_N]; // 后继任务编号数组 } task_t; typedef struct { int src; // 边的起点 int dst; // 边的终点 double data; // 前驱传给后继的数据量,单位 MB } dag_edge_t;

这段定义的 task_t 把计算量放在节点上,把依赖关系放在定长数组里。为什么用定长数组而不是链表?因为 Q 学习的训练循环要高频访问前驱和后继,数组的 cache 命中率比链表高,出 bug 时直接 printf 下标就能定位,不用一路追指针。代价是 MAX_N 和 MAX_E 写死在头文件里,节点数超过 256 就必须改宏重编。

参数说明:computation 的随机范围常见取 10~200,太小会让 makespan 的差异被传输时间淹没,太大则计算型任务主导,调度策略的区别被抹平。data 的单位是 MB,后续带宽单位取 MB/s 时,传输时间等于 data 除以带宽。我一般会把 MAX_N 放大到实际节点数的两倍再编译,防止训练到一半因为数组越界产生未定义行为。

2.2 daggen_commons.c 生成流程:分层、连边与 CCR 回填

任务图生成在很多调度论文里是一笔带过的黑匣子,实际上生成的图质量直接决定算法结论是否可信。daggen_commons.c 的核心流程分三步:给节点分配层级、按出度随机连边、按 CCR 回填计算量和通信量。

// 用 level 分层,保证生成的边始终从低层指向高层 for (int i = 0; i < node_num; i++) { level[i] = rand() % layer_num; // 先给每个节点分配一个层级 } for (int i = 0; i < node_num; i++) { int out = rand() % (max_out - min_out + 1) + min_out; for (int k = 0; k < out; k++) { int j = rand() % node_num; if (level[j] <= level[i]) continue; // 同层或低层连边会成环,直接跳过 add_edge(i, j); } } // 按 CCR 回填通信量,让通信/计算比值贴近目标 for (int e = 0; e < edge_cnt; e++) { edges[e].data = total_computation * ccr / edge_cnt; edges[e].data += (rand() % 1000) / 1000.0; // 加一点随机抖动 }

逻辑里最关键的是那行if (level[j] <= level[i]) continue。它保证边永远从低层指向高层,按拓扑排序天然无环。如果不做这层过滤,随机连边很容易产生环,带环的 DAG 会让调度器陷入死循环——就这一个细节,能过滤掉一半的调试时间。

回填 CCR 那段的意义是把通信总量约束到目标范围内。先算总计算量,乘以 ccr 得到总通信量,再摊到每条边上加扰动,这样整张图的通信计算比基本贴近设定值。CCR 小于 1 是计算密集型,大于 1 是通信密集型,两种场景下 QPLMTS 的收益方向完全不同,对比实验最好让 ccr 分别取 0.5、1.0、2.0、5.0 各生成一组。

生成完我习惯跑一次拓扑排序自检,排出来的序列长度小于节点数就说明有环。另外 layer_num 的值决定 DAG 是宽还是窄:层数少则每层任务多、并行度高,层数多则依赖链短、并行度低。想模拟真实工作流,一般把层数设成节点数的 20%~40%。

注意:同一份源码生成的图,seed 不同就是完全不同的实验样本。做算法对比前,先固定 seed,再谈在同一个图上谁优谁劣。

2.3 从生成文件反推任务图:文件格式与解析接入点

生成完的 DAG 最终要落到文件里给 QPL.py 吃。常见做法是头部注释记录节点数和边数,中间是节点计算量,后面是边关系:

# node_num=30 edge_num=52 ccr=2.0 machines=4 # node_id computation 0 45.6 1 78.2 # edge src dst data 0 2 12.3 1 2 8.7 2 5 23.1

解析时用 Python 按字段数区分两段即可,QPL.py 里的读取逻辑通常是这个思路:

def load_dag(path): nodes, edges = [], [] with open(path) as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split() if len(parts) == 2: nodes.append((int(parts[0]), float(parts[1]))) elif len(parts) == 3: edges.append((int(parts[0]), int(parts[1]), float(parts[2]))) return nodes, edges

逻辑说明:跳过注释行后,每行两个字段是计算量,三个字段是边。split() 不加参数会自动处理连续空格和制表符,不用额外清洗。参数说明:如果 daggen 输出的是dst src data的顺序,记得把解包顺序换过来,否则边方向全反,调度器的依赖关系直接乱掉。

接入点说明:QPL.py 在读取文件后会同时缓存计算量、边表和邻接矩阵。邻接矩阵用于算传递依赖,边表用于算传输时间。如果你的实验里任务图来自真实工作流而不是随机 DAG,只要把输出格式对齐到这个文件格式,调度主流程完全不用改。课程设计换数据集时,我一般只动 loader 函数,算法部分保持原样。

3. QPLMTS 算法拆解:Q 学习、优先级列表与 Min-Min 怎么捏在一起

3.1 为什么边缘场景要选 Q 学习:三个动态变量

边缘计算里的调度难点是三重不可控:节点算力异构,同一个任务在不同机器上耗时差几倍;节点之间有数据竞争,带宽随时间变化;任务 DAG 的层级让早启动的任务反过来卡住后面所有后继。传统的 HEFT 是静态启发式,按 upward rank 排一次序就固定了;Min-Min 每一步贪心选最早完成时间最小的节点,但只看当前一步,不看全局排队代价。

Q 学习的价值在于它不需要显式建模带宽和算力的变化,而是把“当前状态 + 动作 → 未来累计代价”的映射直接编码进 Q 表。边缘场景状态空间虽然大,但离散化成若干档位后 Q 表规模是可控的,这也正是 QPLMTS 把它当调度核心的原因。课程设计里这套选择的解释口径是:动态环境下,Q 表比静态列表多一层自适应能力。

3.2 QPL.py 的状态、动作与奖励设计

Q 学习落地的第一件事是定义状态边界。从文件结构看,QPL.py 里常见的状态由两部分拼起来:当前任务所在 DAG 层级,以及各边缘节点的负载档位。层级决定这个任务最早能开始的时间窗口,负载档位决定选某个节点时的等待代价。

import numpy as np M = 6 # 边缘节点数,和 daggen 的 -m 保持一致 LOAD_BINS = 3 # 每个节点负载分成 0/1/2 三档 def encode_state(level, load_bins): # 用 3 的幂次做基数编码,避免不同状态映射到同一个整数 state = level * (LOAD_BINS ** M) for i, lb in enumerate(load_bins): state += lb * (LOAD_BINS ** i) return state def choose_action(state, q_table, epsilon): if np.random.rand() < epsilon: return np.random.randint(M) # 探索:随机选机器 return int(np.argmax(q_table[state])) # 利用:选 Q 值最大的机器 def update_q(q_table, state, action, reward, next_state, alpha, gamma): td = reward + gamma * np.max(q_table[next_state]) - q_table[state][action] q_table[state][action] += alpha * td

逻辑说明:状态编码用 3 的幂次把 M 个节点的负载档位拼成整数,比如 M=4、第 2 层、负载档位是 [0,2,1,0] 时,state = 2 * 81 + 0 + 23 + 19 + 0 = 177。这样不同负载组合不会互相干扰。choose_action 里 epsilon 控制探索比例,训练前期多探索,后期多利用。update_q 就是标准 Q 学习更新式:当前 Q 值加上学习率乘以时序差分误差。

参数说明:M 取 4~16 比较常见,负载档位 3 档够用,档位再多 Q 表会变得稀疏。alpha 取 0.1~0.2,gamma 取 0.9 附近,epsilon 初始 0.5 左右。reward 必须设计成负的——常见做法是取负的任务完成时间增量,让 Q 值越大代表该动作越省时间。

3.3 优先级列表与 Min-Min 融合:先定序再分配

QPLMTS 里的 P 不是 HEFT 那种静态优先级,而是把 Q 表当成动态排序器:每轮先按 Q 值对候选任务重新排序,再对排在前面的任务用 Min-Min 思想选节点。这样 Min-Min 的单步贪心被 Q 学习校正,带上了全局前瞻。

ready = find_ready_tasks(dag, scheduled) # 前驱全部完成的任务集合 # 用 Q 值做动态优先级,优先调度当前状态收益最大的任务 ready.sort(key=lambda t: -np.max(q_table[encode_state(level[t], load_bins)])) for t in ready: best_finish = float('inf') best_m = None for m in range(M): est = max(avail_time[m], max(pred_finish[p] + comm_time[p][m] for p in preds[t])) finish = est + compute_time[t][m] # 最早完成时间 EFT if finish < best_finish: best_finish, best_m = finish, m schedule[t] = best_m avail_time[best_m] = best_finish reward = -best_finish update_q(q_table, encode_state(level[t], load_bins), best_m, reward, encode_state(level[t] + 1, load_bins), alpha, gamma)

逻辑说明:find_ready_tasks 保证只有前驱全部完成的任务进入候选。排序键是 Q 值而不是固定 rank,这是与 HEFT 的核心差异。选节点时对每个机器算 EFT 取最小值,这就是 Min-Min 的“最小最早完成时间”部分。关键在最后一步,把真实算出来的 best_finish 作为负奖励回填 Q 表,让下一次同类状态能避开慢节点。

参数说明:avail_time 是每个边缘节点的最早可用时刻,初始为 0。comm_time[p][m] 是前驱 p 到当前任务在机器 m 上的传输代价,这里假设数据要从前驱所在机器搬过来;如果前驱正好也在 m 上,comm_time 应为 0。这个细节很多实现会漏,漏掉的结果是传输代价被重复计算,makespan 虚高。

3.4 超参对结果的影响:从收敛曲线看迭代预算

Q 学习最容易被说玄学的就是超参。从实践看,几个关键设置如下:

参数经验取值说明
alpha0.1~0.2太大不收敛,太小学得慢
gamma0.9折扣未来代价,取 0.5 容易短视
epsilon0.5 衰减到 0.05前 30% 回合探索,后面利用
episodes500~200020~50 个节点的任务图够用

实际操作时我一般固定 alpha=0.1、gamma=0.9,只调 epsilon 衰减速度。如果 makespan 曲线一直抖动不下降,优先怀疑奖励设计而不是学习率——负 reward 的大小没对齐,Q 表会来回跳。把每个 episode 结束后的 makespan 存进列表,用 matplotlib 画曲线,斜率从陡变平说明 Q 表开始收敛;整条曲线持续波动,就先去看 reward 的符号和量级是否稳定。

提示:课程设计里至少跑够 800 个 episode,曲线末尾要还在明显下降就加到 1500。图太小或迭代太少,优化还没完成就停,结论没有说服力。

4. 跑通整套调度器:从 daggen 编译到 QPL.py 输出 makespan

4.1 环境准备与目录结构梳理

解压后目录结构如下,QPL.py 是唯一入口,daggen.exe 是预编译的 DAG 生成器,其余是源码和说明。

文件角色
README.md运行说明与参数约定
QPL.pyQPLMTS 调度主程序
daggen.cDAG 生成器主程序
daggen_commons.c / daggen_commons.h生成逻辑与结构体定义
daggen.exe已编译的 DAG 生成可执行文件
.gitignoreGit 忽略规则

环境方面,QPL.py 依赖 Python 3.7 以上和 numpy;daggen.c 编译需要 gcc。Windows 可以直接用 exe,Linux 或 macOS 需要重新编译。拿到压缩包先别急着跑,按 README → daggen_commons.h → daggen.c → QPL.py 的顺序读,README 写清楚依赖,头文件告诉你数据结构,daggen.c 告诉你命令行参数,QPL.py 的 main 函数则把整个实验流程串起来。

我一般会在项目目录里建虚拟环境,避免污染系统 Python:

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install numpy python -c "import numpy; print(numpy.__version__)"

逻辑说明:源码用 numpy 存 Q 表、做 argmax,没有 numpy 会在 import 阶段直接中断。最后一行是验证安装成功的稳妥方式,比直接跑主程序省时间。参数说明:Python 3.7 足够,3.10、3.11 也没问题;如果机器同时有 python2 和 python3,把命令换成 python3。

4.2 第 1 步:生成任务图数据

设计实验时用 daggen 生成指定规模的 DAG。典型命令行参数组合如下:

# 50 个节点、平均出度 2、ccr=2.0、6 个边缘节点、固定随机种子 ./daggen -n 50 -minout 1 -maxout 3 -ccr 2.0 -m 6 -seed 2024 -o dag_50_90.dag

逻辑说明:-n 控制节点数,-minout 和 -maxout 控制每个节点的后继数量上下限,-ccr 控制通信计算比,-m 是边缘节点数,-seed 固定随机种子。固定 seed 是复现实验的前提,不加 seed 每次生成的 DAG 都不同,算法对比就没有意义。

先跑一下帮助命令确认参数名:

./daggen -h

逻辑说明:不同版本参数名可能有差异,比如 -n 写成 -node_num,-ccr 写成 -CCR。先确认再批量生成,避免 20 个实验全部作废。参数说明:如果你的平台没有预编译 exe,用 gcc 重新编译:

gcc -O2 -Wall daggen.c daggen_commons.c -o daggen -lm

逻辑说明:-lm 链接数学库,CCR 回填和随机扰动大概率用了 sqrt、log 或 pow,不加 -lm 在 Linux 上会报 undefined reference。-Wall 会把潜在的类型错误打出来,宁可多看几个 warning 也不要直接忽略。Windows 平台换用常见的 C 编译器同样能编过,源码里没有平台相关的系统调用。

4.3 第 2 步:运行 QPL.py 完成调度

图生成后,调度主流程一条命令就能跑通:

python QPL.py --dag dag_50_90.dag --machines 6 --episodes 800 \ --alpha 0.1 --gamma 0.9 --epsilon 0.1

逻辑说明:--dag 接上一步生成的 DAG 文件,--machines 必须与生成参数里的 -m 一致,否则机器数和 Q 表维度对不上。--episodes 是训练回合数,800 是保守起步值,任务节点更多时加到 1500。训练过程中程序会打印每个 episode 的 makespan,全部结束后输出一份调度方案。

参数说明:--alpha 和 --gamma 对应 Q 更新公式里的学习率与折扣因子,--epsilon 是初始探索率。如果默认实现带 epsilon 衰减,命令行传的是起始值。答辩或做实验记录时,把这几组参数如实写进文档,能减少很多“这个结果怎么跑出来”的追问。

4.4 第 3 步:解析调度结果与验证指标

训练结束后输出通常长这样:

episode 800: makespan = 1254.7 task 0 -> machine 2 (start 0.0, finish 45.6) task 1 -> machine 5 (start 0.0, finish 78.2) task 2 -> machine 0 (start 45.6, finish 113.9)

逻辑说明:makespan 是所有任务 finish 时间的最大值,边缘调度里一般指最后一个任务完成的时刻。每行调度记录给出任务、机器、开始与结束时间,用于复算节点利用率和传输开销。验证调度不是瞎排,常见做法是保留同一张 DAG 跑三组实验:QPLMTS、HEFT、随机分配,对比 makespan。

参数说明:验证时固定 QPL.py 内部的随机种子,调度结果才能逐位复现。课程设计里会把三组结果画成柱状图,横轴是 ccr 取值,纵轴是 makespan 或 SLR。SLR 定义为 makespan 除以关键路径总计算量的下界,越接近 1 说明调度越优。

5. 避坑现场:编译、运行与结果复现的五个常见问题

5.1 坑一:daggen.exe 在 Windows 上闪退或提示缺少运行库

现象:在文件管理器里双击 daggen.exe,窗口一闪而过;或者在命令行执行时报“无法启动,缺少运行库”之类的错误。

原因:这个 exe 是用 Windows 平台的编译器动态链接编译的,目标机器缺少对应的 C 运行库;另外 daggen 是标准命令行程序,本来就不会驻留窗口,双击本来就不该是它的使用方式。

解决:不要在文件管理器里双击。先打开 cmd 或 PowerShell,切到解压目录执行.\daggen -h,看帮助信息是否正常输出。如果还是报缺运行库,装对应的可再发行运行库包,或者直接用源码自己编译——gcc 那行命令在 4.2 里已经给过,重编一次,问题就彻底绕开。

5.2 坑二:QPL.py 报 ModuleNotFoundError: No module named 'numpy'

现象:运行 QPL.py 时提示找不到 numpy,或者版本过低导致 np.argmax 相关调用行为异常。

原因:pip 把包装到了系统 Python,而命令行里实际用的是另一个解释器。常见于机器上同时存在 Python 3.8 和 Python 3.11,虚拟环境没激活就直接跑。

解决:先执行python -c "import sys; print(sys.executable)",确认当前解释器路径。再按 4.1 的方式建虚拟环境并激活,在 venv 里pip install numpy,最后用python QPL.py --help验证导入是否成功。这个坑踩过一次就容易记住,现在拿到新源码第一件事就是看 import 语句,缺什么先补什么,别直接跑主程序。

5.3 坑三:生成的任务图文件 QPL.py 解析报格式错误

现象:daggen 正常生成文件,但 QPL.py 读文件时报 unexpected line,或者提示边数对不上。

原因:常见做法有两种文件格式——一种是计算量区、边区分开;另一种是每行任务信息自带后继列表。QPL.py 写的是前者,而 daggen 版本输出的是后者,字段错位,解析器自然不认识。

解决:打开生成文件看头几行,和 2.3 里的示例对齐。如果差异只在注释行或表头,写一个几行的过滤脚本把注释和多余字段去掉,再喂给 QPL.py。这个坑属于格式约定不一致,README 里没写清楚时,直接按解析脚本的报错行号反推,比瞎猜快。

5.4 坑四:makespan 忽高忽低,多次训练结果对不上

现象:同一张 DAG 图,两次运行 QPL.py 得到的 makespan 相差超过 10%,训练曲线也没有明显下降趋势。

原因:daggen 没固定 seed,导致两次实验根本不是同一张图;或者 QPL.py 内部随机初始化没固定,epsilon 探索在关键时刻选了不同节点;episodes 太短也是常见原因。

解决:daggen 生成时固定 -seed,QPL.py 里在导入 numpy 后立刻设置随机种子:

import numpy as np import random np.random.seed(2024) random.seed(2024)

逻辑说明:两行种子把 numpy 和 Python 自带随机模块都锁住,避免 Q 表初始化、epsilon 探索各自走一套随机序列。参数说明:训练回合加到 1000 以上,记录每 50 个 episode 的平均 makespan 而不是只记最后一个点。做对比实验时,我还会对同一配置跑 5 次取平均,报告里带标准差,课程设计评审看到误差线,说服力会高一截。

5.5 坑五:与 HEFT、Min-Min 对比时统计口径不统一

现象:QPLMTS 的 makespan 比 HEFT 好一大截,但换台机器或者换个实现,HEFT 又反超,结论完全相反。

原因:传输时间有没有算、节点等待队列怎么建模、机器数相不相等,这三处只要有一处不一致,对比就是无效的。很多二次实现里 HEFT 用理想化通信模型,QPLMTS 用排队模型,数值当然对不齐。

解决:统一在同一个 DAG 文件上跑,统一用“包含通信时间、包含排队等待”的 makespan 口径。对比前先跑一遍随机分配,确认自己实现的 HEFT 确实优于随机分配,验证基线可信后再看 QPLMTS 的改进。算法对比先验基线,再谈优化,这条经验后来一直留着。

6. 进阶玩法:把单目标奖励改成能耗感知的加权目标

QPLMTS 这套框架的扩展性比表面看起来好。原版奖励是负 makespan 增量,语义等价于“最快完成”,但边缘计算里能耗往往同样重要。改造起来只要动两处:reward 的定义和 Q 更新前的开销计算。

# 在 update_q 之前,把单目标奖励替换为双目标加权 energy_delta = power[m] * compute_time[t][m] # 计算能耗 energy_delta += trans_power[m][p] * comm[p][m] # 传输能耗 w_makespan = 0.7 w_energy = 0.3 reward = -(w_makespan * best_finish + w_energy * energy_delta)

逻辑说明:改动量很小,但语义变了——Q 表现在学到的是“时间与能耗加权和最小”的分配策略。传输能耗要遍历前驱,数据量越大代价越高,这会让算法更倾向于把有强依赖的任务排到同一台机器上。参数说明:两个权重加和为 1,想验证多目标效果就分别取 0.9/0.1、0.7/0.3、0.5/0.5 三组,看 makespan 与能耗的帕累托前沿。

验证方法:同一张 DAG 分别跑原版和新版各 5 次,记录 makespan 与总能耗。如果 0.7/0.3 配置下 makespan 只上升 3% 而能耗下降 15%,说明框架能支撑多目标扩展。再把三组权重画成散点图,横轴 makespan、纵轴能耗,权衡曲线一目了然。

给课程设计做改进时,我还会额外做一次 sanity check:对同一个 DAG 跑两次,一次固定 seed、一次不固定,确认改进后的差距大于随机波动,否则不写进结论。从那以后我每次拿到调度算法源码,第一件事都是先按原参数复现基线、画收敛曲线,再动一个变量重新训练,等曲线稳定了才谈改进幅度。这套流程对 QPLMTS 也好,对别的调度器也好,都很实用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询