Was fehlt/suboptimal in dieser Memcpy-Implementierung?

Lesezeit: 3 Minuten

Benutzer-Avatar
einpoklum

Ich interessiere mich für das Schreiben von a memcpy() als pädagogische Übung. Ich werde keine ganze Abhandlung darüber schreiben, was ich tat und woran ich nicht dachte, aber hier ist es
die Implementierung eines Typen:

__forceinline   // Since Size is usually known,
                // most useless code will be optimized out
                // if the function is inlined.

void* myMemcpy(char* Dst, const char* Src, size_t Size)
{
        void* start = Dst;
        for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
        {
                __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
                _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
        }

#define CPY_1B *((uint8_t * &)Dst)++ = *((const uint8_t * &)Src)++
#define CPY_2B *((uint16_t* &)Dst)++ = *((const uint16_t* &)Src)++
#define CPY_4B *((uint32_t* &)Dst)++ = *((const uint32_t* &)Src)++
#if defined _M_X64 || defined _M_IA64 || defined __amd64
#define CPY_8B *((uint64_t* &)Dst)++ = *((const uint64_t* &)Src)++
#else
#define CPY_8B _mm_storel_epi64((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const uint64_t* &)Src, ++(uint64_t* &)Dst
#endif
#define CPY16B _mm_storeu_si128((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const __m128i* &)Src, ++(__m128i* &)Dst

    switch (Size) {
    case 0x00:                                                      break;
    case 0x01:      CPY_1B;                                         break;
    case 0x02:              CPY_2B;                                 break;
    case 0x03:      CPY_1B; CPY_2B;                                 break;
    case 0x04:                      CPY_4B;                         break;
    case 0x05:      CPY_1B;         CPY_4B;                         break;
    case 0x06:              CPY_2B; CPY_4B;                         break;
    case 0x07:      CPY_1B; CPY_2B; CPY_4B;                         break;
    case 0x08:                              CPY_8B;                 break;
    case 0x09:      CPY_1B;                 CPY_8B;                 break;
    case 0x0A:              CPY_2B;         CPY_8B;                 break;
    case 0x0B:      CPY_1B; CPY_2B;         CPY_8B;                 break;
    case 0x0C:                      CPY_4B; CPY_8B;                 break;
    case 0x0D:      CPY_1B;         CPY_4B; CPY_8B;                 break;
    case 0x0E:              CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x0F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x10:                                      CPY16B;         break;
    case 0x11:      CPY_1B;                         CPY16B;         break;
    case 0x12:              CPY_2B;                 CPY16B;         break;
    case 0x13:      CPY_1B; CPY_2B;                 CPY16B;         break;
    case 0x14:                      CPY_4B;         CPY16B;         break;
    case 0x15:      CPY_1B;         CPY_4B;         CPY16B;         break;
    case 0x16:              CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x17:      CPY_1B; CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x18:                              CPY_8B; CPY16B;         break;
    case 0x19:      CPY_1B;                 CPY_8B; CPY16B;         break;
    case 0x1A:              CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1B:      CPY_1B; CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1C:                      CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1D:      CPY_1B;         CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1E:              CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    }
#undef CPY_1B
#undef CPY_2B
#undef CPY_4B
#undef CPY_8B
#undef CPY16B
        return start;
}

Der Kommentar bedeutet übersetzt “Größe ist normalerweise bekannt, da der Compiler den Code inline am nutzlosesten optimieren kann”.

Ich würde diese Implementierung gerne verbessern, wenn möglich – aber vielleicht gibt es nicht viel zu verbessern. Ich sehe, dass es SSE/AVX für die größeren Speicherblöcke verwendet, dann anstelle einer Schleife über die letzten <32 Bytes das Äquivalent des manuellen Entrollens mit einigen Optimierungen. Hier also meine Fragen:

  • Warum die Schleife für die letzten paar Bytes aufrollen, aber die erste (und jetzt einzelne) Schleife nicht teilweise aufrollen?
  • Was ist mit Ausrichtungsproblemen? Sind sie nicht wichtig? Sollte ich die ersten paar Bytes bis zu einem gewissen Ausrichtungsquantum anders handhaben und dann die 256-Bit-Operationen an ausgerichteten Bytesequenzen ausführen? Und wenn ja, wie bestimme ich das passende Ausrichtungsquantum?
  • Was ist das wichtigste fehlende Feature in dieser Implementierung (falls vorhanden)?

Bisher in den Antworten erwähnte Merkmale/Prinzipien

  • Du solltest __restrict__ Ihre Parameter. (@chux)
  • Die Speicherbandbreite ist ein limitierender Faktor; Messen Sie Ihre Implementierung daran.(@Zboson)
  • Bei kleinen Arrays können Sie davon ausgehen, dass Sie sich der Speicherbandbreite annähern; für größere Arrays – nicht so viel. (@Zboson)
  • Mehrere Threads (möglicherweise | sind) sind erforderlich, um die Speicherbandbreite zu sättigen. (@Zboson)
  • Es ist wahrscheinlich ratsam, für große und kleine Kopiengrößen unterschiedlich zu optimieren. (@Zboson)
  • (Ausrichtung ist wichtig? Nicht explizit angesprochen!)
  • Der Compiler sollte expliziter auf “offensichtliche Tatsachen” aufmerksam gemacht werden, die er zur Optimierung nutzen kann (zB die Tatsache, dass Size < 32 nach der ersten Schleife). (@chux)
  • Es gibt Argumente dafür, Ihre SSE/AVX-Aufrufe abzubrechen (@BenJackson, hier) und Argumente dagegen (@PaulR)
  • Nicht-temporäre Übertragungen (mit denen Sie der CPU mitteilen, dass Sie den Zielspeicherort nicht zwischenspeichern müssen) sollten zum Kopieren größerer Puffer nützlich sein. (@Zboson)

  • @MichaelDorgan: Ich dachte auch, dass er/sie etwas Arkanes und Magisches tut, aber bei näherer Betrachtung ist es ziemlich einfach. Es sah für mich aus wie ein Pfeifenorgel-Arrangement …

    – einpoklum

    7. Oktober 2014 um 22:19 Uhr

  • Das ausdrucksstarke Arrangement gefällt mir sehr gut switch Geäst. Sieht ganz nett aus. 10/10 würde zugesagt 🙂

    – dom0

    7. Oktober 2014 um 22:25 Uhr

  • “Wichtiges fehlendes Feature in dieser Implementierung” ist eine falsche Signatur. Erwartete Übereinstimmung mit: void *memcpy(void * restrict s1, const void * restrict s2, size_t n);

    – chux – Wiedereinsetzung von Monica

    7. Oktober 2014 um 22:31 Uhr

  • Selbst mit einem optimierenden Compiler kann man das nicht erkennen switch (Size) mit seinen 32 Fällen passt Size Angebot 0<=Size<32. Vielleicht switch (Size&31)? Vermeiden Sie das intern Generierte if size > 31.

    – chux – Wiedereinsetzung von Monica

    7. Oktober 2014 um 22:34 Uhr


  • Beachten Sie, dass “restrict” nur für die Teile Ihres Codes ohne Intrinsic hilft. Einschränken mit Intrinsic ist nutzlos.

    – Z-Boson

    8. Oktober 2014 um 18:00 Uhr


  • Ein paar Anmerkungen/Fragen: 1. „Größen größer als die Hälfte eine Cache-Line in der größten Ebene”, richtig? 2. Haben Sie Ihren Standpunkt zu Optimierungen erster und zweiter Ordnung verstanden, aber angenommen, ich wähle Ihre unroll8-Variante; ist die Ausrichtung dort wichtig? Ich nehme an, Ihr Benchmark verwendet ausgerichtete Puffer. 3. Tut das omp_parallel Hilfe wegen des Vorhandenseins von 2 Load/Store-Einheiten? Wird es zwei Threads erzeugen? 4. Ist OpenMP hier nicht wie Schummeln?

    – einpoklum

    8. Oktober 2014 um 14:02 Uhr

  • @einpoklum, ich meine die Hälfte der Größe des langsamsten Caches. Auf einem System mit 8 MB L3-Cache wäre die Hälfte der Größe 4 MB. Ich kann nicht sagen, dass ich diese Faustregel aus Erfahrung kenne. Es ist etwas, was ich gelesen habe. Aber es steht außer Frage, dass nicht-temporäre Speicher einen signifikanten Unterschied machen, wenn die Größe viel größer ist als der langsamste Cache (z. B. für 1 GB).

    – Z-Boson

    8. Oktober 2014 um 17:41 Uhr

  • @einpoklum, für die Ausrichtung solltest du es versuchen und sehen. Ich habe nur die ausgerichteten vs. nicht ausgerichteten Anweisungen mit ausgerichtetem Speicher verglichen und mit den ausgerichteten Anweisungen bessere Ergebnisse erzielt. Meine Puffer sind auf 4096 Bytes ausgerichtet. Denken Sie daran, dass ich versuche, dem theoretischen Maximum am nächsten zu kommen. Sobald ich dies erreicht habe, kann ich für weniger Ideenfälle optimieren, aber ich bezweifle, dass ich dies tun werde, da dies wie Sie nur zu Bildungszwecken dient.

    – Z-Boson

    8. Oktober 2014 um 17:43 Uhr

  • @einpoklum, ich habe die Anzahl der Threads auf die Anzahl der physischen Kerne gesetzt und dann die Threads gebunden. Um zu verstehen, warum, lesen Sie die Frage, Antworten und Kommentare unter stackoverflow.com/questions/25179738/…. Aber ich denke nicht, dass es Betrug ist, mehrere Threads zu verwenden. Dies könnte wirklich verwendet werden, um die Effizienz (Geschwindigkeit) von a zu verbessern memcpy für große Arrays (insbesondere für ein NUMA-System). Bei kleinen Arrays dominiert jedoch der OpenMP-Overhead und das Ergebnis wäre tatsächlich schlechter.

    – Z-Boson

    8. Oktober 2014 um 17:47 Uhr

  • Ja, rep movsb ist deutlich schneller als movntdqa beim Streamen in den Speicher auf Ivybridge und Haswell (aber beachten Sie, dass es vor Ivybridge langsam ist!)

    – Stefan Kanon

    8. Oktober 2014 um 19:24 Uhr

  • @MaximMasiutin – Ihre “Sprungkette” ist wahrscheinlich schlimmer als der indirekte Sprungansatz. Grundsätzlich muss man sich das anschauen Vorhersagbarkeit jeder Sequenz. Im Allgemeinen wird Ihre Sequenz unvorhersehbar sein, wenn die Sequenz unvorhersehbar ist, und ansonsten OK – genau wie der indirekte Sprung. Eine falsch vorhergesagte Verzweigung ist ungefähr genauso schlimm, egal ob sie indirekt ist oder nicht, daher gewinnen Sie normalerweise nicht in Bezug auf die Vorhersage, indem Sie sie in eine Reihe bedingter Verzweigungen ändern. Sie verlieren eine Menge: mehr Anweisungen, Kopieren von jeweils einem Byte, mehr verbrauchte Verzweigungsvorhersageressourcen usw.

    – BeeOnRope

    9. Mai 2017 um 4:16 Uhr

  • Ich fange gerade erst an, diese Antwort zu lesen … (1) +1 bereits für die Erwähnung des Problems der Codegröße. Aber – sind Sie sicher, dass der Compiler nichts dagegen tun wird? (2) Was meinen Sie mit „Speicherkonfiguration“? ob wir passende Module haben? Oder meinen Sie die genauen Timing-Zahlen? Wie würde das helfen? Was die Architektur betrifft – fragen Sie nur wegen der Verfügbarkeit von AVX, AVX- 2, AVX-512 oder aus anderen Gründen?

    – einpoklum

    9. Mai 2017 um 16:16 Uhr

  • (3) Über die Verzweigungsvorhersage – tatsächlich, wenn Sie etwas mit fester Länge kopieren – und kurze Kopien haben höchstwahrscheinlich eine feste Länge – sollte (?) Der Compiler die Verzweigung einfach ganz löschen, wenn sie inline ist. Für lange, zur Kompilierzeit unbekannte Kopien – obwohl sie theoretisch beliebig lang sein können, ist es nicht unangemessen anzunehmen, dass der übliche Fall eine durch 32 teilbare Länge ist, dh der Switch-Fall für 0x0. Ich weiß, das ist alles spekulativ, aber es ist keine weit hergeholte Spekulation …

    – einpoklum

    9. Mai 2017 um 16:21 Uhr

  • @einpoklum – der Compiler unternimmt nichts dagegen (außer dass er es einigermaßen gut kompiliert, aber es sind immer noch 32 separate Fälle) und ich behandle es in meiner Antwort, einschließlich eines Links zur generierten Assembly auf x86 für gcc und clang (siehe Fußnote 2).

    – BeeOnRope

    9. Mai 2017 um 16:22 Uhr


  • @einpoklum – neuere Intel-Chips können etwa 30 GB/s von einem Kern aus fahren, und viele Chips haben etwa so viel Bandbreite. Bei größeren Teilen mit Quad-Channel-Speicher braucht man sicher mehr als einen Kern. Grundsätzlich können Sie Ihr volles BW von einem Kern aus erreichen, Sie möchten auf jeden Fall NT-Speicher. Wenn Sie dies nicht können, stellen Sie möglicherweise fest, dass normale Speicher schneller sind (aber nur für einen Kern, sobald Sie zu mehr Kernen wechseln, wird NT schließlich gewinnen, da es Bandbreite spart).

    – BeeOnRope

    9. Mai 2017 um 16:32 Uhr

  • Die switch-Anweisung ist nicht eine ausgerollte Schleife – es sind nur 32 verschiedene Codepfade, je nachdem, wie viele Bytes noch kopiert werden müssen.

    – PaulR

    7. Oktober 2014 um 22:12 Uhr


  • Beachten Sie die unterschiedlichen Kopiengrößen (1, 2, 4, 8 Bytes) – dies ist keine Skalarschleife, die entrollt wurde, sondern nur 31 verschiedene kleine optimierte Kopien, um die verbleibenden Bytes zu bereinigen. Nennen Sie es wie Sie wollen, aber Sie verfehlen den Punkt – im allgemeinen Fall wird das schwere Heben von der AVX-Schleife erledigt.

    – PaulR

    7. Oktober 2014 um 22:20 Uhr

  • Die Schleife wird nicht ausgerollt, weil sie es nicht ist. Wenn es entrollt worden wäre, wären die Ergebnisse für kleine Array-Größen sehr unterschiedlich. Für Core2-Haswell erhalte ich bessere Ergebnisse, wenn ich mit dieser Schleife vier- oder achtmal abrolle. Auf Haswell erhält das Nicht-Abrollen weniger als 50 % der Spitze (ich bekomme etwa 47 %). Das achtmalige Abrollen auf Haswell ergibt etwa 98%.

    – Z-Boson

    8. Oktober 2014 um 11:54 Uhr


  • Ja, ich habe versucht, das zu Beginn meiner Antwort klar zu machen. Ein General memcpy Funktion muss für klein und groß unterschiedlich optimiert werden.

    – Z-Boson

    8. Oktober 2014 um 12:12 Uhr

  • @Zboson: Ich habe Ihre Antwort zu NT-Speichern kommentiert, aber ich werde hier erweitern: Die Semantik von x86 NT-Speichern ist für die Verwendung in fehlerhaft memcpy; Sie sind katastrophal langsam, wenn sie L1 erreichen, und sie erfordern ein Read-for-Ownership, wenn sie L3 verfehlen. Daher, vmovaps ist viel schneller für kleine Kopien, und rep movs ist viel schneller für große Kopien (auf Ivybridge und höher). Denken Sie auch daran, dass die NT-Läden einen Zaun benötigen, was kein großer Aufwand ist, aber es ist ein weiteres Detail, an das Sie sich erinnern müssen.

    – Stefan Kanon

    8. Oktober 2014 um 19:10 Uhr

1382860cookie-checkWas fehlt/suboptimal in dieser Memcpy-Implementierung?

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy