Нелинейное масштабирование операций .NET на многоядерной машине - PullRequest
8 голосов
/ 20 сентября 2009

Я столкнулся со странным поведением в приложении .NET, которое выполняет некоторую высокопараллельную обработку набора данных в памяти.

При работе на многоядерном процессоре (IntelCore2 Quad Q6600 2,4 ГГц) он демонстрирует нелинейное масштабирование, поскольку для обработки данных запускается несколько потоков.

При запуске в виде не многопоточного цикла на одном ядре процесс может выполнять примерно 2,4 миллиона вычислений в секунду. При работе с четырьмя потоками вы ожидаете пропускной способности в четыре раза больше - где-то около 9 миллионов вычислений в секунду - но, увы, нет. На практике это всего лишь около 4,1 миллиона в секунду ... совсем немного от ожидаемой пропускной способности.

Кроме того, поведение происходит независимо от того, использую ли я PLINQ, пул потоков или четыре явно созданных потока. Довольно странно ...

Больше ничего не выполняется на машине, использующей процессорное время, и при этом нет никаких блокировок или других объектов синхронизации, участвующих в вычислении ... это должно просто прорвать данные вперед. Я подтвердил это (насколько это возможно), просматривая данные perfmon во время выполнения процесса ... и не сообщается о конфликтах потоков или действиях по сбору мусора.

Мои теории на данный момент:

  1. Затраты всех техник (переключение контекста потока и т. Д.) Перегружают вычисления
  2. Потоки не присваиваются каждому из четырех ядер и проводят некоторое время в ожидании на одном и том же ядре процессора ... не знаю, как проверить эту теорию ...
  3. .NET CLR потоки не работают с ожидаемым приоритетом или имеют некоторые скрытые внутренние издержки.

Ниже приведен типичный отрывок из кода, который должен демонстрировать такое же поведение:

    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 );

Ответы [ 4 ]

5 голосов
/ 21 сентября 2009

Итак, я наконец выяснил, в чем проблема - и думаю, было бы полезно поделиться ею с SO-сообществом.

Вся проблема с нелинейной производительностью была результатом одной строки в методе Evaluate():

var coordMatrix = new long[100];

Поскольку Evaluate() вызывается миллионы раз, это выделение памяти происходило миллионы раз. Когда это происходит, CLR внутренне выполняет некоторую межпотоковую синхронизацию при выделении памяти - в противном случае распределение по нескольким потокам может непреднамеренно перекрываться. Изменение массива с локального экземпляра метода на экземпляр класса, который выделяется только один раз (но затем инициализируется в локальном цикле метода), устраняет проблему масштабируемости.

Обычно антипаттерном является создание члена уровня класса для переменной, которая используется (и имеет смысл) только в рамках одного метода. Но в этом случае, поскольку мне нужна максимально возможная масштабируемость, я буду жить (и документировать) эту оптимизацию.

Эпилог: После того как я внес это изменение, параллельный процесс смог достичь 12,2 миллиона вычислений / сек.

P.S. Благодарю Игоря Островского за его немецкую ссылку на блоги MSDN, которая помогла мне определить и диагностировать проблему.

5 голосов
/ 20 сентября 2009

Взгляните на эту статью: http://blogs.msdn.com/pfxteam/archive/2008/08/12/8849984.aspx

В частности, ограничьте выделение памяти в параллельной области и тщательно осмотрите записи, чтобы убедиться, что они не происходят вблизи областей памяти, которые другие потоки читают или пишут.

3 голосов
/ 20 сентября 2009

Нелинейное масштабирование следует ожидать с параллельным алгоритмом по сравнению с последовательным алгоритмом, поскольку при распараллеливании есть некоторые накладные расходы. (В идеале, конечно, вы хотите подойти как можно ближе.)

Кроме того, обычно в параллельном алгоритме вам нужно позаботиться о некоторых вещах, которые вам не нужны в последовательном алгоритме. Помимо синхронизации (которая действительно может затормозить вашу работу), могут произойти и другие вещи:

  • Процессор и ОС не могут посвятить все свое время вашему приложению. Таким образом, необходимо время от времени переключать контекст, чтобы позволить другим процессам выполнить некоторую работу. Когда вы используете только одно ядро, менее вероятно, что ваш процесс будет переключен, потому что у него есть три других ядра на выбор. Обратите внимание, что даже если вы думаете, что ничего не работает, ОС или некоторые службы могут выполнять некоторые фоновые операции.
  • Если каждый из ваших потоков имеет доступ к большому количеству данных, и эти данные не являются общими для потоков, вы, скорее всего, не сможете сохранить все это в кэше ЦП. Это означает, что требуется гораздо больше доступа к памяти, что (относительно) медленно.

Насколько я могу судить, ваш текущий явный подход использует общий итератор между потоками. Это нормальное решение, если обработка сильно варьируется по всему массиву, но, вероятно, возникнут накладные расходы на синхронизацию, чтобы предотвратить пропуск элемента (получение текущего элемента и перемещение внутреннего указателя на следующий элемент должно быть атомарной операцией, чтобы предотвратить пропуск элемента).

Следовательно, было бы лучше разделить массив, если предположить, что время обработки каждого элемента будет примерно одинаковым независимо от положения элемента. Учитывая, что у вас есть 10 миллионов записей, это означает, что поток 1 должен работать с элементами от 0 до 2 499 999, поток 2 работает с элементами от 2 500 000 до 4 999 999 и т. Д. Вы можете назначить каждому потоку идентификатор и использовать его для вычисления фактического диапазона.

Еще одним небольшим улучшением было бы позволить основному потоку действовать как один из вычисляющих потоков. Однако, если я правильно помню, это очень незначительная вещь.

0 голосов
/ 20 сентября 2009

Я, конечно, не ожидал бы линейных отношений, но я бы подумал, что вы бы увидели больший выигрыш, чем этот. Я предполагаю, что использование процессора максимально на всех ядрах. Всего лишь пара мыслей с моей головы.

  • Используете ли вы какие-либо общие структуры данных (явные или неявные), которые требуют синхронизации?
  • Вы пробовали профилировать или записывать счетчики производительности, чтобы определить узкое место? Можете ли вы дать больше подсказок.

Редактировать: Извините, я только что заметил, что вы уже рассмотрели оба моих пункта.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...