为什么我的手动调整,启用SSE的代码如此之慢?

neu*_*rte 30 c++ optimization opencv sse

长话短说:我正在用C++开发一个计算密集型图像处理应用程序.它需要在从较大图像中提取的小像素块上计算图像变形的许多变体.该程序运行速度不如我想的那么快.分析(OProfile)显示变形/插值函数占用CPU时间的70%以上,因此尝试优化它似乎是显而易见的.

到目前为止,我正在使用OpenCV图像处理库完成任务:

// some parameters for the image warps (position, stretch, skew)
struct WarpParams;

void Image::get(const WarpParams &params)
{
    // fills matrices mapX_ and mapY_ with x and y coordinates of points to be
    // inteprolated.
    updateCoordMaps(params); 
    // perform interpolation to obtain pixels at point locations 
    // P(mapX_[i], mapY_[i]) based on the original data and put the 
    // result in pixels_. Use bicubic inteprolation.
    cv::remap(image_->data(), pixels_, mapX_, mapY_, CV_INTER_CUBIC);
}
Run Code Online (Sandbox Code Playgroud)

我编写了自己的插值函数并将其放入测试工具中以确保在我进行实验时的正确性,并将其与旧的相比较.

我的功能运行得很慢,这是预料之中的.一般来说,这个想法是:

  1. 迭代mapX_,mapY_坐标图,提取下一个要插值的像素的(实值)坐标;
  2. 从围绕内插像素的原始图像中检索4×4像素值(整数坐标);
  3. 计算这16个像素中每个像素的卷积核的系数;
  4. 计算内插像素的值作为16个像素值和内核系数的线性组合.

旧功能在我的Wolfdale Core2 Duo上的时间为25us.新的拿了587us(!).我急切地把我的巫师帽子打开并开始破解代码.我设法删除所有分支,省略一些重复计算,并将3个嵌套循环转换为坐标映射中的一个.这就是我想出的:

void Image::getConvolve(const WarpParams &params)
{
    __declspec(align(16)) static float kernelX[4], kernelY[4];

    // grab pointers to coordinate map matrices and the original image
    const float 
        *const mapX = mapX_.ptr<float>(),
        *const mapY = mapY_.ptr<float>(),
        *const img  = image_->data().ptr<float>();
    // grab pointer to the output image
    float *const subset = pixels_.ptr<float>(),
        x, y, xint, yint;

    const ptrdiff_t imgw = image_->width();
    ptrdiff_t imgoffs; 

    __m128 v_px, v_kernX, v_kernY, v_val;

    // iterate over the coordinate matrices as linear buffers
    for (size_t idx = 0; idx < pxCount; ++idx)
    {
        // retrieve coordinates of next pixel from precalculated maps,
        // break up each into fractional and integer part
        x = modf(mapX[idx], &xint);
        y = modf(mapY[idx], &yint);
        // obtain offset of the top left pixel from the required 4x4 
        // neighborhood of the current pixel in the image's 
        // buffer (sadly, the position will be unaligned)
        imgoffs = (((ptrdiff_t)yint - 1) * imgw) + (ptrdiff_t)xint - 1;

        // calculate all 4 convolution kernel values for every row and 
        // every column
        tap4Kernel(x, kernelX);
        tap4Kernel(y, kernelY);

        // load the kernel values for the columns, these don't change
        v_kernX = _mm_load_ps(kernelX);

        // process a row of the 4x4 neighborhood
        // get set of convolution kernel values for the current row
        v_kernY = _mm_set_ps1(kernelY[0]);     
        v_px    = _mm_loadu_ps(img + imgoffs); // load the pixel values
        // calculate the linear combination of the pixels with kernelX
        v_px    = _mm_mul_ps(v_px, v_kernX);   
        v_px    = _mm_mul_ps(v_px, v_kernY);   // and kernel Y
        v_val   = v_px;                        // add result to the final value
        imgoffs += imgw;
        // offset points now to next row of the 4x4 neighborhood

        v_kernY = _mm_set_ps1(kernelY[1]);
        v_px    = _mm_loadu_ps(img + imgoffs);
        v_px    = _mm_mul_ps(v_px, v_kernX);
        v_px    = _mm_mul_ps(v_px, v_kernY);
        v_val   = _mm_add_ps(v_val, v_px);
        imgoffs += imgw;

        /*... same for kernelY[2] and kernelY[3]... */

        // store resulting interpolated pixel value in the subset's
        // pixel matrix
        subset[idx] = horizSum(v_val);
    }
}

// Calculate all 4 values of the 4-tap convolution kernel for 4 neighbors
// of a pixel and store them in an array. Ugly but fast.
// The "arg" parameter is the fractional part of a pixel's coordinate, i.e.
// a number in the range <0,1)
void Image::tap4Kernel(const float arg, float *out)
{
    // chaining intrinsics was slower, so this is done in separate steps
    // load the argument into 4 cells of a XMM register
    __m128
        v_arg   = _mm_set_ps1(arg),
        v_coeff = _mm_set_ps(2.0f, 1.0f, 0.0f, -1.0f);

    // subtract vector of [-1, 0, 1, 2] to obtain coorinates of 4 neighbors
    // for kernel calculation
    v_arg = _mm_sub_ps(v_arg, v_coeff);
    // clear sign bits, this is equivalent to fabs() on all 4
    v_coeff = _mm_set_ps1(-0.f);
    v_arg = _mm_andnot_ps(v_coeff, v_arg); 

    // calculate values of abs(argument)^3 and ^2
    __m128
        v_arg2 = _mm_mul_ps(v_arg, v_arg),
        v_arg3 = _mm_mul_ps(v_arg2, v_arg),
        v_val, v_temp;

    // calculate the 4 kernel values as 
    // arg^3 * A + arg^2 * B + arg * C + D, using 
    // (A,B,C,D) = (-0.5, 2.5, -4, 2) for the outside pixels and 
    // (1.5, -2.5, 0, 1) for inside
    v_coeff = _mm_set_ps(-0.5f, 1.5f, 1.5f, -0.5f);
    v_val   = _mm_mul_ps(v_coeff, v_arg3);
    v_coeff = _mm_set_ps(2.5f, -2.5f, -2.5f, 2.5f);
    v_temp  = _mm_mul_ps(v_coeff, v_arg2);
    v_val   = _mm_add_ps(v_val, v_temp);
    v_coeff = _mm_set_ps(-4.0f, 0.0f, 0.0f, -4.0f),
    v_temp  = _mm_mul_ps(v_coeff, v_arg);
    v_val   = _mm_add_ps(v_val, v_temp);
    v_coeff = _mm_set_ps(2.0f, 1.0f, 1.0f, 2.0f);
    v_val   = _mm_add_ps(v_val, v_coeff);

    _mm_store_ps(out, v_val);
}
Run Code Online (Sandbox Code Playgroud)

我很高兴能够将运行时间设置为低于40us,甚至在将SSE引入主循环之前,我将其保存到最后.我期待至少3倍的加速,但它在36us时只跑得快,比我试图改进的旧的get()慢.更糟糕的是,当我改变基准测试循环以进行更多运行时,旧函数具有相同的平均运行时间,而我的延伸超过127us,这意味着对于某些极端扭曲参数值需要更长时间(这是有意义的,因为更多warps意味着我需要从原始图像到达广泛分散的像素值来计算结果).

我认为原因必须是未对齐的负载,但这无法帮助(我需要达到不可预测的像素值).我无法在优化部门看到任何其他内容,所以我决定查看cv :: remap()函数以了解它们是如何做到的.想象一下,我惊讶地发现它包含一堆嵌套的循环和大量的分支.他们还做了很多论证验证,我不需要打扰.据我所知(代码中没有注释),S​​SE(带有未对齐的加载!)仅用于从坐标图中提取值并将它们舍入为整数,然后调用一个函数进行实际插值定期浮动算术.

我的问题是,为什么我如此悲惨地失败(为什么我的代码如此缓慢,为什么他们的代码更快,即使它看起来像一团糟)我该怎么做才能改进我的代码?

我没有在这里粘贴OpenCV代码,因为这已经太长了,你可以在pastebin上查看它.

我在VC++ 2010下的Release模式下测试并编译了我的代码.使用的OpenCV是v2.3.1的预编译二进制包.

编辑:像素值浮动范围为0..1.分析显示tap4Kernel()函数不相关,大部分时间都花在getConvolve()中.

EDIT2:将生成的代码反汇编粘贴到pastebin.这是在旧的Banias Celeron处理器(具有SSE2)上编译的,但看起来或多或少相同.

EDIT3:在阅读了每个程序员应该知道的关于内存之后我意识到我错误地假设OpenCV函数实现了与我相同或多或少相同的算法,但事实并非如此.对于I插值的每个像素,我需要检索其4x4邻域,其像素非顺序地放置在图像缓冲区内.我滥用CPU缓存,而OpenCV可能没有.VTune分析似乎同意,因为我的函数有5,800,000个内存访问,而OpenCV只有400,000.他们的功能很乱,可能会进一步优化,但它仍然设法优于我,可能是由于一些更智能的内存和缓存使用方法.

更新:我设法改善像素值加载到XMM寄存器的方式.我在对象中分配一个缓冲区,该缓冲区为图像的每个像素保存16个元素的单元格.在图像加载时,我为每个像素填充预先排列的4x4邻域序列的单元缓冲区.空间效率不是很高(图像需要16倍的空间),但是这样,负载总是对齐的(不再是_mm_loadu_ps()),并且我避免了从图像缓冲区中分散读取像素,因为所需的像素按顺序存储.令我惊讶的是,几乎没有任何改善.我听说未对齐的载荷可能慢了10倍,但显然这不是问题所在.但是通过注释掉部分代码,我发现modf()调用负责75%的运行时!我将专注于消除这些并发布答案.

小智 7

先是几点意见.

  • 你使用函数静态变量,这可能导致同步(我不认为它在这里)
  • 该程序集混合了x87和sse代码.
  • tap4kernel是内联的,这很好,但配置文件可能不准确.
  • modf没有内联.
  • 程序集使用_ftol2_sse(_ftol2_sse,有更快的选项吗?).
  • 程序集将寄存器移动很多.

尝试执行以下操作:

  1. 确保您的编译器正在针对基于SSE2的体系结构进行积极优化.
  2. 使modf可用于内联.
  3. 避免使用函数静态变量.
  4. 如果程序集仍然使用x87指令,请尝试避免使用float-int imgoffs = (((ptrdiff_t)yint - 1) * imgw) + (ptrdiff_t)xint - 1;强制转换并使浮点变量__m128.

它可以通过预取地图进一步优化(预取大约4kb)