Halide: A Language and Compiler for Optimizing Parallelism,
Locality, and Recomputation in Image Processing Pipelines
他人笔记:https://blog.csdn.net/tiaozhanzhe1900/article/details/102812485
Halide简介:https://blog.csdn.net/weixin_42261213/article/details/100030830
zz图像、神经网络优化利器:了解Halide:https://my.oschina.net/u/4342183/blog/3420173
Halide的官网:https://halide-lang.org/
https://github.com/opencv/opencv/wiki/DNN-Efficiency
https://github.com/halide/Halide
图像处理管道结合了模板计算和流程序的挑战。它们由具有不同模板阶段的大型图、复杂的缩减图和具有全局或依赖数据的访问模式的阶段组成。由于其复杂的结构,一个简单的管道实现和一个优化的管道实现之间的性能差异通常是一个数量级。高效的实现需要并行性和局部性的优化,但是由于模板的特性,并行性、局部性和引入共享值的冗余重计算之间存在着基本的紧张关系。
我们提出了一个系统模型的权衡空间的基本模板管道;一个时间表表示,该表示在这个空间中 针对图像处理管道的每个阶段来描述具体点;以及用于Halide图像处理语言的优化编译器,该编译器从Halide算法和时间表中综合高性能实现。将此编译器与调度空间上的随机搜索相结合,可以使简洁、可组合的程序在广泛的真实图像处理管道上以及跨不同的硬件体系结构(包括带有SIMD的多核处理器和异构CPU+GPU执行)上实现最先进的性能。在几个小时内编写的简单Halide程序中,我们展示了比专家们在数周或数月内优化的手工优化的C、内联函数和CUDA实现快5倍的性能,适用于过去自动编译器无法企及的图像处理应用程序。
关键词:领域特定语言;编译器;图像处理;局部性;并行性;冗余计算;优化;GPU;矢量化
图像处理管道无处不在,对于捕捉、分析、挖掘和渲染由无数摄像头和基于图像的传感器收集的视觉信息流至关重要。从原始处理到目标检测和识别,到微软的Kinect,到Instagram和Photoshop,再到医学成像和神经扫描,所有的应用都要求极高的性能,以应对图像传感器的分辨率和帧速率的迅速提高以及算法的日益复杂。同时,不断缩小的摄像机和移动设备需要极高的效率才能在电池供电的情况下持续几分钟以上。虽然耗电量大的收音机和视频编解码器可以在定制硬件中实现缓慢变化的标准,但图像处理管道正在迅速发展和多样化,需要高性能的软件实现。
图像处理管道结合了模板计算和流程序的挑战。它们由许多不同操作的大型图形组成,其中大多数是模板计算。这些管道同时又宽又深:每个阶段在它必须处理的许多像素上都表现出数据并行性,整个管道由不同操作的长序列组成,这些操作的算术强度很低(执行的计算与从前一级读取并写入后级的数据的比率)。例如,一个最近的算法,局部拉普拉斯滤波器[3,22 ] 的实现是99个不同阶段的图(图1),包括许多不同的模板和大的依赖于数据的重采样。
图1。图像管道使用大量相互连接的异构级。这里我们展示了局部拉普拉斯滤波器的结构[3,22],它用于摄影后期制作中的各种任务。每个框表示中间数据,每个箭头表示定义该数据的一个或多个函数。该管道包括水平和垂直模板、重采样、依赖于数据的聚集和简单的逐点函数。
由于这种结构,给定管道的原始实现与高度优化的实现之间的性能差异通常是一个数量级或更多。在当前的工具中,达到最高性能的唯一方法通常是在低级C、CUDA、内联函数和汇编中手工编写并行、矢量化、平铺和全局融合的代码。简单的管道变成了成百上千行错综复杂的交错代码;复杂的管道,比如Adobe的Camera Raw引擎,变成了数十万行。对它们进行调优需要专家程序员付出巨大的努力,而且最终的结果既不能移植到不同的体系结构中,也不能与其他算法组合在一起,同时也不能牺牲这些辛苦获得的性能。优化的子程序库也不能解决问题,因为许多关键的优化都涉及到跨阶段的生产者-消费者局部性的融合。
我们通过提高抽象级别、将算法定义与其执行策略解耦来解决这一挑战,以提高可移植性和可组合性,同时自动搜索结果管道到并行机和复杂内存层次结构的优化映射。有效的抽象和自动优化使简单的程序能够获得比手动调整的专家实现更高的性能,同时运行在广泛的体系结构中。
在科学应用中,模板以迭代模板计算的形式得到了很好的研究,其中一个或几个小模板在多次迭代中应用于同一网格中[10,16,19 ]。相比之下,我们对其他应用感兴趣,比如图像处理和计算机图形学,这些领域的模板很常见,但通常以一种非常不同的形式:模板管道。模板管道是不同模板计算的图形。同一个模具会发生迭代,但这是例外,而不是规则;大多数阶段在将数据传递到下一个阶段之前只应用一次模具,下一个阶段在不同的模具上执行不同的数据并行计算。
图结构程序已经在流式语言的背景下进行了研究[4,11,29 ]。静态通信分析允许流编译器通过交叉计算和内核之间的通信来同时优化数据并行性和生产者-消费者局部性。然而,大多数流编译研究都集中在1D流上,其中滑动窗口通信允许1D模板模式。图像处理管道可以看作是二维和三维流和模板上的程序。图像处理所需的计算模型也比模板更通用。虽然大多数阶段都是对先前阶段的结果进行点或模板操作,但有些阶段从任意数据相关地址收集,而其他阶段则分散到任意地址以计算像直方图之类的操作。
简单地图操作的管道可以通过传统的循环融合进行优化:将每个点上的多个连续操作合并为一个单一的复合操作,通过最大化生产者-消费者局部性,在流经管道时将中间数据值保存在快速本地内存(缓存或寄存器)中,从而提高了运算强度。但传统的循环融合不适用于模板操作,即消费者阶段中的相邻点依赖于生产者阶段的重叠区域。相反,模板需要在生产者-消费者位置、同步和冗余计算之间进行复杂的权衡。因为这种权衡是通过交错分配、执行和每个阶段的通信顺序来完成的,所以我们称之为管道调度。在科学应用中,这些权衡存在于调度单个迭代模板计算中,并且选择空间的复杂性通过在过去的工作中引入的许多不同的平铺和调度策略来反映[10,16,19 ]。在图像处理管道中,这种权衡必须针对图中各个阶段之间的生产者-消费者关系(通常是几十个或数百个)进行,而理想的时间表取决于每个阶段之间的全局交互作用,通常需要许多不同策略的组合。
Halide是一种开源领域专用语言,用于现代计算摄影和视觉应用中的复杂图像处理管道[26 ]。在本文中,我们提出了这种语言的优化编译器。我们介绍:
我们的调度表示在局部性、并行性和避免冗余工作之间建立了一系列折衷模型。当然,也可以用先前的模板组合来表达。与以往的模板代码生成系统不同,它不只是描述一个模板调度策略,而是在模板图和其他图像处理计算中分别处理每个生产者-消费者边缘。
我们的拆分表示法将调度与底层算法分离,再加上编译器的由内而外的设计,使编译器能够自动搜索最佳调度。可能的时间表空间是巨大的,有数百个相互依赖的维度。对于现有的模板编译器和自动调谐器所采用的多面体优化或穷尽性参数搜索,维数太高。然而,我们证明了使用随机搜索可以发现高质量的调度。
给定一个时间表,我们的编译器会自动合成用于x86和ARM cpu的高质量并行矢量代码(SSE/AVX和NEON),以及与主机管理代码交织在一起的CUDA内核图形,以执行混合GPU。它使用简单但通用的区间分析自动推断所有内部分配和完整的循环嵌套[18 ]。直接将数据并行维度映射到SIMD执行,包括对跨步访问模式的仔细处理,可以生成高质量的向量代码,而不需要任何通用的循环自动矢量化。
最终的结果是一个系统,它使简洁、可组合的程序能够在广泛的真实图像处理管道上实现最先进的性能,并跨越不同的硬件体系结构,包括具有SIMD的多核和异构CPU+GPU执行。在几个小时内编写的简单Halide程序中,我们展示的性能比专家在数周或数月内编写的手工调整的C、内联函数和CUDA实现快5倍,适用于过去自动编译器无法实现的图像处理应用程序。
我们使用Halide DSL以简单的功能风格描述图像处理管道[26 ]。局部拉普拉斯滤波器的简单C++实现(图1)由几十个循环嵌套和数百行代码描述。这对于用传统的循环优化系统进行全局优化是不现实的。Halide版本将其提取成62行,仅描述99级管道中的基本数据流和计算,所有关于程序如何合成的选择都被单独描述(第3节)。
在Halide中,命令式语言中可变数组的值是从坐标到值的函数。它将图像表示为定义在无限整数域上的纯函数,其中函数在某一点的值表示相应像素的颜色。管道被指定为函数链。函数可以是参数中的简单表达式,也可以是有界域上的约化。定义函数的表达式没有副作用,与任何简单函数语言中的表达式非常相似,包括:
例如,一个可分离的3x3非标准化盒形滤波器表示为x;y中的两个函数链:
UniformImage in(UInt(8), 2)
Var x, y
Func blurx(x,y) = in(x-1,y) + in(x,y) + in(x+1,y)
Func out(x,y) = blurx(x,y-1) + blurx(x,y) + blurx(x,y+1)
这种表示比大多数函数式语言简单。它不包括高阶函数、动态递归或其他数据结构(如列表)。从标量函数简单地映射到标量坐标。更高级功能(如高阶函数)的约束版本被添加为语法糖,但不会更改底层表示。
这种表示形式足以描述各种图像处理算法,并且这些约束使得在编译过程中能够灵活地分析和转换算法。关键的是,这种表示在每个函数的域内自然是数据并行的。另外,由于函数定义在一个无限域上,可以根据需要通过计算任意的附加值保护带来安全有效地处理边界条件。保护带是图像处理代码中的一种常见模式,既考虑到对齐等性能问题,也考虑到了安全性。当特定的边界条件对算法的意义有影响时,函数可以定义自己的边界条件。
减法功能。 为了表示像直方图和一般卷积之类的操作,Halide还需要一种表示迭代或递归计算的方法,如求和、直方图和扫描。减法分为两部分:
与纯函数不同,归约的意义取决于归约函数的应用顺序。程序员通过定义一个归约域来指定顺序,该域由每个维度的最小和最大表达式限定。输出域中每一点上的值由该点上的归约函数的最终值定义,然后按字典顺序在整个归约域中递归。
这种模式可以描述一系列超出传统模板计算范围的算法,但对图像处理管道至关重要,在某种程度上限制了副作用。例如,直方图均衡化结合了多次减少和数据相关的聚集。散射缩减计算直方图,递归扫描将其集成到CDF中,逐点操作使用CDF重新映射输入:
UniformImage in(UInt(8), 2)
RDom r(0..in.width(), 0..in.height()), ri(0..255)
Var x, y, i
Func histogram(i) = 0; histogram(in(r.x, r.y))++
Func cdf(i) = 0; cdf(ri) = cdf(ri-1) + histogram(ri)
Func out(x, y) = cdf(in(x, y))
归约和扫描的迭代边界由程序员使用显式归约域(RDOM)表示。
Halide表示的图像处理算法避免了对执行顺序和数据放置施加限制。在使用值之前,需要先计算值,以尊重算法中的基本依赖关系,但许多选项仍然未指定:
这些选择不能改变算法的意义或结果,但它们对最终实现的性能至关重要。我们调用一组特定的选项来确定何时何地计算管道的计划值。
在存在模板访问模式的情况下,这些选择受到生产者-消费者局部性、并行性和共享值的冗余重新计算之间的基本紧张关系的约束。为了理解这种折衷空间,可以看一个例子。
考虑一个简单的两级模糊算法,它将一个3×3的框过滤器计算为两个3×1的过程。
第一阶段 blurx 通过在3x1窗口上平均计算输入的水平模糊:
blurx(x,y) = in(x-1,y) + in(x,y) + in(x+1,y)
第二阶段 out 通过平均第一阶段输出的1x3窗口来计算最终的各向同性模糊:
out(x,y) = blurx(x,y-1) + blurx(x,y) + blurx(x,y+1)
考虑管道调度的一种自然方式是从输出阶段的角度考虑:它应该如何计算其输入?这条管道有三个明显的选择。
图2。可视化调度选择空间的一种自然方式是通过存储粒度(x轴)和计算粒度(y轴)来实现的。广度优先执行将粗粒度计算转换为粗粒度存储。全融合执行细粒度计算到细粒度存储(小型临时缓冲区)。滑动窗口策略为整个中间阶段分配了足够的空间,但尽可能晚地以细粒度块计算。这些极端都有各自的陷阱。广度优先执行的局部性较差,全融合经常做冗余的工作,使用滑动窗口来避免冗余的重新计算,通过在循环迭代中引入依赖关系来约束并行性。最好的策略往往是混合的,并且位于空间的中间。
首先,它可以计算并存储blurx中的每一个需要的点,然后再对任何点求值。应用于600万像素(3k x 2k)图像,这相当于循环嵌套:
alloc blurx[2048][3072]
for each y in 0..2048:
for each x in 0..3072:
blurx[y][x] = in[y][x-1] + in[y][x] + in[y][x+1]
alloc out[2046][3072]
for each y in 1..2047:
for each x in 0..3072:
out[y][x]=blurx[y-1][x] + blurx[y][x] + blurx[y+1][x]
这是手工编写的管道中最常见的策略,也是将库例程组合在一起的结果:每个阶段在其输入上执行宽度优先,然后将其整个输出传递到下一个阶段。由于每个阶段中所有需要的点都可以独立地计算和存储,因此存在大量的并行性,但是由于在out使用第一个blurx之前必须计算和存储blurx的所有值,因此几乎没有生产者-消费者局部性。
在另一个极端,out stage可以在使用它的点之前计算出blurx中的每个点。这打开了一个进一步的选择:blurx中被out中的多个点使用的点应该被存储和重用,还是由每个消费者独立地重新计算?
交叉两个阶段,不存储中间结果的使用,相当于循环嵌套:
alloc out[2046][3072]
for each y in 1..2047:
for each x in 0..3072:
alloc blurx[-1..1]
for each i in -1..1:
blurx[i]= in[y-1+i][x-1]+in[y-1+i][x]+in[y-1+i][x+1]
out[y][x] = blurx[0] + blurx[1] + blurx[2]
每个像素都可以独立计算,从广度优先策略提供同样丰富的数据并行性。从生产者到消费者的距离很小,最大限度地扩大了地方性。但是由于blurx中的共享值不会在迭代中重用,所以这种策略会执行冗余的工作。这可以看作是通过模板依赖模式应用经典循环融合的结果:第一个循环的主体被移动到第二个循环中,但是它的工作被模板的大小放大。
在存储blurx的值时,这两个阶段也可以交叉使用:
alloc out[2046][3072]
alloc blurx[3][3072]
for each y in -1..2047:
for each x in 0..3072:
blurx[(y+1)%3][x]=in[y+1][x-1]+in[y+1][x]+in[y+1][x+1]
if y < 1: continue
out[y][x] = blurx[(y-1)%3][x] + blurx[ y % 3 ][x] + blurx[(y+1)%3][x]
这将在滑动窗口上交错计算,不使用模板半径(一条扫描线)拖尾模糊。它不浪费任何工作,只计算一次blurx中的每个点,并且在blurx中产生的值和消耗的值之间的最大距离与模板高度(三条扫描线)成比例,而不是整个图像。但是为了实现这一点,它引入了循环迭代之间的依赖关系:out的给定迭代依赖于blurx的最后三个外循环迭代。只有当这些循环按顺序求值时,这才有效。在只产生每个值一次的情况下,交错阶段需要严格同步计算顺序,牺牲并行性。
每一种策略都有一个主要的缺陷:失去局部性、冗余的工作或有限的并行性(图3)。实际上,对于给定的管道,正确的选择几乎总是介于这两个极端之间。对于我们的两阶段示例,可以通过在平铺级别交错计算blurx和out来实现更好的平衡:
图3。图2中选择空间中的不同点在局部性、冗余重新计算和并行性之间做出了不同的权衡。在这里,我们为我们的两级模糊管道量化这些影响。span通过计算有多少线程或simd通道可以忙着做有用的工作来度量可用的并行度。最大重用距离通过计算值和读回值之间可能发生的最大操作数来度量局部性。工作放大通过比较在广度优先的情况下所做的算术运算的数量来衡量冗余的工作。前三种策略中的每一种都代表了选择空间的一个极端点,并且在一个方面是薄弱的。最快的调度是混合策略,例如最后两行中平铺的策略。
alloc out[2046][3072]
for each ty in 0..2048/32:
for each tx in 0..3072/32:
alloc blurx[-1..33][32]
for y in -1..33:
for x in 0..32:
blurx[y][x] = in[ty*32+y][tx*32+x-1] + in[ty*32+y][tx*32+x] + in[ty*32+y][tx*32+x+1]
for y in 0..32:
for x in 0..32:
out[ty*32+y][tx*32+x] = blurx[y-1][x] + blurx[y ][x] + blurx[y+1][x]
这就牺牲了在分片边界上的少量冗余计算,以获得更大的生产者-消费者局部性,同时仍然使片内和跨片之间的并行性不受约束。(在迭代模板计算文献中,冗余区域通常被称为“重影区”,这种策略有时被称为“重叠拼接”[17,31 ]。)在现代x86上,这种策略比使用相同数量的多线程和向量并行的宽度优先策略快10倍。这是因为缺乏生产者-消费者的局部性,使得广度优先的版本受到带宽的限制。这种差异会随着管道变长而增大,中间数据与输入和输出的比率也会增加,而且在摩尔定律下,随着计算资源的指数级扩展速度超过外部内存带宽,这种差异只会进一步扩大。
我们在该架构中发现的最快策略是使用扫描线上的滑动窗口交错计算两个阶段,同时将图像分割成单独处理的独立扫描线条带:
alloc out[2046][3072]
for each ty in 0..2048/8:
alloc blurx[-1..1][3072]
for y in -2..8:
for x in 0..3072:
blurx[(y+1)%3][x] = in[ty*8+y+1][tx*32+x-1] + in[ty*8+y+1][tx*32+x] + in[ty*8+y+1][tx*32+x+1]
if y < 0: continue
for x in 0..3072:
out[ty*8+y][x] = blurx[(y-1)%3][x] + blurx[ y % 3][x] + blurx[(y+1)%3][x]
相对于原来的滑动窗口策略,这牺牲了在独立处理的blurx条带重叠的顶部和底部的两条扫描线的冗余工作,而不是恢复每个扫描线内的细粒度并行度和扫描线条带的粗粒度并行度。最终结果是在一台基准机器上比平铺策略快10%,但在另一台机器上慢10%。这些策略和许多其他策略之间的最佳选择因不同的目标体系结构而异。对于一个更大的管道中的每个阶段所做的决策也会产生全球性的影响,因此理想的选择取决于阶段的组成,而不仅仅是孤立的每个阶段。真正的图像处理管道通常有几十到几百个阶段,使得选择空间巨大。
我们介绍了一个用于调度模板管道的重要选择空间的模型,该模型基于每个阶段选择计算每个输入的粒度,存储每个输入以供重用的粒度,以及在这些粒度中,以什么顺序遍历其域(图4)。
域顺序 我们的模型首先使用一组传统的循环转换概念定义遍历每个函数域所需区域的顺序,我们称之为域顺序:
图4。即使是简单的管道也展示了丰富的调度选择空间,每个管道都在并行性、局部性和冗余重新计算之间表达了自己的权衡。每个阶段的选择是双重的。更简单的选择是域顺序,它可以表示线程并行性、向量化和遍历顺序(row major vs column major)。维度可以分为内部和外部两个部分,递归地扩展了选择空间,并且可以表达拼接策略。每个阶段必须回答的更复杂的问题是何时计算其输入。选择包括提前计算所有依赖项(宽度优先)、尽可能晚地计算值然后丢弃它们(完全融合),以及尽可能晚地计算值,但重用以前的计算(滑动窗口)。这两类选择相互作用。例如,最快的调度通常将域拆分为并行处理的分片,然后计算宽度优先或在每个分片内使用滑动窗口。
递归拆分打开了更多的选择,并在与其他转换结合时启用了许多常见模式,如平铺。矢量化和展开的建模方法是先按矢量宽度或展开因子拆分一个维度,然后将新的内部维度调度为矢量化或展开。由于Halide的函数模型在构造上是数据并行的,维度可以以任何顺序交错,并且任何维度都可以被调度为串行、并行或矢量化。
对于归约函数,只有当归约更新是关联的时,约简域的维数才能被重新排序或并行化。自由变量维可以按任何顺序进行调度,就像纯函数一样。
我们的模型只考虑轴对齐的边界区域,而不考虑一般的多面体,这是图像处理和许多其他应用的一种实用简化。但这也允许使用简单的区间分析来定义和分析区域。由于我们的调度模型依赖于以后的编译器推理来确定每个函数和循环的求值和存储边界,因此边界分析必须能够分析Halide语言中的每个表达式和构造。区间分析比现代工具(如多面体分析)简单,但它可以通过更广泛的表达式有效地进行分析,这对于本设计至关重要。
调用时间表 除了每个函数域内的求值顺序外,该调度还指定了将函数的计算与它所依赖的每个函数的存储和计算交错的粒度。我们把这些选择称为召唤时间表。我们为卤化物管道的整个调用图中的每个函数指定唯一的调用计划。每个函数的调用调度被定义为其调用方的循环嵌套中存储和计算它的点(图4,顶部)。例如,可以沿着这些轴查看上一节中的三个极端:
调用调度和域顺序一起为矩形网格上的模具管道调度定义了一个代数。组合这些选择可以定义无限范围的调度,包括在手工优化的图像处理管道中由从业者开发的绝大多数通用模式。
域顺序定义的循环转换与调用调度选择的阶段间交错粒度交互作用,因为调用调度是通过指定要存储或计算的循环级别来定义的。函数调用站点可以在任何循环中被存储或计算,从直接调用函数的最内部维度到它本身被调度计算的周围维度,等等,通过它的消费者链。拆分维度允许以比调用函数的内在维度更细的粒度来指定调用调度,例如通过扫描线块而不是单个扫描线交错,或者用像素块而不是单个像素进行交错。由于每个计算出的值都需要一个逻辑位置来存储其结果,因此存储粒度必须等于或大于计算粒度。
时间表示例 回顾第3.1节中的优化示例,平铺时间表可以建模如下:
我们的编译器将描述Halide管道的函数与每个函数的完全指定时间表结合起来,为实现整个管道的单个过程合成机器代码。生成的指针和输出函数都是ABI函数的输出函数。该实现是多线程和矢量化的,根据时间表,内部管理所有中间存储器的分配,并可选地包括合成的GPU内核,它也自动管理。
编译器不会就应用哪个循环转换或什么将生成快速代码做出启发式决策。对于所有这些问题,我们都按日程安排。同时,生成的代码通过构造是安全的。所有循环的边界都是推断的。边界推断生成的循环边界最终只取决于输出图像的大小。有界循环是我们控制流的唯一方法,所以我们可以保证终止。所有分配都足够大,足以覆盖计划使用的区域。
给定定义Halide管道和完全指定的时间表作为输入的函数(图5,左图),我们的编译器将继续执行以下主要步骤。
图5。我们的编译器是由一个自动调整器驱动的,它随机地搜索有效调度的空间,以找到给定卤化物程序的高性能实现。编译器的核心使用调度将映像管道的函数表示降低为命令式代码。它首先构造一个循环嵌套,生成管道的最后一个阶段(在本例中是out),然后递归地将管道早期阶段的存储和计算注入到调度指定的循环级别。计算的区域的位置和大小在这一点上是符号化的。它们由随后的边界推理过程来解决,该过程在每个循环级别的前导码中注入区间算术计算,从而将每个阶段产生的区域设置为至少与后续阶段消耗的区域一样大。其次,滑动窗口优化和存储折叠消除了存储粒度高于计算粒度的冗余计算和多余存储。一个简单的展平变换将每个函数无穷域中的多维坐标转换为相对于相应缓冲区基部的简单一维索引。矢量化和展开过程将常数循环替换为k,并计划将其矢量化或展开为相应的k宽向量代码或循环体的k个副本。最后,后端代码生成通过LLVM为计划的管道发出机器代码。
我们编译器的第一步是一个降低过程,在给定一个Halide管道和一个完全指定的时间表的情况下,合成一组完整的循环嵌套和分配(图5,中间)。
降低从定义输出的函数开始(在本例中为out)。给定调度表中函数的域顺序,它生成一个覆盖输出所需区域的循环嵌套,其主体在该域中的一个单点处对函数求值(图5,中间顶部)。循环的顺序由明细表给出,并包括拆分标注的其他循环。循环由其最小值和范围定义,并且所有循环隐式跨距为1。由于所有循环都有一个基和范围表达式,因此此过程将已拆分的维度的总遍历域取整为拆分因子的最接近倍数。
在这一阶段,循环边界作为输出函数所需区域的简单符号表达式,并进行了解析待会儿。那个边界不能在单个函数的循环之间具有相互依赖的维度,因此它们表示在轴对齐的边界框上的密集迭代。根据计划,每个循环被标记为串行、并行、展开或矢量化。
调用者从这里开始,然后从调用者到调用者。被调用者(除了那些被调度的内联)被安排在某个调用者函数的某个维度的粒度上进行计算。这对应于到目前为止生成的代码中的一个现有循环。该站点被定位,并且计算被调用方的代码被注入到循环体的开头。此代码采用循环嵌套的形式,使用被调用方的域顺序构造。调用方的分配类似地在由指定的某个包含循环级别注入时间表图5,中间,blurx在平铺层被分配(输出.xo),而它是根据平铺内每个扫描线的需要计算的(出去。伊). blurx的分配和计算在循环嵌套中的相应点插入。
缩减被降低为一对循环嵌套:第一个初始化域,第二个应用缩减规则。分配和循环范围都被跟踪为其调用者使用的函数所需区域的符号。一旦下降递归到管道末端,所有函数都被合成为一组循环。
在这个阶段,对于分配大小和循环边界,管道依赖于每个函数的每个维度的符号边界变量。下一个降低阶段为这些变量生成并注入适当的定义。与函数降低一样,边界推理从输出递归返回。调用方根据调用方的每个函数的符号边界,对调用方的每个函数的边界进行符号求值。在每个步骤中,每个维度的所需边界都是通过对调用方中为该维度建立索引的表达式进行区间分析来计算的,该表达式给出了所有下游函数先前计算的边界。
在bounds推理递归到管道顶部之后,它返回到输出,为在降低过程中用作代理的bounds变量注入定义。它们由表达式定义,这些表达式在每个循环级别计算具体的边界作为前导(例如,在图5中,blurx.y的最小界限是根据访问它的索引表达式的区间分析和调用函数out的边界来计算的)。实际上,将动态边界求值表达式提升到最外层的循环级别,使得更复杂的边界表达式的运行时开销可以忽略不计。
区间分析是现代循环综合和代码生成系统中的一个不寻常的选择。与多面体模型相比,生成的每个维度的最小/最大边界没有表现力。它们只能描述轴对齐的长方体上的迭代,而不是任意的多面体。然而,与扫描一般多面体的问题相比,在任何一组区间内合成有效的循环都是微不足道的。对于许多领域,包括图像处理,这是一个可接受的简化:大多数函数应用于直线区域。
最关键的是,区间分析可以分析一类更一般的表达式:通过几乎任何计算,从基本算法到条件表达式,到先验,甚至从内存中加载,计算区间是很简单的。因此,该分析可以广泛地应用于Halide中的任何管道中,每个回路的完整边界和分配。它还通过符号瓷砖大小等构造进行了推广,这些结构超出了多面体分析的范围。对于区间分析过于保守的情况(例如,当计算从程序员知道的从存储器加载的浮点数的边界在0到1之间时),Halide包括一个简单的钳夹操作符,该操作符同时声明并强制表达式上的绑定。
在边界推断之后,编译器遍历循环嵌套,寻找滑动窗口优化的机会。如果一个函数的实现被存储在比它的计算更高的循环级别上,并且有一个介入的串行循环,那么这个循环的迭代可以重用以前迭代生成的值。使用与边界推断相同的区间分析机制,我们通过排除所有先前迭代计算的区域来缩小每次迭代要计算的区间。正是这种转换使我们能够权衡并行性(因为中间的循环必须是串行的)以便重用(因为我们避免重新计算以前迭代中已经计算过的值)
例如,在图5中,针对该平铺中的每个扫描线,将模糊存储在每个输出平铺中,但根据需要计算以供重用。因为扫描线(出局。易)按顺序遍历,在需要模糊的第一条扫描线之前立即计算Blux的中间值,但可以在平铺中重用我以后的扫描线。对于每一次迭代出局。易,计算Blux.y的范围,以排除平铺内计算的所有先前迭代所覆盖的间隔。
存储折叠是第二个类似的优化,在这个下降阶段使用。如果一个区域被分配到一个串行循环之外,但只在其中使用,并且每个循环迭代所使用的子区域在分配的区域上单调行进,我们可以通过重写访问该区域时使用的索引来折叠存储,方法是将索引与任何给定迭代中使用的区域的最大范围相乘。例如,在图5中,每个迭代出去。伊只需要访问blurx的最后3条扫描线,因此blurx的存储可以减少到仅3条扫描线,并且值blurx(x,y+3)将重用与blurx(x,y),blurx(x,y-3)等相同的内存地址。这减少了峰值内存使用和工作集大小。
接下来,编译器将多维加载、存储和分配展平到它们的一维等效项中。这是以传统方式发生的:为每个维度计算一个步幅和一个最小偏移量,与多维站点对应的缓冲区索引是站点坐标和跨距的点乘,减去最小值。(参见图5,右)按照惯例,我们总是将最内层维度的跨距设置为1,以确保我们可以在该维度中执行密集的向量加载和存储。对于图像,这将按扫描线顺序在内存中进行布局。虽然我们的调度模型允许在执行顺序上具有极大的灵活性,但我们不支持更不寻常的布局内存,例如平铺或稀疏存储。(我们发现,现代缓存内存层次结构在很大程度上消除了对平铺存储布局的需要。)
展平后,矢量化和展开过程将按矢量化或展开的固定大小的循环替换为其循环体的变换版本。展开将大小为n的循环替换为n个顺序语句,依次执行每个循环迭代。也就是说,它完全展开循环。以较小数量展开表示的方法是先将一个维度拆分为两个维度,然后展开内部维度。
最后,我们执行低级优化,并为生成的管道发出机器代码。我们的主要后端使用LLVM来生成低级代码。我们首先在IR上运行一个标准的常量折叠和死代码消除过程,它还对边界推断产生的常见模式进行符号简化。此时,表示可以降低到LLVM-IR。我们的表示和LLVM之间基本上是一对一的映射,但是有两种特定的模式值得一提。
首先,并行for循环被降为LLVM代码,该代码首先构建一个包含for循环体中引用的状态的闭包。循环体被降低为一个单独的函数,该函数接受闭包作为参数并执行循环的一次迭代。我们最终生成的代码将循环的迭代排队到一个任务队列中,线程池在运行时使用该队列。
其次,如果直接传递给LLVM,许多向量模式很难表达或生成糟糕的代码。我们使用窥视孔优化将这些路由到特定于体系结构的内部函数。例如,我们执行自己的分析过程来确定向量加载和存储的对齐方式,并捕捉常见模式,如交错存储、跨步加载、向量平均、钳制算法、固定点算法、加宽或缩小算法,等。通过将特定的表达式IR模式映射到每个体系结构上的特定SIMD操作码,我们为程序员提供了一种在ARM(使用NEON)和x86(使用SSE和AVX)上使用所有相关SIMD操作的方法。
GPU生成代码 定义Halide管道的数据并行网格自然适合GPU编程模型。我们的编译器使用相同的调度原语以及一些简单的约定来建模GPU的执行选择。GPU内核的启动被建模为安排为并行的维度(循环),并用它们对应的GPU块和线程维度进行注释。
GPU执行的限制对如何调度这些维度施加了一些限制。特别是,块和线程循环的序列必须是连续的,块和线程级别之间没有其他干预循环,因为内核启动对应于一个多维、平铺的并行循环嵌套。在当前不直接实现嵌套数据并行的gpu上,内核循环集不能相互嵌套。此外,线程循环的范围必须符合目标设备的相应限制。除此之外,所有的标准循环构造仍然可以在块和栅格标注之外或之内进行明细表。这分别对应于内部启动GPU内核的循环和GPU内核的每个线程中的循环。
给定一个用GPU块和线程维度注释的调度,我们的编译器会像以前一样继续,为整个管道合成一组循环嵌套。在后端之前没有任何阶段知道GPU的执行;块和线程的维度被视为任何其他循环。GPU后端扩展了x86后端,包括其完整的功能集。在块和线程维度上的循环之外,编译器生成与纯CPU目标相同的优化SSE代码。在每个GPU块循环嵌套的开始,我们分割子嵌套,就像CPU后端的并行for循环一样,只是它是在GPU上生成的。我们首先在流入GPU的所有状态上构建一个闭包循环。我们然后从这些循环体生成一个GPU内核。最后,我们生成主机API调用,在主机代码的相应点启动内核,并将闭包作为参数传递。我们还在启动前后生成动态代码,以跟踪哪些缓冲区需要复制到设备或从设备复制。GPU上使用的每个分配的缓冲区都有相应的设备内存分配,并且只有在需要时才延迟地复制它们的内容。
CPU/GPU混合模式的执行结果与CPU/GPU混合后的结果不同。一个小小的改变可以将一个由几十个GPU内核和向量化的CPU循环嵌套组成的图,通过复杂的内存管理和同步连接在一起,变成一个完全不同的内核和循环图,产生相同的结果,在将一个给定的管道映射到一个异构的机器时,用表达的方式建模一个巨大的可能的融合空间和其他选择。
我们应用随机搜索的方法,自动寻找卤化物管线的最佳排程。自动优化器采用固定的算法,并试图通过搜索最有效的调度来优化运行时间。调度搜索空间太大,无法彻底搜索。例如,在本地Laplacian过滤器管道中,我们估计10720个调度的下界。这是通过标记每个函数三个平铺的函数和所有可能的存储和计算粒度得到的。空间的实际维度可能要高得多。最优调度以复杂的方式依赖于机器结构、图像尺寸和代码生成,并且由于循环融合和缓存行为,显示了选择之间的全局依赖关系。
在本节中,我们将介绍我们的自动调谐器。由于搜索问题有许多局部极小值,我们利用遗传算法来寻找一个合理的近似解,这是受到PetaBricks[2]中搜索过程的启发。我们首先描述了调度搜索空间。我们展示了如何使用特定领域的知识来选择合理的起点。然后介绍了遗传算法的一般操作。最后,我们展示了如何将进一步的知识作为更有效的变异规则的搜索先验。
计划搜索空间 我们完整的调度模型是每个调用,但为了简化自动调整,我们在所有调用站点上以相同的方式调度每个函数。域转换包括拆分和重新排序维度,并将它们标记为并行、矢量化或展开,或者将一对维度映射到GPU网格启动。变量和块大小的参数是随机的,并从2的小幂次中选择。
由于调度具有复杂的全局依赖关系,并不是所有的调度都有效:例如,可以将调度计算或存储在调用方循环顺序中不存在的维度中。遗传操作,如突变和交叉可能会使正确的亲本计划失效。因此,一般来说,我们拒绝任何部分完成的无效计划,并继续抽样,直到我们获得有效的时间表。我们还根据一个正确的参考时间表,在几个输入图像上验证程序输出。这只是一个健全的检查:所有有效的计划都应该生成正确的代码。最后,为了防止生成的代码因复杂的管道而爆炸,我们限制了每个函数的域调度操作的数量。
搜索起点 一个有效的开始计划是将所有函数标记为计算和存储的宽度优先(在最外层,根粒度)。调谐器从这个起点开始收敛,尽管速度很慢。我们通常可以通过在初始种群中设定合理的时间表来做得更好。对于每个函数,我们会找到它相对于调用者的矩形足迹(通过边界推断)和具有足迹1的内联函数。其余函数被随机调度为(1)完全并行化并平铺,或(2)简单地在y上并行化。我们将完全并行化和平铺定义为在x和y上平铺,在平铺的内部x坐标内矢量化,在y外平铺维度上并行化。这些选择是由一个重量从零到一的重量取决于个人的加权硬币来选择的。这使我们能够经常发现向量化良好的函数的良好起点,或者在情况不是这样的情况下返回到朴素的并行性。维度x和y是从相邻维度随机选择的,除非有Halide程序员提供的可选边界注释(例如颜色通道的数量):边界较小的维度不平铺。
遗传算法搜索 我们使用一个固定的种群规模(每代128个个体,在本文中的所有例子中)并用精英主义、交叉、变异个体和随机个体的种群频率构造每一个新世代。精英主义照搬了上一代的顶尖人物。交叉通过锦标赛选择选择父母,然后是两点交叉,交叉点在函数之间随机选择。随机个体可以由前面描述的合理调度生成,也可以通过随机调度选择独立地调度每个函数来以等概率生成。这是直接从PetaBricks autotuner[2]派生出来的。
调度变异规则 我们在变异规则中加入了更多关于时间表的先验知识。变异随机选择一个函数,并用随机选择的八个操作中的一个来修改它的调度。有六种是相当通用的:随机化常量、用随机生成的替换函数的调度、从随机选择的函数的调度中复制、对于函数的域转换列表,可以添加、删除或替换为随机选择的转换。
我们剩下的两个突变结合了关于成像的特定知识。这些选择的概率更高。首先,计算或存储粒度的点态变异通常在融合循环时是无效的。因此,我们有一个循环融合规则,它将所选函数调度为完全并行化和平铺,然后将被调用者调度为x向量化,并在tile的内部x维下计算和存储。被叫方调度递归地重复,直到掷硬币失败为止。最后,我们通过应用一个模板来合并先验知识:我们用从文本文件中采样的三种常见调度模式之一替换函数的调度。它们是:(1)计算和存储在x下,并通过x矢量化,(2)完全并行化和平铺,以及(3)在y上并行化和在x上矢量化。如果生成一个CUDA调度,我们将在GPU上注入一个简单平铺的第四个模式。x和y尺寸在起点处确定。
为了评估我们的表示和编译器,我们将它们应用到一系列图像处理应用程序中,并将我们的编译器发现的最佳自动调谐结果与我们可以找到的最好的专家实现进行了比较。我们选择这组示例来涵盖各种算法和通信模式。它包括从两个到99个阶段的管道,包括许多不同的模板、依赖于数据的访问模式和直方图缩减。我们将在下面描述每个应用程序。图6总结了它们的特性,图7总结了它们的性能。所有评估都是在四核XeonW3520 x86 CPU和NVIDIA Tesla C2070 GPU上进行的。
Blur 是Sec中使用的简单示例。一个核1.3在一个3.3核的水平方向上卷积成一个3.3级的核。这是两个连续模板的简单示例。我们的参考比较是完全在SSE intrinsics中定义的手工优化、手动融合和多线程循环嵌套[26]。这个版本比gcc4.7编译的C语言中的一对简单循环快12倍。我们的autotuner发现的版本仍然比原来快10%,同时它是由两行Halide算法生成的,而不是35行内部函数。
图6。示例应用程序的属性。在一些情况下,函数的数目超过了图7中的程序行的数目,因为Halide函数是使用高阶函数进行元编程的。
图7。自动调谐Halide程序运行时间与领域专家在C、intrinsics和CUDA中创建的手动优化程序的比较。Halide程序既快又需要更少的代码行。(与CPU引用相比,没有可用的GPU引用。)
摄像管线 将相机传感器记录的原始数据转换为可用图像。它的脱模,单是一个复杂的组合21交织和相互依赖的模板。
参考比较是从FrcCeNeMaLa中的一个精心的平铺和融合的循环嵌套,用C++的306行(1)表示。所有的生产者-消费者通信都通过暂存缓冲区进行,分片使用OpenMP分布在并行线程上,紧密循环由GCC自动矢量化。Halide算法是145行,描述32个函数和22个不同的模板,从字面上翻译自注释中的伪代码解释原始源代码。它编译到实现3.4的速度比手动调整的原始版本快。自动调整的时间表通过重叠瓷砖上复杂的交错模板将长链的阶段融合在一起,完全融合其他阶段,将每个阶段矢量化,并将扫描线块分布在线程上。
多尺度插值 使用图像金字塔插值像素数据以实现无缝合成。这需要以许多不同的分辨率处理数据。由此产生的金字塔是一系列的阶段链,这些阶段在小模板上局部重采样,但通过这些阶段依赖性在整个图像中全局传播。
参考实现是一组精心构造的循环嵌套,由Adobe工程师手工调整,以在GCC中生成完全矢量化的实现。Halide算法基本上更简单,但编译到实现1.7比原来的并行向量代码快。同样的Halide算法也可以自动编译成CUDA内核和x86代码的图形,速度更快5.9。
双边网格是双边滤波器的有效实现,它在平滑图像的同时保留其主要边缘[5,21]。它首先将图像数据分散到一个三维网格中,在网格的每一列有效地构建一个窗口化的直方图,然后用三个5点模板沿着每个is轴模糊网格。最后,在由输入图像确定的位置,在网格内通过三线性插值来构造输出图像。
CPU引用代码是由原始作者在122行C++中的一个调谐但干净的实现。GCC对它进行了部分自动矢量化,但对于多线程来说是非常重要的(主要阶段的幼稚OpenMP并行化会导致基准CPU速度减慢),因此引用是单线程的。Halide算法是34行,编译到实现4.2比原来的更快。加速来自于并行性、某些阶段的分片级融合以及对维度的仔细重新排序以控制网格中的平行粒度。
我们还将我们的实现与原始作者用370行CUDA代码编写的手工调优的GPU实现进行了比较。同样的Halide算法不到十分之一的代码找到了一个不同的调度,比手写的CUDA快2.3。Halide编译器生成与引用相似的CUDA代码,但自动调谐器在调度空间中发现了一个不直观的点,该点牺牲了网格构建步骤中的一些并行性,以减少散射减少中的同步开销。它还使用了一种分片融合策略,通过GPU暂存存储器传递中间结果,以牺牲冗余计算为代价,通过模糊步骤提高局部性。这些权衡与原作者的直觉背道而驰,而且在CUDA中也很难表达,但是很容易被我们的时间表表示和我们的自动调优器发现。
局部拉普拉斯滤波器使用多尺度方法对地图图像进行色调调整,并以尊重边缘的方式增强局部对比度[3,22]。Adobe lightshop和其他的filters都在使用。该算法构建并操作多个相互依赖的图像金字塔。滤波器输出是通过从多个金字塔。有我们使用的参数,管道包含99个不同的阶段,在许多尺度上运行,并且具有不同的计算模式。
参考实现是C++的262行,在Adobe开发的,并且与OpenMP仔细并行化,并且从英特尔性能原语(14, 20)中卸载大多数密集内核到已调整的汇编例程。它的性能与在他们的产品中部署的版本非常相似,这个版本需要几个月的时间来开发,包括2-3周的优化。它比纯C++中作者编写的算法相同的参考版本快10,没有IPP或OpenMP。卤化物版本是在一天之内,用52行代码写成的。它编译成一个比高度优化的专家实现快1.7的实现(比没有IPP和OpenMP的C++ 20快)。由此产生的调度非常复杂,在扩展的99阶段图中混合了不同的融合、平铺、矢量化和多线程策略。在C语言中,它对应于超过10000行代码的数百个循环。
同一个程序用不同的自动生成的时间表编译成一个混合CPU/GPU程序,每个内核代表整个图形中不同平铺和部分融合的子集,并且在CPU上用查找表和几个较小级别的金字塔作为向量代码调度。生成的程序比描述它的Halide算法有更多不同的CUDA内核。它比手工调优的Adobe实现快7.5,比CPU上最好的并行向量实现快4倍多。这是迄今为止我们所知的最快的局部拉普拉斯滤波器的实现。
图8。跨分辨率对自动调整的时间表进行交叉测试。每个程序根据源图像大小自动调谐。在目标图像大小上测试产生的时间表,给出一个交叉测试的时间。我们将交叉测试时间与自动调整的目标时间之比报告为减速。”注意,从低分辨率到高分辨率,时间表的通用性更好。理论上,减速应该至少是一个,但是由于搜索的随机性,当自动调整到目标上时,一些调度会慢一些。
这些例子花了2小时到2天的时间来调整(从10代到100代)。在所有情况下,在一台机器上进行不到一天的调整后,调谐器收敛到最终性能的15%以内。对编译和调优基础设施的改进(例如,跨集群分发测试)可以显著减少这些时间。
我们通常发现调整后的时间表对分辨率或体系结构中的适度变化不敏感,但是极端的变化会导致最佳计划发生巨大变化。表8显示了在不同分辨率下进行交叉测试的实验。 我们观察到,从低分辨率到高分辨率,时间表可以更好地概括。我们还将本地Laplacian过滤器的最佳GPU调度映射到CPU,发现这比最佳CPU调度慢7。
在一系列图像处理应用程序和目标体系结构中,我们的调度表示能够建模,我们的编译器能够生成,并且我们的自动调谐器能够发现提供最先进性能的实现策略。这种性能来自于对图像处理管道中局部性、并行性和冗余重新计算之间的权衡的极高维空间的仔细导航。手工进行这些权衡是很有挑战性的,正如手工编写的实现更复杂的情况所示,但是当程序员想要测试的每一个更改都需要完全重写一个数百行长的复杂循环时,找到理想的点是令人望而生畏的。Halide实现的性能优势是在空间中测试比人类程序员在显式循环级别手动描述的更多的点的直接结果。
然而,根据我们的经验,在实际系统中,暴力自动调整仍然对健壮性和可用性提出了重大挑战。调整过程是程序员正常开发过程中增加的一个额外的阶段。简单的实现对测试环境中的噪声和许多其他因素都很敏感。在卤化物中,虽然简单的管道(如blur)只需简单的变异规则就可以有效地进行调整,但我们发现启发式变异规则在调整更复杂的成像管道时对于在合理的时间内收敛是至关重要的。然而,这些规则可能是特定于算法结构的。例如,对于3D体素数据或非常规颜色布局,可能需要不同的模板变异规则。我们还发现,有必要保持多样性,以避免陷入局部极小值,这是我们通过使用大量种群(每代128个个体)来实现的。即使如此,调谐器偶尔也会陷入困境,需要重新启动一个新的随机初始化,并从几次运行中获得最佳效果。总的来说,我们认为高维随机自调整还没有开发出适合编译器社区其他地方的实际使用的健壮的方法或基础设施。
研究了几种与流水线结构相似的图像编译器问题。
拆分编译器 红杉映射和螺旋s循环综合代数反映了我们将调度模型从算法描述中分离出来,并将其提升到我们的编译器之外[9,25]。
流式程序 流程序的编译器优化在StreamIt和Brook项目中得到了广泛的研究[4,11,29]。在这个框架中,滑动窗口通信在1D流上实现模板[12,24,30]。流编译器通常不会将引入冗余工作作为主要的优化选择,而且几乎所有流内编译工作都集中在这些1D流上。相比之下,图像处理管道实际上是2D-4D流上的流程序。
模板优化 迭代模板计算对于许多科学应用都很重要,并且已经研究了几十年。
Frigo和Strumpen提出了一种缓存不经意的遍历,以实现高效的模板计算[10]。这种在空间和时间上交错模板应用的局部性优化观点启发了我们的调度模型。Pochoir编译器使用类似的算法自动将模板代码从串行形式转换为并行缓存形式[28]。
重叠平铺是一种策略,它将模板计算分为多个分片,并权衡沿分片边界的冗余计算,以提高局部性和并行性[16],在我们的调度表示中,模型是在分片循环内交错存储和计算。其他平铺策略代表了我们的表示法所模拟的权衡空间中的不同点[19]。
过去的编译器使用多面体模型在CPU和GPU上自动合成具有重叠平铺的并行循环嵌套[13,16]。这些编译器专注于通过一组用户定义的重叠平铺参数来合成高质量的代码。自动调整也被应用于迭代模板计算,但过去的调整工作集中在对一个或几个策略的小参数空间进行彻底搜索[15]。
图像处理语言 关键的是,许多迭代模板计算的优化都是基于迭代的时间维相对于网格的空间维数大的假设。在图像处理管道中,大多数单独的模板只应用一次,而图像的大小是数百万像素。图像处理管道还包括比模板本身更多类型的计算,调度它们不仅需要不同的参数,而且需要针对许多异构阶段的每个阶段选择完全不同的策略,这对于穷举搜索或多面体优化都是不可行的。大多数先前的图像处理语言和系统都专注于单个核的高效表达,以及在没有模板的情况下进行简单的融合[6,8,23,27]。最近,康沃尔等人。演示了使用多面体优化快速生成图像处理代码的GPU代码[7]。
Halide语言的早期工作包括一个较弱的调度模型,并要求程序员手工显式地指定调度[26]。这是第一个自动优化器,因此也是第一个全自动编译器,用于Halide程序。我们展示了如何自动推断它们,从定义管道阶段的算法开始,使用相对较少的领域特定知识,而不是在调度空间中枚举点的能力。我们最先进的性能显示了我们的调度模型在表示底层选择空间方面的有效性。本文提出的调度模型也比以往的工作更加丰富。特别是,它在调用调度中分离了计算频率和存储频率,实现了滑动窗口调度和类似的策略,这些策略在保持局部性的同时牺牲了冗余工作的并行性。
总之,这带来了一个引人注目的新结果:模板计算的自动优化,包括充分考虑并行性、局部性和冗余的权衡。过去的自动模板优化针对的是空间中的各个点,但没有在跨越这些多维权衡的不同策略中自动选择,也没有自动优化大型异构管道,只有单个或小型多个模板。