65.9K
CodeProject 正在变化。 阅读更多。
Home

Alphablend 代码的 SSE2 向量化

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (19投票s)

2016年4月12日

Ms-PL

9分钟阅读

viewsIcon

81499

downloadIcon

1253

使用 SSE2 加速 alpha 混合。

目录

引言

在本文中,我们将探讨如何对我在上一篇文章中编写的像素 alpha 混合代码进行向量化。我相信大多数 C/C++ 程序员都曾尝试过 SIMD 指令,如 MMX 和流式 SIMD 扩展 2 (SSE),甚至高级向量扩展 (AVX)。但大多数程序员,我想,在第一次 SIMD 尝试失败后就放弃了,因为他们的努力所产生的性能比原始的非向量化代码要差,甚至更差。性能缓慢的主要原因在于,CPU 时钟周期通常浪费在将基本数据打包到向量中以进行 SIMD 执行,以及在执行后将它们解包回结构中的正确位置。我们将在下一节深入探讨这一点。SIMD 之所以难以被全球程序员普遍采用,主要原因在于 SIMD 代码更难编写,即使使用了内在函数,生成的代码也不易读,更难理解。

在本文中,我们将只关注 SSE2;原因是 MMX 是一种 64 位 SIMD 技术,与 SSE2(英特尔提供的主要是 128 位 SIMD)相比,在当今的 64 位处理器上速度提升不明显。而高级向量扩展 (AVX)(英特尔提供的 256 位 SIMD)之所以没有被选择,是因为并非许多主流用户拥有可以利用 AVX 的最新英特尔处理器:SSE2 自 2001 年以来就已存在。(好吧,我承认,真正的原因是我没有 AVX 支持的处理器。我承诺在我能够访问一台带有 AVX 兼容处理器的 PC 时更新这篇文章。)所有除最老的个人电脑外,都应该配备支持 SSE2 的 x86/x64 处理器。我们将使用 Visual Studio 提供的 SSE2 内在函数来调用 SSE2 指令,而不是使用内联汇编代码:目前,微软的 64 位 C/C++ 编译器不允许内联汇编代码。

数组结构

让我们想象一下,我们有一个名为 SomeStruct 的结构类型,如下所示,并且我们有一个 SomeStruct 的数组,本质上就是一个结构数组。

struct SomeStruct
{
  int    aInteger;
  double aDouble;
  short  aShort;
};

要对 aInteger 使用 SSE2 算术指令,我们必须首先将四个 aInteger 打包到一个 128 位整数类型向量 __m128i 中。

Packing before SSE2 execution

在我们获得计算结果后,我们必须将这四个 aInteger 解包到数组中的各个结构对象中。

Unpacking after SSE2 execution

这通常是拖慢性能的罪魁祸首!为了提高性能,建议程序员将数据打包到数组结构(structure of arrays)而不是结构数组(array of structures)中;同样的建议也适用于 GPGPU 编程,以便实现内存合并。改变现有的数据域/模型以不自然地适应 SIMD 数据打包要求,通常是不可能的。在下面所示的结构中,arrIntegerarrDoublearrShort 通常是指向动态分配数组的指针,其大小仅在运行时可知,但我在此处将它们显示为数组类型,以强调它们是数组类型而不是指针类型。

struct SomeStruct
{
  int    arrInteger[1000];
  double arrDouble[1000];
  short  arrShort[1000];
};

正如读者可能已经注意到的,整数、双精度浮点数和短整数数据类型有不同的字节大小。四个整数可以放入 __m128i,两个双精度浮点数可以放入 __m128i,而八个短整数可以放入 __m128i。这在程序员编写代码循环遍历 __m128i 数组进行计算时可能会遇到困难。对我们来说,这不是问题,因为在这个 alpha 混合文章中,我们只使用 unsigned short 类型。

How many data fit into a 128 bit

代码解释

对于我们的 SSE2 alpha 混合代码,正如我刚才所说,我们将使用 unsigned short 类型来存储每个颜色通道。读者可能会问,为什么使用 unsigned short 来存储一个仅范围在 0 到 255 的值?答案是因为在乘法运算中,中间值很可能超过 255,而最终结果仍会保持在 255 以内。在普通编码中,我们可以使用字节变量进行相同的计算,而不使用 16 位变量。原因是字节值在进行任何计算之前会自动提升为整数(平台的本机字长),并在计算后自动转换回字节。我们声明了一个 __m128i 数组来存储两个 alpha 混合图像每个颜色通道的 unsigned shortm_fullsize 用于存储数组的完整大小(以字节为单位)。m_arraysize 是以 128 位大小元素为单位的数组大小。m_remainder 用于存储一个数字,该数字指示在最后一个 128 位元素中有多少个 unsigned short 是有效的,如果所需的 unsigned short 的数量不能被 8 整除(8 x 16 位 = 128 位)。例如,四个整数可以放入 __m128i;如果我们有六个整数需要计算,则需要一个包含两个 __m128i 元素的数组,但在最后一个 __m128i 元素中,仅使用了两个整数,因此 m_remainder 将反映 2。

__m128i* m_pSrc1R;
__m128i* m_pSrc1G;
__m128i* m_pSrc1B;

__m128i* m_pSrc2R;
__m128i* m_pSrc2G;
__m128i* m_pSrc2B;

// the full size of array in bytes
int m_fullsize;
// the full size of array in 128-bit chunks
int m_arraysize;
// the remainder of last 128-bit element
// which should be filled.
int m_remainder;

通过 GetUShortElement 方法,我们可以使用一个以 unsigned short 元素为单位的索引,从 __m128i 数组中获取一个 unsigned short 元素。

unsigned short& CAlphablendDlg::GetUShortElement(
  __m128i* ptr, 
  int index)
{
  int bigIndex = index>>3;
  int smallIndex = index - (bigIndex<<3);
  return ptr[bigIndex].m128i_u16[smallIndex];
}

通过 Get128BitElement 方法,我们可以使用一个以 __m128i 元素为单位的 bigIndex,从 __m128i 数组中获取一个 __m128i 元素。bigSize__m128i 数组的大小(以 128 位块为单位)。smallRemainder 是最后一个 __m128i 元素中有效 unsigned short 元素的数量。当我们检测到这是最后一个元素且短整数的数量不能被 8 整除时,我们需要将最后一个 __m128i 元素中未使用的 short 值设置为 1,以防止除以零的错误。这是一个好习惯,无论返回的是整数类型还是浮点类型,都要对每个最后一个元素执行此操作。

__m128i& CAlphablendDlg::Get128BitElement(
  __m128i* ptr, 
  int bigIndex, 
  int bigSize, 
  int smallRemainder)
{
  if(bigIndex != bigSize-1) // not last element
    return ptr[bigIndex];
  else if(smallRemainder==0) // last element
    return ptr[bigIndex];
  else // last element
  {
    for(int i=smallRemainder; i<8; ++i)
      ptr[bigIndex].m128i_u16[i] = 1;

    return ptr[bigIndex];
  }
}

我们必须小心地使用 _aligned_malloc 函数来分配我们的 __m128i 数组,以便它们与 128 位 SSE2 指令所需的 16 字节边界对齐。我们计算并存储余数,以防所需的 unsigned short 的数量不能被 8 整除(如前所述)(注意:128 位中有 8 个 unsigned short(16 位数据类型))。

// Calculate the size of 128 bit array
m_fullsize = m_BmpData1.Width * m_BmpData1.Height;
// divide by 8, to get the size in 128bit.
m_arraysize = (m_fullsize) >> 3;
// calculate the remainder
m_remainder = m_fullsize%8; 
// if there is remainder, 
// add 1 to the 128bit m_arraysize
if(m_remainder!=0) 
  ++m_arraysize;

// since we are using unsigned short (16bit) elements,
// let's muliply the total number of bytes needed by 2.
m_fullsize = (m_arraysize) * 8 * 2;

// Allocate 128bit arrays
m_pSrc1R = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));
m_pSrc1G = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));
m_pSrc1B = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));

m_pSrc2R = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));
m_pSrc2G = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));
m_pSrc2B = (__m128i *)(_aligned_malloc((size_t)(m_fullsize), 16));

分配完我们的 __m128i 数组后,我们将用图像对象的颜色信息填充它们。我们使用 GetUShortElement__m128i 数组中获取我们的 unsigned short 元素。

// Populate the SSE2 pointers to array
UINT col = 0;
UINT stride = m_BmpData1.Stride >> 2;

for(UINT row = 0; row < m_BmpData1.Height; ++row)
{
  for(col = 0; col < m_BmpData1.Width; ++col)
  {
    UINT index = row * stride + col;
    BYTE r = (m_pPixels1[index] & 0xff0000) >> 16;
    BYTE g = (m_pPixels1[index] & 0xff00) >> 8; 
    BYTE b = (m_pPixels1[index] & 0xff); 

    // assign the pixel color to the 128bit arrays
    GetUShortElement(m_pSrc1R, (int)(index)) = r;
    GetUShortElement(m_pSrc1G, (int)(index)) = g;
    GetUShortElement(m_pSrc1B, (int)(index)) = b;
  }
}

最后,我们必须记住在父类的析构函数中使用 _aligned_free 来释放 __m128i 数组。在开始主要编码之前就编写释放代码是一个好习惯。

if( m_pSrc1R )
{
  _aligned_free(m_pSrc1R);
  m_pSrc1R = NULL;
}

if( m_pSrc1G )
{
  _aligned_free(m_pSrc1G);
  m_pSrc1G = NULL;
}

if( m_pSrc1B )
{
  _aligned_free(m_pSrc1B);
  m_pSrc1B = NULL;
}

if( m_pSrc2R )
{
  _aligned_free(m_pSrc2R);
  m_pSrc2R = NULL;
}

if( m_pSrc2G )
{
  _aligned_free(m_pSrc2G);
  m_pSrc2G = NULL;
}

if( m_pSrc2B )
{
  _aligned_free(m_pSrc2B);
  m_pSrc2B = NULL;
}

在运行任何 SSE2 代码之前,我们应该首先确保处理器支持 SSE2。我们可以使用作者编写的 SIMD Detector 类来执行检查。

#include "SIMD.h"

SIMD m_simd;

if(m_simd.HasSSE2()==false)
{
  MessageBox(
    "Sorry, your processor does not support SSE2", 
    "Error", 
    MB_ICONERROR);
  return;
}

现在我们来到了文章的重点:使用 SSE2 进行 alpha 混合。使用的公式是 ((SrcColor * Alpha) + (DestColor * InverseAlpha)) >> 8。下面的代码列表有详细的注释。我们使用 _mm_set1_epi16 指令在 __m128i 元素的每个 short 元素中设置相同的 short 值(0)。_mm_mullo_epi16 指令用于将无符号 short 相乘,仅保留 16 位,如果最终结果溢出为 32 位。我们使用 _mm_add_epi16 指令来相加 unsigned short_mm_srli_epi16 指令用于右移 8 位,以模拟除以 256。我们可以不用除法指令,这是一个福音,因为 SSE2 不支持整数除法。在 SSE2 操作之后,我们需要将数据解包回 ARGB 格式。这种解包会抵消一部分性能提升。

// set alpha 128 bit
__m128i nAlpha128 = _mm_set1_epi16 ((short)(Alpha));
// set inverse alpha 128 bit
__m128i nInvAlpha128 = _mm_set1_epi16 ((short)(255-Alpha));

// initialize the __m128i variables
// so that the compiler shut up about
// uninitialized variables.
__m128i src1R = _mm_set1_epi16 ((short)(0));
__m128i src1G = _mm_set1_epi16 ((short)(0));
__m128i src1B = _mm_set1_epi16 ((short)(0));

__m128i src2R = _mm_set1_epi16 ((short)(0));
__m128i src2G = _mm_set1_epi16 ((short)(0));
__m128i src2B = _mm_set1_epi16 ((short)(0));

__m128i rFinal = _mm_set1_epi16 ((short)(0));
__m128i gFinal = _mm_set1_epi16 ((short)(0));
__m128i bFinal = _mm_set1_epi16 ((short)(0));

for(int i=0;i<m_arraysize;++i)
{
  // multiply and retain the result in lower 16bits
  src1R = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc1R,i,m_arraysize,m_remainder), 
    nAlpha128);
  src1G = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc1G,i,m_arraysize,m_remainder), 
    nAlpha128);
  src1B = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc1B,i,m_arraysize,m_remainder), 
    nAlpha128);

  // multiply and retain the result in lower 16bits
  src2R = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc2R,i,m_arraysize,m_remainder), 
    nInvAlpha128);
  src2G = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc2G,i,m_arraysize,m_remainder), 
    nInvAlpha128);
  src2B = _mm_mullo_epi16 (
    Get128BitElement(m_pSrc2B,i,m_arraysize,m_remainder), 
    nInvAlpha128);

  // Add a and b together
  rFinal = _mm_add_epi16 (src1R, src2R);
  gFinal = _mm_add_epi16 (src1G, src2G);
  bFinal = _mm_add_epi16 (src1B, src2B);

  // Use shift right by 8 to do division by 256
  rFinal = _mm_srli_epi16 (rFinal, 8);
  gFinal = _mm_srli_epi16 (gFinal, 8);
  bFinal = _mm_srli_epi16 (bFinal, 8);

  // unpack the final results into the 8 pixels 
  // if possible
  int step = i << 3; // multiply by 8
  if(i!=m_arraysize-1) // not the last element
  {
    pixels[step+0] = 
      0xff000000                | 
      rFinal.m128i_u16[0] << 16 | 
      gFinal.m128i_u16[0] << 8  | 
      bFinal.m128i_u16[0];
    pixels[step+1] = 
      0xff000000                | 
      rFinal.m128i_u16[1] << 16 | 
      gFinal.m128i_u16[1] << 8  | 
      bFinal.m128i_u16[1];
    pixels[step+2] = 
      0xff000000                | 
      rFinal.m128i_u16[2] << 16 | 
      gFinal.m128i_u16[2] << 8  | 
      bFinal.m128i_u16[2];
    pixels[step+3] = 
      0xff000000                | 
      rFinal.m128i_u16[3] << 16 | 
      gFinal.m128i_u16[3] << 8  | 
      bFinal.m128i_u16[3];
    pixels[step+4] = 
      0xff000000                | 
      rFinal.m128i_u16[4] << 16 | 
      gFinal.m128i_u16[4] << 8  | 
      bFinal.m128i_u16[4];
    pixels[step+5] = 
      0xff000000                | 
      rFinal.m128i_u16[5] << 16 | 
      gFinal.m128i_u16[5] << 8  | 
      bFinal.m128i_u16[5];
    pixels[step+6] = 
      0xff000000                | 
      rFinal.m128i_u16[6] << 16 | 
      gFinal.m128i_u16[6] << 8  | 
      bFinal.m128i_u16[6];
    pixels[step+7] = 
      0xff000000                | 
      rFinal.m128i_u16[7] << 16 | 
      gFinal.m128i_u16[7] << 8  | 
      bFinal.m128i_u16[7];

  }
  // last 128 bit element, not all 16 bit element are filled with valid value
  else if(m_remainder!=0) 
  {
    for(int smallIndex=0; smallIndex<m_remainder; ++smallIndex)
    {
      pixels[i+smallIndex] = 
        0xff000000                         | 
        rFinal.m128i_u16[smallIndex] << 16 | 
        gFinal.m128i_u16[smallIndex] << 8  | 
        bFinal.m128i_u16[smallIndex];
    }
  }
  // last 128 bit element, all 16 bit element are filled with valid value
  // Because the remainder is zero
  else
  {
    pixels[step+0] = 
      0xff000000                | 
      rFinal.m128i_u16[0] << 16 | 
      gFinal.m128i_u16[0] << 8  | 
      bFinal.m128i_u16[0];
    pixels[step+1] = 
      0xff000000                | 
      rFinal.m128i_u16[1] << 16 | 
      gFinal.m128i_u16[1] << 8  | 
      bFinal.m128i_u16[1];
    pixels[step+2] = 
      0xff000000                | 
      rFinal.m128i_u16[2] << 16 | 
      gFinal.m128i_u16[2] << 8  | 
      bFinal.m128i_u16[2];
    pixels[step+3] = 
      0xff000000                | 
      rFinal.m128i_u16[3] << 16 | 
      gFinal.m128i_u16[3] << 8  | 
      bFinal.m128i_u16[3];
    pixels[step+4] = 
      0xff000000                | 
      rFinal.m128i_u16[4] << 16 | 
      gFinal.m128i_u16[4] << 8  | 
      bFinal.m128i_u16[4];
    pixels[step+5] = 
      0xff000000                | 
      rFinal.m128i_u16[5] << 16 | 
      gFinal.m128i_u16[5] << 8  | 
      bFinal.m128i_u16[5];
    pixels[step+6] = 
      0xff000000                | 
      rFinal.m128i_u16[6] << 16 | 
      gFinal.m128i_u16[6] << 8  | 
      bFinal.m128i_u16[6];
    pixels[step+7] = 
      0xff000000                | 
      rFinal.m128i_u16[7] << 16 | 
      gFinal.m128i_u16[7] << 8  | 
      bFinal.m128i_u16[7];
  }
}

基准测试结果

Application screenshot

下面列出了每个基准测试中使用的公式

未优化代码使用的公式

Unoptimised

优化代码使用的公式

Optimised

带有移位优化的代码使用的公式

Optimised With Shift

SSE2 优化代码使用的公式

Optimised With Shift

这是在 2.93 Ghz 的 Intel i7 870 上进行 1000 次 alpha 混合的计时。性能特征与上一篇文章中发布的不同,因为基准测试是在不同的 PC 上进行的。之前使用的 PC 已“退役”,不再服务于这个世界。

Benchmark results

VS2010 Release mode(optimization disabled)(Lower is better)
Author code
Unoptimized Code          : 4679 milliseconds
Optimized Code            : 2108 milliseconds
Optimized Code with Shift : 1931 milliseconds
SSE2 Optimized Code       :  862 milliseconds (works for all image width)
Yves Daoust's code        
C version                 : 1923 milliseconds
MMX 1                     : 1888 milliseconds
MMX 2                     : 1355 milliseconds (image width must be even)
SSE                       :  762 milliseconds (image width must be multiple of 4)
VS2010 Release mode(optimization enabled)(Lower is better)
Author code
Unoptimized Code          : 3324 milliseconds
Optimized Code            : 1378 milliseconds
Optimized Code with Shift :  802 milliseconds
SSE2 Optimized Code       :  406 milliseconds (works for all image width)
Yves Daoust's code        
C version                 :  604 milliseconds
MMX 1                     :  213 milliseconds
MMX 2                     :  167 milliseconds (image width must be even)
SSE                       :  104 milliseconds (image width must be multiple of 4)

下面是我们与未优化版本相比获得的速度提升倍数。但将 SSE2 的速度提升与第二名(带有移位优化的代码)进行比较,速度提升仅为 2.2 倍,而不是我们预期的 8 倍。那么 SSE2 加速所需的内存消耗是多少?每个颜色通道 2 字节,而不是 1 字节。原始实现需要 4 字节来存储一个颜色像素 (ARGB)。(使用 4 字节是因为处理器加载和存储 32 位数据比 24 位数据更快,这可能需要将其移位到正确的 32 位边界才能加载到 32 位寄存器中)。SSE2 版本需要 6 字节来存储一个颜色像素 (RGB)(因为每个颜色通道使用两个字节)。单独的 alpha 通道不存储,因为它们不被使用。SSE2 版本需要多 50% 的内存来执行其魔术。

(Higher is better)
Unoptimized Code          : 1x
Optimized Code            : 2.2x
Optimized Code with Shift : 2.4x
SSE2 Optimized Code       : 5.4x

结论

我们使用 SSE2 将之前的最快性能提升了两倍。速度提升并不显著。这可能不值得付出大量的额外努力和编写难以理解的代码。为了实现更好的可读性,我们可以使用dvec.h头文件中列出的向量类。这个向量类支持 -, +, *, /, 等运算符重载。欢迎提出任何进一步提高性能的建议。我们可以通过结合使用 OpenMP并行模式库 (PPL) 和 SSE2 来提高性能。但这将是另一天的文章。任何有兴趣了解关于使用 OpenCL 进行 alpha 混合的未来文章的读者,请在下方给我留言。OpenCL 是一种异构技术;OpenCL 内核可以在 CPU 或 GPU 上运行。

参考文献

历史

  • 2011 年 11 月 17 日:添加了 Yves Daoust 的 MMX 和 SSE 代码及基准测试结果
  • 2011 年 11 月 14 日:首次发布
© . All rights reserved.