Нелинейное масштабирование of.NET операции на многоядерной машине

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

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

при запуске в виде не многопоточного цикла на одном ядре процесс может выполнять примерно 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 );

5 ответов


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

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


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

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

var coordMatrix = new long[100];

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

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

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

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


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

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

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

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

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

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


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

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

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


Я задал аналогичный вопрос здесь под названием " Почему мое потоковое приложение .Net не масштабируется линейно при выделении больших объемов памяти?"

почему мое потоковое приложение .Net не масштабируется линейно при выделении больших объемов памяти?