简介:这份PDF是Boise State University计算机博士Eddie C. Davis的学位论文《Polyhedral Dataflow Graphs》,面向编译器优化、多面体编译模型与科学计算方向的研究生及工程人员。论文提出一种以数据流图表达的中间编译器表示,用于描述多面体编译模型中的计算语句与数据映射,并强调临时存储需求与执行计划,以指导优化决策;该表示可集成到现有编译器基础设施,在C++中以领域特定语言实现图形组件与变换规则,并支持可视化推理与转换为源代码。相关技术已应用于微分方程求解器、稀疏矩阵运算、张量分解和结构化多重网格方法,与矩阵运算及C++软件实现紧密相关。资源包为1个PDF文件,约3.83MB,内容完整涵盖摘要、目录、答辩委员会信息与正文结构,便于系统研读。目前已有155人学习下载,适合希望深入理解多面体编译与数据流图优化方法的读者参考。
1. 数据流图为什么成了多面体编译的“通用语”
多面体编译模型里最让人头疼的,不是循环嵌套有多深,而是同一段计算在不同循环变换下到底“算的是不是同一件事”。数据流图用于描述多面体编译模型中的计算语句和数据映射,本质上就是给这个问题提供一套不依赖具体循环顺序的中间表示:节点是计算语句的实例,边是数据依赖,而多面体负责刻画这些实例在迭代空间里的几何形状。你如果正在做循环优化、自动并行化或者领域专用编译器的前端,这套表示能让你把“调度”和“映射”分开处理,而不是每换一个循环顺序就重推一遍依赖。它适合已经写过至少一个编译器 pass、能看懂仿射约束的从业者,也适合刚进组、需要快速复现博士论文里那套推导流程的新手。下面我按自己复现时的顺序,把选型、建模、验证和踩坑一次讲清。
2. 从计算语句到数据流图:建模的四个关键决策
2.1 计算语句的粒度选多大才不丢依赖
多面体模型里,计算语句通常写成一条赋值或一个归约操作,比如S1: A[i][j] = A[i-1][j] + B[i][j]。数据流图的节点就是这条语句在迭代空间中的每个实例,边表示实例之间的数据流动。粒度选得太粗,比如把整个循环体当成一个节点,依赖关系会被掩盖,后续调度就找不到并行机会;粒度选得太细,比如把每个标量运算都拆成节点,图会膨胀到无法处理。常见做法是以“一次内存写”为节点边界,读操作作为边的属性而不是独立节点。我一般会先列出所有写语句,再为每个写语句建立迭代空间的多面体表示,最后把读访问映射成从生产者节点到消费者节点的边。
这里有个容易翻车的点:归约语句的写和读是同一个地址,如果直接按写语句建节点,会得到自环边,调度器会认为无法并行。正确做法是把归约拆成“局部累加”和“最终写回”两个节点,中间用临时变量连接。这个拆分在论文里通常一笔带过,但复现时不做,后面所有并行化都会卡住。
2.2 迭代空间的多面体表示与参数化
迭代空间是一个整数点集,用仿射约束描述。比如for i in 0..N-1, for j in 0..N-1对应的多面体是{ (i,j) : 0 <= i < N, 0 <= j < N }。参数 N 是符号常量,不参与具体求解,只影响边界。数据流图的边需要带上依赖距离向量,比如A[i-1][j]对A[i][j]的依赖距离是(1,0)。这个距离向量就是多面体里两个实例点之间的差。
用 Python 写一个最小建模脚本,依赖islpy或polylib的 Python 绑定。下面这段代码用islpy构建迭代空间并提取依赖距离:
import islpy as isl # 定义参数 N 和迭代变量 i, j ctx = isl.Context() space = isl.Space.create_from_names(ctx, set=["i", "j"], params=["N"]) # 迭代空间: 0 <= i < N, 0 <= j < N uset = isl.UnionSet.empty(space) uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {"i": 1, "N": -1})) uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {"i": -1})) uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {"j": 1, "N": -1})) uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {"j": -1})) # 依赖距离 (1,0) 对应的映射: i' = i + 1, j' = j map_space = isl.Space.create_from_names(ctx, set=["i", "j"], params=["N"]) map_space = map_space.map_from_set() dep_map = isl.Map.from_domain_and_range(uset, uset) dep_map = dep_map.add_constraint(isl.Constraint.eq_from_names(map_space, {"i'": 1, "i": -1})) dep_map = dep_map.add_constraint(isl.Constraint.eq_from_names(map_space, {"j'": 1, "j": -1})) # 检查依赖是否在迭代空间内 dep_map = dep_map.intersect_domain(uset).intersect_range(uset) print(dep_map)这段代码的逻辑是:先建迭代空间,再用等式约束表达i' = i + 1和j' = j,最后取交集确保依赖实例都在合法范围内。参数说明:ineq_from_names里的系数表示不等式左边各项的系数,比如{"i": 1, "N": -1}表示i - N <= 0,即i < N。eq_from_names用于等式。跑通后你会得到一个Map对象,里面就是数据流图的边集合。
2.3 数据映射:把数组访问变成多面体上的仿射函数
数据映射描述的是计算语句访问的内存地址与迭代变量之间的关系。比如A[i][j]的映射是(i,j) -> (i,j),A[i-1][j]是(i,j) -> (i-1,j)。在数据流图里,每条边对应一个数据映射,生产者写地址,消费者读同一地址。多面体编译的核心操作之一就是检查两个映射的交集是否非空,非空则存在依赖。
常见做法是用isl的Map表示访问函数,然后做intersect和lexmax等操作。我一般会为每个数组建一个访问映射表,键是数组名,值是一组Map,每个Map对应一条语句的访问。这样在后续调度变换时,只需要对映射做复合,不需要重新解析源码。
2.4 调度与数据流图的分离
多面体编译把调度表示为一个多维仿射函数,把迭代空间中的每个实例映射到新的时间戳。数据流图不直接参与调度计算,但它提供了合法性约束:任何调度变换后,数据流图的边必须仍然满足依赖方向,即生产者时间戳小于消费者时间戳。这个约束通常写成schedule(producer) << schedule(consumer),其中<<是字典序比较。
分离的好处是,你可以独立尝试不同的调度算法,只要最后用数据流图验证一下合法性即可。我通常先用isl的schedule工具生成一个初始调度,再手动调整循环交换和倾斜,每改一次就跑一遍依赖检查。这个流程比直接改源码再编译测试快得多,尤其当循环嵌套超过三层时。
3. 用 ISL 和 Python 把数据流图跑起来:最小可复现流程
3.1 环境准备与依赖安装
复现这套流程不需要完整的编译器框架,只需要islpy和numpy。islpy是isl库的 Python 绑定,安装时可能需要先装isl的开发包。在 Ubuntu 上可以这样:
sudo apt-get install libisl-dev pip install islpy numpy如果pip install islpy编译失败,常见原因是isl版本不匹配。我一般会从源码编译isl再装islpy,或者直接用conda install -c conda-forge islpy。注意不要混用系统包和 conda 包,否则运行时会报符号冲突。
3.2 构建一个三语句的数据流图
下面用一个二维卷积的简化例子,包含三条计算语句:S1写A,S2写B,S3读A和B写C。代码会构建迭代空间、访问映射和依赖边。
import islpy as isl ctx = isl.Context() # 迭代空间: 0 <= i < N, 0 <= j < N space = isl.Space.create_from_names(ctx, set=["i", "j"], params=["N"]) uset = isl.UnionSet.empty(space) for var in ["i", "j"]: uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {var: 1, "N": -1})) uset = uset.add_constraint(isl.Constraint.ineq_from_names(space, {var: -1})) # S1: A[i][j] = ... 访问映射: (i,j) -> (i,j) map_space = isl.Space.create_from_names(ctx, set=["i", "j"], params=["N"]).map_from_set() acc_A = isl.Map.from_domain_and_range(uset, uset) acc_A = acc_A.add_constraint(isl.Constraint.eq_from_names(map_space, {"i'": 1, "i": -1})) acc_A = acc_A.add_constraint(isl.Constraint.eq_from_names(map_space, {"j'": 1, "j": -1})) # S2: B[i][j] = A[i-1][j] + ... 读 A 的映射: (i,j) -> (i-1,j) read_A = isl.Map.from_domain_and_range(uset, uset) read_A = read_A.add_constraint(isl.Constraint.eq_from_names(map_space, {"i'": 1, "i": -1, "one": -1})) read_A = read_A.add_constraint(isl.Constraint.eq_from_names(map_space, {"j'": 1, "j": -1})) # 依赖: S1 写 A 与 S2 读 A 的交集 dep = acc_A.intersect(read_A) dep = dep.intersect_domain(uset).intersect_range(uset) print("依赖边数量:", dep.count_val()) print(dep)逻辑说明:acc_A是写访问映射,read_A是读访问映射,两者取交集得到依赖边。one是isl里表示常量 1 的保留名,用于表达i' = i - 1。参数说明:count_val()返回整数点的个数,如果为 0 说明无依赖。跑通后你会看到依赖边数量随 N 变化,但结构不变。
3.3 用依赖边验证调度合法性
假设我们想把i循环和j循环交换,调度函数变成(i,j) -> (j,i)。验证方法是检查每条依赖边的生产者时间戳是否小于消费者时间戳。用isl的lex_lt比较:
# 原调度: (i,j) -> (i,j) sched_orig = isl.Map.from_domain_and_range(uset, uset) sched_orig = sched_orig.add_constraint(isl.Constraint.eq_from_names(map_space, {"i'": 1, "i": -1})) sched_orig = sched_orig.add_constraint(isl.Constraint.eq_from_names(map_space, {"j'": 1, "j": -1})) # 交换后调度: (i,j) -> (j,i) sched_swap = isl.Map.from_domain_and_range(uset, uset) sched_swap = sched_swap.add_constraint(isl.Constraint.eq_from_names(map_space, {"i'": 1, "j": -1})) sched_swap = sched_swap.add_constraint(isl.Constraint.eq_from_names(map_space, {"j'": 1, "i": -1})) # 对依赖边 dep,检查 sched_swap 下是否满足 lex 序 dep_swap = dep.apply_range(sched_swap).apply_domain(sched_swap) # 如果 dep_swap 中存在反向依赖,则调度非法 illegal = dep_swap.lex_gt(dep_swap) print("非法依赖数量:", illegal.count_val())这段代码先把依赖边映射到新调度空间,再用lex_gt检查是否存在生产者时间戳大于消费者的情况。如果illegal非空,说明交换循环非法。这个检查比手动推依赖距离可靠得多,尤其当依赖是多维的时候。
3.4 把数据流图导出成可读格式
调试时我习惯把依赖边导出成文本,方便和论文里的图对照。isl的Map可以直接print,但输出是集合表达式,不够直观。可以写一个小函数把每个依赖边转成(生产者迭代点) -> (消费者迭代点)的形式:
def dump_deps(dep_map): for basic_map in dep_map.get_basic_maps(): # 提取等式约束中的系数 print(basic_map) dump_deps(dep)输出会类似{ [i, j] -> [i' = i + 1, j' = j] : ... }。如果依赖边很多,可以只打印前 10 条,避免刷屏。这个导出步骤在写论文时特别有用,因为你可以直接把输出贴到附录里,审稿人也能复现。
4. 避坑与排查:复现数据流图时最容易翻车的五件事
4.1 依赖距离算错导致调度器“假并行”
现象:调度器报告可以并行,但实际运行结果错误。原因:依赖距离的符号搞反了,比如把A[i-1][j]的依赖距离写成(-1,0)而不是(1,0)。解决:用isl的intersect检查依赖边是否在迭代空间内,如果交集为空,说明距离方向反了。我一般会先打印依赖边的Map,确认i' = i + 1而不是i' = i - 1。
4.2 归约语句的自环边被忽略
现象:归约循环被判定为可并行,结果累加值错误。原因:写和读同一地址时,数据流图里出现自环边,但有些调度算法会忽略自环。解决:把归约拆成两个节点,中间用临时变量,这样自环变成两条边,调度器必须处理。这个拆分在论文里可能没写,但复现时不做,后面所有并行化都会出错。
4.3 参数 N 的符号处理不一致
现象:同一个多面体在两次运行中边界不同。原因:isl里参数默认是符号常量,但如果你在某个地方把 N 当成具体整数传入,约束会变成常量。解决:统一用params=["N"]声明参数,不要混用set和params。我一般会在脚本开头固定参数列表,后面所有Space都从同一个ctx创建。
4.4 访问映射的域和值域搞混
现象:依赖边数量为 0,但明明有依赖。原因:Map的域是生产者迭代空间,值域是消费者迭代空间,如果建反了,交集为空。解决:写访问映射时,域是写语句的迭代变量,值域是数组下标;读访问映射时,域是读语句的迭代变量,值域是数组下标。两者取交集时,数组下标必须对齐。我一般会先画一个表格,列出每条语句的迭代变量和数组下标,再写映射。
4.5 调度变换后忘记重新检查依赖
现象:手动改了循环顺序,编译通过但结果不对。原因:调度变换后没有重新跑依赖检查,以为原来的依赖关系不变。解决:每次改调度,都重新执行 3.3 节的lex_gt检查。这个检查很快,几毫秒就能跑完,但能避免几小时的调试。我习惯把检查脚本做成一个函数,改完调度直接调用。
5. 进阶:用数据流图做自动调度搜索与论文复现验证
5.1 把调度空间编码成多面体
自动调度的核心思想是把调度系数当成未知数,用多面体约束表达合法性,然后求解。比如二维循环的调度可以写成[i,j] -> [a*i + b*j, c*i + d*j],其中a,b,c,d是整数系数。合法性约束是:对每条依赖边,生产者时间戳字典序小于消费者时间戳。这个约束可以转成isl的Map上的不等式,然后用isl的lex_lt求解。
我一般会先固定调度维数,比如二维,然后枚举系数范围[-2,2],对每个组合跑依赖检查。虽然暴力,但对于小循环嵌套足够快。如果循环嵌套超过四层,可以用isl的schedule工具自动生成,再手动微调。
5.2 用数据流图验证论文里的调度
博士论文里通常会给出一个调度函数和对应的数据流图。复现时,先把论文里的调度写成isl的Map,再用 3.3 节的方法检查合法性。如果论文里的调度在你的模型下不合法,可能是你对计算语句的粒度理解不同,或者依赖距离算错了。这时候不要急着改论文,先检查自己的建模。
我遇到过一种情况:论文里的调度在某个参数范围下合法,但参数超出范围后非法。原因是论文假设了N > 2,而我的测试用例N=1。解决:在约束里显式加上参数范围,比如N >= 2,这样检查时就不会误判。
5.3 一个具体的技巧:用依赖边数量判断循环变换的收益
依赖边数量本身就是一个有用的指标。如果某个循环变换后依赖边数量大幅减少,说明并行度提高。我一般会在调度搜索时记录每个候选调度的依赖边数量,然后按数量排序,优先尝试数量少的。这个技巧在论文里不常见,但实际用起来很省时间。
下面是一个简单的搜索循环:
best = None for a in range(-2, 3): for b in range(-2, 3): for c in range(-2, 3): for d in range(-2, 3): if a == 0 and b == 0: continue sched = build_sched(a, b, c, d) illegal = check_illegal(dep, sched) if illegal.count_val() == 0: score = dep.apply_range(sched).apply_domain(sched).count_val() if best is None or score < best[0]: best = (score, (a, b, c, d)) print("最佳调度系数:", best)这段代码枚举系数,检查合法性,然后选依赖边最少的。build_sched和check_illegal需要自己实现,逻辑和 3.3 节类似。跑完后你会得到一个系数组合,可以直接用到循环变换里。
5.4 验证方法:用随机输入对比变换前后的输出
最后一步验证不能只靠依赖检查,还要跑实际数据。我一般会生成随机数组,分别用原始循环和变换后的循环计算,对比结果。如果结果一致,说明调度合法且实现正确。这个步骤在论文复现时特别重要,因为依赖检查只能保证合法性,不能保证代码生成没有 bug。
我自己的习惯是:每改一次调度,先跑依赖检查,再跑随机输入对比,两个都通过才继续。这个流程看起来慢,但比事后调试快得多。希望帮到你。
本文还有配套的精品资源,点击获取