Nutzen Sie den nicht ausgerichteten Speicherzugriff von ARM, während Sie sauberen C-Code schreiben

Lesezeit: 10 Minuten

Benutzer-Avatar
Cyan

Früher waren ARM-Prozessoren nicht in der Lage, nicht ausgerichtete Speicherzugriffe (ARMv5 und niedriger) richtig zu verarbeiten. Etwas wie u32 var32 = *(u32*)ptr; würde einfach fehlschlagen (Ausnahme auslösen), wenn ptr war nicht richtig auf 4-Bytes ausgerichtet.

Das Schreiben einer solchen Anweisung würde jedoch für x86/x64 gut funktionieren, da diese CPUs solche Situationen immer sehr effizient gehandhabt haben. Aber nach dem C-Standard ist dies keine “richtige” Schreibweise. u32 ist anscheinend gleichbedeutend mit einer Struktur von 4 Bytes, die muss auf 4 Bytes ausgerichtet werden.

Ein richtiger Weg, um das gleiche Ergebnis zu erzielen und gleichzeitig die Korrektheit der Orthodoxie beizubehalten und Gewährleistung der vollen Kompatibilität mit jeder CPU ist:

u32 read32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

Dieser ist korrekt und generiert den richtigen Code für jede CPU, die an nicht ausgerichteten Positionen lesen kann oder nicht. Noch besser, auf x86/x64 ist es richtig für einen einzelnen Lesevorgang optimiert und hat daher die gleiche Leistung wie die erste Anweisung. Es ist tragbar, sicher und schnell. Wer kann mehr fragen?

Nun, das Problem ist, dass wir bei ARM nicht so viel Glück haben.

Schreiben der memcpy Version ist in der Tat sicher, scheint aber zu systematischen vorsichtigen Operationen zu führen, die für ARMv6 und ARMv7 (im Grunde jedes Smartphone) sehr langsam sind.

In einer leistungsorientierten Anwendung, die stark auf Leseoperationen angewiesen ist, konnte der Unterschied zwischen der 1. und 2. Version gemessen werden: er liegt bei > 5x bei gcc -O2 die Einstellungen. Das ist viel zu viel, um es zu ignorieren.

Bei dem Versuch, einen Weg zu finden, ARMv6/v7-Funktionen zu nutzen, habe ich nach Anleitungen zu einigen Beispielcodes gesucht. Unglücklicherweise scheinen sie die erste Aussage auszuwählen (direct u32 Zugriff), was nicht korrekt sein soll.

Das ist noch nicht alles: Neue GCC-Versionen versuchen jetzt, Auto-Vektorisierung zu implementieren. Auf x64 bedeutet das SSE/AVX, auf ARMv7 bedeutet das NEON. ARMv7 unterstützt auch einige neue „Load Multiple“ (LDM) und „Store Multiple“ (STM) Opcodes, die benötigen Zeiger ausgerichtet werden.

Was bedeutet das ? Nun, dem Compiler steht es frei, diese erweiterten Anweisungen zu verwenden, auch wenn sie nicht ausdrücklich aus dem C-Code aufgerufen wurden (keine intrinsischen). Um eine solche Entscheidung zu treffen, nutzt es die Tatsache, dass an u32* pointer soll auf 4 Bytes ausgerichtet werden. Wenn dies nicht der Fall ist, sind alle Wetten ungültig: undefiniertes Verhalten, Abstürze.

Das bedeutet, dass selbst auf CPUs, die nicht ausgerichteten Speicherzugriff unterstützen, es jetzt gefährlich ist, direkt zu verwenden u32 zugreifen, da dies bei hohen Optimierungseinstellungen zu fehlerhafter Codegenerierung führen kann (-O3).

Dies ist also ein Dilemma: Wie kann auf die native Leistung von ARMv6/v7 bei nicht ausgerichtetem Speicherzugriff zugegriffen werden? ohne falsche Version schreiben u32 Zugang ?

PS: Habe ich auch probiert __packed() Anweisungen, und aus Performance-Sicht scheinen sie genauso zu funktionieren wie die memcpy Methode.

[Edit] : Vielen Dank für die bisher erhaltenen hervorragenden Elemente.

Wenn ich mir die generierte Assembly ansehe, könnte ich bestätigen, dass @Notlikethat das gefunden hat memcpy Version generiert tatsächlich richtig ldr Opcode (nicht ausgerichtetes Laden). Ich habe jedoch auch festgestellt, dass die generierte Assembly nutzlos aufruft str (Befehl). Der vollständige Vorgang ist also jetzt ein nicht ausgerichtetes Laden, ein ausgerichtetes Speichern und dann ein endgültig ausgerichtetes Laden. Das ist viel mehr Arbeit als nötig.

Antwort auf @haneefmubarak, ja, der Code ist richtig eingebettet. Und nein, memcpy ist weit davon entfernt, die bestmögliche Geschwindigkeit bereitzustellen, da der Code gezwungen wird, direkt zu akzeptieren u32 access führt zu enormen Leistungssteigerungen. Es muss also eine bessere Möglichkeit geben.

Ein großes Dankeschön an @artless_noise. Der Link zum Godbolt-Service ist von unschätzbarem Wert. Ich war noch nie in der Lage, die Äquivalenz zwischen einem C-Quellcode und seiner Assembly-Darstellung so deutlich zu sehen. Das ist sehr inspirierend.

Ich habe eines der @artless-Beispiele fertiggestellt und es gibt Folgendes:

#include <stdlib.h>
#include <memory.h>
typedef unsigned int u32;

u32 reada32(const void* ptr) { return *(const u32*) ptr; }

u32 readu32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

einmal kompiliert mit ARM GCC 4.8.2 bei -O3 oder -O2 :

reada32(void const*):
    ldr r0, [r0]
    bx  lr
readu32(void const*):
    ldr r0, [r0]    @ unaligned
    sub sp, sp, #8
    str r0, [sp, #4]    @ unaligned
    ldr r0, [sp, #4]
    add sp, sp, #8
    bx  lr

Ziemlich vielsagend….

  • Ich bezweifle, dass Sie leider etwas schneller als memcpy finden können.

    – Adrian

    18. August 2015 um 3:31 Uhr

  • Es ist nicht gefährlich, u32 zu verwenden. Es ist gefährlich, dem Compiler zu sagen, dass Sie besser wissen, worauf er zugreift (explizites Casting), wenn dies tatsächlich nicht der Fall ist.

    – Unixschlumpf

    18. August 2015 um 7:44 Uhr

  • Keine Repro. Unter Verwendung eines Linaro GCC 4.8.3 mit -march=armv6 und -O1 kompiliert die obige Funktion im Wesentlichen ldr r0, [r0]; str r0, [sp, #4]; ldr r0, [sp, #4]. Schade, dass die Verwendung der lokalen Variablen nicht vollständig vermieden werden kann, aber genau dort ist Ihre unausgerichtete Wortlast; keine mehrfachen Byte-Ladevorgänge oder Out-of-Line-Aufrufe an memcpy.

    – Nicht so

    18. August 2015 um 8:40 Uhr

  • Zum Beispiel Gottriegel gibt echte Ausgabe und ein Beispiel mit main.

    – ungekünstelter Lärm

    18. August 2015 um 15:43 Uhr

  • Danke für diese aufschlussreichen Elemente. Ich habe die Frage dank Godbolt mit neuen Informationen aktualisiert.

    – Cyan

    18. August 2015 um 19:05 Uhr

Benutzer-Avatar
Cyan

OK, die Situation ist verwirrender als man möchte. Um dies zu verdeutlichen, hier die Ergebnisse dieser Reise:

Zugriff auf nicht ausgerichteten Speicher

  1. Die einzige portable C-Standardlösung für den Zugriff auf nicht ausgerichteten Speicher ist die memcpy eines. Ich hatte gehofft, durch diese Frage einen weiteren zu bekommen, aber anscheinend ist es der einzige, der bisher gefunden wurde.

Beispielcode:

u32 read32(const void* ptr)  { 
    u32 value; 
    memcpy(&value, ptr, sizeof(value)); 
    return value;  }

Diese Lösung ist unter allen Umständen sicher. Es kompiliert auch in ein triviales load register Betrieb auf x86-Ziel mit GCC.

Auf einem ARM-Ziel mit GCC führt dies jedoch zu einer viel zu großen und nutzlosen Montagesequenz, die die Leistung beeinträchtigt.

Verwenden von Clang auf dem ARM-Ziel, memcpy funktioniert gut (siehe @notlikethat Kommentar unten). Es wäre einfach, GCC insgesamt die Schuld zu geben, aber es ist nicht so einfach: die memcpy Die Lösung funktioniert gut auf GCC mit x86/x64-, PPC- und ARM64-Zielen. Wenn Sie schließlich einen anderen Compiler, icc13, ausprobieren, ist die memcpy-Version auf x86/x64 überraschend schwerer (4 Anweisungen, obwohl eine ausreichen sollte). Und das sind nur die Kombinationen, die ich bisher testen konnte.

Ich muss Godbolts Projekt danken, um solche Aussagen zu machen leicht zu beobachten.

  1. Die zweite Lösung ist zu verwenden __packed Strukturen. Diese Lösung ist kein C-Standard und hängt vollständig von der Erweiterung des Compilers ab. Folglich hängt die Art und Weise, wie es geschrieben wird, vom Compiler und manchmal von seiner Version ab. Dies ist ein Chaos für die Wartung von portablem Code.

Davon abgesehen führt dies in den meisten Fällen zu einer besseren Codegenerierung als memcpy. Nur in den meisten Fällen …

Zum Beispiel in Bezug auf die oben genannten Fälle, in denen memcpy Lösung funktioniert nicht, hier sind die Ergebnisse:

  • auf x86 mit ICC: __packed Lösung funktioniert
  • auf ARMv7 mit GCC: __packed Lösung funktioniert
  • auf ARMv6 mit GCC: funktioniert nicht. Die Montage sieht noch hässlicher aus als memcpy.

    1. Die letzte Lösung ist die direkte Verwendung u32 Zugriff auf nicht ausgerichtete Speicherpositionen. Diese Lösung funktionierte früher jahrzehntelang auf x86-CPUs, wird aber nicht empfohlen, da sie gegen einige C-Standardprinzipien verstößt: Der Compiler ist berechtigt, diese Anweisung als Garantie dafür zu betrachten, dass die Daten richtig ausgerichtet sind, was zu fehlerhafter Codegenerierung führt.

Leider ist es in mindestens einem Fall die einzige Lösung, die in der Lage ist, Leistung aus dem Ziel zu extrahieren. Nämlich für GCC auf ARMv6.

Verwenden Sie diese Lösung jedoch nicht für ARMv7: GCC kann Anweisungen generieren, die für ausgerichtete Speicherzugriffe reserviert sind, nämlich LDM (Load Multiple), was zum Absturz führt.

Selbst auf x86/x64 wird es heutzutage gefährlich, Ihren Code auf diese Weise zu schreiben, da die Compiler der neuen Generation möglicherweise versuchen, einige kompatible Schleifen automatisch zu vektorisieren und SSE/AVX-Code zu generieren basierend auf der Annahme, dass diese Speicherpositionen richtig ausgerichtet sindAbsturz des Programms.

Als Zusammenfassung sind hier die Ergebnisse in einer Tabelle zusammengefasst, wobei die Konvention verwendet wird: memcpy > gepackt > direkt.

| compiler  | x86/x64 | ARMv7  | ARMv6  | ARM64  |  PPC   |
|-----------|---------|--------|--------|--------|--------|
| GCC 4.8   | memcpy  | packed | direct | memcpy | memcpy |
| clang 3.6 | memcpy  | memcpy | memcpy | memcpy |   ?    |
| icc 13    | packed  | N/A    | N/A    | N/A    | N/A    |

  • Dieses Diagramm ist praktisch, aber es scheint, dass seit etwa gcc 5 ein -march=armv7-a wird gut mit dem memcpy() Variante. Das Problem ist die Art und Weise, wie ältere ARM-CPUs mit nicht ausgerichteten Lese-/Schreibvorgängen umgehen würden. Jeder, der diesen Beitrag derzeit 2019 liest, sollte sich dessen bewusst sein -march Werte werden die Dinge erheblich beeinflussen. Es ist möglich, dass das GCC ARM-Backend (und die Infrastruktur) aktualisiert wurde, um zu glauben, dass neuere ARM-CPUs in Ordnung sind und nicht ausgerichteten Zugriff haben. Weitere Informationen zu diesem Thema finden Sie unter Linux, das nicht ausgerichteten Zugriff abfängt.

    – ungekünstelter Lärm

    12. September 2019 um 14:08 Uhr


Ein Teil des Problems ist wahrscheinlich, dass Sie keine einfache Inlinierbarkeit und weitere Optimierung zulassen. Das Vorhandensein einer spezialisierten Funktion für das Laden bedeutet, dass bei jedem Aufruf ein Funktionsaufruf ausgegeben werden kann, was die Leistung verringern könnte.

Eine Sache, die Sie tun könnten, ist zu verwenden static inlinewodurch der Compiler die Funktion einbetten kann load32(), wodurch die Leistung gesteigert wird. Bei höheren Optimierungsstufen sollte der Compiler dies jedoch bereits für Sie einbetten.

Wenn der Compiler einen 4-Byte-Memcpy einbettet, wird er ihn wahrscheinlich in die effizienteste Reihe von Lade- oder Speichervorgängen umwandeln, die immer noch an nicht ausgerichteten Grenzen funktionieren. Wenn Sie auch bei aktivierten Compiler-Optimierungen immer noch eine geringe Leistung sehen, kann dies daher der Fall sein das ist die maximale Leistung für nicht ausgerichtete Lese- und Schreibvorgänge auf den von Ihnen verwendeten Prozessoren. Da du gesagt hast “__packed Anweisungen” liefern identische Leistung zu memcpy()das scheint der Fall zu sein.


An diesem Punkt können Sie nur sehr wenig tun, außer Ihre Daten abzugleichen. Wenn Sie es jedoch mit einem zusammenhängenden Array von nicht ausgerichteten zu tun haben u32‘s, es gibt eine Sache, die Sie tun könnten:

#include <stdint.h>
#include <stdlib.h>

// get array of aligned u32
uint32_t *align32 (const void *p, size_t n) {
    uint32_t *r = malloc (n * sizeof (uint32_t));

    if (r)
        memcpy (r, p, n);

    return r;
}

Dies verwendet nur, um ein neues Array mit zuzuweisen malloc()Weil malloc() und Freunde weisen Speicher mit korrekter Ausrichtung für alles zu:

Die Funktionen malloc() und calloc() geben einen Zeiger auf den zugewiesenen Speicher zurück, der für jede Art von Variable geeignet ausgerichtet ist.

malloc(3)Handbuch für Linux-Programmierer

Dies sollte relativ schnell gehen, da Sie dies nur einmal pro Datensatz tun müssen. Auch beim Kopieren memcpy() nur für den anfänglichen Mangel an Ausrichtung anpassen und dann die schnellsten verfügbaren ausgerichteten Lade- und Speicheranweisungen verwenden können, wonach Sie in der Lage sein werden, Ihre Daten mit den normalen ausgerichteten Lese- und Schreibvorgängen bei voller Leistung zu verarbeiten.

1382180cookie-checkNutzen Sie den nicht ausgerichteten Speicherzugriff von ARM, während Sie sauberen C-Code schreiben

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

Privacy policy