程序的结构和运行效率常常被人们看作是难以调和的。这个事实源于我们把一个数学上结构清晰良好的(比如,用递归形式刻画的)算法映射到一个现实的不完美的计算模型上,这个模型计算是有代价的,要极小化这个代价就需要尽可能的重用中间计算结果,减少依赖增加指令级并行,充分利用空间和时间的Locality和存储体系的分级结构,等等…
于是长久以来,程序中描述算法要”做什么“的逻辑,掩盖在了用来优化性能,描述”怎么做“的芜杂逻辑之下。当然,我们也有能够只通过清晰刻画”做什么“来编程的语言(如函数式语言家族,尤其是以Haskell为代表的纯函数式语言),这些语言收起了让用户自己规划计算过程的权限,把计算过程的优化交给编译器自动完成。这导致了人们对其效率的不信任,事实上这个问题也确实普遍存在,聪明的编译器只是少数,而且他们也未必能保证给出一个高度优化的程序。所以至今,对高性能有要求的各种库仍然采用C等提供了底层操作的语言实现。
性能优化会破坏软件结构这一点带来了无尽的苦恼,和风险。这使得我们每做一次优化都需要格外慎重。性能优化后的代码基本被凝固,不再具有良好的可维护、可修改性质,这样我们跨平台移植或者改变优化方案时就会面临代码需要大片重写的风险。所以人们确立了”不成熟的优化是万恶之源“这样的信条,强迫自己把性能优化留到最后一步,万不得已时采用最成熟最保守的思路去优化。
真的只能接受这个现状吗?我们知道抽象和解耦是软件的灵魂,当两件不同目的的事情纠缠在同一段代码中时,意味着需要把两件事情各自抽象出来,解耦成简单独立的逻辑。这里我们正面对一个典型的解耦问题:算法描述(做什么)和性能优化(怎么做)需要被解耦。函数式语言虽然能做到这个解耦,但是它把优化工作交给不那么靠谱的编译器了,那能否把这个优化工作交还给设计者自己呢?设想我们如果能够独立构造两个逻辑,就可以利用如纯函数式语言这样具有强大描述力的工具,寥寥数笔刻画出算法;然后再针对具体的硬件平台,实现相应的优化计算方案。不同的平台间的移植只需要替换这个优化方案部分。更好的一点是,我们可以尝试更激进的优化方案,测试各种各样的方案,这不过是个替换而已,而且对算法的功能本身没有影响。
解耦工作的难度一定程度上取决于要解耦的两个概念是否能够清晰的区分开来。算法描述和性能优化的解耦是不容易的,因为一般说来这两个概念不易区分。但在图像处理这样的领域里,计算具有典型的模式(数据在pipeline上流动,被各个节点依次处理),我们仍然可以把二者很好地解耦。
Halide就是这样一门语言。
Halide是由MIT、Adobe和Stanford等机构合作实现的图像处理语言,它的核心思想即解耦算法和优化,事实也证明它是成功的,在各种实例中它均以几分之一的代码量实现出同等或者数倍于手工C++代码的效能,更不用提代码的可维护性和开发效率。
先上例子(来自Halide的文献"Decoupling Algorithms from Schedules for Easy Optimization of Image Processing Pipelines"),三个版本的图像模糊算法,以及他们各自的性能。
void box_filter_3x3(const Image & in, Image & blury) { Image blurx(in.width(), in.height()); // allocate blurx array for (int y = 0; y < in.height(); y++) for (int x = 0; x < in.width(); x++) blurx(x, y) = (in(x - 1, y) + in(x, y) + in(x + 1, y)) / 3; for (int y = 0; y < in.height(); y++) for (int x = 0; x < in.width(); x++) blury(x, y) = (blurx(x, y - 1) + blurx(x, y) + blurx(x, y + 1)) / 3; }
9.96 ms/megapixel
(quad core x86)
代码1. C++实现图像模糊,结构良好但效率差。
void box_filter_3x3(const Image & in, Image & blury) { __m128ione_third = _mm_set1_epi16(21846); #pragmaomp parallel for for (int yTile = 0; yTile < in.height(); yTile += 32) { __m128ia, b, c, sum, avg; __m128i blurx[(256 / 8)*(32 + 2)]; // allocate tile blurx array for (int xTile = 0; xTile < in.width(); xTile += 256) { __m128i*blurxPtr = blurx; for (int y = -1; y < 32 + 1; y++) { const uint16_t *inPtr = & (in[yTile + y][xTile]); for (int x = 0; x < 256; x += 8) { a = _mm_loadu_si128((__m128i*)(inPtr - 1)); b = _mm_loadu_si128((__m128i*)(inPtr + 1)); c = _mm_load_si128((__m128i*)(inPtr)); sum = _mm_add_epi16(_mm_add_epi16(a, b), c); avg = _mm_mulhi_epi16(sum, one_third); _mm_store_si128(blurxPtr++, avg); inPtr += 8; } } blurxPtr = blurx; for (int y = 0; y < 32; y++) { __m128i*outPtr = (__m128i*)(& (blury[yTile + y][xTile])); for (int x = 0; x < 256; x += 8) { a = _mm_load_si128(blurxPtr + (2 * 256) / 8); b = _mm_load_si128(blurxPtr + 256 / 8); c = _mm_load_si128(blurxPtr++); sum = _mm_add_epi16(_mm_add_epi16(a, b), c); avg = _mm_mulhi_epi16(sum, one_third); _mm_store_si128(outPtr++, avg); } } } } }
11x fasterthan a
naïve implementation
0.9 ms/megapixel
(quad core x86)
代码2. 上一段代码的优化版本,效率好但结构性破坏。
Func halide_blur(Func in) { Func tmp, blurred; Var x, y, xi, yi; // The algorithm tmp(x, y) = (in(x - 1, y) + in(x, y) + in(x + 1, y)) / 3; blurred(x, y) = (tmp(x, y - 1) + tmp(x, y) + tmp(x, y + 1)) / 3; // The schedule blurred.tile(x, y, xi, yi, 256, 32) .vectorize(xi, 8).parallel(y); tmp.chunk(x).vectorize(x, 8); return blurred; }
0.9 ms/megapixel
代码3. Halide代码,清晰简短,且一样高效。
我们可以看到,在Halide所实现的版本中,代码分成两部分,一部分是描述算法的algorithm部分,采用典型的函数式风格定义出要计算什么;另一部分则是指定”如何计算“的schedule部分。Halide目前没有自己的语法解析器,它的前端直接嵌入在C++里,作为一个库来使用。我们构造出一个图像处理算法后,可以把它编译到诸如x86/SSE, ARM v7/NEON, CUDA, Native Client, OpenCL各种平台上。
用schedule这样一个概念来抽象各种各样的底层优化技巧是整个方案里最关键的一环。不同平台有不同的优化技巧,怎样才能用一个统一的观点去处理它们,使得我们能在一个足够简单、与底层细节无关的世界观里处理优化问题?Halide用这样的观点来统一各种性能优化方法:它们都是控制存储或计算顺序的手段。这样,Halide通过提供一系列控制计算过程中存储和计算顺序的工具而帮助我们描绘性能优化方案。
Halide目前并没有太多的考虑编译器自动优化的问题,但这是一个漂亮的开端。如果将来在手动优化的同时仍有强大的编译器优化做后盾,将会是一番什么景象?
本站将持续跟踪这方面的进展。关于Halide语言进一步的剖析,请等待下篇。
(未完待续)