如何在2019年使用OpenCV进行正确的多线程编程?

31

背景:

我阅读了一些关于OpenCV中多线程的文章和帖子:

  • 一方面,您可以使用TBB或OpenMP支持构建OpenCV,这会在内部并行化OpenCV的函数。
  • 另一方面,您可以自己创建多个线程,并调用函数以实现应用程序级别的多线程。

但我无法得到一致的答案,哪种多线程方法是正确的。

关于TBB,来自2012年的答案获得了5个赞:

使用WITH_TBB = ON,OpenCV尝试为某些函数使用多个线程。问题在于,目前只有少量函数使用TBB进行线程处理(可能有十几个)。因此,很难看到任何加速。 OpenCV的理念是应用程序应该是多线程的,而不是OpenCV函数。[...]

关于应用程序级别的多线程,answers.opencv.org上的一个评论来自一位主持人:

请避免在opencv中使用自己的多线程。许多函数明确不支持多线程。 而是使用TBB或openmp支持重新构建opencv库。

但是另一个答案得到了3个赞同,它说:

这个库本身是线程安全的,因为你可以同时对该库进行多次调用,但是数据并不总是线程安全的。

问题描述:

所以我认为在应用程序级别上使用(多)线程至少是可以的。但是当我运行我的程序一段时间后,遇到了奇怪的性能问题。

在调查这些性能问题后,我创建了这个最小、完整和可验证的示例代码:

#include "opencv2\opencv.hpp"
#include <vector>
#include <chrono>
#include <thread>

using namespace cv;
using namespace std;
using namespace std::chrono;

void blurSlowdown(void*) {
    Mat m1(360, 640, CV_8UC3);
    Mat m2(360, 640, CV_8UC3);
    medianBlur(m1, m2, 3);
}

int main()
{
    for (;;) {
        high_resolution_clock::time_point start = high_resolution_clock::now();

        for (int k = 0; k < 100; k++) {
            thread t(blurSlowdown, nullptr);
            t.join(); //INTENTIONALLY PUT HERE READ PROBLEM DESCRIPTION
        }

        high_resolution_clock::time_point end = high_resolution_clock::now();
        cout << duration_cast<microseconds>(end - start).count() << endl;
    }
}

实际行为:

如果程序运行了一段时间,{{打印出的时间跨度}}会变得很长。

cout << duration_cast<microseconds>(end - start).count() << endl;

越来越大。

在运行程序约10分钟后,打印的时间跨度加倍了,这不能用正常波动来解释。

期望的行为:

我期望程序的行为是时间跨度基本保持不变,即使它们可能比直接调用函数的时间跨度长。

注意事项:

直接调用函数时:

[...]
for (int k = 0; k < 100; k++) {
    blurSlowdown(nullptr);
}
[...]

打印的时间跨度保持不变。
在不调用cv函数时:
void blurSlowdown(void*) {
    Mat m1(360, 640, CV_8UC3);
    Mat m2(360, 640, CV_8UC3);
    //medianBlur(m1, m2, 3);
}

打印的时间跨度也保持不变。因此,在将线程与OpenCV函数组合使用时,必须出了问题。
  • 我知道上面的代码并没有实现真正的多线程,只会有一个线程在同时调用blurSlowdown()函数。
  • 我知道创建线程和清理它们后来不是免费的,并且比直接调用函数要慢。
  • 这不是关于代码总体上慢的问题。问题在于打印的时间跨度随着时间的推移越来越长
  • 问题与medianBlur()函数无关,因为它也会发生在其他函数(如erode()blur())中。
  • 该问题在Mac下的clang++下复制,详情请参见@Mark Setchell的评论。
  • 使用调试库而不是发布库可以放大该问题。

我的测试环境:

  • Windows 10 64位
  • MSVC编译器
  • 官方OpenCV 3.4.2二进制文件

我的问题:

  • 在OpenCV的应用程序级别使用多线程是否可行?
  • 如果可以,为什么我的程序打印的时间跨度随着时间的推移增长
  • 如果不行,那么为什么OpenCV被认为是线程安全的,请解释如何解释来自Kirill Kornyakov的声明
  • 2019年TBB / OpenMP现在被广泛支持吗?
  • 如果可以,多线程应用程序级别(如果允许)和TBB / OpenMP哪个提供更好的性能?

1
在循环内部加入线程会有效地使它们串行化。你应该使用一个线程的std::vector,在循环中填充它,并在循环外部加入所有线程。或者使用std::futurestd::promise - πάντα ῥεῖ
1
有趣,我也可以重现这个问题,时间似乎是线性增长的(大约需要6000次外部循环才能翻倍)。内存似乎也随着时间增长而增长,从大约7MB稳步增加到40MB左右。可能需要进行更详细的分析。我对产生如此多的短暂线程有些怀疑……可能存在一些不断增加的开销(个人更喜欢保留一些长期运行的线程)。 - Dan Mašek
1
@DanMašek 谢谢您能够重现这个问题,真的!当不调用 medianBlur 只分配 mats 时,时间保持恒定。所以我认为仅有线程并不是问题。此外,当使用调试二进制文件而非发布二进制文件时,时间跨度会增长得更快。 - Crigges
1
可能相关(以及相关问题):每个线程中的内存泄漏#9745。看起来正确使用多线程的推荐方法是使用线程池。 - Catree
1
我对这个问题进行了深入的研究,因为坦率地说,我正在部署类似于OpenCV的内容,并且需要长时间运行。看起来问题出在许多基本函数主体中的“CV_OCL_RUN”宏上,即使将“cv :: ocl :: useOpenCL”设置为false,该宏也会设置“TLSData”(假定)以接收运行时链接的OpenCL函数地址。幸运的是,“WITH_OPENCL” CMake选项将此宏设置为0,因此基本解决了问题。在我的情况下,问题是具有“std :: thread”的泄漏内存。 - mainactual
显示剩余22条评论
1个回答

23

首先,感谢你的清晰问题。

问:在使用OpenCV时,在应用程序级别使用多线程是否可行?

答:是的,除非您使用可以利用多线程的函数(如模糊、颜色空间转换),在这种情况下,您可以将图像分成多个部分,并在整个划分的部分中应用全局函数,然后重新组合它以给出最终输出。

对于某些函数,例如Hough、pca_analysis等,在将其应用于划分的图像部分并重新组合时无法给出正确结果,因此在应用程序级别上应用多线程可能不会给出正确的结果,因此不应该这样做。

正如πάντα ῥεῖ所提到的,您的多线程实现不会给您带来优势,因为您正在for循环中加入线程。我建议您使用promise和future对象(如果您想要如何的示例,请在评论中告诉我,我将分享片段)。

下面的答案经过了大量的研究,感谢您的提问,这确实帮助我增加了多线程知识的信息 :)

问:如果是的话,为什么我的程序打印的时间跨度随时间增长而增加?

答:经过大量的研究,我发现创建和销毁线程会占用大量的CPU和内存资源。当我们初始化一个线程(在您的代码中通过这行:thread t(blurSlowdown, nullptr);)时,会将标识符写入指向该变量的内存位置,该标识符使我们能够引用该线程。现在,在您的程序中,您正在以非常高的速率创建和销毁线程,现在所发生的是:程序分配了一个线程池,通过该线程池,我们的程序可以运行和销毁线程,让我们简要地看一下下面的解释:

  1. 创建线程时,会创建一个指向该线程的标识符。
  2. 销毁线程时,该内存被释放。

但是

  1. 当您再次创建线程时,新线程的标识符指向一个新位置(其他位置而不是上一个线程)。

  2. 在重复创建和销毁线程之后,线程池被耗尽,因此CPU被迫减慢我们的程序周期,以便线程池再次被释放以为新线程腾出空间。

Intel TBB和OpenMP在线程池管理方面非常出色,因此在使用它们时可能不会发生此问题。

问:2019年,TBB现在得到广泛支持吗?

A: 是的,在构建OpenCV时启用TBB支持后,您可以在OpenCV程序中利用TBB。

这是一个使用TBB实现medianBlur的程序:

#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <iostream>
#include <chrono>

using namespace cv;
using namespace std;
using namespace std::chrono;

class Parallel_process : public cv::ParallelLoopBody
{

private:
    cv::Mat img;
    cv::Mat& retVal;
    int size;
    int diff;

public:
    Parallel_process(cv::Mat inputImgage, cv::Mat& outImage,
                     int sizeVal, int diffVal)
        : img(inputImgage), retVal(outImage),
          size(sizeVal), diff(diffVal)
    {
    }

    virtual void operator()(const cv::Range& range) const
    {
        for(int i = range.start; i < range.end; i++)
        {
            /* divide image in 'diff' number
               of parts and process simultaneously */

            cv::Mat in(img, cv::Rect(0, (img.rows/diff)*i,
                                     img.cols, img.rows/diff));
            cv::Mat out(retVal, cv::Rect(0, (retVal.rows/diff)*i,
                                         retVal.cols, retVal.rows/diff));

            cv::medianBlur(in, out, size);
        }
    }
};

int main()
{
    VideoCapture cap(0);

    cv::Mat img, out;

    while(1)
    {
        cap.read(img);
        out = cv::Mat::zeros(img.size(), CV_8UC3);

        // create 8 threads and use TBB
        auto start1 = high_resolution_clock::now();
        cv::parallel_for_(cv::Range(0, 8), Parallel_process(img, out, 9, 8));
        //cv::medianBlur(img, out, 9); //Uncomment to compare time w/o TBB
        auto stop1 = high_resolution_clock::now();
        auto duration1 = duration_cast<microseconds>(stop1 - start1);

        auto time_taken1 = duration1.count()/1000;
        cout << "TBB Time: " <<  time_taken1 << "ms" << endl;

        cv::imshow("image", img);
        cv::imshow("blur", out);
        cv::waitKey(1);
    }

    return 0;
}

在我的机器上,使用TBB实现需要大约10ms,而不使用TBB则需要40ms。 问:如果可以的话,什么能够提供更好的性能,应用程序级别的多线程(如果允许)还是TBB / OpenMP? 答:我建议使用TBB/OpenMP而非POSIX多线程(pthread/thread),因为TBB提供了更好的线程控制和更好的编写并行代码的结构,并在内部管理pthread。如果你使用pthread,你将需要在你的代码中处理同步和安全等问题。但是使用这些框架抽象了处理线程的需要,这可能变得非常复杂。 编辑:我检查了有关图像尺寸与您想要分配处理的线程数不兼容的评论。所以这里有一个潜在的解决办法(尚未测试,但应该有效),将图像分辨率缩放到兼容尺寸,例如:
如果您的图像分辨率为485 x 647,请将其缩放到488 x 648,然后传递给Parallel_process,然后将输出缩放回原始大小458 x 647即可。
有关TBB和OpenMP的比较,请查看此答案

编辑:回答了线程执行增长时间的问题。 - FutureJJ
非常好的答案,谢谢。TBB是否自动使用线程池,还是我需要手动初始化它们?我遇到了与问题中相同的行为,但在我的程序中从未手动创建线程。我只直接使用cv::Mat::forEachtbb::parallel_for。我没有进一步调查它,因为增长很小(在我的情况下,在约300个连续运行后从12秒增加到14秒)。如果不是这样,也许有一天我会深入挖掘它。 :) - Carsten
你可能想要查看英特尔 软件开发区论坛,以了解TBB是否自动使用线程池的详细答案。 - FutureJJ
使用TBB parallel_for时,14秒的时间增长不应该发生,您能否分享您的程序?如果有问题,我可能能够解决它。 - FutureJJ
几点注意事项:在这个例子中,如果图像高度不能被8整除,则会通过四舍五入错过一些扫描线。此外,cv::medianBlur在每个接缝处使用BORDER_REPLICATE,这会导致...嗯,在图像中出现接缝。其他滤波器可能使用其他边界模式,但总的来说,您需要扩展每个区域以进行处理,然后将请求的区域裁剪到retVal以正确执行它。 - mainactual
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接