Совместное использование ресурсов во вложенном цикле Parallel.For C#

Фон

У меня есть фрагмент кода, который хорошо распараллеливается, и я обнаружил, что большую часть времени я использую только одно ядро ​​​​на 100%, а остальные ничего не делают. Чтобы решить эту проблему, я возился с многопоточностью, реализацией семафоров и тем, что не осознавал, что Parallel.For() является мелкозернистым и более эффективным, чем любое из моих решений.

Кодекс

Для упрощения я буду писать только структурно важные фрагменты кода.

int sharedResource = 0;

for (int i = 0; i < someMax; i++)
{
    for (int j = 0; j <= i; j++)
    {
        if (someCondition(i, j))
            sharedResource += someFunction(i, j);
        else break;
    }
}

Все неоднозначно названные функции являются более или менее просто математическими уравнениями и имеют временную сложность O(1).

Важные детали

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

Вопрос

Разумно ли использовать вложенный цикл Parallel.For() в этом сценарии или мне следует использовать его только вместо внешнего цикла (или только во внутреннем цикле соответственно)?

Единственное, что меня беспокоит, это sharedResource, так как я не очень хорошо понимаю, как работает Parallel.For() из документации. Еще одна важная вещь заключается в том, что если я использую два цикла Parallel.For(), некоторые экземпляры завершатся почти мгновенно из-за break, в то время как другим потребуется гораздо больше времени. Удастся ли это сбалансировать?


person Ilhan    schedule 24.03.2018    source источник
comment
Общий ресурс не является потокобезопасным из-за оператора присваивания   -  person arekzyla    schedule 24.03.2018
comment
Почему бы тебе просто не попробовать?   -  person Eser    schedule 24.03.2018
comment
Потому что это может сработать ложноположительно, и мне нужен конкретный ответ. Выяснение того, почему программа выходит из строя через несколько дней из-за кода, не поддерживающего потоки, может стать кошмаром для отладки @Eser   -  person Ilhan    schedule 24.03.2018
comment
Просто создайте ConcurrentBag<int> (при условии, что ваш общий ресурс имеет тип int), который является потокобезопасным, и добавьте свои результаты в корзину. После завершения Parallel.For - просуммируйте результаты.   -  person Vidmantas Blazevicius    schedule 24.03.2018
comment
И сколько времени в среднем требуется для завершения одной итерации внутреннего цикла?   -  person Evk    schedule 24.03.2018
comment
Общая сложность составляет около n log (n). В зависимости от ввода это может варьироваться от нескольких микросекунд до пары секунд или более. (внутренняя петля). При случайном вводе это миллисекунда.   -  person Ilhan    schedule 24.03.2018
comment
@Ilhan Because it might work false positively and I need a concrete answer. Итак, если вы скажете, что делаете это так, вы будете кодировать это и считать, что это правильно? Лучше сначала попробовать, а затем задать свой вопрос с кодами, которые вы пробовали до сих пор.   -  person Eser    schedule 24.03.2018
comment
@VidmantasBlazevicius Нет. Предположим, глупый вопрос о суммировании значений от 1 до 1 миллиарда параллельно (без использования метода Гаусса n * (n + 1)/2) добавили бы вы все промежуточные результаты в коллекцию. (см., например, использование класса Interlocked в ответе ниже)   -  person Eser    schedule 24.03.2018


Ответы (2)


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

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

Обратите внимание, что цикл Parallel.For разбивает интервал на определенное количество диапазонов (в зависимости от степени параллелизма), а затем эти диапазоны выполняются параллельно в отдельных потоках. Это означает следующее: если время обработки ваших элементов распределено неравномерно, некоторые диапазоны могут выполняться намного быстрее, чем другие. Скажем, вы работаете со степенью параллелизма 4 и обрабатываете 100 элементов, из которых первые 75 возвращают false вместо someCondition, поэтому для выполнения требуется 0 времени, а последние 25 возвращают true. В результате первые 3 диапазона будут завершены немедленно, а последний диапазон со всей реальной работой будет выполняться в одном потоке, что, по сути, делает все последовательно.

Если ожидается неравномерное распределение, вместо этого вы можете использовать Parallel.ForEach с "настоящим" IEnumerable (под реальным я подразумеваю не массив или список, а настоящий "ленивый" IEnumerable):

Parallel.ForEach(Enumerable.Range(0, i), j => {...})

Но обратите внимание, что на равномерно распределенных данных это будет медленнее, чем версия с предварительным разделением.

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

Что касается безопасности потоков. Конечно, это

sharedResource += someFunction(i, j);

не является потокобезопасным внутри параллельных циклов. Использование здесь lock может сильно снизить производительность, если someFunction работает быстро, да и в любом случае не обязательно. Либо просто используйте

Interlocked.Add(ref sharedResource, someFunction(i, j))

Или вы можете использовать перегрузки Parallel.For`Parallel.ForEach`, которые позволяют накапливать значения для каждого запущенного потока, а затем агрегировать результаты. Например:

Parallel.For(0, 100, (i, outerState) =>
{
   Parallel.ForEach(Enumerable.Range(0, i), () => 0, (j, innerState, subTotal) =>
   {
       if (someCondition(i, j))
           return subTotal + someFunction(i, j);
       else {
           innerState.Break();
           return subTotal;
       }
   }, subTotalOfThread => Interlocked.Add(ref sharedResource, subTotalOfThread));
});
person Evk    schedule 24.03.2018

Вы можете использовать какой-нибудь пользовательский разделитель с включенной балансировкой нагрузки и использовать его в цикле Parallel.ForEach. Балансировка нагрузки гарантирует, что каждое ядро ​​будет занято до конца выполнения. Например:

int sharedResource = 0;
var iterations = Enumerable.Range(0, someMax);

//this creates partitioner with load balancing (true is default for IEnumerable really)
var customPartitioner = Partitioner.Create(iterations, true); 

Parallel.ForEach(customPartitioner, i =>
{
    for (int j = 0; j <= i; j++)
    {
        if (someCondition(i, j))
            Interlocked.Add(ref sharedResource, someFunction(i, j)); 
        else break;
    }
});

В вашем примере оператор присваивания действительно не является потокобезопасным, поэтому вместо этого я использовал Interlocked.Add.

Вы также можете написать некоторый функциональный код, который можно распараллелить с помощью LINQ. Обратите внимание, что нет синхронизации общих ресурсов или потоков, потому что в FP нет состояния.

var result = customPartitioner
    .AsParallel()
    .Select(i => Enumerable.Range(0, i + 1)
        .AsParallel()
        .TakeWhile(j => someCondition(i, j))
        .Sum(j => someFunction(i, j)))
    .Sum();

Одна вещь, которую вы также должны принять во внимание, — это стоимость создания потока. Чем больше потоков вы создаете, тем больше времени процессора тратится на них вместо фактической работы. Кроме того, Parallel.Foreach требует дополнительных затрат при определении того, в каком потоке должна выполняться каждая итерация. Поэтому иногда лучше иметь однопоточный внутренний цикл. В примере с LINQ в некоторых случаях внутренний AsParallel действительно может привести к дополнительным затратам.

person arekzyla    schedule 24.03.2018
comment
Я бы удалил внутренний цикл и сделал бы это за один Parallel.For - person Eser; 24.03.2018