Multithread Benchmark



  • Hi!

    Ich möchte meine asynchrone Socket-Library benchmarken, insbesondere interessiere ich mich für das Skalierverhalten mit steigender Anzahl an Threads. Als Fingerübung habe ich 4 Versionen eines Tests geschrieben, in der mehrere Threads je eine Sekunde lang eine Variable inkrementieren. Der langsamste Test benutzt für alle Threads eine gemeinsame Variable, also müsste das Caching quasi unmöglich sein. Der zweite Test benutzt je Thread eine eigene Variable. Der dritte Test fügt Padding-Bytes hinzu, um false sharing zu vermeiden. Der vierte Test benutzt dann keine Atomics mehr, sondern normale Variablen und Memory-Barrieren.

    Da ich noch kein wirkliches Gefühl für die Kosten von Atomics und die Wirkung vom Caching habe (auf unterschiedlichen Prozessoren), würde ich mich freuen, wenn ein paar Leute den Test mal laufen lassen und ihre Daten mit mir teilen. Ich fange mal an:
    Intel Core i7 2720 QM (Quadcore, 4+4 Hardware Threads (Hyperthreading))
    Visual Studio 2015, Release, x64
    Plot, auf der X-Achse die Anzahl der Threads, auf der Y-Achse der Counter am Ende (Mittelwert aus 2 Durchläufen).
    Der grüne Graph müsste unter False Sharing leiden. So einen oszillierenden Verlauf hätte ich jedoch nicht erwartet. Die beiden schnellsten Tests unterscheiden sich garnicht, obwohl der 3. Test immer noch sequentiell konsistent sein muss.

    Hier der Code:

    #include <iostream>
    #include <chrono>
    #include <fstream>
    #include <array>
    
    template <std::size_t ThreadCount>
    struct slow_test {
    	static void start() {
    		{
    			std::size_t runs = 2;
    			std::size_t result = 0;
    			for(std::size_t n = 0; n < runs; ++n) {
    				std::atomic<std::uint64_t> counter{ };
    				std::array<std::thread, ThreadCount> threads{ };
    				std::atomic_bool flag{ false };
    
    				for(std::size_t i = 0; i < ThreadCount; ++i) {
    					threads[i] = std::thread{ [&counter, &flag]() {
    						while(!flag.load()) std::this_thread::yield();
    						auto t1 = std::chrono::high_resolution_clock::now();
    						for(;;) {
    							++counter;
    							if(std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - t1).count() > 1) break;
    						}
    					} };
    				}
    
    				flag.store(true);
    				for(auto& t : threads) t.join();
    				result += counter.load();
    			}
    			std::ofstream ofs("slow.dat", std::ios::app);
    			ofs << ThreadCount << '\t' << (result / runs) << std::endl;
    		}
    		slow_test<ThreadCount - 1>::start();
    	}
    };
    
    template <>
    struct slow_test<0> {
    	static void start() { }
    };
    
    template <std::size_t ThreadCount>
    struct med_test {
    	static void start() {
    		{
    			std::size_t runs = 2;
    			std::size_t result = 0;
    			for(std::size_t n = 0; n < runs; ++n) {
    				std::array<std::atomic<std::uint64_t>, ThreadCount> counters{ };
    				std::array<std::thread, ThreadCount> threads{ };
    				std::atomic_bool flag{ false };
    
    				for(std::size_t i = 0; i < ThreadCount; ++i) {
    					auto& ref = counters[i];
    					threads[i] = std::thread{ [&ref, &flag]() {
    						while(!flag.load()) std::this_thread::yield();
    						auto t1 = std::chrono::high_resolution_clock::now();
    						for(;;) {
    							++ref;
    							if(std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - t1).count() > 1) break;
    						}
    					} };
    				}
    
    				flag.store(true);
    				for(auto& t : threads) t.join();
    				for(auto& elem : counters)
    					result += elem.load();
    			}
    			std::ofstream ofs("med.dat", std::ios::app);
    			ofs << ThreadCount << '\t' << (result / runs) << std::endl;
    		}
    		med_test<ThreadCount - 1>::start();
    	}
    };
    
    template <>
    struct med_test<0> {
    	static void start() { }
    };
    
    struct aligned {
    	std::atomic<std::uint64_t> count;
    	char pad[64];
    };
    
    template <std::size_t ThreadCount>
    struct fast_test {
    	static void start() {
    		{
    			std::size_t runs = 2;
    			std::size_t result = 0;
    			for(std::size_t n = 0; n < runs; ++n) {
    				std::array<aligned, ThreadCount> counters{ };
    				std::array<std::thread, ThreadCount> threads{ };
    				std::atomic_bool flag{ false };
    
    				for(std::size_t i = 0; i < ThreadCount; ++i) {
    					auto& ref = counters[i].count;
    					threads[i] = std::thread{ [&ref, &flag]() {
    						while(!flag.load()) std::this_thread::yield();
    						auto t1 = std::chrono::high_resolution_clock::now();
    						for(;;) {
    							++ref;
    							if(std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - t1).count() > 1) break;
    						}
    					} };
    				}
    
    				flag.store(true);
    				for(auto& t : threads) t.join();
    				for(auto& elem : counters)
    					result += elem.count.load();
    			}
    			std::ofstream ofs("fast.dat", std::ios::app);
    			ofs << ThreadCount << '\t' << (result / runs) << std::endl;
    		}
    		fast_test<ThreadCount - 1>::start();
    	}
    };
    
    template <>
    struct fast_test<0> {
    	static void start() { }
    };
    
    struct aligned2 {
    	std::uint64_t count;
    	char pad[64];
    };
    
    template <std::size_t ThreadCount>
    struct fastest_test {
    	static void start() {
    		{
    			std::size_t runs = 2;
    			std::size_t result = 0;
    			for(std::size_t n = 0; n < runs; ++n) {
    				std::array<aligned, ThreadCount> counters{ };
    				std::array<std::thread, ThreadCount> threads{ };
    				std::atomic_bool flag{ false };
    
    				for(std::size_t i = 0; i < ThreadCount; ++i) {
    					auto& ref = counters[i].count;
    					threads[i] = std::thread{ [&ref, &flag]() {
    						while(!flag.load()) std::this_thread::yield();
    						auto t1 = std::chrono::high_resolution_clock::now();
    						for(;;) {
    							++ref;
    							if(std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - t1).count() > 1) break;
    						}
    						std::atomic_thread_fence(std::memory_order::memory_order_release);
    					} };
    				}
    
    				flag.store(true);
    				for(auto& t : threads) t.join();
    				std::atomic_thread_fence(std::memory_order::memory_order_acquire);
    				for(auto& elem : counters)
    					result += elem.count;
    			}
    			std::ofstream ofs("fastest.dat", std::ios::app);
    			ofs << ThreadCount << '\t' << (result / runs) << std::endl;
    		}
    		fastest_test<ThreadCount - 1>::start();
    	}
    };
    
    template <>
    struct fastest_test<0> {
    	static void start() { }
    };
    
    int main() {
    	slow_test<31>::start();
    	med_test<31>::start();
    	fast_test<31>::start();
    	fastest_test<31>::start();
    }
    

    Am Ende gibt es 4 Dateien mit den Ergebnissen. Zum Plotten habe ich GnuPlot benutzt, mit

    set xrange [1:35]
    plot "slow.dat" using 1:2 with lines, "med.dat" using 1:2 with lines, "fast.dat" using 1:2 with lines, "fastest.dat" using 1:2 with lines
    

    Macht der Test so Sinn? Bekommt es jemand noch schneller hin? Vom Skalierverhalten her sind die letzten beiden Tests bereits schön linear, was aber verursacht bei mehr als 8 Threads, dass das Skalierverhalten so abrupt konstant wird? Sind es die Context Switches?

    Vielen Dank!



  • Hi Jodocus,

    du solltest deinem Code noch #include <atomic> und #include <thread> hinzufügen, sonst läuft der nicht.

    Ich habe leider auch noch keine Erfahrung mit Multithreading, kann dir aber gerne die Daten die bei mir herauskamen geben:

    http://www.directupload.net/file/d/4367/f8pd4vlk_png.htm][IMG]http://fs5.directupload.net/images/160526/temp/f8pd4vlk.png

    CPU: Intel i7 2600k
    mit Windows 7 x64 und Visual Studio 2015

    Ich habe keine Erklärung warum bei mir alle 4 Versionen ca. gleich schnell sind, eventuell hat der Compiler den Code optimiert.

    Die i7 Prozessoren haben 4 Prozessorkerne die jeweils doppelt angesteuert werden können, deshalb wäre meine Erklärung zum Kurvenverlauf:

    Von 2-4 Threads steigt die Geschwindigkeit linear an -> Pro Thread rechnet ein Prozessorkern mehr

    Von 5-8 linearer anstieg allerdings geringere Steigung -> Ist die Rechengeschwindigkeit des Prozessors nicht der limitierende Faktor so kann ein Prozessorkern während ein Thread z.B. auf den Speicher zugreift für einen zweiten Thread die Rechnung ausführen.

    Von 8+ der Zugriff auf die Prozessorkerne ist auf 8 Threads limitiert, mehr Threads erhöhen die Geschwindigkeit nur durch parallelen Zugriff auf den Speicher.



  • Jodocus schrieb:

    Macht der Test so Sinn?

    Kann ich mir nicht vorstellen, es sei denn, das Inkrementieren eines Zählers wäre das primäre, zu lösende Problem bei asynchronem IO. An Deiner Stelle würde ich das Interface zu Deiner Library vernünftig designen, dann ein "typisches" Anwendungsbeispiel entwerfen und dann profilen.

    Ansonsten dürfte bei Deinen Tests die Ermittlung des aktuellen Zeit in der Regel deutlich mehr Zeit kosten, als das Inkrementieren eines Zählers. Wenn schon, dann solltest Du eine feste Zahl an inkrements wählen und die dafür benötigte Zeit ermitteln (als Bonus kannst Du dann sogar kontrollieren, ob die Summe aller Inkrements stimmt).

    mfg Torsten



  • Hm okay danke für die Rückmeldungen, das Thema scheint wohl nicht ganz so einfach zu sein. Auf die Idee, das so zu testen, hat mich dieser Text gebracht, weil er etwas von Faktor 100 schrieb. Ich werde es bei Zeiten noch mal mit mehr Increments versuchen, damit die Zeitmessungen lange genug dauern.


Log in to reply