分析SIMD代码

8

更新 - 请查看下方

尽量简短地说明。如有需要,我很乐意添加更多细节。

我有一些用于向量归一化的SSE代码。 我正在使用QueryPerformanceCounter(包装在帮助结构中)来测量性能。

如果我像这样测量:

for( int j = 0; j < NUM_VECTORS; ++j )
{
  Timer t(norm_sse);
  NormaliseSSE( vectors_sse+j);
}

我得到的结果通常比使用4个双精度表示向量进行标准归一化要慢(在相同的配置下进行测试)。
for( int j = 0; j < NUM_VECTORS; ++j )
{
  Timer t(norm_dbl);
  NormaliseDBL( vectors_dbl+j);
}

然而,仅仅像这样计时整个循环是不够准确的。
{
  Timer t(norm_sse);
  for( int j = 0; j < NUM_VECTORS; ++j ){
    NormaliseSSE( vectors_sse+j );
  }    
}

展示了SSE代码比普通版本快一个数量级,但实际上并没有对双精度版本的测量产生影响。我进行了一些实验和搜索,但似乎找不到一个合理的答案。
例如,我知道将结果转换为float时可能会有惩罚,但这里没有发生任何情况。
有人可以提供任何见解吗?是什么导致在每个normalise之间调用QueryPerformanceCounter使SIMD代码变得如此缓慢?
谢谢阅读 :)
以下是更多细节:
- 两种normalise方法都是内联的(在反汇编中验证) - 在发布模式下运行 - 32位编译
简单向量结构
_declspec(align(16)) struct FVECTOR{
    typedef float REAL;
  union{
    struct { REAL x, y, z, w; };
    __m128 Vec;
  };
};

规范化SSE代码:

  __m128 Vec = _v->Vec;
  __m128 sqr = _mm_mul_ps( Vec, Vec ); // Vec * Vec
  __m128 yxwz = _mm_shuffle_ps( sqr, sqr , 0x4e ); 
  __m128 addOne = _mm_add_ps( sqr, yxwz ); 
  __m128 swapPairs = _mm_shuffle_ps( addOne, addOne , 0x11 );
  __m128 addTwo = _mm_add_ps( addOne, swapPairs ); 
  __m128 invSqrOne = _mm_rsqrt_ps( addTwo ); 
  _v->Vec = _mm_mul_ps( invSqrOne, Vec );   

规范化double类型的代码

double len_recip = 1./sqrt(v->x*v->x + v->y*v->y + v->z*v->z);
v->x *= len_recip;
v->y *= len_recip;
v->z *= len_recip;

辅助结构体

struct Timer{
  Timer( LARGE_INTEGER & a_Storage ): Storage( a_Storage ){
      QueryPerformanceCounter( &PStart );
  }

  ~Timer(){
    LARGE_INTEGER PEnd;
    QueryPerformanceCounter( &PEnd );
    Storage.QuadPart += ( PEnd.QuadPart - PStart.QuadPart );
  }

  LARGE_INTEGER& Storage;
  LARGE_INTEGER PStart;
};

更新 感谢John的评论,我想我已经确认了是QueryPerformanceCounter对我的simd代码产生了负面影响。

我添加了一个新的计时器结构,直接使用RDTSC进行测量,并且看起来给出了我期望的一致结果。结果仍然比整个循环的计时慢得多,而不是每次迭代分别计时,但我认为这是因为获取RDTSC需要刷新指令流水线(有关更多信息,请查看http://www.strchr.com/performance_measurements_with_rdtsc)。

struct PreciseTimer{

    PreciseTimer( LARGE_INTEGER& a_Storage ) : Storage(a_Storage){
        StartVal.QuadPart = GetRDTSC();
    }

    ~PreciseTimer(){
        Storage.QuadPart += ( GetRDTSC() - StartVal.QuadPart );
    }

    unsigned __int64 inline GetRDTSC() {
        unsigned int lo, hi;
        __asm {
             ; Flush the pipeline
             xor eax, eax
             CPUID
             ; Get RDTSC counter in edx:eax
             RDTSC
             mov DWORD PTR [hi], edx
             mov DWORD PTR [lo], eax
        }

        return (unsigned __int64)(hi << 32 | lo);

    }

    LARGE_INTEGER StartVal;
    LARGE_INTEGER& Storage;
};
2个回答

13
当只有SSE代码在运行循环时,处理器应该能够保持其流水线处于满负荷状态,并每单位时间执行大量的SIMD指令。但是当你在循环中添加计时器代码时,现在每个易于优化的操作之间就会出现一堆非SIMD指令,这些指令可能不那么可预测。很可能QueryPerformanceCounter(QPC)调用要么足够昂贵,使得数据操作部分微不足道;要么它所执行代码的性质破坏了处理器保持最大速率执行指令的能力(可能由于缓存驱逐或不良预测的分支)。
您可以尝试注释掉Timer类中实际的QPC调用,看看它的性能如何 - 这可以帮助您发现问题是在构造和销毁Timer对象还是QPC调用方面。同样地,尝试直接在循环中调用QPC而不是制作一个计时器,然后比较一下两者之间的性能差异。

嗨John,谢谢你的回答。我尝试了你的建议,正如预期的那样,QPC调用绝对是导致性能大幅下降的原因。我仍然不完全清楚为什么它会对性能产生如此巨大的影响。 - JBeFat
我又进行了另一次测试——用另一个函数调用(肯定不是内联的)替换了QPC的调用,结果对其影响要比有QPC少得多。因此,显然调用QueryPerformanceCounter有些特别的地方。 - JBeFat
2
由于各种原因,QPC通常不使用RDTSC实现。因此,QPC的开销相当高,并且声称“QPC不执行浮点运算”是值得怀疑的。 - rwong

2

QPC是一个内核函数,调用它会导致上下文切换,这本质上比任何等效的用户模式函数调用更昂贵和破坏性,并且肯定会摧毁处理器以正常速度处理的能力。除此之外,请记住QPC/QPF是抽象的,需要它们自己的处理-这可能涉及使用SSE本身。


嗨,谢谢你的回答。你们说得对,这绝对是QPC正在做的事情。我真的很想知道为什么它似乎比标准SISD代码更影响SIMD指令。也许这与浮点和SIMD寄存器之间的交换有关吗? - JBeFat

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