С# parallel foreach не дает ожидаемого ускорения

Я пытаюсь выяснить, почему параллельный foreach не дает ожидаемого ускорения на машине с 32 физическими ядрами и 64 логическими ядрами при простом тестовом вычислении.

... 
var parameters = new List<string>();
for (int i = 1; i <= 9; i++) {
    parameters.Add(i.ToString());
    if (Scenario.UsesParallelForEach)
    {
        Parallel.ForEach(parameters, parameter => {
            FireOnParameterComputed(this, parameter, Thread.CurrentThread.ManagedThreadId, "started");
            var lc = new LongComputation();
            lc.Compute();
            FireOnParameterComputed(this, parameter, Thread.CurrentThread.ManagedThreadId, "stopped");
        });
    } 
    else
    {
        foreach (var parameter in parameters)
        {
            FireOnParameterComputed(this, parameter, Thread.CurrentThread.ManagedThreadId, "started");
            var lc = new LongComputation();
            lc.Compute();
            FireOnParameterComputed(this, parameter, Thread.CurrentThread.ManagedThreadId, "stopped");
        }
    }
}
...

class LongComputation
{
    public void Compute()
    {
        var s = "";
        for (int i = 0; i <= 40000; i++)
        {
            s = s + i.ToString() + "\n";
        }
    }
}

Выполнение функции Compute занимает около 5 секунд. Мое предположение состояло в том, что с параллельным циклом foreach каждая дополнительная итерация создает параллельный поток, работающий на одном из ядер и берущий столько, сколько потребуется для вычисления функции Compute только один раз. Итак, если я запущу цикл дважды, то с последовательным foreach это займет 10 секунд, а с параллельным foreach всего 5 секунд (при условии, что доступно 2 ядра). Ускорение будет 2. Если я запущу цикл три раза, то с последовательным foreach это займет 15 секунд, но снова с параллельным foreach всего 5 секунд. Ускорение будет 3, затем 4, 5, 6, 7, 8 и 9. Однако то, что я наблюдаю, является постоянным ускорением 1,3.

Последовательный и параллельный foreach. Ось X: количество последовательных/параллельных вычислений. Ось Y: время в секундах

Ускорение, деление времени последовательного foreach на параллельное foreach

Событие, запущенное в FireOnParameterComputed, предназначено для использования в индикаторе выполнения GUI для отображения хода выполнения. В индикаторе выполнения хорошо видно, что для каждой итерации создается новый поток.

Мой вопрос: почему я не вижу ожидаемого ускорения или, по крайней мере, близкого к ожидаемому ускорению?


person Hubert    schedule 24.10.2018    source источник
comment
Вы ничего не распараллеливаете в этом коде. Вы запускаете один и тот же метод несколько раз параллельно. Если Compute занимает 5 секунд, запуск его 4 раза параллельно на четырехъядерной машине все равно займет 5 секунд. Если бы вы не использовали Parallel.Foreach, это заняло бы 20 минут.   -  person Panagiotis Kanavos    schedule 24.10.2018
comment
Конечно, если вы запускаете это на двухъядерной машине, попытка запустить более двух экземпляров одновременно не улучшит производительность. Ведь одно ядро ​​может одновременно запускать только один поток   -  person Panagiotis Kanavos    schedule 24.10.2018
comment
Объединение таких строк приводит к большим копиям памяти, эти строки занимают около половины мегабайта. Производительность этого может быть немного увеличена с несколькими ядрами, но далеко не так, как фактические вычисления.   -  person harold    schedule 24.10.2018
comment
@harold Код Compute не имеет значения. Реальные вычисления представляют собой сложные симуляции, которые занимают от нескольких секунд до минут и используют значительный объем памяти (несколько сотен мегабайт). Я намеренно выбрал неэффективный способ объединения строк, чтобы создать простой цикл, требующий нетривиального объема вычислений. Сервер, на котором я это запускаю, имеет 128 ГБ основной памяти.   -  person Hubert    schedule 24.10.2018
comment
@Hubert для реальной задачи, а также для этой фальшивой, по-прежнему дело в том, что, если она слишком сильно зависит от общего ресурса, такого как пропускная способность LLC (или пропускная способность памяти), она не будет хорошо масштабироваться для многих ядер.   -  person harold    schedule 24.10.2018
comment
@PanagiotisKanavos Это всего лишь тривиальный тестовый код, который показывает мою проблему. Реальный код включает в себя симуляцию, которая вычисляет разные результаты при каждом запуске. Что я хотел бы сделать, так это передать каждую симуляцию одному ядру и выполнить вычисления там. Если это возможно, то время для запуска n симуляций (n ‹= количество доступных ядер) должно быть таким же, как и для однократного запуска симуляции (в моем случае все симуляции будут выполняться одинаково, независимо от результата). . Мой эксперимент с тривиальной вычислительной функцией показывает, что это не так.   -  person Hubert    schedule 24.10.2018
comment
Например, если я попытаюсь использовать более простой тестовый код подобный этому, то накладные расходы на параллелизм будут небольшими и он хорошо масштабируется до нескольких ядер (здесь я не использовал очень агрессивное число, так как использую четырехъядерный процессор)   -  person harold    schedule 24.10.2018
comment
Я заметил, что в режиме отладки параллельный цикл для каждого цикла будет работать очень медленно, иногда медленнее, чем обычный цикл ForEach. Попробуйте собрать его в режиме выпуска и запустить .exe и посмотреть, ускорит ли это процесс.   -  person pg1988    schedule 24.10.2018
comment
@ pg1988 Мы не занимаемся разработкой на вычислительном сервере. Вместо этого мы создаем релизную версию и устанавливаем ее на компьютер-сервер. Таким образом, тесты производительности не проводились в режиме отладки.   -  person Hubert    schedule 24.10.2018
comment
@Hubert код не показывает никаких проблем, если только это не непонимание того, что делает Parallel.ForEach. Parallel.For/ForEach имеет смысл внутри Compute. Compute должен содержать Parallel.For/ForEach вместо цикла. Это будет разделять входные данные на столько разделов, сколько ядер (примерно) и использовать одну задачу для обработки каждого раздела на полной скорости. Для 4 ядер это может привести к улучшению на 400%. Это не то, что делает код вопросов, это почти как если бы он пытался использовать Parallel.ForEach как Task.Run для одновременного запуска нескольких экземпляров Compute   -  person Panagiotis Kanavos    schedule 25.10.2018
comment
@PanagiotisKanavos В чем разница между обычным циклом foreach и последующим использованием Task.Run для запуска кода Compute вместо использования цикла Parallel.ForEach? Разве оба не должны вести себя одинаково?   -  person Hubert    schedule 25.10.2018
comment
@harold Спасибо за ваш вклад. Действительно, разные потоки борются за доступ к основной памяти, что все тормозит. Я изменил код Compute, чтобы он не использовал много памяти, и теперь получаю ожидаемое ускорение.   -  person Hubert    schedule 25.10.2018
comment
Я полагаю, вы могли бы использовать спящий режим потока или task.delay для имитации долго работающей функции.   -  person Magnus    schedule 01.07.2020


Ответы (1)


Задачи — это не потоки.

Иногда запуск задачи приводит к созданию потока, но не всегда. Создание потоков и управление ими требует времени и системных ресурсов. Когда задача занимает короткое время, даже если это противоречит здравому смыслу, однопоточная модель часто работает быстрее.

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

Для Parallel.ForEach, если вы уверены, что хотите создать несколько потоков, попробуйте передать ParallelOptions.

Parallel.ForEach(parameters, new ParallelOptions { MaxDegreeOfParallelism = 100 }, parameter => {});
person Doug Johnson    schedule 24.10.2018
comment
Мы оснастили наш код таким образом, чтобы он показывал, выполняется ли каждое вычисление в новом потоке или нет. В приведенных выше примерах все вычисления выполняются в собственных потоках на вычислительном сервере. - person Hubert; 25.10.2018
comment
Мы уже используем MaxDegreeOfParallelism в другой части кода. Насколько я заметил (и прочитал), это то, что MaxDegreeOfParallelism можно использовать только для ограничения количества задач, которые ОС выбирает для запуска в своем собственном потоке, но не для увеличения этого числа. - person Hubert; 25.10.2018