0xDEADBEEF

RSS odkazy
««« »»»

Jak přesunout 1 až 16 bajtů do SIMD registru

12. 3. 2021 #SIMD #x86

Zase jsem zašel příliš hluboko. Zkouším, jak rychlá může být hash-mapa pro jedno specifické použití a měl jsem už dávno přestat. Ale na tom pocitu, když mikro-benchmarku spadne další nanosekunda, je něco návykového. I když ve finále je zajímavější, co se člověk naučí cestou, než finální výsledek.

Problém je jednoduchý: Jak co nejrychleji dostat 1-16 bajtů z paměti do XMM registru a nevyvolat u toho segfault. Na rychlosti záleží. Mluvíme tu o jednotkách nanosekund.

Hlavní komplikace spočívá v tom, že velikost čteného pole není konstantní a bajt za jeho koncem už může ležet v nenamapované stránce paměti.

Nejjednodušší varianta memcpy + __builtin_ia32_loaddqu je pomalá. Jednak volá funkci z libc, což v tomto kontextu je samo o sobě příliš drahé, ale její kód je plný skoků, smyček (jinak by nezvládal proměnnou délku) a přesunů dat z paměti do paměti. Jedno vadí z důvodu branch misspredict, druhé kvůli latenci L1 cache, která bývá mezi 3 a 5 takty a i to je moc. (Říkal jsem že jde o nanosekundy, nebo ne?)

jazyce D to vypadá takhle:

import gcc.builtins;

ubyte16 maskedLoadCopy(char* arr, ulong len) {
  char[16] tmp = 0;
  tmp[0 .. len] = arr[0 .. len];
  return __builtin_ia32_loaddqu(tmp.ptr);
}

Na zásobníku leží dočasné pole vyplněné nulami, do něj se zkopíruje len bajtů a pak se to šoupne do xmm registru.

Když to rozeberu, je třeba splnit 3 podmínky: Zkopírovat do vektoru jen potřebné bajty, nečíst z nenamapované paměti a udělat to co nejsvižněji. Proto jsou podmínky, smyčky a použití paměti pro dočasné výsledky instrukce non grata. Fragment výše má tohle všechno a proto trvá asi 7 ns na mém stroji (i5-4570).

Nadešel čas na špinavé triky.

První podmínku (dostat do xmm jen to, co je třeba) může vyřešit prostý manévr, kdy načteme celý 16 B vektor a vymaskujeme, co je potřeba.

ubyte16 maskedLoad1(char* arr, ulong len) {
  ubyte16 mask = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
  ubyte16 val = __builtin_ia32_loaddqu(arr);
  ubyte16 lenVec = cast(ubyte) len;
  ubyte16 cmp = __builtin_ia32_pcmpgtb128(lenVec, mask);
  return val & cmp;
}

S časem asi 2 nanosekundy jde o suverénně nejrychlejší variantu. Má ale jedno ale. movdqu (přes __builtin_ia32_loaddqu) čte i nezarovnaná data, v tom není problém. Ale konec, který vyjede mimo pole, může zabrousit do nenamapované stránky paměti a segfaultnout.

Mohli bychom si vypůjčit některé techniky odsud, ale tam řeší memset, který nastavuje paměť a cesta přes RAM je, jak padlo výše, pomalá.

Chce to instrukce pro maskovaný load, kde neaktivní elementy nevyvolají fault, když zabrousí do nenamapované paměti. x86 nabízí vmaskmovpsvmaskmovd. Splňují požadavek, jen mají granularitu 4B. Tady je potřeba bajtová granularita.

Nicméně přesto je můžeme použít pro načtení celých 1-4 intů s tím, že poslední slovo se přečte skalárně. Konec tohoto slova bude zarovnaný stejně jako konec pole a nemůže přetéct do další stránky. Jeho obsah pak shiftneme, aby bajty seděly a pak skalární vektor vložíme na příslušné místo ve vektoru.

ubyte16 maskedLoad2(char* arr, ulong len) {
  int4 positions = [0, 1, 2, 3];
  int4 lengths   = cast(uint) (len / 4);
  int4 mask = __builtin_ia32_pcmpgtd128(lengths, positions);
  int4 vec = cast(int4)__builtin_ia32_maskloadps(cast(int4*)arr, mask);

  ulong base = (len/4)*4;
  auto end = arr+len;
  uint lastWord = *(cast(uint*)(end-4));
  lastWord = ((len & 3) == 0) ? 0 : lastWord;
  lastWord >>= ((4-len-base)*8);
  int4 lastVec = lastWord;

  int4 ll = cast(int) (len/4);
  int4 lastMask = __builtin_ia32_pcmpeqd128(ll, positions);

  return cast(ubyte16) (vec | (lastVec & lastMask));
}

Jde o relativně hodně instrukcí, ale to příliš nevadí. Všechno zůstává v registrech a nejsou tam komplikované skoky. Není nutné pochopit, co se v programu děje, nejspíš jsou v něm stejně chyby a používání SIMD je forma mentálního mučení. Je to o něco rychlejší než naivní varieta, asi 3.5 ns.

Jeden maličký problém spočívá v korektnosti. Vypořádá se s přesahem dál, ale ne zpět. Chci načíst 1 B, první ve stránce paměti, skalární load načte 4B na adresách [-3, -2, -1, 0 ], první tři zasahují do předešlé stránky a můžou faultnout. Dalo by se to jistě obejít ještě komplikovanější aritmetikou, ale na tu nemám mozek.

Někdy se musí udělat krok stranou a přidat další špinavý trik. Paměť je vždy mapovaná po stránkách, které mají minimálně 4 kB. Když jsem víc jak 16 bajtů před hranicí stránky, můžu použít rychlou verzi, která je v té situaci bezpečná. Když se nacházím v riskantní zóně u konce stránky, použiju pomalou, která si poradí s přesahem na konci.

Pravda, přidá to jeden skok, ale ten je předvídatelný. Ve většině případů se použije rychlá verze, pomalá jen okrajově, něco jako v 1/256 případech. Branch predictor rychle pochytí, že se má hnát cestou rychlé větve.

ubyte16 maskedLoad(char* arr, ulong len) {
  enum pageSize = 1 << 12;
  auto p = cast(size_t) arr;
  if ((pageSize - (p % pageSize)) > len) {
    return maskedLoad1(arr, len);
  } else {
    return maskedLoad2(arr, len);
  }
}

Ve finálním hybridu pak příliš nezáleží, co bude pomalá verze. Je volána jen zřídka. Bezpečná a jednoduchá nouzovka s memcpy bude perfektně stačit.


píše k47 (@kaja47, k47)