Chiharu の日記

絵描き C/C++ プログラマーの日記です。

Parallel force - パラレル・フォース 〜XMM レジスタによるメモリアクセス

Paralell force - パラレル・フォースのレンダラの画素アクセスは下記の方針で対応しています。

  • 可能な限り XMM レジスタを使用する
  • 可能な限り movdqa 命令でロードおよびストアする

前者はともかくとして、後者がなかなか曲者なのです。
movdqa の使用条件は『ストア先またはロード元のアドレスが 16 バイト アライメントされていること』です。レンダラを作ったことがある人は分かると思うのですが、たとえばイメージのアフィン変換などで転送元と転送先の双方の画素を 16 バイト アライメントさせるというのはなかなか至難の業です。16 バイト アライメントされないアドレスへのアクセスには、ちょっと低速な movdqu という命令を使用することになります。
そこで、Parallel force - パラレル・フォースでは、転送先の画素を XMM レジスタ使用してストアする場合についてのみ、16 バイト アライメントを保証するようにしています。まぁ、処理の都合上、ロードとストアの双方をアライメントするのが大変だったので、ストアだけでもアライメントしようとした次第です。
これについて、効果を計測していなかったので、今回計測してみました。Core i7 で、L2 キャッシュに収まるサイズの単純なメモリ コピー ルーチンです。memcpy とあわせて 5 ケース計測しました。
まずはロードとストアがアライメントされるケース。

void xmmcpy_da_sa(void* oDst, const void* iSrc, const std::size_t iLength)
{
 assert(oDst != nullptr);
 assert(iSrc != nullptr);
 assert((iLength & 15) == 0);
 
 auto aDst = reinterpret_cast<__m128i*>(
  (reinterpret_cast<std::size_t>(oDst) + 15) & ~15);
 auto aDstE = aDst + iLength / sizeof(__m128i);
 auto aDst4 = aDst + (aDstE - aDst) / 4;
 auto aSrc = reinterpret_cast<const __m128i*>(
  (reinterpret_cast<std::size_t>(iSrc) + 15) & ~15);
 
 while (aDst != aDst4) {
  const auto aSrc0 = _mm_load_si128(aSrc + 0);
  const auto aSrc1 = _mm_load_si128(aSrc + 1);
  const auto aSrc2 = _mm_load_si128(aSrc + 2);
  const auto aSrc3 = _mm_load_si128(aSrc + 3);
  _mm_store_si128(aDst + 0, aSrc0);
  _mm_store_si128(aDst + 1, aSrc1);
  _mm_store_si128(aDst + 2, aSrc2);
  _mm_store_si128(aDst + 3, aSrc3);
  aDst += 4;
  aSrc += 4;
 }
 while (aDst != aDstE) {
  _mm_store_si128(aDst++, _mm_load_si128(aSrc++));
 }
}

次いで、ストアのみがアライメントされるケース。

void xmmcpy_da_s(void* oDst, const void* iSrc, const std::size_t iLength)
{
 assert(oDst != nullptr);
 assert(iSrc != nullptr);
 assert((iLength & 15) == 0);
 
 auto aDst = reinterpret_cast<__m128i*>(
  (reinterpret_cast<std::size_t>(oDst) + 15) & ~15);
 auto aDstE = aDst + iLength / sizeof(__m128i);
 auto aDst4 = aDst + (aDstE - aDst) / 4;
 auto aSrc = reinterpret_cast<const __m128i*>(
  ((reinterpret_cast<std::size_t>(iSrc) + 15) & ~15) + 1); // ずらす
 
 while (aDst != aDst4) {
  const auto aSrc0 = _mm_loadu_si128(aSrc + 0);
  const auto aSrc1 = _mm_loadu_si128(aSrc + 1);
  const auto aSrc2 = _mm_loadu_si128(aSrc + 2);
  const auto aSrc3 = _mm_loadu_si128(aSrc + 3);
  _mm_store_si128(aDst + 0, aSrc0);
  _mm_store_si128(aDst + 1, aSrc1);
  _mm_store_si128(aDst + 2, aSrc2);
  _mm_store_si128(aDst + 3, aSrc3);
  aDst += 4;
  aSrc += 4;
 }
 while (aDst != aDstE) {
  _mm_store_si128(aDst++, _mm_loadu_si128(aSrc++));
 }
}

また、ロードのみがアライメントされるケース。

void xmmcpy_d_sa(void* oDst, const void* iSrc, const std::size_t iLength)
{
 assert(oDst != nullptr);
 assert(iSrc != nullptr);
 assert((iLength & 15) == 0);
 
 auto aDst = reinterpret_cast<__m128i*>(
  ((reinterpret_cast<std::size_t>(oDst) + 15) & ~15) + 1); // ずらす
 auto aDstE = aDst + iLength / sizeof(__m128i);
 auto aDst4 = aDst + (aDstE - aDst) / 4;
 auto aSrc = reinterpret_cast<const __m128i*>(
  (reinterpret_cast<std::size_t>(iSrc) + 15) & ~15);
 
 while (aDst != aDst4) {
  const auto aSrc0 = _mm_load_si128(aSrc + 0);
  const auto aSrc1 = _mm_load_si128(aSrc + 1);
  const auto aSrc2 = _mm_load_si128(aSrc + 2);
  const auto aSrc3 = _mm_load_si128(aSrc + 3);
  _mm_storeu_si128(aDst + 0, aSrc0);
  _mm_storeu_si128(aDst + 1, aSrc1);
  _mm_storeu_si128(aDst + 2, aSrc2);
  _mm_storeu_si128(aDst + 3, aSrc3);
  aDst += 4;
  aSrc += 4;
 }
 while (aDst != aDstE) {
  _mm_storeu_si128(aDst++, _mm_load_si128(aSrc++));
 }
}

最後に、ロードもストアもアライメントされないケース。

void xmmcpy_d_s(void* oDst, const void* iSrc, const std::size_t iLength)
{
 assert(oDst != nullptr);
 assert(iSrc != nullptr);
 assert((iLength & 15) == 0);
 
 auto aDst = reinterpret_cast<__m128i*>(
  ((reinterpret_cast<std::size_t>(oDst) + 15) & ~15) + 1); // ずらす
 auto aDstE = aDst + iLength / sizeof(__m128i);
 auto aDst4 = aDst + (aDstE - aDst) / 4;
 auto aSrc = reinterpret_cast<const __m128i*>(
  ((reinterpret_cast<std::size_t>(iSrc) + 15) & ~15) + 1); // ずらす
 
 while (aDst != aDst4) {
  const auto aSrc0 = _mm_loadu_si128(aSrc + 0);
  const auto aSrc1 = _mm_loadu_si128(aSrc + 1);
  const auto aSrc2 = _mm_loadu_si128(aSrc + 2);
  const auto aSrc3 = _mm_loadu_si128(aSrc + 3);
  _mm_storeu_si128(aDst + 0, aSrc0);
  _mm_storeu_si128(aDst + 1, aSrc1);
  _mm_storeu_si128(aDst + 2, aSrc2);
  _mm_storeu_si128(aDst + 3, aSrc3);
  aDst += 4;
  aSrc += 4;
 }
 while (aDst != aDstE) {
  _mm_storeu_si128(aDst++, _mm_loadu_si128(aSrc++));
 }
}

これらを下記のように計測してみました。

#include <emmintrin.h>
#include <cstdint>
#include <cstring>
#include <cassert>
#include <ctime>
#include <iostream>

const std::size_t gLength = 64 * 1024;
std::uint8_t gDst[gLength + 16 + 1];
std::uint8_t gSrc[gLength + 16 + 1];

template <class Func>
void calc(const char* iPrefix, Func iFunc)
{
 const auto aPrev = std::clock();
 for (std::uint_fast32_t aCt = 0; aCt < 1000000 * 2; aCt++) {
  iFunc(gDst, gSrc, gLength);
 }
 const auto aCurr = std::clock();
 std::cout << iPrefix <<
  (static_cast<double>(aCurr - aPrev) / CLOCKS_PER_SEC) << std::endl;
}

int main()
{
 calc("prepare   : ", std::memcpy);
 calc("d/s align : ", xmmcpy_da_sa);
 calc("dst align : ", xmmcpy_da_s);
 calc("src align : ", xmmcpy_d_sa);
 calc("not align : ", xmmcpy_d_s);
 calc("normal cpy: ", std::memcpy);
 
 return 0;
}

結果はこちら。

prepare   : 13.572
d/s align : 6.723
dst align : 6.911
src align : 6.724
not align : 7.051
normal cpy: 13.541

std::memcpy が遅いのはともかくとして…。XMM レジスタを使用したケースでは、なんと。ロードとストアのいずれかをアライメントするのであればロードの方が有利という結果に。まぁ、Parallel force - パラレル・フォースで採用した、ストアだけをアライメントするケースでも、非アライメントのケースよりは速いので良しとします。計測してみると、いろいろと傾向が分かるものですね。