How to use the OpenCV parallel_for_ to parallelize your code
如何使用 OpenCV parallel_for_ 来并行化你的代码
Goal
本教程的目标是演示如何使用 OpenCV parallel_for_ framework轻松并行化您的代码。 为了说明这个概念,我们将编写一个程序来对图像执行卷积操作。 完整的教程代码在这里。
https://github.com/opencv/opencv/blob/4.x/samples/cpp/tutorial_code/core/how_to_use_OpenCV_parallel_for_/how_to_use_OpenCV_parallel_for_new.cpp
Precondition
Parallel Frameworks
第一个前提条件是使用并行框架构建 OpenCV。 在 OpenCV 4.5 中,以下并行框架按顺序可用:
英特尔线程构建模块(第 3 方库,应显式启用)
OpenMP(集成到编译器,应显式启用)
APPLE GCD(系统范围,自动使用(仅限 APPLE))
Windows RT 并发(系统范围,自动使用(仅限 Windows RT))
Windows 并发(运行时的一部分,自动使用(仅限 Windows - MSVC++ >= 10))
线程
- Intel Threading Building Blocks (3rdparty library, should be explicitly enabled)
- OpenMP (integrated to compiler, should be explicitly enabled)
- APPLE GCD (system wide, used automatically (APPLE only))
- Windows RT concurrency (system wide, used automatically (Windows RT only))
- Windows concurrency (part of runtime, used automatically (Windows only - MSVC++ >= 10))
- Pthreads
如您所见,OpenCV 库中可以使用多个并行框架。 一些并行库是第三方库,必须在构建之前在 CMake 中显式启用,而其他并行库是平台自动可用的(例如 APPLE GCD)。
Race Conditions
当多个线程同时尝试写入或读取和写入特定内存位置时,就会出现竞争条件。 基于此,我们可以将算法大致分为两类:-
-
- 只有单个线程将数据写入特定内存位置的算法。
例如,在卷积中,即使多个线程可以在特定时间从像素读取,也只有单个线程写入特定像素。
In convolution, for example, even though multiple threads may read from a pixel at a particular time, only a single thread writes to a particular pixel.
-
- 多个线程可以写入单个内存位置的算法。
寻找轮廓、特征等。此类算法可能需要每个线程同时将数据添加到全局变量。 例如,在检测特征时,每个线程会将图像各自部分的特征添加到公共向量中,从而创建竞争条件。
Finding contours, features, etc. Such algorithms may require each thread to add data to a global variable simultaneously. For example, when detecting features, each thread will add features of their respective parts of the image to a common vector, thus creating a race condition
Convolution
我们将使用执行卷积的示例来演示使用 parallel_for_ 来并行化计算。 这是一个不会导致竞争条件的算法示例。
Theory
卷积是一种简单的数学运算,广泛用于图像处理。 在这里,我们在图像上滑动一个称为内核的较小矩阵,像素值与内核中相应值的乘积之和为我们提供输出中特定像素的值(称为内核的锚点) . 根据内核中的值,我们得到不同的结果。 在下面的示例中,我们使用 3x3 内核(锚定在其中心)并在 5x5 矩阵上进行卷积以生成 3x3 矩阵。 可以通过使用合适的值填充输入来更改输出的大小。
?Convolution Animation
有关不同内核及其作用的更多信息,请查看此处en.wikipedia.org
出于本教程的目的,我们将实现函数的最简单形式,该函数采用灰度图像(1 通道)和奇数长度的方形内核并生成输出图像。 该操作不会就地执行。
笔记
我们可以临时存储一些相关像素,以确保在卷积期间使用原始值,然后就地执行。 但是,本教程的目的是介绍 parallel_for_ 函数,就地实现可能过于复杂。
Pseudocode
InputImage src, OutputImage dst, kernel(size n)
makeborder(src, n/2)
for each pixel (i, j) strictly inside borders, do:
{
value := 0
for k := -n/2 to n/2, do:
for l := -n/2 to n/2, do:
value += kernel[n/2 + k][n/2 + l]*src[i + k][j + l]
dst[i][j] := value
}
对于 n 大小的内核,我们将添加大小为 n/2 的边框来处理边缘情况。 然后我们运行两个循环以沿着内核移动并将乘积相加
Implementation
Sequential implementation
顺序执行
void conv_seq(Mat src, Mat &dst, Mat kernel)
{
??? int rows = src.rows, cols = src.cols;
??? dst = Mat(rows, cols, src.type());
??? //处理边缘值
??? // Make border = kernel.rows / 2;
??? int sz = kernel.rows / 2;
??? copyMakeBorder(src, src, sz, sz, sz, sz, BORDER_REPLICATE);
??? for (int i = 0; i < rows; i++)
??? {
??????? uchar *dptr = dst.ptr(i);//第i行像素指针
??????? for (int j = 0; j < cols; j++)
??????? {
??????????? double value = 0;
??????????? for (int k = -sz; k <= sz; k++)//遍历卷积核所有行
??????????? {
??????????????? // slightly faster results when we create a ptr due to more efficient memory access. 由于更有效的内存访问,当我们创建 ptr 时结果会稍微快一些。
??????????????? uchar *sptr = src.ptr(i + sz + k);//第i行 卷积核对应像素值指针
??????????????? for (int l = -sz; l <= sz; l++) //遍历卷积核所有列
??????????????? {?? //计算卷积
??????????????????? value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
??????????????? }
??????????? }
?????? ?????dptr[j] = saturate_cast<uchar>(value);// 防止颜色溢出操作
??????? }
??? }
}
copyMakeBorder()
void cv::copyMakeBorder??? (????? InputArray ?? src,
OutputArray dst,
int top,
int bottom,
int left,
int right,
int borderType,
const Scalar & ???? value = Scalar()
)????????????
Python:
cv.copyMakeBorder(??? src, top, bottom, left, right, borderType[, dst[, value]]????? ) -> dst
我们首先制作一个与 src 大小相同的输出矩阵(dst),并为 src 图像添加边框(以处理边缘情况)。
??? int rows = src.rows, cols = src.cols; ??? dst = Mat(rows, cols, src.type()); ??? // Taking care of edge values ??? // Make border = kernel.rows / 2; ??? int sz = kernel.rows / 2; ??? copyMakeBorder(src, src, sz, sz, sz, sz, BORDER_REPLICATE); |
然后我们依次遍历 src 图像中的像素并计算内核和相邻像素值的值。 然后我们将值填充到 dst 图像中的相应像素。
? for (int i = 0; i < rows; i++)
??? {
??????? uchar *dptr = dst.ptr(i);
??????? for (int j = 0; j < cols; j++)
??????? {
??????????? double value = 0;
??????????? for (int k = -sz; k <= sz; k++)
??????????? {
??????????????? // slightly faster results when we create a ptr due to more efficient memory access.
??????????????? uchar *sptr = src.ptr(i + sz + k);
?? ?????????????for (int l = -sz; l <= sz; l++)
??????????????? {
??????????????????? value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
??????????????? }
??????????? }
??????????? dptr[j] = saturate_cast<uchar>(value);
??????? }
??? }
Parallel implementation
并行实现
在查看顺序实现时,我们可以注意到每个像素依赖于多个相邻像素,但一次只编辑一个像素。 因此,为了优化计算,我们可以利用现代处理器的多核架构将图像分割成条带并在每个条带上并行执行卷积。 OpenCV cv::parallel_for_ 框架自动决定如何有效地拆分计算并为我们完成大部分工作。
笔记
尽管特定条带中的像素值可能取决于条带外的像素值,但这些只是只读操作,因此不会导致未定义的行为。
我们首先声明一个继承自 cv::ParallelLoopBody 的自定义类,并覆盖 virtual void operator ()(const cv::Range& range) const。
class parallelConvolution : public ParallelLoopBody
{
private:
??? Mat m_src, &m_dst;
??? Mat m_kernel;
??? int sz;
public:
??? parallelConvolution(Mat src, Mat &dst, Mat kernel)
??????? : m_src(src), m_dst(dst), m_kernel(kernel)
?? ?{
??????? sz = kernel.rows / 2;
??? }
??? virtual void operator()(const Range &range) const CV_OVERRIDE
??? {
??????? for (int r = range.start; r < range.end; r++)
??????? {
??????????? int i = r / m_src.cols, j = r % m_src.cols;//i:列。 J:行。
??????????? double value = 0;
??????????? for (int k = -sz; k <= sz; k++)//遍历卷积核的行
??????????? {
??????????????? uchar *sptr = m_src.ptr(i + sz + k);//当前卷积核所在图像第i行指针
??????????????? for (int l = -sz; l <= sz; l++)//遍历卷积核的列
??????????????? { ???//卷积核第 k + sz 行,l+sz列 ?* sptr行的第j + sz + l列像素值
??????????????????? value += m_kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
??????????????? }
??????????? }
??????????? m_dst.ptr(i)[j] = saturate_cast<uchar>(value);//放置颜色溢出
??????? }
??? }
};
运算符 () 中的范围表示将由单个线程处理的值的子集。 根据需求,可能有不同的分割范围的方法,这反过来会改变计算。
例如,我们可以
- 分割图像的整个遍历,通过如下方式获取[row, col]坐标(如上代码所示):
? virtual void operator()(const Range &range) const CV_OVERRIDE
??? {
??????? for (int r = range.start; r < range.end; r++)
??????? {
??????????? int i = r / m_src.cols, j = r % m_src.cols;
??????????? double value = 0;
??????????? for (int k = -sz; k <= sz; k++)
??????????? {
??????????????? uchar *sptr = m_src.ptr(i + sz + k);
??????????????? for (int l = -sz; l <= sz; l++)
??????????????? {
??????????????????? value += m_kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
??????????????? }
??????????? }
??????????? m_dst.ptr(i)[j] = saturate_cast<uchar>(value);
??????? }
??? }
然后,我们将按以下方式调用 parallel_for_ 函数:
parallelConvolution obj(src, dst, kernel);
parallel_for_(Range(0, rows * cols), obj);
- 拆分行并计算每一行:
?? virtual void operator()(const Range &range) const CV_OVERRIDE
??? {
??????? for (int i = range.start; i < range.end; i++)//并行计算每一行
??????? {
??????????? uchar *dptr = dst.ptr(i);//第i行指针
??????????? for (int j = 0; j < cols; j++)
??????????? {
??????????????? double value = 0;
??????????????? for (int k = -sz; k <= sz; k++)
??????????????? {
??????????????????? uchar *sptr = src.ptr(i + sz + k);//扩展后的图像第i行指针
??????????????????? for (int l = -sz; l <= sz; l++)//遍历卷积核所有列
??????????????????? {?? // 内核的k + sz行l + sz列*? sptr第j + sz + l列像素值
??????????????????????? value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
??????????????????? }
??????????????? }
??????????????? dptr[j] = saturate_cast<uchar>(value);
??????????? }
??????? }
??? }
在这种情况下,我们调用具有不同范围的 parallel_for_ 函数:
parallelConvolutionRowSplit obj(src, dst, kernel);
parallel_for_(Range(0, rows), obj);
笔记
在我们的例子中,两种实现的性能相似。 某些情况可能允许更好的内存访问模式或其他性能优势。
要设置线程数,可以使用:cv::setNumThreads。 您还可以使用 cv::parallel_for_ 中的 nstripes 参数指定拆分次数。 例如,如果您的处理器有 4 个线程,则设置 cv::setNumThreads(2) 或设置 nstripes=2 应该与默认情况下相同,它将使用所有可用的处理器线程,但只会将工作负载拆分到两个线程上。
笔记
C++ 11 标准允许通过去掉 parallelConvolution 类 并将其替换为 lambda 表达式来简化并行实现:
?? ?parallel_for_(Range(0, rows * cols), [&](const Range &range)
??????????????????? {
??????????????????????? for (int r = range.start; r < range.end; r++)
??????????????????????? {
??????????????????????????? int i = r / cols, j = r % cols;
??????????????????????????? double value = 0;
??????????????????????????? for (int k = -sz; k <= sz; k++)
??????????????????????????? {
??????????????????????????????? uchar *sptr = src.ptr(i + sz + k);
??????????????????????????????? for (int l = -sz; l <= sz; l++)
??????????????????????????????? {
??????????????????????????????????? value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
???????????????????????? ???????}
??????????????????????????? }
??????????????????????????? dst.ptr(i)[j] = saturate_cast<uchar>(value);
??????????????????????? }
??????????????????? });
在 a 上执行这两个实现所花费的时间
512x512 input?with a?5x5 kernel:
This program shows how to use the OpenCV parallel_for_ function and
? compares the performance of the sequential and parallel implementations for a
? convolution operation
? Usage:
? ./a.out [image_path -- default lena.jpg]
? Sequential Implementation: 0.0953564s
? Parallel Implementation: 0.0246762s
? Parallel Implementation(Row Split): 0.0248722s
512x512 input with a 3x3 kernel
? This program shows how to use the OpenCV parallel_for_ function and
? compares the performance of the sequential and parallel implementations for a
? convolution operation
? Usage:
? ./a.out [image_path -- default lena.jpg]
? Sequential Implementation: 0.0301325s
? Parallel Implementation: 0.0117053s
? Parallel Implementation(Row Split): 0.0117894s
并行实现的性能取决于您拥有的 CPU 类型。 例如,在 4 核 - 8 线程 CPU 上,运行时间可能比顺序实现快 6 到 7 倍。 有很多因素可以解释为什么我们没有实现 8 倍的加速:
- 创建和管理线程的开销,
- 并行运行的后台进程,
- 4 个硬件内核(每个内核有 2 个逻辑线程)和 8 个硬件内核之间的区别。
在本教程中,我们使用了一个水平渐变滤镜(如上面的动画所示),它产生了一个突出垂直边缘的图像。
?result image
?
|