ID #1562

借助C++进行Windows开发:探索高性能算法

在并发空间中,诸如协调、异步行为、响应性和可伸缩性等问题会成为关注的焦点。这些都是开发人员在设计应用程序时必须考虑的一些比较深奥的主题。但是,也许是由于缺乏经验或缺乏合适的性能工具,一些同样重要的主题却常常被忽略。高性能算法就是其中一例。

在企业级别,开发人员会仔细斟酌分布式文件系统和缓存、群集、消息队列和数据库等问题。但是如果最核心的算法和数据结构效率低下,考虑这些又有什么用呢?

算法效率并不像您认为的那样简单。单处理器上设计良好的算法通常可以胜过多处理器上的低效实现。但是现在,当多处理器已经可用时,设计良好的算法还要显示出可衡量的可伸缩性和效率。由于会使问题变得更为复杂,因此针对单处理器进行优化的算法通常很难并行执行,而效率略低的算法通常可以在多处理器环境中发挥更好的性能。

为了说明这一点,我将使用 Visual C++ 展示一个非常简单的算法的开发过程,但实际上它不简单,即使乍看起来像是如此。下面是我们需要实现的一些内容:

void MakeGrayscale(BYTE* bitmap,
          const int width,
          const int height,
          const int stride);

位图参数,指向一幅 32 位/像素的图像。再次重申,这是本文的重点。跨距的绝对值,指示内存中一行像素到下一行像素的字节数。每行的末尾可能存在填充内容。跨距的符号,指示这些行在内存中是自上而下(正跨距)还是自下而上(负跨距)。

让我们首先确定着手点。我们可以使用下面的结构来表示内存中的像素:

typedef unsigned char BYTE; // from windef.h
struct Pixel
{
  BYTE Blue;
  BYTE Green;
  BYTE Red;
  BYTE Alpha;
};

通过快速的 Web 搜索,我们确定对于一个给定颜色的像素可通过混合 30% 的红色、59% 的绿色和 11% 的蓝色来获得合理的灰度值。下面是将一个像素转换为灰度级的简单函数:

void MakeGrayscale(Pixel& pixel)
{
  const BYTE scale = static_cast<BYTE>(0.30 * pixel.Red +
                     0.59 * pixel.Green +
                     0.11 * pixel.Blue);
  pixel.Red = scale;
  pixel.Green = scale;
  pixel.Blue = scale;
}

要计算位图内某个特定像素的字节偏移,可计算其水平位置与像素大小的乘积以及其垂直位置与跨距的乘积,然后将这些值相加:

offset = x * sizeof(Pixel) + y * stride

那么,该如何实现 MakeGrayscale 函数呢?如果您跳过这部分而不做其他考虑,您可能会写出类似于图 1 所示的算法行。乍一看这似乎是合理的,采用这样的方法,似乎可以很好地对小位图进行处理。但对于较大的位图会怎样呢?对 20,000 * 20,000 像素的位图又会如何?

图 1 低效的单线程算法

void MakeGrayscale(BYTE* bitmap,
          const int width,
          const int height,
          const int stride)
{
  for (int x = 0; x < width; ++x)
  for (int y = 0; y < height; ++y)
  {
    const int offset = x * sizeof(Pixel) + y * stride;
    Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);
    MakeGrayscale(pixel);
  }
}

我恰好有一个配有四核 Intel Xeon X3210 处理器的 Dell PowerEdge。这种机器的时钟速度为 2.13GHz,前端总线为 1066MHz,二级缓存为 8MB,此外还具有其他各种超炫的功能。不可否认,它并不是最新的 Intel Xeon 处理器,但它确实值得称道。它由 64 位版本的 Windows Server 2008 驱动,非常适合做性能测试。

有了这些支持,我对宽度为 20,000 像素且高度也为 20,000 像素的位图运行了图 1 所示的算法。平均来说,它在 46 秒钟内进行了 10 余次迭代。不可否认,这张位图相当大,约占 1.5GB 的空间。但是这真的是问题所在吗?我的服务器有 4GB 内存,因此不需要分页磁盘。但图 2 显示了大家都非常熟悉的处理器使用率视图。

图 2 低效单线程算法的处理器使用率

虽然此算法是连续不断地工作,但却仅使用了计算机处理能力的四分之一。解决方案很明显,是不是?最常见的反应是在出现问题的位置抛出多个线程。这是一种合理的做法。毕竟,到目前为止,此算法仅使用了一个线程,只用到了测试系统中四个内核中的一个。图 3 中的代码增加了线程。

图 3 低效的多线程算法

void MakeGrayscale(BYTE* bitmap,
          const int width,
          const int height,
          const int stride) {
  #pragma omp parallel for
  for (int x = 0; x < width; ++x)
  for (int y = 0; y < height; ++y) {
    const int offset = x * sizeof(Pixel) + y * stride;
    Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);
    MakeGrayscale(pixel);
  }
}

唯一的区别是添加了 OpenMP 编译指示。外部 for 循环现在将在各线程之间分区,因此它们可以并行运行并转换位图的不同部分。OpenMP 默认情况下会为每个处理器创建一个线程。(不要过分沉迷于使用 OpenMP。它只是在各线程之间划分 for 循环的一种比较简洁的方式。还有其他各种技术可以实现同样的效果,具体取决于您的编译器和运行时。)这种微小的改变产生了非常大的影响,如图 4 所示。

图 4 低效多线程算法的处理器使用率

但是结果仍不够理想。与单线程版本耗时 46 秒相比,多线程算法大约耗时 30 秒。提高 34% 的性能却需要增加 300% 的处理成本,有些得不偿失。如果我告诉您有一种高效的实现只需一个线程便可在 2 秒左右完成,而且如果使用多线程,它甚至可以在 0.5 秒左右完成(在同一测试系统中),您会做何感想?这要比 30 秒快多了。

它比您想象的还要简单。只需考虑一下用来运行算法的物理硬件以及数据在内存中的组织方式即可。开发人员常常对最终运行其代码的硬件漠不关心。我将展示一个高效的算法,然后讨论一下它为何更加理想。

请看一下图 5。这里唯一的区别在于 for 循环的顺序,您可能会对此感到惊讶。也就是说,我改变了图 3 中的算法,使外部循环遍历 Y 轴,内部循环遍历 X 轴。现在,为何这一看似无关紧要的变化会将时间从 30 秒锐减到不足 1 秒?答案就在于这样一个事实,算法往往在很大程度上受到它所处理的对象的形状和位置以及硬件特征等因素的影响。

图 5 高效的算法

void MakeGrayscale(BYTE* bitmap,
          const int width,
          const int height,
          const int stride) {
  #pragma omp parallel for
  for (int y = 0; y < height; ++y)
  for (int x = 0; x < width; ++x) {
    const int offset = x * sizeof(Pixel) + y * stride;
    Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);
    MakeGrayscale(pixel);
  }
}

要了解这一点,我们必须重新研究一下在其中运行该算法的数据结构。位图的内存缓冲区在逻辑上包含各个像素行。显然,内存不是表格形式的,但需要通过一种轻松有效的方式来寻址任何坐标已给定的像素;按照行进行考虑有助于理解这一点。但是,如果把这些行视为可能包含填充内容,则这种抽象确实揭示了其物理实现,填充内容对于正确对齐各行的开头至关重要,尤其是在使用带有索引颜色表的位图时(参见图 6)。

图 6 行和填充(自下而上)

Y 轴的方向说明了自下而上的布局(由负跨距指示)。理论上来说,这里的方向也可能会影响性能,但如果没有非常适合的工具支持则很难对此进行衡量。

一个很好的并发经验法是相隔很远的内存不应由单处理器同时使用,而紧邻在一起的内存不应由不同的多个处理器使用。也就是说,使结合使用的数据紧邻在一起,而使分开使用的数据尽量远离。这样,硬件缓存管理器可以缓存由不同处理器使用的数据,而不会引入任何争用。

要弄清楚内存布局如何影响性能,必须记住内存是连续的而不是表格式的。虚拟地址空间是平面的。图 7 说明了如何使用图 3 中的低效算法对位图进行分区。

图 7 低效数据分区

现在可以很明显地看出此算法为何执行起来性能低下。虽然不存在循环间的依赖关系,但此算法在并行化时所采用的方法几乎肯定会导致在硬件缓存级别发生争用。较大的位图可能会减少争用,但不断的缓存缺失会强制缓存管理器不断重新加载缓存行,致使性能受到影响。即使在单处理器环境中,它的执行情况也不容乐观,因为它必须不断替换缓存行中的数据。图 8 更明确地表明了其位置存在问题。此模式将对位图中的所有行都重复执行一遍。

图 8 低效数据分区(重复)

相比之下,图 5 中的高效算法沿 Y 轴分区位图,从而使每个处理器都能处理紧邻在一起但又与其他处理器处理的像素相隔甚远的像素流,这样一来缓存管理器就能够跨处理器共享位图,而不会产生争用(参见图 9 和 10)。特别是,图 10 显示了每个进程都有一个连续内存块要进行处理,这极大地提高了定位和缓存性能。

图 9 高效数据分区

图 10 高效数据分区(无重复)

达到上面提到的 0.5 秒这一目标会很困难(如果使用默认的 OpenMP 静态调度)。OpenMP 还提供了动态的可指导调度,通过它可从算法中榨出最后几毫秒。这里最根本的问题是在可用处理器之间完美地划分工作可能并不会提供最佳的结果,因为计算机可能要在后台执行一些辅助任务。这可能会导致某些线程先于其他线程完成任务,然后等待其他线程完成,而在这期间它们将处于空闲状态。

另一方面,您或许可以通过使用不同的算法或数据结构使其运行得更快。您可以将位图分区加载到非一致存储访问结构 (NUMA) 系统的各个单独物理内存节点中。甚至可以将其发送给 Windows Server 高性能计算 (HPC) 网格上的不同节点。如果使用低效算法,则这些措施均无法提高速度。其实您只要考虑一下数据结构和算法,再注意一下运行代码的硬件对性能的影响,就可以大幅提高代码的性能。


2011-07-01 16:30
阅读:
I'm VC , Just U know Y
本站部分文章来源于互联网,版权归原作者所有。

延伸阅读:

浅谈VC++ 6.0中临时数据的存储方法

使用VC++开发考场随机排座系统

VC6下用控件进行串口通信

VC++ 2008开发网络百家乐街机游戏(下)

VC++ 2008开发网络百家乐街机游戏(上)