使用现代 C++ 技术增强多核优化

butterfly enhanced with modern cpp

如今,多核处理器已经在 PC 中普及,内核数量不断增长,软件工程师必须适应这种情况。通过学习如何处理潜在的性能瓶颈和并发性问题,工程师可以使他们的代码适应未来,以无缝处理添加到消费者系统的额外内核。

为了促进这项工作,英特尔软件团队创建了一款图形工具套件,展示了并行处理技术成为 8 种不同图形过滤器的绝佳选择。整个源代码包包含 C++ 文件、头文件、项目文件、滤波器和数据库文件。拥有简单用户界面的 DLL overlay 显示了在单核系统中和使用并行处理技术时每个滤波器的应用速度。

在本白皮书中,读者将了解如何使用现代 C++ 技术跨内核并行处理数据。通过研究示例代码,下载应用和学习技术,开发人员将更好地了解英特尔® 架构和多核技术。

并行处理入门

关于并行处理技术的文章和书籍不计其数。Ian Foster 进行了很好的总结,John Owens 等人在 SIGGRAPH 上发表了多篇文章《并行计算的编程模型》(Pavan Balaji 编辑,2015 年)提供了出色的参考。它包括广泛的并行编程模型,首先介绍了消息传递接口 (MPI),它是分布式内存计算最常见的并行编程模型。

由于应用广泛应用于整个计算领域,从数据库处理到图像渲染,并行处理是开发人员需要了解的关键概念。假设读者已经积累了一定的计算科学经验和背景知识,可以从本文描述的概念中受益。源代码以 C++ 的形式编写,但是概念延伸至其他语言,将使受众感兴趣,以帮助他们更好地了解如何针对多核处理器优化代码。

关于英特尔架构的深入探讨不在本文的范围之内。软件开发人员应在英特尔® 开发人员专区注册,并查看英特尔架构文档下载页面,以参阅以下手册:

入门

研究 Image Filters 项目解决方案的系统要求较低。任何搭载 Windows® 10 的多核系统便已足够。

本项目假设您有一个 C++ 工具套件,如 Microsoft Visual Studio*(包含 .NET framework)。如果您想尝试不同的 C++ 工具,请点击此处,访问 Freebyte* 提供的一整套链接。为了简单地浏览代码,您可能需要使用免费的代码查看器,如 NotePad++* 或类似产品。

执行以下步骤,开始探索 Image Filters 项目:

  1. 在您的电脑上创建一个空白目录,添加一个独一无二的标题,如“Image Filters 项目”或“英特尔 C++ DLL 项目”。
    采用您熟悉的任何命名策略,您也可以添加年份。
  2. 将 .zip 文件下载至您的新目录。
    文件不大 - 约 40 KB。提取文件和构建项目后,将耗费大约 65 MB 磁盘空间。
  3. 在新的目录中提取文件。
    例如,如果使用了 7-Zip*,右击 .zip 文件并选择 7-Zip > Extract here。您可以使用任何文件压缩软件,如 7-ZipWinZip*WinRAR*
  4. 打开 Microsoft Visual Studio 或类似的 C++ 工具。这些指令假设您已下载最新版 Microsoft Visual Studio。
  5. 通过使用 File > Open > Project/Solution 和定位 ImageFilters.sln 文件来打开项目。
    ImageFilters.sln 文件应出现在左侧的 Solution Explorer 中。
    ImageFilters 解决方案包含两个项目:
    a) ImageFiltersWPF - 利用 ImageProcessing DLL 的客户端应用,展示了如何使用 C# 与它进行交互。
    b) ImageProcessing - 包含的多核图像处理算法的 C++ DLL。

    build both projects inside the Solution Explorer
    图1.您必须在 Solution Explorer 中构建这两个文件。

  6. 从 Solution Explorer 中选择 ImageFiltersWPF 项目,然后按住 CTRL 键并选择 ImageProcessing 项目。
  7. 右击某一个突出显示的文件,以拉开 Actions 菜单,选择Build Selection。这将为两者启动编译器。
  8. 等待系统将现有的源文件快速编译为二进制、.DLL 和 .EXE 文件。

以下文件显示在项目解决方案 bin 目录中:

Compiled files in the bin
图2.bin > debug 文件夹中的已编译文件,包括 ImageFiltersWPF 可执行文件。

多线程技术

在默认情况下,应用在系统的单个处理内核上运行。由于所有新计算系统的 CPU 均拥有多个内核和线程,这意味着某些复杂计算可以智能地分布,这极大地节省了计算时间。

OpenMP*(开放多处理)是 1981 年面向 Fortran 1.0 首次发布的 API,支持多数平台、指令集架构和操作系统中使用 C、C++ 和 Fortran 的多平台共享内存多处理编程。它包含一套编译器指令、库例程和影响运行时行为的环境变量。

在执行实际发生的 C++ DLL 示例中,OpenMP 使用编译器指令并行执行滤波例程。对于图片处理,输入图像的每个像素均被处理,以将例程应用于该图像中。并行化通过使工作分散在多个线程中,提供了一种有趣的优化执行时间的方式,它可以作用于图像的不同区域。

#pragma omp parallel

在应用中的每个滤波例程中,处理被实施为一个循环。目标是逐一研究图像内的每个像素。“#pragma omp parallel”编译器指令使循环练习被划分为几部分,并分配到内核中。

#pragma omp parallel for if(openMP)
	for (int i = 0; i < height; ++i) {
		auto offset = i * stride;
		BGRA* p = reinterpret_cast<BGRA*>(inBGR + offset);
		BGRA* q = reinterpret_cast<BGRA*>(outBGR + offset);
		for (int j = 0; j < width; ++j) {
			if (i == 0 || j == 0 || i == height - 1 || j == width - 1)
				q[j] = p[j];	// if conv not possible (near the edges)
			else {
				BYTE R, G, B;
				double red(0), blue(0), green(0);
				// Apply the conv kernel to every applicable
				// pixel of the image
				for (int jj = 0, dY = -radius; jj < size; jj++, dY++) {
					for (int ii = 0, dX = -radius; ii < size; ii++, dX++) {
						int index = j + dX + dY * width;
						// Multiply each element in the local
						// neighborhood
						// of the center pixel by the corresponding
						// element in the convolution kernel
						// For the three colors
						blue += p[index].B * matrix[ii][jj];
						red += p[index].R * matrix[ii][jj];
						green += p[index].G * matrix[ii][jj];
					}
				}
				// Writing the results to the output image
				B = blue;
				R = red;
				G = green;
				q[j] = BGRA{ B,G,R,255 };
			}
		}
	}

针对 BoxBlur.cpp 设置并行处理的示例代码

如果您遵循了代码中的注释,“BoxBlur.cpp”正在设置偏移,处理计算(当边界条件使卷积无法实现时),以及将卷积内核应用于每个红、蓝、绿色元素。

#pragma omp parallel for if(openMP)
		for (int i = 0; i < height; ++i) {
			auto offset = i * stride;
			BGRA* p = reinterpret_cast<BGRA*>(tmpBGR + offset);
			BGRA* q = reinterpret_cast<BGRA*>(outBGR + offset);
			for (int j = 0; j < width; ++j) {
				if (i == 0 || j == 0 || i == height - 1 || j == width - 1)
					q[j] = p[j];	// if conv not possible (near the edges)
				else {
					double _T[2];
					_T[0] = 0; _T[1] = 0;
					// Applying the two Sobel operators (dX dY) to
					// every available pixel
					for (int jj = 0, dY = -radius; jj < size; jj++, dY++) {
						for (int ii = 0, dX = -radius; ii < size; ii++, dX++) {
							int index = j + dX + dY * width;
							// Multiplicating each pixel in the
							// neighborhood by the two Sobel
							// Operators
							// It calculates the vertical and
							// horizontal derivatives of the image
							// at a point.
							_T[1] += p[index].G * M[1][ii][jj];
							_T[0] += p[index].G * M[0][ii][jj];
						}
					}
					// Then is calculated the magnitude of the
					// derivatives
					BYTE a = sqrt((_T[0] * _T[0]) + (_T[1] * _T[1]));
					// Condition for edge detection
					q[j] = a > 0.20 * 255 ? BGRA{ a,a,a,255 } : BGRA{ 0,0,0,255 };
				}
			}
		}

		// Delete the allocated memory for the temporary grayscale image
		delete tmpBGR;
	}
	return 0;
}

SobelEdgeDetector.cpp 的并行结构

在摘自“SobelEdgeDetector.cpp”的第二个示例“omp parallel for”中,相似的滤波操作正在运行,边缘检测器处理灰度图像。

内存管理

在软件开发过程中,开发人员必须注意内存管理,以避免严重影响应用性能。如果使用了 Harris 和 Shi-Tomasi 角点检测算法,内存管理对于创建 3 个矩阵和存储 SxSySxy 的结果至关重要。

// Creating a temporary memory to keep the Grayscale picture
	BYTE* tmpBGR = new BYTE[stride*height * 4];
	if (tmpBGR) {
		// Creating the 3 matrices to store the Sobel results, for each thread
		int max_threads = omp_get_max_threads();
		double *** Ix = new double**[max_threads];
		double *** Iy = new double**[max_threads];
		double *** Ixy = new double**[max_threads];
		for (int i = 0; i < max_threads; i++) {
			Ix[i] = new double*[size_kernel];
			Iy[i] = new double*[size_kernel];
			Ixy[i] = new double*[size_kernel];
			for (int j = 0;j < size_kernel;j++) {
				Ix[i][j] = new double[size_kernel];
				Iy[i][j] = new double[size_kernel];
				Ixy[i][j] = new double[size_kernel];
			}
		}

设置面向 Shi-Tomasi 角点检测滤波器的临时内存

为来源的每个像素分配这些矩阵需要大量的内存,应用多线程可能不会带来什么好处。事实上,处理庞大的内存空间的开销甚至会导致计算速度降低。

为了避免内存问题,设置一个多线程加快计算速度的场景,这 3 个矩阵可以被视为一组矩阵,每个可用的线程包含自己的矩阵组。然后,对应用进行设置,使其在并行部分以外分配和可用的应用线程一样多的矩阵组。为了得到最大的线程数量,使用以下函数:来自“omp.h”文件的“omp_get_max_threads()”。该文件和其他头文件均位于 ImageProcessing > External Dependencies 目录中。

Oracle* 支持页面所示,该函数不应与名称相似的“omp_get_num_threads()”相混淆。“max”调用返回可以在并行区域使用的最大线程数量。“num”调用返回当前活跃并且正在执行任务的线程数量。显然,这些数字不相同。在串行区域,“omp_get_num_threads”返回 1;在并行区域,它返回正在使用的线程数量。

“omp_set_num_threads”调用设置可以使用的最大线程数量(相当于设置 OMP_NUM_THREADS 环境变量)。

在处理循环中,每个线程访问正确的矩阵组。这些矩阵组被保存在动态分配的阵列中。为了访问正确的组,搭配使用实际线程的索引与“omp_get_thread_num()”函数。一旦执行了例程,3 个矩阵将被重置为初始值,下次,相同的线程需要针对另一个像素执行例程时,矩阵可以随时使用。

卷积原理

图像滤波出色地展示了多核处理,因为它涉及到卷积原理。卷积是一种累积效果的数学运算;首先,把它视作两个函数,如 fg,以生成第三个函数,它表示一个函数迁移至另一个时的重叠量。

在本迭代版本中,卷积是将图像的每个元素添加至本地邻近像素的过程,由内核加权。通过将图像的每个元素添加至本地邻近像素,由内核加权,卷积可以被用于模糊、锐化、浮雕、边缘检测等。通过快速搜索便可获得诸多资源,如关于内核和图像处理的详细介绍

在图像滤波过程中,卷积与内核协同工作,后者是数字矩阵。然后将该内核应用于图像的每个像素,卷积内核的中心元素被放置在源像素上 (Px)。然后,源像素被替换为自身和附近像素的加权和。多线程通过将流程分解为多个部分,帮助加快图像滤波速度。

Convolution using weighting in 3 x 3 kernel
图3.3 x 3 内核中使用权重的卷积(资料来源:GNU 图像处理程序)。

在本示例中,卷积内核是一个用绿色方框表示的 3 x 3 矩阵。源像素为 50,在 3 x 3 矩阵中心用红框表示。所有本地邻近像素或邻近像素都直接位于绿色方框中。内核越大,邻近像素数量越多。

在本示例中,第一行第二个元素是唯一非零的权重,由 1 来表示。3 x 3 矩阵中的所有其他元素均为 0。运算规则是使每个元素乘以 0,删除它,除了表示为 42 的单个像素。因此,新的源像素为 42 x 1 = 42。源像素上方的像素被卷积内核的权重 1 所覆盖。

如果您将每个权重视作一个分数,而不是 0,您可以想像分析与处理每个周围像素将如何模糊图像。

滤波技术

为了查看滤波技术的结果,您需要根据“入门”章节的描述,构建一个项目。然后双击 Image Filters > ImageFiltersWPF > Bin > Debug > ImageFiltersWPF.exe 文件。

ImageFilters W P F executable main window
图4.ImageFiltersWPF 可执行主窗口。使用屏幕右上角的小灰框来定位您想要实验的图像所在的目录。

界面非常简单明了。您可以使用右上角灰色方框中的目录搜索特性来选择您系统上的图像。使用“Stretch”按钮来确保图像完全填满图形用户界面 (GUI)。选择一张图像,然后应用滤波器。查看界面左下方的“时间(秒)”计算器,以了解滤波器应用于多核系统和单核系统需要花费多长时间。

总共有 8 个滤波器;每个滤波器改变原始图像,但是某些滤波器带来更多的显著变化。

盒状模糊滤波器

盒状模糊是一个空间域线性滤波器,在该滤波器中,生成图像中的每个像素值均等于输入图像中邻近像素的平均值。因其使用相等权重的性质,可以使用简单的累加算法来实施它,这通常是一种快速实现模糊的方法。它的名字是指盒状的像素化结果。

盒状模糊的卷积内核权重均相同。假设我们拥有一个 3 x 3 矩阵,这意味着总共有 9 个元素插入矩阵。

Formula to calculate the elements

计算每个元素的权重,使每个元素的总和为 1。

First image is vivid, the other is blured
图5.左侧的原始图像展现了生动的细节,而右侧的图像应用了盒状模糊效果。

使用应用时,通过计算得出,单核系统应用盒状模糊耗时 0.1375 秒,多核系统(在本示例中为英特尔® 酷睿™ i7-4220 处理器)仅耗时 0.004 秒。

我们来深入探索 BoxBlur.cpp 的内部机制,以了解多线程原则。

include "stdafx.h"
#include <fstream>
#include "omp.h"

using namespace std;

extern "C" __declspec(dllexport) int __stdcall BoxBlur(BYTE* inBGR, BYTE* outBGR, int stride, int width, int height, KVP* arr, int nArr)
{
	// Pack the following structure on one-byte boundaries:
	// smallest possible alignment
	// This allows us to use the minimal memory space for this type:
	// exact fit - no padding
#pragma pack(push, 1)
	struct BGRA {
		BYTE B, G, R, A;
	};

BoxBlur.cpp 文件的开头

首先,文件被设置为包含“stdafx.h”标头,这是一个标准的系统组件。最后,omp.h 是引入 OpenMP 指令的头文件。

然后,BoxBlur 使用extern "C” 函数来声明调用和变量。剩下的 C++ 文件专门用于多核功能。首先,使用“#pragma pack(push, 1)”,文件定义了如何使用最小的队列高效处理 1 字节边界上的 BGRA(蓝、绿、红、alpha)色彩成分打包结构。

接下来,文件声明“#pragma pack (pop)”,以设置默认的打包模式,定义布尔算子,以确定是否检测到多个内核,设置卷积内核和分配内存。

最后,如果有多个内核 (OpenMP = true),文件使用“#pragma omp parallel for if (openMP)”。代码决定偏移与投影,在不可能进行卷积的边缘处理情况。结果被写入输出图像,为卷积内核清除分配的内存。每个滤波器中均包含类似的代码段。

高斯模糊滤波器

高斯模糊是通过高斯内核模糊图像的结果,旨在减少图像噪音和细节。类似于盒状模糊滤波;通过将高斯内核的中心像素放置在图像像素以及用原始图像的值乘以内核中重叠的像素,使图像中的每个像素均倍增。将这些乘法得出的值相加,该结果被用于目标像素的值。

高斯矩阵 N x N元素的权重通过以下公式求得:

Gaussian matrix

此处,xy 是卷积内核中元素的坐标。左上角元素位于坐标 (0, 0),右下角元素位于坐标 (N-1, N-1)。

每个元素的总和必须为 1,原因与盒状模糊的相同。因此,最后,我们需要用内核的每个元素除以内核权重的总和。

阈值滤波器

阈值例程是唯一不采用卷积原则的技术。阈值滤波器检查输入数据集的每个值,并更改不符合边界条件的所有值。结果是,如果亮度小于阈值,像素变为黑色;否则,保持不变。

Threshold filtering example
图6.阈值滤波示例。

Sobel 边缘检测

Sobel 算子是一个使用两个卷积和两个不同内核的边缘检测器。这两个内核在一个点上计算图像的水平与垂直导数。虽然它的目标不同,但它被用于检测图片内的边缘。应用该内核将提供一个分数,表明像素是否被视为边缘的一部分。如果分数大于特定阈值,它将被视为边缘的一部分。

Edge detection relies on two different kernels
图7.Sobel 边缘检测依赖两个不同的内核。

这意味着对于每个像素,会有两个卷积结果 GxGy。将它们视为 2D 矢量的一个标量,通过以下公式计算 G 的大小:

formula to calculate the magnitude G

拉普拉斯边缘检测器

该滤波技术与盒状模糊和高斯模糊非常相似。它依赖于单个卷积,使用像下面这样的内核来检测图片中的边缘。结果可以应用于阈值,因此,视觉效果更流畅、更逼真。

Laplacian Edge D

高斯拉普拉斯

拉普拉斯边缘检测器对噪音极为敏感,为了得到更好的结果,应用拉普拉斯滤波器之前,我们将高斯模糊应用于整个图像。该技术被命名为“高斯拉普拉斯”。

Harris 角点检测算法

卷积还被用于 Harris 角点检测算法和 Shi-Tomasi 角点检测算法,计算比之前的滤波器技术更复杂。针对源像素 Px 的每个本地邻近像素(以及它本身)计算垂直与水平导数(详见 Sobel 算子)。该区域(窗口)的大小必须为奇数,这样它才会有中心像素,也被称为源像素。

因此,针对窗口中的每个像素计算 SxSySxy 的计算方法为 Sxy = Sx * Sy

这些结果存储在 3 个不同的矩阵中。这些矩阵分别代表源像素周围每个像素(以及它本身)的 SxSySxy 值。

然后,将高斯矩阵(高斯模糊中使用的矩阵)应用于这 3 个矩阵,将得出 3 个 SxSySxy 加权值。我们将它们命名为 IxIyIxy

这 3 个值被存储在 2 x 2 矩阵 A 中:

 A 2 x 2 matrix A

然后计算 k 的得分,表示源像素是否被视为角点的一部分。

Then a score k is calculated

接下来,如果 k 大于特定阈值,源像素将变为白色,成为角点的一部分。否则,它将被设置为黑色。

Shi-Tomasi 角点检测器

该检测器基于 Harris 角点检测器;但是,角点检测的条件发生了变化。该检测器的性能比上一个有所提高。

通过上述方法计算矩阵 A 后,得出了矩阵 A 的特征值。矩阵的特征值是以下方程式 det (A) = 0 的解。

 the eigen values of the matrix A

和 Harris 角点检测器一样,根据 k 值来确定该源像素能否被视为角点的一部分。

Example of Shi-Tomasi corner detector filter
图8.Shi-Tomasi 角点检测器滤波器示例,导致像素几乎完全变为黑色。使用单个内核的滤波器耗时 61 秒,而使用多个内核的滤波器耗时 14 秒。

结论

该 C++ DLL 应用很好地展示了将多线程技术应用于软件开发项目的重要性。在几乎所有场景中,使用单核技术在四核系统上应用各种滤波器所需的计算时间是使用多核技术的 3 倍左右。

开发人员使用 OpenMP 在 N 个处理器平台上并行运行程序时,不应期望获得 N 倍的加速。根据多个资料来源,它的合理性体现在以下几个方面:

  • 关联组件存在时,进程必须等待它依赖的数据计算完成。
  • 当多个进程共享一个非并行验证资源(如文件写入)时,按顺序执行它们的请求。因此,每个线程必须等待其他线程释放资源。
  • OpenMP 可能不会对大部分程序进行并行处理,这意味着根据阿姆达尔定律,加速的理论上限是有限的。
  • 对称多处理 (SMP) 中的 N 颗处理器可能拥有 N 倍的计算能力,但是内存带宽通常不会扩展 N 倍。原始内存路径通常被多颗处理器共享,当它们争夺共享内存带宽时,可能观察到性能下降。
  • 影响并行计算最终加速的许多其他常见问题也适用于 OpenMP,如负载均衡和同步开销。

由于当前系统受到英特尔® 酷睿™i9-7980XE 至尊版处理器(18 个内核,36 个线程)等的支持,开发优化型代码以处理多线程的优势变得很明显。如欲了解更多信息,请下载应用,使用 Microsoft Visual Studio 等集成开发环境来分析该应用,并着手开展您自己的项目。

附录 A 关于 ImageFiltersWPF

ImageFiltersWPF 是一款使用可扩展应用标记语言 (XAML) 来展示 GUI 的 Windows* WPF 客户端应用。该应用的入口点是 MainWindow.xaml/MainWindow.xaml.cs。除主窗口外,还有几个用于保持功能简洁的支持类。

ImageFileList.cs

该类的主要功能是生成一系列可以选择的映像文件,以应用滤波器。

ImageFilterList.cs

这是一个非常简单的列表,包含 C++ DLL 提供的一系列滤波器。创建成功后,它将被 GUI 元素 lbImageFilters 使用。

BitmapWrapper.cs

该类接收源位图图像,并将其转换为能被 C++ DLL 使用的字节数组。

ImageProcessWrapper.cs

该类加载 DLL,提供调用函数,后者将字节数组传输至 DLL。

MainWindow.xmal.cs

这是 MainWindow GUI 代码。该函数获取当前滤波器的名称,并将 bitmapwrapper 实例发送至 DLL。它运行了两次,第一次面向单核,第二次作为多核。每次运行之后,它都会更新标签,其中包含处理图像的耗时(秒)。一旦处理完成后,将显示新图像。

资料来源

OpenMP

英特尔® 集成众核(英特尔® MIC)架构

有关编译器优化的更完整信息,请参阅优化通知