Napotkano na dziwne zachowanie w aplikacji .NET, która wykonuje wysoce równoległe przetwarzanie na zbiorze danych w pamięci.Nieliniowe skalowanie operacji .NET na wielordzeniowej maszynie
Po uruchomieniu na procesorze wielordzeniowym (Intel Core 2 Quad Q6600 2,4 GHz) wykazuje nieliniowe skalowanie, ponieważ wiele wątków jest uruchamianych w celu przetworzenia danych.
Po uruchomieniu jako wielowątkowa pętla na pojedynczym rdzeniu proces może wykonać około 2,4 miliona obliczeń na sekundę. Po uruchomieniu jako cztery wątki można oczekiwać czterokrotnie większej przepustowości - gdzieś w sąsiedztwie 9 milionów obliczeń na sekundę - ale niestety, nie. W praktyce kończy się to około 4,1 miliona na sekundę ... całkiem niewiele od oczekiwanej przepustowości.
Co więcej, zachowanie to występuje bez względu na to, czy używam PLINQ, puli wątków, czy czterech bezpośrednio utworzonych wątków. Dość dziwne ...
Nic więcej nie jest uruchomione na komputerze przy użyciu czasu procesora, ani nie ma żadnych blokad ani innych obiektów synchronizacji zaangażowanych w obliczenia ... powinno tylko przedrzeć się przez dane. Potwierdziłem to (w miarę możliwości), patrząc na dane perfmon, podczas gdy proces jest uruchamiany ... i nie ma zgłaszanych wątków lub działań usuwania śmieci.
Moje teorie w tej chwili:
- szczytowy wszystkich technik (przełącza kontekst nici, etc) jest przytłaczająca obliczeń
- Nici nie są coraz przypisane do każdego z czterech rdzeni i spędzić trochę czasu czekając na ten sam rdzeń procesora .. nie wiesz jak przetestować tę teorię ...
- .NET CLR wątki nie działają zgodnie z oczekiwanym priorytetem lub mają jakiś ukryty wewnętrzny narzut.
Poniżej jest reprezentatywny fragment kodu, który powinien wykazywać takie samo zachowanie:
var evaluator = new LookupBasedEvaluator();
// find all ten-vertex polygons that are a subset of the set of points
var ssg = new SubsetGenerator<PolygonData>(Points.All, 10);
const int TEST_SIZE = 10000000; // evaluate the first 10 million records
// materialize the data into memory...
var polygons = ssg.AsParallel()
.Take(TEST_SIZE)
.Cast<PolygonData>()
.ToArray();
var sw1 = Stopwatch.StartNew();
// for loop completes in about 4.02 seconds... ~ 2.483 million/sec
foreach(var polygon in polygons)
evaluator.Evaluate(polygon);
s1.Stop();
Console.WriteLine("Linear, single core loop: {0}", s1.ElapsedMilliseconds);
// now attempt the same thing in parallel using Parallel.ForEach...
// MS documentation indicates this internally uses a worker thread pool
// completes in 2.61 seconds ... or ~ 3.831 million/sec
var sw2 = Stopwatch.StartNew();
Parallel.ForEach(polygons, p => evaluator.Evaluate(p));
sw2.Stop();
Console.WriteLine("Parallel.ForEach() loop: {0}", s2.ElapsedMilliseconds);
// now using PLINQ, er get slightly better results, but not by much
// completes in 2.21 seconds ... or ~ 4.524 million/second
var sw3 = Stopwatch.StartNew();
polygons.AsParallel(Environment.ProcessorCount)
.AsUnordered() // no sure this is necessary...
.ForAll(h => evalautor.Evaluate(h));
sw3.Stop();
Console.WriteLine("PLINQ.AsParallel.ForAll: {0}", s3.EllapsedMilliseconds);
// now using four explicit threads:
// best, still short of expectations at 1.99 seconds = ~ 5 million/sec
ParameterizedThreadStart tsd = delegate(object pset) { foreach (var p in (IEnumerable<Card[]>) pset) evaluator.Evaluate(p); };
var t1 = new Thread(tsd);
var t2 = new Thread(tsd);
var t3 = new Thread(tsd);
var t4 = new Thread(tsd);
var sw4 = Stopwatch.StartNew();
t1.Start(hands);
t2.Start(hands);
t3.Start(hands);
t4.Start(hands);
t1.Join();
t2.Join();
t3.Join();
t4.Join();
sw.Stop();
Console.WriteLine("Four Explicit Threads: {0}", s4.EllapsedMilliseconds);
Można to również rozwiązać, korzystając z puli zasobów. To pytanie pomogło mi zrozumieć, dlaczego pule mogą być ważne podczas próby wykonywania masowych równoległych operacji. – Will
Sp twoja oryginalna nierównoległa implementacja działa z szybkością 2.4Mop/s, twoja najnowsza, zoptymalizowana wersja na 4 rdzeniach działa przy 12,2Mop/s. To super-liniowe skalowanie, które jest godne uwagi i warte zbadania. Czy po wprowadzeniu zmiany nie powtórzyłeś ponownie wykonania pojedynczego klucza kodu? –
Zmiana alokacji pamięci poprawiła wydajność pojedynczego rdzenia do 3,2 Mopsa, więc wyniki 4-rdzeniowe 12.2 są uzasadnione. – LBushkin