Должен ли я всегда использовать Parallel.Foreach, потому что большее количество потоков ДОЛЖНО ускорять все?
Имеет ли для вас смысл использовать для каждого нормального foreach цикл параллельного.foreach?
Когда я должен начать использовать параллельный. Foreach, только итерируя 1 000 000 пунктов?
8 ответов
Нет, это не имеет смысла для каждого foreach. Несколько причин:
- Ваш код не может быть на самом деле распараллеливаемым. Например, если вы используете "результаты до сих пор" для следующей итерации и порядок важен)
- Если вы агрегируете (например, суммируете значения), то есть способы использования
Parallel.ForEach
для этого, но вы не должны делать это вслепую - Если ваша работа в любом случае будет выполнена очень быстро, это не принесет никакой пользы, и это может замедлить процесс
В принципе ничего в потоке нельзя делать вслепую. Подумайте, где на самом деле имеет смысл распараллеливать. Да, и оцените влияние, чтобы убедиться, что выгода стоит добавленной сложности. (Это будет сложнее для таких вещей, как отладка.) TPL - это здорово, но это не бесплатный обед.
Нет, тебе определенно не следует этого делать. Важным моментом здесь является не количество итераций, а работа, которую необходимо выполнить. Если ваша работа действительно проста, параллельное выполнение 1000000 делегатов добавит огромных накладных расходов и, скорее всего, будет медленнее, чем традиционное однопоточное решение. Вы можете обойти это, разделив данные, так что вместо этого вы выполняете куски работы.
Например, рассмотрим ситуацию ниже:
Input = Enumerable.Range(1, Count).ToArray();
Result = new double[Count];
Parallel.ForEach(Input, (value, loopState, index) => { Result[index] = value*Math.PI; });
Операция здесь настолько проста, что накладные расходы, связанные с параллельным выполнением, уменьшат выгоду от использования нескольких ядер. Этот код работает значительно медленнее, чем обычный цикл foreach.
Используя раздел, мы можем уменьшить накладные расходы и реально наблюдать увеличение производительности.
Parallel.ForEach(Partitioner.Create(0, Input.Length), range => {
for (var index = range.Item1; index < range.Item2; index++) {
Result[index] = Input[index]*Math.PI;
}
});
Мораль этой истории такова, что параллелизм сложен, и вы должны применять его только после внимательного изучения ситуации. Кроме того, вы должны профилировать код до и после добавления параллелизма.
Помните, что независимо от любого потенциального увеличения производительности параллелизм всегда добавляет сложности к коду, поэтому, если производительность уже достаточно высока, нет причин добавлять сложность.
Короткий ответ: нет, вы не должны просто использовать Parallel.ForEach
или связанные конструкции в каждом цикле, который вы можете. Параллель имеет некоторые издержки, которые не оправданы в циклах с небольшим количеством быстрых итераций. Также, break
значительно сложнее внутри этих петель.
Parallel.ForEach
это запрос на планирование цикла, как планировщик задач сочтет нужным, основываясь на количестве итераций в цикле, количестве ядер ЦП на оборудовании и текущей загрузке на этом оборудовании. Фактическое параллельное выполнение не всегда гарантируется и менее вероятно, если имеется меньше ядер, число итераций мало и / или текущая нагрузка высока.
См. Также Ограничивает ли Parallel.ForEach количество активных потоков? и использует ли Parallel.For одну задачу на итерацию?
Длинный ответ:
Мы можем классифицировать петли по тому, как они падают на две оси:
- От нескольких итераций до многих итераций.
- Каждая итерация проходит быстро, и каждая итерация идет медленно.
Третий фактор заключается в том, что если задачи сильно различаются по продолжительности - например, если вы вычисляете баллы на множестве Мандельброта, некоторые баллы вычисляются быстро, а некоторые занимают гораздо больше времени.
Когда имеется несколько быстрых итераций, вероятно, не стоит использовать распараллеливание каким-либо образом, скорее всего, оно будет медленнее из-за накладных расходов. Даже если распараллеливание ускоряет конкретный небольшой быстрый цикл, оно вряд ли будет представлять интерес: выигрыш будет небольшим, и это не является узким местом для производительности вашего приложения, поэтому оптимизируйте его для удобства чтения, а не производительности.
Там, где цикл имеет очень мало медленных итераций и вам нужен больший контроль, вы можете рассмотреть возможность использования Задач для их обработки по следующим направлениям:
var tasks = new List<Task>(actions.Length);
foreach(var action in actions)
{
tasks.Add(Task.Factory.StartNew(action));
}
Task.WaitAll(tasks.ToArray());
Там, где много итераций, Parallel.ForEach
в своей стихии.
Документация Microsoft гласит, что
Когда выполняется параллельный цикл, TPL разделяет источник данных, чтобы цикл мог одновременно работать с несколькими частями. За кулисами планировщик заданий разбивает задачу на основе системных ресурсов и рабочей нагрузки. Когда это возможно, планировщик перераспределяет работу между несколькими потоками и процессорами, если рабочая нагрузка становится несбалансированной.
Это разделение и динамическое перепланирование будет труднее эффективно выполнять, так как число итераций цикла уменьшается, и оно становится более необходимым, если итерации различаются по продолжительности и при наличии других задач, выполняющихся на той же машине.
Я запустил некоторый код.
Приведенные ниже результаты теста показывают машину, на которой больше ничего не работает, и никакие другие потоки из.Net Thread Pool не используются. Это не типично (на самом деле в сценарии с веб-сервером это крайне нереально). На практике вы можете не увидеть распараллеливания с небольшим количеством итераций.
Тестовый код:
namespace ParallelTests
{
class Program
{
private static int Fibonacci(int x)
{
if (x <= 1)
{
return 1;
}
return Fibonacci(x - 1) + Fibonacci(x - 2);
}
private static void DummyWork()
{
var result = Fibonacci(10);
// inspect the result so it is no optimised away.
// We know that the exception is never thrown. The compiler does not.
if (result > 300)
{
throw new Exception("failed to to it");
}
}
private const int TotalWorkItems = 2000000;
private static void SerialWork(int outerWorkItems)
{
int innerLoopLimit = TotalWorkItems / outerWorkItems;
for (int index1 = 0; index1 < outerWorkItems; index1++)
{
InnerLoop(innerLoopLimit);
}
}
private static void InnerLoop(int innerLoopLimit)
{
for (int index2 = 0; index2 < innerLoopLimit; index2++)
{
DummyWork();
}
}
private static void ParallelWork(int outerWorkItems)
{
int innerLoopLimit = TotalWorkItems / outerWorkItems;
var outerRange = Enumerable.Range(0, outerWorkItems);
Parallel.ForEach(outerRange, index1 =>
{
InnerLoop(innerLoopLimit);
});
}
private static void TimeOperation(string desc, Action operation)
{
Stopwatch timer = new Stopwatch();
timer.Start();
operation();
timer.Stop();
string message = string.Format("{0} took {1:mm}:{1:ss}.{1:ff}", desc, timer.Elapsed);
Console.WriteLine(message);
}
static void Main(string[] args)
{
TimeOperation("serial work: 1", () => Program.SerialWork(1));
TimeOperation("serial work: 2", () => Program.SerialWork(2));
TimeOperation("serial work: 3", () => Program.SerialWork(3));
TimeOperation("serial work: 4", () => Program.SerialWork(4));
TimeOperation("serial work: 8", () => Program.SerialWork(8));
TimeOperation("serial work: 16", () => Program.SerialWork(16));
TimeOperation("serial work: 32", () => Program.SerialWork(32));
TimeOperation("serial work: 1k", () => Program.SerialWork(1000));
TimeOperation("serial work: 10k", () => Program.SerialWork(10000));
TimeOperation("serial work: 100k", () => Program.SerialWork(100000));
TimeOperation("parallel work: 1", () => Program.ParallelWork(1));
TimeOperation("parallel work: 2", () => Program.ParallelWork(2));
TimeOperation("parallel work: 3", () => Program.ParallelWork(3));
TimeOperation("parallel work: 4", () => Program.ParallelWork(4));
TimeOperation("parallel work: 8", () => Program.ParallelWork(8));
TimeOperation("parallel work: 16", () => Program.ParallelWork(16));
TimeOperation("parallel work: 32", () => Program.ParallelWork(32));
TimeOperation("parallel work: 64", () => Program.ParallelWork(64));
TimeOperation("parallel work: 1k", () => Program.ParallelWork(1000));
TimeOperation("parallel work: 10k", () => Program.ParallelWork(10000));
TimeOperation("parallel work: 100k", () => Program.ParallelWork(100000));
Console.WriteLine("done");
Console.ReadLine();
}
}
}
результаты на 4-ядерном компьютере с Windows 7:
serial work: 1 took 00:02.31
serial work: 2 took 00:02.27
serial work: 3 took 00:02.28
serial work: 4 took 00:02.28
serial work: 8 took 00:02.28
serial work: 16 took 00:02.27
serial work: 32 took 00:02.27
serial work: 1k took 00:02.27
serial work: 10k took 00:02.28
serial work: 100k took 00:02.28
parallel work: 1 took 00:02.33
parallel work: 2 took 00:01.14
parallel work: 3 took 00:00.96
parallel work: 4 took 00:00.78
parallel work: 8 took 00:00.84
parallel work: 16 took 00:00.86
parallel work: 32 took 00:00.82
parallel work: 64 took 00:00.80
parallel work: 1k took 00:00.77
parallel work: 10k took 00:00.78
parallel work: 100k took 00:00.77
done
Выполнение кода, скомпилированного в.Net 4 и.Net 4.5, дает практически одинаковые результаты.
Серийные работы выполняются все одинаково. Неважно, как вы его нарезаете, он работает примерно за 2,28 секунды.
Параллельная работа с 1 итерацией немного длиннее, чем вообще без параллелизма. 2 элемента короче, так что 3 и с 4 или более итерациями все около 0,8 секунд.
Он использует все ядра, но не со 100% эффективностью. Если бы серийная работа была разделена на 4 части без дополнительных затрат, она бы завершилась за 0,57 секунды (2,28 / 4 = 0,57).
В других сценариях я вообще не видел ускорения с параллельными 2-3 итерациями. У вас нет детального контроля над этим с Parallel.ForEach
и алгоритм может решить "разделить" их на один блок и запустить его на 1 ядре, если машина занята.
Не существует нижнего предела для выполнения параллельных операций. Если у вас есть только 2 элемента для работы, но каждый из них займет некоторое время, возможно, имеет смысл использовать Parallel.ForEach
, С другой стороны, если у вас есть 1000000 элементов, но они делают не очень много, параллельный цикл может работать не быстрее обычного цикла.
Например, я написал простую программу для определения времени вложенных циклов, где внешний цикл выполнялся с for
цикл и с Parallel.ForEach
, Я рассчитал это на своем 4-х процессорном (двухъядерном, гиперпоточном) ноутбуке.
Вот прогон с двумя предметами для работы, но каждый занимает некоторое время:
2 внешних итерации, 100000000 внутренних итераций: для цикла: 00:00:00.1460441 ForEach: 00:00:00.0842240
Вот пример с миллионами предметов для работы, но они не делают очень много:
100000000 внешних итераций, 2 внутренних итерации: для цикла: 00:00:00.0866330 ForEach: 00:00:02.1303315
Единственный реальный способ узнать это попробовать.
Как правило, если вы превысите поток на ядро, каждый дополнительный поток, участвующий в операции, будет делать это медленнее, а не быстрее.
Однако, если часть каждой операции будет блокироваться (классический пример - ожидание на диске или сетевой ввод-вывод, а другой - производители и потребители, которые не синхронизированы друг с другом), тогда больше потоков, чем ядер, могут начать ускорять процесс, потому что задачи могут быть выполнены, в то время как другие потоки не могут прогрессировать, пока не вернется операция ввода-вывода.
По этой причине, когда одноядерные машины были нормой, единственными реальными оправданиями в многопоточности были случаи, когда либо происходило блокирование ввода-вывода сортировки, либо для улучшения отзывчивости (немного медленнее для выполнения задачи, но гораздо быстрее чтобы начать отвечать на ввод пользователя снова).
Тем не менее, в наши дни одноядерные машины встречаются все реже, поэтому может показаться, что с параллельной обработкой все должно быть как минимум в два раза быстрее.
Это все равно не будет иметь место, если порядок важен, или что-то присущее задаче заставляет его иметь синхронизированное узкое место, или если число операций настолько мало, что увеличение скорости от параллельной обработки перевешивается накладными расходами, вовлеченными в настраивая эту параллельную обработку. Это может или не может иметь место, если ресурс общего ресурса требует, чтобы потоки блокировали другие потоки, выполняющие ту же самую параллельную операцию (в зависимости от степени конкуренции за блокировку).
Кроме того, если ваш код изначально является многопоточным, вы можете оказаться в ситуации, когда вы по существу конкурируете за ресурсы с самим собой (классическим случаем является код ASP.NET, обрабатывающий одновременные запросы). Здесь преимущество в параллельной работе может означать, что одна тестовая операция на 4-ядерном компьютере увеличивает производительность в 4 раза, но как только число запросов, для которых требуется выполнить ту же задачу, достигает 4, то каждый из этих 4 запросов является пытаясь использовать каждое ядро, становится немного лучше, чем если бы у них было ядро каждое (возможно, немного лучше, возможно, немного хуже). Преимущества параллельной работы, следовательно, исчезают по мере того, как использование изменяется от теста с одним запросом к реальному множеству запросов.
Не следует слепо заменять каждый отдельный цикл foreach в вашем приложении на параллельный foreach. Больше потоков не обязательно означает, что ваше приложение будет работать быстрее. Вам нужно разделить задачу на более мелкие задачи, которые могут выполняться параллельно, если вы действительно хотите использовать несколько потоков. Если ваш алгоритм не распараллелен, вы не получите никакой выгоды.
Это мои тесты, показывающие, что чистый серийный номер самый медленный, наряду с различными уровнями разбиения.
class Program
{
static void Main(string[] args)
{
NativeDllCalls(true, 1, 400000000, 0); // Seconds: 0.67 |) 595,203,995.01 ops
NativeDllCalls(true, 1, 400000000, 3); // Seconds: 0.91 |) 439,052,826.95 ops
NativeDllCalls(true, 1, 400000000, 4); // Seconds: 0.80 |) 501,224,491.43 ops
NativeDllCalls(true, 1, 400000000, 8); // Seconds: 0.63 |) 635,893,653.15 ops
NativeDllCalls(true, 4, 100000000, 0); // Seconds: 0.35 |) 1,149,359,562.48 ops
NativeDllCalls(true, 400, 1000000, 0); // Seconds: 0.24 |) 1,673,544,236.17 ops
NativeDllCalls(true, 10000, 40000, 0); // Seconds: 0.22 |) 1,826,379,772.84 ops
NativeDllCalls(true, 40000, 10000, 0); // Seconds: 0.21 |) 1,869,052,325.05 ops
NativeDllCalls(true, 1000000, 400, 0); // Seconds: 0.24 |) 1,652,797,628.57 ops
NativeDllCalls(true, 100000000, 4, 0); // Seconds: 0.31 |) 1,294,424,654.13 ops
NativeDllCalls(true, 400000000, 0, 0); // Seconds: 1.10 |) 364,277,890.12 ops
}
static void NativeDllCalls(bool useStatic, int nonParallelIterations, int parallelIterations = 0, int maxParallelism = 0)
{
if (useStatic) {
Iterate<string, object>(
(msg, cntxt) => {
ServiceContracts.ForNativeCall.SomeStaticCall(msg);
}
, "test", null, nonParallelIterations,parallelIterations, maxParallelism );
}
else {
var instance = new ServiceContracts.ForNativeCall();
Iterate(
(msg, cntxt) => {
cntxt.SomeCall(msg);
}
, "test", instance, nonParallelIterations, parallelIterations, maxParallelism);
}
}
static void Iterate<T, C>(Action<T, C> action, T testMessage, C context, int nonParallelIterations, int parallelIterations=0, int maxParallelism= 0)
{
var start = DateTime.UtcNow;
if(nonParallelIterations == 0)
nonParallelIterations = 1; // normalize values
if(parallelIterations == 0)
parallelIterations = 1;
if (parallelIterations > 1) {
ParallelOptions options;
if (maxParallelism == 0) // default max parallelism
options = new ParallelOptions();
else
options = new ParallelOptions { MaxDegreeOfParallelism = maxParallelism };
if (nonParallelIterations > 1) {
Parallel.For(0, parallelIterations, options
, (j) => {
for (int i = 0; i < nonParallelIterations; ++i) {
action(testMessage, context);
}
});
}
else { // no nonParallel iterations
Parallel.For(0, parallelIterations, options
, (j) => {
action(testMessage, context);
});
}
}
else {
for (int i = 0; i < nonParallelIterations; ++i) {
action(testMessage, context);
}
}
var end = DateTime.UtcNow;
Console.WriteLine("\tSeconds: {0,8:0.00} |) {1,16:0,000.00} ops",
(end - start).TotalSeconds, (Math.Max(parallelIterations, 1) * nonParallelIterations / (end - start).TotalSeconds));
}
}
Нет. Вы должны понимать, что делает код и поддается ли он распараллеливанию. Зависимости между вашими элементами данных могут затруднить распараллеливание, т. Е. Если поток использует значение, вычисленное для предыдущего элемента, он должен все же дождаться вычисления значения и не может работать параллельно. Вы также должны понимать свою целевую архитектуру, хотя, как правило, у вас будет многоядерный процессор практически на всем, что вы покупаете в наши дни. Даже на одном ядре вы можете получить некоторые преимущества от большего количества потоков, но только если у вас есть некоторые задачи блокировки. Также следует помнить, что при создании и организации параллельных потоков возникают накладные расходы. Если эти накладные расходы составляют значительную долю (или более) времени, которое занимает ваша задача, вы можете замедлить ее.