Inter-Core-Datentransfer-Latenz: Versuchskaninchen gesucht

V

VikingGe

Gast
Guten Morgen,

nachdem ich mal beim Programmieren ein paar threadsichere Datenstrukturen gebenchmarkt habe und dabei teils überraschende Ergebnisse herauskamen, würde mich doch mal interessieren, wie genau sich verschiedene CPUs beim Datentransfer zwischen mehreren Kernen verhalten. Dazu habe ich mal einen kleinen Benchmark geschrieben:

Code:
#include <chrono>
#include <iostream>
#include <thread>

#include <cstddef>
#include <cstdint>

void count_down(volatile int64_t& counter, int64_t divisor) {
  int64_t current = counter;
  while (current > 0) {
    while ((current & 1l) != divisor)
      current = counter;
    counter = --current;
  }
}

template<typename Fn>
std::chrono::milliseconds timed_run(const Fn& function) {
  auto start = std::chrono::high_resolution_clock::now();
  function();
  return std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::high_resolution_clock::now() - start);
}

int main(int argc, char** argv) {
  
  constexpr int64_t loop_count  =  100000000l;
  constexpr int64_t loop_factor = 1000000000l / loop_count;
  
  auto ms = timed_run([] {
    volatile int64_t counter(loop_count);
    std::thread t1([&counter] { count_down(counter, 0l); });
    std::thread t2([&counter] { count_down(counter, 1l); });
    t1.join();
    t2.join();
  });
  
  const double latency = static_cast<double>(loop_factor * ms.count()) / 1000.0;
  std::cout << latency << " ns" << std::endl;
  
}

Compilieren mit:
Code:
g++ -std=c++11 -O3 -pthread -o bench bench.cpp
oder mit irgendeinem anderen Compiler, der sowohl C++11 als auch ein paar Optimierungen beherrscht.

Jetzt suche ich Leute, die das Ding mal testweise ausführen. Das Programm macht nichts weiter, als einen Zähler 100 Millionen mal herunterzuzählen, wobei ein Thread die geraden Zahlen übernimmt und ein anderer die ungeraden, sodass jeder Zählschritt abwechselnd im jeweils anderen Thread stattfindet. Das ganze dauert einige Sekunden.


Ein paar Anmerkungen für die Leute, die sich auskennen:
- Ein Delay von einigen Takten zwischen den Loads, um die CPU zu entlasten, bringt hier scheinbar überhaupt nichts.
- Ich habe ganz bewusst auf atomare Operationen verzichtet. Die sind hier einerseits nicht nötig, andererseits auch eine völlig andere Baustelle - ich will hier lediglich ermitteln, wie lang es dauert, bis ein Kern einen Store eines anderen Kerns "sieht", ohne, dass sonst viel passiert.
- Ich weiß selbst, dass der Test nicht genug Aussagekraft hat, um damit die ganze Welt zu erklären.


Ergebnisse, die ich bereits habe:

i7-4770K @3.5, 1 Kern26.05 nsNai
A10-7350B @3.3, 1 Modul29.22 ns
i5-2500 @3.342.42 ns:freak:
i5-4670 @3.453.07 nsstrex
i7-4720HQ53.80 nsR4Z3R
i7-4770K @3.554.13 nsNai
i5-6600K @3.558.24 nsMr.Seymour Buds
Athlon 5350 @2.161.65 ns
i5-2500 @1.762.56 nstitanskin
Phenom II X6 @4.1/3.077.94 ns
A10-7350B @2.8217.94 ns
2x Xeon E5345 @2.33, 1 CPU1905.95 ns
2x Xeon E5345 @2.33, 2 CPUs1981.54 ns


Um bei AMD-CPUs zu verhindern, dass die beiden Threads des Programms auf demselben Modul laufen:
Code:
taskset -c 0,2 ./bench
Um zu erzwingen, dass nur ein Modul genutzt wird:
Code:
taskset -c 0,1 ./bench

Wie bei Intel+SMT die Aufteilung ist, weiß ich nicht, da muss man aber auch darauf achten.

Mich würden vor allem Ergebnisse von aktuellen Intel-CPUs und von FX-Chips mit ggf. NB-OC interessieren, prinzipiell ist aber alles gern gesehen. Schreibt aber auch bitte dabei, mit welchem Takt eure CPU läuft, und ggf. auch RAM-Takt und -Timings. Wäre nett, wenn sich jemand beteiligen würde :)
 
Zuletzt bearbeitet:
Da die meisten user ihre CPU im idle runter takten wäre es wohl sinnvoll als profil Höchstleistung zu wählen. Sonst können stark schwankende ergebnisse entstehen. Außerdem sollte die Thread priorität auch hoch gedreht werden!
 
Core i7 - 4770k @ 3.5 GHZ, Windows 10, Visual Studio 2013, 2400 MHZ DDR-RAM mit Timings um die 10 und mit einem leicht modifizierten Quelltext:
-54.13 ns unterschiedliche Cores
-26.05 ns auf dem selben Core

Code:
#include <chrono>
#include <iostream>
#include <thread>

#include <cstddef>
#include <cstdint>
#include "Windows.h"


void count_down(volatile int64_t& counter, int64_t divisor, int CoreID) 
{
	SetThreadAffinityMask(GetCurrentThread(), 1 << CoreID);

	int64_t current = counter;
	while (current > 0) {
		while ((current & 1l) != divisor)
			current = counter;
		counter = --current;
	}
}


int main(int argc, char** argv) {

	int64_t loop_count = 100000000l;
	int64_t loop_factor = 1000000000l / loop_count;

	int CoreID1 = 0;
	int CoreID2 = 2;


	auto start = std::chrono::high_resolution_clock::now();

	volatile int64_t counter(loop_count);
	std::thread t1([&counter, CoreID1] { count_down(counter, 0, CoreID1); });
	std::thread t2([&counter, CoreID2] { count_down(counter, 1, CoreID2); });
	t1.join();
	t2.join();


	auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
		std::chrono::high_resolution_clock::now() - start);
	const double latency = static_cast<double>(loop_factor * ms.count()) / 1000.0;
	std::cout << latency << " ns" << std::endl;

	system("pause");
}
 
Zuletzt bearbeitet:
Intel Core i7-4720HQ, 3,6 Ghz Turbo, 4C/8T (HyperThreading aktiv), DDR3L-1600, HM87 Board.

QtCreator Release-Profile (nicht sicher ob die Optimierungen gegriffen haben), MSVC 2015 Compiler, Windows 10 x64, Energieprofil Höchstleistung, Intel-Grafik

Bester Lauf nach einigen Versuchen: 53,8ns. Ansonsten Jitter nach oben (55-58s, manchmal größere Ausreißer vermutlich wg. Last-Spikes).

(hatte grade noch nen Spike auf 51ns, aber eventuell war das eher ein Artefakt durch SMT - wie bei den anderen hier scheint Haswell ungefähr bei ~54ns zu liegen idr.)
 
Zuletzt bearbeitet:
Der Test bringt aus mehreren Gründen nichts:

1. Da hier Messungen im niedrigen Nanosekundenbereich gemacht werden ist die Gangungenauigkeit deiner Clock Source wahrscheinlich so groß, dass du unterschiedliche Hardwarekomponenten wahrscheinlich garnicht miteinander vergleichen kannst.

2. Der Betriebssystemscheduler wird völlig ignoriert.

3. Operationen sind nicht atomar. Auf x86 sind 64 Bit store Befehle sicherlich nicht atomar. Dort hast du undefiniertes Verhalten. Bestenfalls funktioniert es auf x64 korrekt.
 
1. Die Synchronisation wird ja mit 100 000 000 sehr oft ausgeführt wodurch die gesamte Messung so 6 Sekunden dauert. Dann wird aber von der Gesamtzeit auf die Zeit einer einzigen Synchronisation geschlossen. Dementsprechend ist die Auflösung des Timers zu vernachlässigen.
2. Ebenfalls ein zu vernachlässigender Fehler, da das OS die Threads die meiste Zeit rechnen lässt und sie nur selten zwischen den Kernen hin und her bewegt oder sogar idlen lässt.
3. Selbst dieser Fehler sollte sich nur in seltenen "Ausreißern" bei den Messungen äußern, da die oberen bytes des Zählers nur sehr selten manipuliert werden. Bei den aktuellen Wert für loop_count von 100 000 000 sind die oberen bytes alle 0 und werden deshalb überhaupt nicht manipuliert. Dementsprechend kann der Fehler hier nicht auftreten.

Nachtrag: 64 bit stores sind selbst bei x32 atomar:
The Intel486 processor (and newer processors since) guarantees that the following
basic memory operations will always be carried out atomically:
• Reading or writing a byte
• Reading or writing a word aligned on a 16-bit boundary
• Reading or writing a doubleword aligned on a 32-bit boundary
The Pentium processor (and newer processors since) guarantees that the following
additional memory operations will always be carried out atomically:
• Reading or writing a quadword aligned on a 64-bit boundary
• 16-bit accesses to uncached memory locations that fit within a 32-bit data bus
The P6 family processors (and newer processors since) guarantee that the following
additional memory operation will always be carried out atomically:
• Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache
line

Nachtrag II: Ist doch nicht atomar. Der Text bezieht sich in diesem Fall nur auf SIMD instruktionen. Bei skalaren 64 bit stores baut der Compiler zwei 32 bit store Instruktionen ein.
 
Zuletzt bearbeitet:
Nai schrieb:
1. Die Synchronisation wird ja mit 100 000 000 sehr oft ausgeführt wodurch die gesamte Messung so 6 Sekunden dauert. Dann wird aber von der Gesamtzeit auf die Zeit einer einzigen Synchronisation geschlossen. Dementsprechend ist die Auflösung des Timers zu vernachlässigen.
2. Ebenfalls ein zu vernachlässigender Fehler, da das OS die Threads die meiste Zeit rechnen lässt und sie nur selten zwischen den Kernen hin und her bewegt oder sogar idlen lässt.

Ok, habs nicht ausgeführt sondern nur die Zahlen gelesen.
 
In einer VM (VirtualBox) mit Ubuntu 15.10 x64 ergibt das Ganze für den i7-4720HQ im besten Fall ca 58,39ns, mit den Werten ansonsten meist im Rahmen 59-62ns.
 
Erstmal danke für die Werte. Hätte anhand des i5-2500 ja gedacht, dass Haswell besser abschneidet, aber die Ergebnisse sind ja durchaus konsistent.

Hat jemand eine Idee, warum das ganze auf demselben Kern bei dem Intel so langsam ist? MOESI-Probleme (die Cache Line wechselt mit etwas Pech ja 1x den "Besitzer")? Greift das Store-to-Load-Forwarding nur bei einem Thread? Gerade in dem Fall hätte ich jedenfalls mit deutlich geringeren Zahlen, eher im einstelligen Bereich, gerechnet.

Bin mal gespannt, ob noch jemand mit nem Skylake oder nem FX vorbei kommt.

@wayne_757 @Nai zu der 32 Bit-Problematik: Da habe ich mir ehrlich gesagt nicht einmal Gedanken drum gemacht, weil ich davon ausging, dass ohnehin jeder mit ner x86-64-Plattform unterwegs ist und auch eine entsprechende Toolchain nutzt. Und da sind die Stores eben doch wieder atomar. Ursprünglich hatte ich den Loop in Assembler geschrieben, aber das hätte dann wieder nur mit GCC und Clang funktioniert.
 
Zuletzt bearbeitet:
Geforce Titan @ 837 MHZ: 450.2 ns
Code:
#include <cuda.h>
#include <cuda_runtime.h>
#include <chrono>
#include <iostream>
#include <thread>

#include <cstddef>
#include <cstdint>
#include "Windows.h"




__global__ void count_downKernel(volatile int* counter)
{
	int divisor = 0;
	if (blockIdx.x == 1)
		divisor = 1;

	int current = *counter;

	while (current > 0)
	{
		while ( (current & 1)  != divisor)
		{
			current = *counter;
		}

		*counter = --current;

	}
}

int main(int argc, char** argv) {

	int loop_count = 4000000l;
	int loop_factor = 1000000000l / (float) loop_count;

	int CoreID1 = 0;
	int CoreID2 = 1;


	auto start = std::chrono::high_resolution_clock::now();



	int IterCount = 5;

	int* CounterGPU;
	cudaMalloc(&CounterGPU, sizeof(int));


	for (int i = 0; i < IterCount; i++)
	{
		cudaMemcpy(CounterGPU, &loop_count, sizeof(int), cudaMemcpyDefault);
		//volatile int64_t counter(loop_count);
		//std::thread t1([&counter, CoreID1] { count_down(counter, 0, CoreID1); });
		//std::thread t2([&counter, CoreID2] { count_down(counter, 1, CoreID2); });
		//t1.join();
		//t2.join();
		count_downKernel << <2, 1 >> >(CounterGPU);

		cudaDeviceSynchronize();
	}

	auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
		std::chrono::high_resolution_clock::now() - start);
	const double latency = static_cast<double>(loop_factor * ms.count()) / 1000.0 / (float)IterCount;
	std::cout << latency << " ns" << std::endl;
	system("pause");
}
 
Zuletzt bearbeitet:
58.24 ns

Lubuntu 14.04 LTS
i5-6600K (stock)
 
Zurück
Oben