Allokator auf Windows programmieren - Denkanstöße und Korrekturen
-
Bonita.M schrieb:
Meinst Du einen Ersatz für std::allocator<T>?
Wir reden hier von C.
Bonita.M schrieb:
Wenn das so ist, dann kommst Du nicht umhin, dass der Speicher beim Vergrößern eines Containers wie std::vector doppelt allokiert ist.
Ordentliche Kernel (Linux z.B.) bekommen das hin, indem, wenn der virtuelle Adressraum an der Stelle zu klein wird, die Pages einfach umgemappt werden (siehe auch hier - dort werden die Nutzerdaten nicht ein einziges Mal kopiert, was geändert wird, sind die Speichermappings).
Windows kann das im Kernel aber scheinbar nicht, deswegen erstelle ich mir meine eigene Liste für jedes Mal, wenn ich das Mapping "vergrößere" (ich also neuen Speicher vom Kernel anfordere).
Bonita.M schrieb:
Wenn nicht, dann kannst Du ja eigene Container schreiben.
Jaah ... ich habe bereits geschrieben, dass ich einen Array-Basistypen habe, von dem man relativ einfach ableiten kann? Funktionieren tut das unter Linux herrlich (alloziert 2MiB und 1GiB-Pages einfach so, Zero Copy). Das Problem ist die Speicherverwaltung unter Windows, und dass ich um Grunde dabei bin, meine eigene Page Table pro Memory Mapping im Userspace anzulegen.
Übrigens, nicht falsch verstehen - Speicherallokation und Array-Logik sind ordentlich voneinander abgekapselt. Dem Array ist es komplett egal, wie der Speicher aussieht, Hauptsache, der Allokator sagt an, dass für X Elemente jetzt Speicherbereich vorhanden ist. Das Einzige, was das Array den Allokator NOCH fragt, ist, wo sich die Elemente befinden - und das ist ein einfacher Zeiger, also nix weltbewegendes.
Bonita.M schrieb:
Und wenn Du z.B. sowas analoges wie std::vector hast, dann kannst Du nachdem Du den neuen Speicher für ein Vergrößertes Array allokiert hast während des Umkopierens (am saubersten über Placement-new) dann Abschnittsweise im alten Speicherblock den Speicher dann decommitten.
Um das Umkopieren geht es hier doch?
Ich alloziere in einem 64 Bit Prozess (heißt: JEDE MENGE virtuellen Speicher) mir 4 MiB. Am Anfang eines riesigen Blocks, der
MEM_FREE
deklariert ist. Jetzt merke ich, hm, mit 4 MiB komm ich nicht aus, ich brauch mehr Speicher.Unter Linux kann ich
mremap
aufrufen, wahlweise mitMREMAP_MAYMOVE
, damit ich dem Kernel sagen kann, dass er ruhig das Mapping verschieben dürfte (wenn denn nicht genug virtueller Speicher vorhanden wäre - und das ist ja bei Wachstum nach oben und am Beginn des genannten RIESIGEN freien Adressblocks ja eh nicht gegeben).mremap
kopiert mir den Speicher nicht ein einziges Mal, sondern legt ihn nur um - wie die Rente.Unter Windows kann ich nur 50% davon erreichen, es sei denn, ich hacke mir einen Kernel-Treiber zurecht. Ich habe einfach keinen direkten Zugriff auf meine Page Entries, die kann ich vom Userspace nicht so einfach verschieben (wüsst' jedenfalls nicht, wie). Wenn ich also keinen virtuellen Speicher mehr habe, dann MUSS ich umkopieren. ABER: in den oben genannten Beispiel habe ich ja noch MASSIG virtuellen Speicher. Anstatt also umkopieren, lasse ich den Kernel lieber noch mal 4 MIB am Ende meines bestehenden Mappings einfügen, muss nix kopieren, sondern nur Buch darüber führen, wo das neue Mapping liegt (damit ich's später kopieren/zusammenfassen/freigeben kann), und habe nicht eine einzige Kopie angelegt.
Bonita.M schrieb:
Die physischen Pages werden dann gleich vom Windows recycelt, da der Speicher für die neuen Pages erst beim ersten Zugriff auf eine Page physisch allokiert werden (beim VirtualAlloc mit MEM_COMMIT wird nur Speicher vom Pagefile subtrahiert, damit der Speicher beim ersten Zugriff im Extremfall durch Paging befriedigt werden kann, und wenn kein Pagefile aktiv ist, dann wird der Speicher gleich beim VirtualAlloc physisch allokiert - deswegen sollte man für Overcommit-Geschichten immer das Pagefile anhaben, selbst wenn tatsächlich nicht gepaget wird);
Jaah, und ...? Darum geht's doch gar nicht. Wobei ich mir hier nicht sicher bin - das Verhalten, was du beschreibst, ist auf jeden Fall so bei Linux implementiert. Bei Windows bin ich mir (bei Default Page Sizes) nicht sicher.
Bonita.M schrieb:
bei large Pages könnte das aber anders sein, da bin ich mir nicht sicher.
Korrekt - bei anderen Page Sizes verhalten sich Windows und Linux (!) beide so, dass sie die Daten im Speicher halten wollen, und halt nicht auf die Festplatte swappen/pagen. Begründung: Das würde dann Probleme mit I/O verursachen - also kurz: es gibt keine Probleme, sie haben's einfach nur nicht implementiert.
Bonita.M schrieb:
Und es ist nicht kritisch wenn Du mehr Speicher committest als der Container groß ist.
Gerade bei Hugepages ist das Quatsch. Weil der Kernel die Pages immer im Speicher halten will. Stell dir mal vor, ich reserviere 16 1-GiB Hugepages - "weil können wir ja mal gebrauchen". Abgesehen davon, dass Windows eh Probleme mit der ordentlichen Allokation von Hugepages hat - Linux supportet ein Interface, mit dem man über die Boot Line die Anzahl an Pages einer bestimmten Größe angeben kann - weil sofort danach der Speicher fragmentiert, und für so große Pages brauchst du große Blöcke freien Speichers. Andere Programme, die dann ebenfalls Hugepages brauchen, kriegen diese nicht.
Du siehst, es hat seine Gründe, warum wir das Ganze abstrahieren wollen.
Und wenn ich dann neuen Speicher anfordern muss, dann soll nicht wild rumkopiert werden, wenn das komplett unnötig ist. Das lohnt sich (zumindest meinem Verständnis nach) auch schon, wenn wir nur mit 4 KiB arbeiten würden, weil wir den neuen Speicher eh reservieren und irgendwann freigeben müssen - mit meiner Methode sparen wir uns das unnötige Kopieren in jeden Fall.
Bonita.M schrieb:
Das mit den TLBs ist übrigens nicht so kitisch wenn Du linear auf den Speicher zugreifst; da kannst Du selbst mit 4kB-Pages arbeiten. Wenn Du Random-Access auf den Speicher zugreifst is sieht das anders aus.
WIE mit dem Speicher gearbeitet wird, weiß ich im Besten Fall noch gar nicht. Deswegen achten wir darauf, lieber von Anfang an die Variablen zu minimieren.
Bonita.M schrieb:
Allerdings musst Du um unter Windows Speicher mit large Pages allokieren zu können dem Process-Token das Recht dazu hinzufügen.
Siehe obigen Post. Manuell habe ich das bereits alles getestet, es geht jetzt um die weitere Logik.
Bonita.M schrieb:
Da das Umkopieren von solch großen Datenstrukturen kaum über Cache-Hits geht ist der erste Zugriff auf einen 64-Byte-Block (Cachezeilen-Größe) sehr teuer. In Relation dazu ist der Zugriff auf die geladene Cachezeile sehr günstig.
Sach an. Mir ging es ja auch nicht um die Reduzierung von Cache-Misses, sondern von TLB-Misses.
Wobei ich sagen muss, dass meine Implementierung über SSE/AVX gegenüber der glibc-Implementierung rund 16.000 Cycles langsamer ist, gegenüber der Windows-Version aber 1.600 Cycles schneller (beides Durchschnittswerte mehrerer tausend Durchläufe). Da ich das Kopieren aber eh nur für Windows brauche, verbuche ich diesen Aspekt bereits als Erfolg.
-
dachschaden schrieb:
Wir reden hier von C.
Igitt, wie kann man heute noch plain C programmieren, das ist doch ein Wartbarkeits und Produktivitäts-Hemmnis.
Außerdem ist das ein C++-Forum, da sollte man annehmen, dass sich die Fragen um C++ drehen.Windows kann das im Kernel aber scheinbar nicht, deswegen erstelle ich mir meine eigene Liste für jedes Mal, wenn ich das Mapping "vergrößere" (ich also neuen Speicher vom Kernel anfordere).
Ne, das kann Windows definitiv nicht.
Gerade bei Hugepages ist das Quatsch.
Ich dachte dabei an normale 4kB Pages. Bei hauptsächlich linearen Speicherzugriffen sind die Kosten für das TLB-Laden nicht hoch.
Sach an. Mir ging es ja auch nicht um die Reduzierung von Cache-Misses, sondern von TLB-Misses.
Meine Aussage bezog sich ja auch nicht darauf, sondern auf die Effizienz des Umkopierens mit verschiedenen Methoden.
Hier http://stackoverflow.com/questions/11621606/faster-way-to-move-memory-page-than-mremap findet sich übrigens ein Beitrag von jemanden der meint, dass mremap langsamer ist als Umkopieren. Ist drei Jahre alt.
-
Bonita.M schrieb:
Igitt, wie kann man heute noch plain C programmieren, das ist doch ein Wartbarkeits und Produktivitäts-Hemmnis.
Außerdem ist das ein C++-Forum, da sollte man annehmen, dass sich die Fragen um C++ drehen.Nicht sicher, ob Troll oder einfach nur unwissend ...
Das hier ist die WinAPI-Ecke. Nicht mehr, nicht weniger. Von mehr als dem auszugehen ist komplett unnötig. Und C kann man auch wunderbar objektorientiert programmieren. Vor allem dann, wenn man Templates und Virtualisierung einfach nicht benötigt (und glaube mir: wir benötigen sie nicht).
Bonita.M schrieb:
Ne, das kann Windows definitiv nicht.
Eben. Deswegen die Frage, ob in meinem Ansatz ein Fehler liegt, oder etwas, was man besser machen könnte.
Bonita.M schrieb:
Ich dachte dabei an normale 4kB Pages. Bei hauptsächlich linearen Speicherzugriffen sind die Kosten für das TLB-Laden nicht hoch.
Und mein "Quatsch" bezog sich auf die Aussage, dass es nichts ausmacht, wenn ich mehr commite, als ich brauche. Dem ist leider nicht der Fall bei Hugepages.
Bonita.M schrieb:
Meine Aussage bezog sich ja auch nicht darauf, sondern auf die Effizienz des Umkopierens mit verschiedenen Methoden.
Ja, aber die habe ich eh, wenn ich kopiere. Speicher stallt im Verhältnis heutzutage noch schlimmer als vor 20 Jahren. Das einzige, was man da tun kann, ist Prefetching, und versuchen, die CPU nicht noch mit schlimmerem Unsinn zu befüllen.
Aber gegen TLB-Misses, gegen DIE kann ich was tun.
Bonita.M schrieb:
Hier http://stackoverflow.com/questions/11621606/faster-way-to-move-memory-page-than-mremap findet sich übrigens ein Beitrag von jemanden der meint, dass mremap langsamer ist als Umkopieren. Ist drei Jahre alt.
Kenn ich. Aber da misst er auch das Ummappen ohne Resizing. Ich hingegen will Resizing ohne Kopierung.
EDIT: Außerdem würde Kopierung erfordern, wieder Pages vom gleichen Typ anzufordern. Kurz, eh schon begrenzten Speicher noch weiter zu begrenzen. Das kann leicht schiefgehen. Wenn der virtuelle Speicher ausläuft, gut, dann geht's halt nicht anders. Aber wenn er das das nicht tut, dann geht's halt auch anders.
-
dachschaden schrieb:
Das hier ist die WinAPI-Ecke. Nicht mehr, nicht weniger. Von mehr als dem auszugehen ist komplett unnötig. Und C kann man auch wunderbar objektorientiert programmieren. Vor allem dann, wenn man Templates und Virtualisierung einfach nicht benötigt (und glaube mir: wir benötigen sie nicht).
Jupp, aber RAII ist auch hier supi praktisch: Türlich ziehen wir hier nur Sprachmittel, die Nullkosten oder weniger gegenüber C haben. C ist halt an sich lahm, weil der Compiler weniger weiß.
Die sind aber völlig egal gegenüber den Betrachtungen, wie Multigigabytespeicher in Mikrosekunden umgemappt werden kann.
Ich höre meistens spätestens auf, wenn ich sicher bin, daß mein Framework weniger als 1Promille der Zeit frisst, die der Anwendungscode frisst, zum Beispiel wenn ich 160T zur Verfügung stelle und die nur linear durchzulaufen 150min Zeit brauchen, dann muss ich nicht mehr als 10s zum ummappen brauchen.
Übrigens war's eh nur eine sparse matrix, 10G hätte gereicht. Also bei mir immer.
-
volkard schrieb:
Jupp, aber RAII ist auch hier supi praktisch: Türlich ziehen wir hier nur Sprachmittel, die Nullkosten oder weniger gegenüber C haben. C ist halt an sich lahm, weil der Compiler weniger weiß.
Brauch ich nicht. Habe ich mal in ein paar Testprogrammen verwendet, als ich mir C++ angeschaut habe, und danach nie wieder. Irgendwie verursacht es mir Magenschmerzen, wenn man nachgucken muss, was genau für ein Typ hinter einem Objekt steht. Das ist ähnlich elegant wie eine Funktion, die die TID holen muss, um zu prüfen, welcher Thread sie gerade aufruft - nämlich gar nicht.
Und normalerweise sind Compiler auch intelligenter als die Programmierer, die mit ihnen arbeiten. Außer in den Fällen, wo's dann plötzlich 16K Cycles Unterschied pro Call macht.
volkard schrieb:
Die sind aber völlig egal gegenüber den Betrachtungen, wie Multigigabytespeicher in Mikrosekunden umgemappt werden kann.
Aber nur im besten Fall. Der auf 64 Bit-Systemen fast immer eintritt.
Aber seien wir mal pessimistisch und sagen, irgendwann muss der Rotz auch auf x86-Prozessoren laufen. Da wird der Worst Case definitiv häufiger zuschlagen - und dennoch wollen wir auch da TLB-Misses verhindern.
-
dachschaden schrieb:
volkard schrieb:
... RAII ...
Brauch ich nicht. Habe ich mal in ein paar Testprogrammen verwendet, als ich mir C++ angeschaut habe, und danach nie wieder. Irgendwie verursacht es mir Magenschmerzen, wenn man nachgucken muss, was genau für ein Typ hinter einem Objekt steht. Das ist ähnlich elegant wie eine Funktion, die die TID holen muss, um zu prüfen, welcher Thread sie gerade aufruft - nämlich gar nicht.
Bin mir angesichts dieser Aussage gerade nicht sicher ob du weisst was RAII ist/bedeutet. Weil sauberer C Code diesbezüglich ("nachgucken was genau für ein Typ hinter einem Objekt steht") nicht viel anders ist als sauberer C++ Code. Nur dass man in C halt immer und immer wieder Code wiederholen muss -- und in C++ halt nicht.
-
hustbaer schrieb:
Bin mir angesichts dieser Aussage gerade nicht sicher ob du weisst was RAII ist/bedeutet. Weil sauberer C Code diesbezüglich ("nachgucken was genau für ein Typ hinter einem Objekt steht") nicht viel anders ist als sauberer C++ Code.
Sauberer C-Code muss im Zweifelsfall gar nichts dynamisch nachschauen. Das Array-Interface habe ich implementiert mit der Größe eines der Elemente, die gespeichert werden soll, mit der das Array dann rechnet, und dieser Wert steht bereits zur Kompilierzeit fest. Da habe ich - meinem Verständnis nach anders als bei RTTI - dann keine andere Metadaten und brauche sie auch gar nicht.
In C nennt man die Art von Abstrahierung, die C++ da macht, übrigens Type-Punning, und ist UB. Selbst, wenn man sich sicher ist, dass das ABI das gleiche ist.
hustbaer schrieb:
Nur dass man in C halt immer und immer wieder Code wiederholen muss -- und in C++ halt nicht.
Bin mir angesichts dieser Aussage gerade nicht sicher, ob du weißt, wie man ordentlich in C programmiert. Weil du, wenn du ordentlich deine Strukturen pflegst und deine Funktionen in Module packst, nur selten Instanzen hat, in denen Code sich immer und immer wieder wiederholen muss.
Und je generalisierter das Interface, desto besser.
Was sich ändern kann, sind die Implementierungen. Weil (zumindest mein Eindruck) eher die C- und Assembler-Leute sich mit Unterschieden zwischen verschiedenen CPU-Architekturen auseinandersetzen und dann gegebenenfalls die Implementierung ändert.
memcpy
der glibc ist das beste Beispiel dafür - Atom bringt Prefetch, dafür muss die Richtung, in der kopiert wird, aber geändert werden, also wird das halt geändert für den Atom.Jetzt simmer aber heftig vom Thema abgekommen.
-
dachschaden schrieb:
... RTTI ...
RAII ist doch nicht das Selbe wie RTTI...
Einen C vs C++-Krieg brauchst du hier gar nicht runterzubrechen, das ist sowas von 1999.
-
Jodocus schrieb:
RAII ist doch nicht das Selbe wie RTTI...
Ugh, da hab' ich mich verlesen. Ja, stimmt. RAII != RTTI. Ich bin blöd.
-
dachschaden schrieb:
volkard schrieb:
Jupp, aber RAII ist auch hier supi praktisch: Türlich ziehen wir hier nur Sprachmittel, die Nullkosten oder weniger gegenüber C haben. C ist halt an sich lahm, weil der Compiler weniger weiß.
Brauch ich nicht. Habe ich mal in ein paar Testprogrammen verwendet, als ich mir C++ angeschaut habe, und danach nie wieder. Irgendwie verursacht es mir Magenschmerzen, wenn man nachgucken muss, was genau für ein Typ hinter einem Objekt steht.
(kleine RTTI/RAII-Verwechslung)
Da sind wir einer Meinung. Ich verwende in C++ auch nie RTTI. RTTI ist nur ein Notnagel, falls man eine total verbuggte und fehlgeplante Fremdlib benutzen muss.
RAII ist kostenlos im Vergleich zu C oder im Zusammenhang mit Exceptions sogar schneller als C. Das wollen wir schon nehmen. Es ist auch angenehm zu benutzen und vermeidet viele Fehler.
-
dachschaden schrieb:
Wobei ich sagen muss, dass meine Implementierung über SSE/AVX gegenüber der glibc-Implementierung rund 16.000 Cycles langsamer ist, gegenüber der Windows-Version aber 1.600 Cycles schneller (beides Durchschnittswerte mehrerer tausend Durchläufe). Da ich das Kopieren aber eh nur für Windows brauche, verbuche ich diesen Aspekt bereits als Erfolg.
Hast du bei Windows/MSVC gegen
memcpy
verglichen oder gegenmemmove
?
Ich frag' nur weilmemmove
bei mir (deutlich) schneller ist (ca. ~9 GB/s vs. ~6.1 beimemcpy
).
(Oder gegen eine WinAPI-Funktion?)dachschaden schrieb:
Ugh, da hab' ich mich verlesen. Ja, stimmt. RAII != RTTI.
OK. Jetzt verstehe ich was du geschrieben hast Kam mir halt komisch vor, aber bin nicht auf die Idee gekommen dass du RTTI meinen könntest.
-
volkard schrieb:
RAII ist kostenlos im Vergleich zu C oder im Zusammenhang mit Exceptions sogar schneller als C. Das wollen wir schon nehmen. Es ist auch angenehm zu benutzen und vermeidet viele Fehler.
Sehe ich nicht so. Gerade bei dem Topic - dem Halten der Mappings, die mir der Kernel zurückgibt - muss ich diese ja irgendwo speichern. Dafür hole ich mir einmal unbürokratisch 64 KiB Speicher. Aber das muss ich ja nur dann machen, wenn ich das Mapping auch wirklich haben will. Jetzt gibt es aber Codepfade innerhalb von Funktionen, wo ein Mapping gar nicht gebraucht wird. Sprich, ich habe das Objekt auf dem Stack, muss es aber in einem Codepfad gar nicht verwenden. Ich will den Code aber dennoch in einer Funktion halten, sonst schreibe ich eventuell anderen Code wieder und wieder.
Wenn die Funktion bei RAII aufgerufen wird, habe ich bei C++ direkt zwei Kernel-Calls sitzen, selbst, wenn ich das Objekt gar nicht brauche (allozieren + freigeben). Ja, kannst du sagen, dann pack die Tabelleninitialisierung doch in eine eigene Funktion deiner Klasse/Stuktur. Dafür brauche ich aber nicht RAII - das kann ich auch über eine
<Strukturname>_init
-Funktion regeln (und<Strukturname>_free
für's freigeben). Nur in diesen Funktionen muss ich mich dann um Ressourcenmanagement kümmern.Das sind alles so Automatismen, die gut gemeint sind, aber dann häufig doch nur im Weg liegen. Ja, Programmierer vergessen die manchmal. Aber deswegen jetzt die Programme langsamer werden zu lassen ist auch blöd.
hustbaer schrieb:
Hast du bei Windows/MSVC gegen
memcpy
verglichen oder gegenmemmove
?Zuerst gegen
memcpy
, jetzt auch mal gegenmemmove
. MSVC'smemmove
und meinmemcpy
brauchen beide so ungefähr 380K Cycles zum Kopieren von 2 MiB.Habe außerdem gemerkt: prefetching hilft, aber nur, wenn man den Schreibzugriff cached, und dann am Besten 2 oder 3 Iterationen vorher laden. Sonst wird's mit den Stalls echt lächerlich.
Nichttemporär (zu schreiben) ist übrigens so ziemlich das langsamste im Universum, hat den Durchsatz verdoppelt, das nicht zu verwenden.hustbaer schrieb:
Ich frag' nur weil
memmove
bei mir (deutlich) schneller ist (ca. ~9 GB/s vs. ~6.1 beimemcpy
).
(Oder gegen eine WinAPI-Funktion?)CopyMemory
ist auf meiner Hardware nochmal durchschnittlich 0.5K Cycles schneller als meine Implementierung. Hat mich erstaunt, weil ich vor gar nicht langer Zeit gelesen habe, dass auf Windowsmemcpy
internCopyMemory
aufrufen würde.Ich glaub' inzwischen, der Speicher hat sowas wie eine Tageslaune, und gibt mir mal schnelleren, mal weniger schnellen Zugriff. Oder die Ausführungen fielen manchmal unglücklich mit dem DRAM-Refresh zusammen.
Der Grund, warum deinmemmove
so viel schneller ist, könnte mit dem Prefetching zusammenhängen - soweit ich weiß sind CPUs da höllisch eigen. Hm ... vielleicht sollte ich mal schauen, ob das ebenfalls Ausschlag auf den Durchsatz hat. EDIT: "sind CPUs mit der Kopierrichtung höllisch eigen", wollte ich schreiben.EDIT2: Nein, die Kopierrichtung zu ändern bringt nichts.
-
Achja, nur kleiner Tip: Vergleich auch mal gegen
rep movsb
.
Ist bei mir gleich schnell wirmemmove
(also auch 9 GB/s).
-
hustbaer schrieb:
Achja, nur kleiner Tip: Vergleich auch mal gegen
rep movsb
.
Ist bei mir gleich schnell wirmemmove
(also auch 9 GB/s).Mit
rep movsb
komme ich beinahe auf glibc's Durchsatz - ist zwar immer noch ein wenig langsamer, aber da werde ich von mir aus nichts mehr dran machen. Vielleicht noch mal Prefetching, mehr aber nicht.Für Windows musste ich mir erst anschauen, wie ich MASM-Support da reingepfriemelt bekomme (MS hat für die 64-Bit-Version Inline-Assembly rausgekommen). Habe dennoch diesen Code hier ans Laufen bekommen:
_text SEGMENT .code memcpy_page_4KiB PROC push RDI push RSI cld ;Windows verwendet für x64 folgende Convention: ;1. Parameter in RCX ;2. Parameter in RDX mov RDI,RCX mov RSI,RDX mov RCX,1000h rep movsb pop RSI pop RDI ret memcpy_page_4KiB ENDP END
Ich bin mir sicher, dass man das noch optimieren könnte - aber ich war noch nie der große Assembly-Held.
Jedenfalls benötige ich damit nur noch 310K Cycles anstelle von 370-380K Cycles. Kann man das mit Memory-Stalls erklären, oder benötigt das Ausführen der AVX-Instuktionen so lange, dass ich damit kaum mithalten kann?
Ich hätte auch erwartet, dass es zumindest einen messbaren Unterschied zwischen
mov RCX,1000h rep movsb
und:
mov RCX,200h rep movsq
gibt, aber die halten sich eigentlich die Waage.
-
MASM muss man im Projekt einfach nur aktivieren:
http://stackoverflow.com/questions/20078021/how-to-enable-assembly-language-support-in-visual-studio-2013Wenn du danach .asm Files im Projekt anlegst sollten die automatisch mit MASM verknüpft werden. (Wenn du davor .asm Files anlegst musst du es nachträglich mit Hand machen, also in den File-Properties als Build-Tool MASM einstellen.)
Was die verwunderliche Geschwindigkeit von
rep movsb
angeht: soweit ich weiss hat Intel das speziell optimiert. Quasi nen fast perfektesmemcpy
in Microcode. Möglicherweise haben manche CPUs sogar ne spezielle "Copy-Engine"?Auf jeden Fall ist
rep movsb
vermutlich der Befehl der bei weitem am häufigsten in exakt der Form zum kopieren von Speicherblöcken verwendet wird (also auch häufiger als kompliziertere Schleifen). Daher zahlt es sich ausrep movsb
so schnell wie möglich zu machen. Und daher halte ich es auch für halbwegs "zukunftssicher".Allerdings gibt es auch Nachteile, auf einigen CPUs ist es wohl deutlich langsamer. Weiss leider nimmer welche es waren, aber wenn du maximale Geschwindigkeit auf allen möglichen CPUs willst/brauchst, dann solltest du ausgiebig testen.
ps: Misst du mit Workloads die in den Cache passen, oder mit grossen? Ich hab meine Tests mit 1 GiB grossen Blöcken (mit 4K Alignment) gemacht, weil ich die "RAM-to-RAM" Copy-Speed messen wollte. Aber sollte klar sein, 9 GB/s für Cache-to-Cache wären schon sehr mickrig.
-
dachschaden schrieb:
Für Windows musste ich mir erst anschauen, wie ich MASM-Support da reingepfriemelt bekomme (MS hat für die 64-Bit-Version Inline-Assembly rausgekommen). Habe dennoch diesen Code hier ans Laufen bekommen:
Für fast alle x86-Instruktionen die man nicht indirekt durch Standard-C(++) ausdrücken kann gibt es Intrinsics, so auch für MOVSB:
https://msdn.microsoft.com/de-de/library/hhta9830.aspx
Da man eben für fast alles was sonst nur in Assembler ging Intrinsics hat, ist Assembler fast nie nötig.
-
hustbaer schrieb:
MASM muss man im Projekt einfach nur aktivieren:
http://stackoverflow.com/questions/20078021/how-to-enable-assembly-language-support-in-visual-studio-2013Nein, eben nicht. Die Datei war noch vom Build ausgeschlossen (frag' mich nicht, warum, gesetzt habe ich es nicht - die ASM-Datei war ja komplett neu).
hustbaer schrieb:
Auf jeden Fall ist
rep movsb
vermutlich der Befehl der bei weitem am häufigsten in exakt der Form zum kopieren von Speicherblöcken verwendet wird (also auch häufiger als kompliziertere Schleifen). Daher zahlt es sich ausrep movsb
so schnell wie möglich zu machen. Und daher halte ich es auch für halbwegs "zukunftssicher".Allerdings gibt es auch Nachteile, auf einigen CPUs ist es wohl deutlich langsamer. Weiss leider nimmer welche es waren, aber wenn du maximale Geschwindigkeit auf allen möglichen CPUs willst/brauchst, dann solltest du ausgiebig testen.
Hm. Notfalls kann man immer noch auf den Prozessor prüfen (CPUID-Interface habe ich hier).
hustbaer schrieb:
ps: Misst du mit Workloads die in den Cache passen, oder mit grossen? Ich hab meine Tests mit 1 GiB grossen Blöcken (mit 4K Alignment) gemacht, weil ich die "RAM-to-RAM" Copy-Speed messen wollte. Aber sollte klar sein, 9 GB/s für Cache-to-Cache wären schon sehr mickrig.
Workloads, die in den Cache passen.
Ich habe allerdings auch mal versuchsweise das Kopieren von einem GiB gemessen, 560M Cycles. Allerdings werde ich da auch ein paar TLB-Misses gehabt haben, einfach, weil ich gerade nicht mit 1-GiB-Pages gebootet habe (Bei 1-GiB werden die Pages schon beim Booten reserviert, und man kann sie danach nicht mehr freigeben, deswegen sind die standardmässig aktiviert - den Platz möcht ich noch für andere Sachen haben). Wenn ich dann mit 1024 2-MiB-Pages arbeite (1 GiB-Source, 1 GiB-Destination), habe ich ungefähr einen Durchsatz von 6 GiB/s. Auf Linux jetzt.
@Bonita.M: damit bin ich nochmal um 10K-20K Cycles schneller. Vielen Dank!
-
@Bonita.M
Sehr cool. Danke auch von mir, kannte ich noch nicht.
-
@dachschaden
Du solltest auf jeden Fall immer alle Varianten mit der selben Programmausführung ausführen und vergleichen, und immer mehrere Versuche machen. Und mit den selben Pufferadressen. Und dann den schnellsten raussuchen. Und so lange versuchen bis z.B. 100 Ausführungen zu keiner Verbesserung mehr geführt haben. Damit bekommt man zumindest halbwegs reproduzierbare Resultate die kaum noch von Sonnenflecken beeinflusst werden.Ca so.
#include <intrin.h> #include <windows.h> #include <immintrin.h> // candidates typedef void __stdcall RtlCopyMemoryType(void*, const void*, size_t Length); RtlCopyMemoryType* MyRtlCopyMemory; RtlCopyMemoryType* MyRtlMoveMemory; void char_copy(unsigned char const* s, unsigned char* d, size_t n) { while (n--) *d++ = *s++; } void int_copy(unsigned char const* s, unsigned char* d, size_t n) { size_t n4 = n / 4; int const* s4 = reinterpret_cast<int const*>(s); // strict aliasing, yes, I know int* d4 = reinterpret_cast<int*>(d); s += n4 * 4; d += n4 * 4; n = n % 4; while (n4--) *d4++ = *s4++; while (n--) *d++ = *s++; } void avx_copy(unsigned char const* s, unsigned char* d, size_t n) { size_t n128 = n / 128; __m256i const* s32 = reinterpret_cast<__m256i const*>(s); // strict aliasing, yes, I know __m256i* d32 = reinterpret_cast<__m256i*>(d); s += n128 * 128; d += n128 * 128; n = n % 128; while (n128--) { auto r1 = _mm256_loadu_si256(s32); auto r2 = _mm256_loadu_si256(s32 + 1); auto r3 = _mm256_loadu_si256(s32 + 2); auto r4 = _mm256_loadu_si256(s32 + 3); _mm256_storeu_si256(d32, r1); _mm256_storeu_si256(d32 + 1, r2); _mm256_storeu_si256(d32 + 2, r3); _mm256_storeu_si256(d32 + 3, r4); s32 += 4; d32 += 4; } while (n--) *d++ = *s++; } extern "C" void repmovsb(unsigned char const* s, unsigned char* d, size_t n); // implemented in the .asm file // timing #include <chrono> #include <iostream> #include <cassert> using namespace std; using namespace std::chrono; extern "C" __declspec(dllexport) volatile void* g_dummy1 = 0; extern "C" __declspec(dllexport) volatile void* g_dummy2 = 0; extern "C" __declspec(dllexport) volatile int g_dummy3 = 0; nanoseconds baseline = nanoseconds(0); unsigned char* buffer0; unsigned char* buffer1; //size_t const buffer_size = 1024 * 1024 * 1024; //size_t const repeat = 1; //size_t const buffer_size = 2048 * 1024; //size_t const repeat = 10; //size_t const buffer_size = 64 * 1024; //size_t const repeat = 100; size_t const buffer_size = 4 * 1024; size_t const repeat = 1000; void optimization_barrier() { _ReadWriteBarrier(); g_dummy1 = buffer0; g_dummy2 = buffer1; g_dummy3++; } template <class F> nanoseconds measure_once(F fun) { auto const t0 = high_resolution_clock::now(); for (size_t i = 0; i < repeat; i++) { optimization_barrier(); fun(); optimization_barrier(); } auto const t1 = high_resolution_clock::now(); return duration_cast<nanoseconds>(t1 - t0); } template <class F> nanoseconds measure(F fun) { nanoseconds best_duration; for (size_t i = 0; i < 100; i++) { nanoseconds duration = measure_once(fun); if (duration < best_duration || i == 0) { best_duration = duration; i = 0; } } return best_duration; } template <class F> void measure(char const* name, F fun) { nanoseconds best_duration = measure(fun) - baseline; auto gbps = 1.0 * buffer_size * repeat / best_duration.count(); cout << name << ": " << best_duration.count() << " ns (" << gbps << " GB/s)\n"; } unsigned char* aligned_alloc(size_t size) { auto buf = new unsigned char[size + 4096]; uintptr_t offset = reinterpret_cast<uintptr_t>(buf) % 4096; buf += 4096 - offset; offset = reinterpret_cast<uintptr_t>(buf) % 4096; assert(offset == 0); return buf; } int main(int argc, char *argv[]) { auto ntdll = ::LoadLibraryW(L"ntdll.dll"); MyRtlCopyMemory = reinterpret_cast<RtlCopyMemoryType*>(GetProcAddress(ntdll, "RtlCopyMemory")); MyRtlMoveMemory = reinterpret_cast<RtlCopyMemoryType*>(GetProcAddress(ntdll, "RtlMoveMemory")); buffer0 = aligned_alloc(buffer_size); buffer1 = aligned_alloc(buffer_size); baseline = measure([]() {}); cout << "baseline: " << baseline.count() << " ns\n"; measure("nothing", []() { }); measure("memcpy", []() { memcpy(buffer0, buffer1, buffer_size); }); measure("memmove", []() { memmove(buffer0, buffer1, buffer_size); }); measure("char_copy", []() { char_copy(buffer0, buffer1, buffer_size); }); measure("int_copy", []() { int_copy(buffer0, buffer1, buffer_size); }); measure("avx_copy", []() { avx_copy(buffer0, buffer1, buffer_size); }); measure("repmovsb", []() { repmovsb(buffer0, buffer1, buffer_size); }); measure("__movsb", []() { __movsb(buffer0, buffer1, buffer_size); }); measure("RtlCopyMemory", []() { MyRtlCopyMemory(buffer0, buffer1, buffer_size); }); measure("RtlMoveMemory", []() { MyRtlMoveMemory(buffer0, buffer1, buffer_size); }); return 0; }
Damit bekomme ich (Haswell Xeon E3-1245 v3 @ 3.4 GHz)
// 1 GiB (x1) memcpy: 173110767 ns (6.20263 GB/s) memmove: 120273184 ns (8.92752 GB/s) char_copy: 374367046 ns (2.86815 GB/s) int_copy: 179932513 ns (5.96747 GB/s) avx_copy: 173484789 ns (6.18926 GB/s) <----------- ??? repmovsb: 121840513 ns (8.81268 GB/s) __movsb: 121446265 ns (8.84129 GB/s) RtlCopyMemory: 111961087 ns (9.59031 GB/s) <----------- ??? RtlMoveMemory: 111988860 ns (9.58793 GB/s) <----------- ??? // 2 MiB (x10) memcpy: 1159800 ns (18.082 GB/s) memmove: 943356 ns (22.2308 GB/s) char_copy: 6663866 ns (3.14705 GB/s) int_copy: 1953426 ns (10.7358 GB/s) avx_copy: 1156177 ns (18.1387 GB/s) <----------- ??? repmovsb: 943054 ns (22.2379 GB/s) __movsb: 930677 ns (22.5336 GB/s) RtlCopyMemory: 1125687 ns (18.63 GB/s) RtlMoveMemory: 1130216 ns (18.5553 GB/s) // 64 KiB (x100) memcpy: 219161 ns (29.9031 GB/s) memmove: 213425 ns (30.7068 GB/s) char_copy: 2021047 ns (3.24268 GB/s) int_copy: 535525 ns (12.2377 GB/s) avx_copy: 217652 ns (30.1105 GB/s) repmovsb: 215840 ns (30.3632 GB/s) __movsb: 215538 ns (30.4058 GB/s) RtlCopyMemory: 218859 ns (29.9444 GB/s) RtlMoveMemory: 219161 ns (29.9031 GB/s) // 4 KiB (x1000) memcpy: 66412 ns (61.6756 GB/s) memmove: 42866 ns (95.5536 GB/s) char_copy: 1154064 ns (3.5492 GB/s) int_copy: 324515 ns (12.6219 GB/s) avx_copy: 31999 ns (128.004 GB/s) <----------- ??? repmovsb: 42564 ns (96.2316 GB/s) __movsb: 40753 ns (100.508 GB/s) RtlCopyMemory: 69733 ns (58.7383 GB/s) RtlMoveMemory: 69431 ns (58.9938 GB/s)
Also durchaus eigenartige Resultate
ps:
Falls du die Seite noch nicht kennst, SEHR COOLE Übersicht über die ganzen Intel Intrinsics:
https://software.intel.com/sites/landingpage/IntrinsicsGuide/#pps: Beim avx_copy fehlen vermutlich noch irgendwelche Fences.
-
Bin gerade erst dazu gekommen, den Code mal nach C für Linux zu portieren.
Dein Code prüft ein paar Fälle, die wir in der Realität nicht supporten:
- die Kopierfunktion soll nur das Kopieren von Pages unterstützen. Das heißt:dest
undsrc
sind immer korrekt aligned (selbst AVX benötigt nur 32 Byte), ebenso wie die Länge. Sprich, wir haben nie kleinere Reste, die noch mitkopiert werden müssen.
- bei meinen AVX-Tests habe ich festgestellt, dass das non-temporal Schreiben (nicht Lesen) doppelt so lange dauert wie die Daten aus dem Cache zu senden. Daher habe ich in meiner AVX-Implementierung einfache Reads und Writes verwendet:while(plength--) { r1 = psrc[0]; r2 = psrc[1]; r3 = psrc[2]; r4 = psrc[3]; pdest[0] = r1; pdest[1] = r2; pdest[2] = r3; pdest[3] = r4; pdest += iterations; psrc += iterations; }
Um Optimierungen durch den Compiler vorzubeugen, habe ich die eigentlichen Kopierfunktionen in eine eigene Library gepackt, ohne LTO. Meines Wissens sollte das genug sein - schaue ich in das Kompilat, sehe ich dort auch den Funktionsaufruf von
measure
und die Funktionspointer in%esi
geschoben.Die Rtl-Funktionen habe ich auf Linux nicht, ein repmovsb war aber schnell gehackt:
void repmovsb ( type_dest dest, type_src src, size_t length ) { __asm__ ( "rep movsd\n\t" : :"S"(src),"D"(dest),"c"(length / 4) :"memory" ); }
Iterations: 1000|Buffer size: 65536 memcpy : 2407023 ns (27.226994 GiB/s) memmove : 2407597 ns (27.220502 GiB/s) char_copy : 2419531 ns (27.086241 GiB/s) <---Wieso ...? int_copy : 2420056 ns (27.080365 GiB/s) avx_copy : 2449989 ns (26.749508 GiB/s) repmovsb : 2407616 ns (27.220288 GiB/s) Iterations: 3|Buffer size: 1073741824 memcpy : 803536257 ns (4.008812 GiB/s) memmove : 797947852 ns (4.036887 GiB/s) char_copy : 1139965098 ns (2.825723 GiB/s) int_copy : 1142812474 ns (2.818682 GiB/s) avx_copy : 1146694691 ns (2.809140 GiB/s) repmovsb : 909587241 ns (3.541415 GiB/s)
Weitere Tests stehen noch aus - und so ganz vertraue ich den Berechnungen noch nicht, das werde ich mir noch mal anschauen, wenn mir nicht die Augen fast zufallen. Wobei ich eine Sache unbesehen glaube - dass
memcpy
bzw.memmove
auf Linux so hochgezüchtet sind, dass sierepmovsb
ohne Probleme schlagen.Ach, und noch eine Sache: in diesem Thread ging es primär nicht um schnelles Kopieren von Daten, obwohl dieses im schlimmsten Fall notwendig wird, sondern um die Mapping-Verwaltung, die ich vorgeschlagen hatte. Ich habe diese nun implementiert und mit einem Produktivprogramm, was auf Linux bereits funktionierte, jetzt auf Windows getestet.
Stellt sich heraus, dass die Leute bei Microsoft die Vorteile von 64 Bit mal so gar nicht nutzen. Linux wie Windows haben im Userspace (der 47-Bit-Adressraum, der einem derzeit zur Verfügung steht) am Anfang einen Block Mappings, am Ende einen Block Mappings, und dazwischen gähnende Leere. Man sollte annehmen, dass für
VirtualAlloc
versucht wird, in dieser gähnenden Leere Speicher zu finden - aber anscheinend versucht das System eher, das Mapping so niedrig wie möglich anzulegen. Das ist natürlich kompletter Unsinn, denn wenn man nun versucht, ein weiteres Mapping danach anzulegen, um fortwährend Speicher zu reservieren, klappt das meist nicht, weil direkt nach dem eigenen Mapping ein reservierter/commiteter Block existiert. Und dann müssen wir Daten kopieren.Ich habe bereits für beide Betriebssysteme Funktionen, mit der ich mir eine Mapping-Table ins Userspace ziehen kann. Unter Windows ist das allerdings der reine Overkill, da ich pro Region einmal einen Kernel-Call habe, um dann die Regioninformation aus den geschriebenen Daten zu ziehen.
Mein Plan war daher, dass ich mir die Speicherverwaltung unter 32-Bit- und 64-Bit-Prozessen ansehen und dann statisch den Beginn des leeren Blocks hinterlegen werde. Anstatt nun von Anfang an die Mappings durchzugehen, beginne ich am Anfang des Blocks zu suchen. Gleiches mache ich, wenn Mappings erfragt werden, die von oben nach unten wachsen sollen (Linux und Windows supporten das nativ, allerdings soll die Windows-Implementierung so ziemlich das langsamste im Universum sein), da fange ich dann halt von oben nach unten an zu suchen.
Irgendwelche Einwände?
EDIT: Für Linux habe ich bereits ein paar Änderungen eingefügt, die mir ein
maps_binary
für einen Prozess in procfs anlegen. Ich kann mir vorstellen, dass Leute jetzt vorschlagen werden, doch einen Treiber für Windows zu schreiben, der das Suchen nach freiem virtuellen Arbeitsspeicher durchführen soll.Problem ist: bei Linux hatte ich ein bisschen Ahnung, was ich tat - bei Windows überhaupt keine. Deswegen stelle ich ja diese ganzen bescheuerten Fragen. Und einen Kerneltreiber zu schreiben wäre meines Erachtens eh Overkill, wobei ich noch nicht mal wüsste, wo da anfangen ...
EDIT2: Ach, das auch noch: Fences wären meines Wissens nur dann nötig, wenn das Schreiben non-temorary wäre. Da ich das aber nicht mache, wären diese nicht notwendig.
@hustbaer: Die Intrinsics-Seite kannte ich schon.