Allokator auf Windows programmieren - Denkanstöße und Korrekturen



  • @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 und src 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 sie repmovsb 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. 🙂


Anmelden zum Antworten