现代编译器实现零抽象开销






4.84/5 (16投票s)
本文展示了一种简单的方法,以类似 STL 的方式为像素迭代器实现基本的图像处理算法,并对不同编译器下的结果进行了基准测试。
引言
问题:通常,每当编写程序并讨论抽象时,反对它的首要论点之一就是性能。正如 Donald Knuth 所说:“我们应该忘记小的效率,大约97%的时间:过早的优化是万恶之源”。循此,完整的代码就是“写下来”。没有使用特定的“性能技巧”来查看这是否适用于使用现代编译器编写更高抽象的算法。
在本文中,我将介绍一个可用于基本图像处理的基本迭代器,以基准测试这种抽象的真实成本。我本想使用更多酷炫的 C++0x 特性,但又不想过分依赖于较新的 g++ 版本。主要想法是在我还在自己编写这类算法并思考是否有更好的处理方法时产生的。
在 C++ 中实现基本图像处理算法时,通常会涉及到大量与算法无关的处理
- 不同的像素“布局”。例如,8位灰度值,16位灰度值,24位RGB等,以及它们之间烦人的转换。
- 处理图像尺寸和 ROI。
- 范围检查以避免缓冲区溢出。
- 内存管理和清理。
- 性能。
这通常会使算法更难阅读,意图不明确,因为这些处理以某种方式隐藏并混杂在真实的算法代码中。有几种标准解决方案可以克服这些问题,例如,可以使用模板来处理不同的像素布局,但这对于 RGB 像素(每个颜色通道都需要单独处理)来说仍然不那么简单。
演示项目用于展示和基准测试 PixelIterator
类的效率。源代码进一步演示了如何在不同的像素表示之间进行转换(如有必要)。由于该项目是跨平台可编译的,我使用 OpenCV 作为执行速度和图像可视化的参考,并使用 OpenMP 来并行化我自己的算法。该项目本身只是为了让我玩一些我一直想尝试的东西:C++0x、OpenMP 和 OpenCV,并且实际上并没有实现任何特别有用的功能 :)
背景
查看基本的图像处理算法和迭代器模式可能会很有用。文章中的代码试图模仿类似 STL 的迭代器行为。
本文描述了一种通用 PixelIterator
的概念,它处理所有上述提到的点,并消除了在算法代码中处理它们的需要。我们还将进一步探讨这种更高抽象所带来的性能损失。实际上,性能方面是我写这篇文章的主要动机,因为我甚至没有在我的任何项目中使用过迭代器。
让我们从一个简单的例子开始,我们只调整图像的整体亮度。这大致就是纯 C++ (实际上没有 ++) 的样子
struct sRGB
{
char cBlue;
char cGreen;
char cRed;
};
sRGB* pInData = (sRGB*)inImage->imageData;
sRGB* pOutData = (sRGB*)out->imageData;
sRGB* pEndIn = pInData + inImage->width * inImage->height;
while( pInData != pEndIn )
{
pOutData->cBlue = pInData->cBlue + 50;
pOutData->cRed = pInData->cRed + 50;
pOutData->cGreen = pInData->cGreen + 50;
++pInData;
++pOutData;
}
为了简单起见,我避免了为 sRGB
结构体定义运算符+,因为大多数关于 C++ 的迷信通常会指出运算符重载的开销,而我希望避免在后续的基准测试中使用它。现在我们来看看使用像素迭代器会是什么样子
Image<RGBPixel> imgIn( in->imageData, in->width, in->height );
Image<RGBPixel> imgOut( imgIn.createNewImage( out->imageData ) );
Image<RGBPixel>::iterator itStart( imgIn.begin() );
Image<RGBPixel>::iterator itEnd( imgIn.end() );
Image<RGBPixel>::iterator itDestStart( imgOut.begin() );
while( itStart != itEnd )
{
*itDestStart = *itStart + (unsigned char)50;
++itDestStart;
++itStart;
}
大体相同,如果我们假设迭代器本来就应该与指针具有相同的行为,这并不奇怪。那么有什么好处呢?假设我们有一个比我们想为8位灰度、24位彩色、32位彩色和16位灰度图像实现的算法复杂得多的算法。它会立即破坏我们的第一个例子,因为它“固定”地为RGB实现,并且专门为此。有趣的是这会如何影响性能,但我们稍后会看。那么ROI呢?我现在不会写“原生”示例进行比较,但对于迭代器来说,它很简单,如下所示
Image<RGBPixel> imgIn( in->imageData, 100, 100, 500, 500, in->width, in->height );
关键是“算法”不需要关心,它仍然保持不变,甚至不知道它正在处理 ROI。图像操作的另一个问题是,它们通常将 2D 问题投影到 1D 数据流上,这正是迭代器已经完成的工作。为了测试这一点,我实现了一个简单的卷积滤波器。为此,需要 2D 导航,因为 2D 滤波器与 2D 图像区域进行卷积(不像简单的 1D 亮度示例)。为此,迭代器实现了用于相对 2D 像素访问的函数
void operator()( size_t iX, size_t iY, TPixelType value );
const TPixelType& operator()( size_t iX, size_t iY )const;
//usage
while( itStart != itEnd )
{
const PixelType pixel( itStartIn( iXPos ,iYPos ) );
//some fancy calculations with the pixel
//...
//than write the result to the relative result position
itOutStart(iDestPosX, iDestPosY, result / m_iFilterNorm );
}
如何使用
基本操作是创建一个图像实例,它在技术上只是图像相关数据的容器。真正的处理工作由幕后的 PixelIterator
类完成,通过迭代器 typedef: (Image<RGBPixel>::iterator)
间接使用。只要支持基本的数学运算,其他任何像素表示都可以传递。
与 OpenMP 的用法
对于 OpenMP 的使用,我发现最好的方法根本不是并行化算法本身,而是使用迭代器来分割图像。并行化算法本身会带来为每个算法进行特定改编的缺点。通过分割,所有算法都可以从中受益。不幸的是,这个“技巧”并非适用于所有图像操作,但对于大多数简单操作来说是可以的。我还发现,在算法内部使用 OpenMP 可能会导致代码慢得多,因为同步开销很容易成为瓶颈,这会进一步损害性能,而不是带来好处。
template<typename TIn, typename TOut, typename TImageOP>
void executeImageOP( PixelIterator<TIn> itStartIn, PixelIterator<TIn> itEndIn,
PixelIterator<TOut> itOutStart, TImageOP imageOP )
{
const int iNumCPUs = omp_get_num_procs();
std::cout << "omp detected " << iNumCPUs << std::endl;
int k =0;
typedef typename PixelIterator<TIn>::ItertorContainer PixelIterCntIn;
typedef typename PixelIterator<TOut>::ItertorContainer PixelIterCntOut;
PixelIterCntIn itStartInPart( itStartIn.partition( iNumCPUs ) );
PixelIterCntOut itStartOutPart( itOutStart.partition( iNumCPUs ) );
#pragma omp parallel for
for( k=0;k < iNumCPUs;k++ )
{
imageOP( itStartInPart[k], itStartInPart[k].end(), itStartOutPart[k] );
}
}
测试与基准
所以问题是,这种抽象的性能损失有多大?正如你在上面 RGB 例子中看到的,每个颜色通道都需要处理,为此,我使用了常规的运算符重载来实现 RGBPixel
。所以肯定会有一个巨大的损失。但是让我们看看基准测试结果
VS 10 (Centrino 2 Duo 1.7GHz) Vista | g++ 4.4.4 (Centrino 2 Duo 1.7GHz) Ubuntu 8.04 | G++4.5.2 (Core 2 Duo 2.1GHz) Ubuntu 11.04 | g++ 4.4.5 (Core 2 Duo 2.1GHz) Ubuntu 10.04 | g++ 4.4.5 (Celeron 550MHz) Ubuntu 10.04 | g++ 4.4.5 (Core 2 Duo 2.1GHz) Debug Ubuntu 10.04 | |
OpenCV Canny | - | 740 | 646 | 683 | 3185 | - |
类C朴素Sobel | 7232 | 3219 | 3163 | 4579 | 18384 | 8342 |
迭代器卷积 | 4242 | 1911 | 2010 | 2092 | 9619 | 9537 |
迭代器灰度卷积 | 1829 | 1126 | 980 | 1077 | 7789 | 6704 |
迭代器灰度并行卷积 | 1087 | 590 | 562 | 457 | 7591 | 6267 |
迭代器并行卷积 | 2466 | 1030 | 915 | 849 | 9548 | 9316 |
亮度调整迭代器 | 181 | 48 | 44 | 35 | 311 | 320 |
亮度调整 | 67 | 43 | 36 | 28 | 255 | 73 |
来自 OpenCV 的 Canny 算子只是为了给一些边缘检测算法提供一些图像处理参考。我很抱歉没有提供 VS10 的 Canny OpenCV 结果,但由于某种原因,代码在那里无法运行,而我太懒了,没有修复(我在家通常只使用 Linux)。
那么这些结果如何?对于不同的处理器类型,没有什么意外。奇怪的是 VS10 产生的代码速度较慢,尽管我只使用了默认的发布设置。总的来说,为迭代器付出的代价似乎很小。它甚至比我作为比较做的朴素 C 实现表现更好。对于 OpenMP 并行化的算法,我们确实具有良好的可伸缩性。在我的结果中,双核的性能提升约为 100%。正如我还发现的,对于较小的图像,这个比例会变得稍微差一些,因为同步开销并不能抵消收益。
同时,很高兴看到用 OpenMP 编写的并行化代码在单处理器系统上执行时不会显著减慢(OpenMP 在调试模式下禁用)。编译器显然在优化我的抽象开销方面做得非常出色。在调试模式下,这非常明显。在这种情况下,迭代器总是会输。所以至少在调试模式下,关于抽象开销性能的一些迷信变得真实了(一点点 :))。
经验教训
- 编写最少的 C 代码并不能保证更快的代码。
- 使用 OpenMP 编写并行和可伸缩算法可以显著提高性能,并且对单核系统没有惩罚。如果可能,请使用数据分区,即使对算法代码本身没有影响也可以实现。
- 使用 OpenMP 时,最快的线程同步是无同步。数据分区有时是更明智的选择。
- 使用现代 C++ 编译器,几乎没有理由牺牲抽象。
- 如果你真的想优化到最后一丝,它通常是特定于编译器的,一旦你切换编译器,这可能会适得其反。