Parallel.For() с Interlocked.CompareExchange(): более низкая производительность и несколько отличные результаты от последовательной версии.

Я экспериментировал с вычислением среднего значения списка, используя Parallel.For(). Я отказался от этого, так как он примерно в четыре раза медленнее, чем простая последовательная версия. Тем не менее, я заинтригован тем фактом, что он не дает точно такого же результата, как последовательный, и я подумал, что было бы поучительно узнать, почему.

Мой код:

public static double Mean(this IList<double> list)
{
        double sum = 0.0;


        Parallel.For(0, list.Count, i => {
                        double initialSum;
                        double incrementedSum;
                        SpinWait spinWait = new SpinWait();

                        // Try incrementing the sum until the loop finds the initial sum unchanged so that it can safely replace it with the incremented one.
                        while (true) {
                            initialSum = sum;
                            incrementedSum = initialSum + list[i];
                            if (initialSum == Interlocked.CompareExchange(ref sum, incrementedSum, initialSum)) break;
                            spinWait.SpinOnce();
                        }
                     });

        return sum / list.Count;
    }

Когда я запускаю код для случайной последовательности из 2000000 точек, я получаю результаты, которые отличаются последними двумя цифрами от среднего значения.

Я искал stackoverflow и нашел это: Текущая сумма VB.NET во вложенном цикле внутри Parallel.for Synclock теряет информацию. Мой случай, однако, отличается от описанного там. Там локальная переменная потока temp является причиной неточности, но я использую единую сумму, которая обновляется (я надеюсь) в соответствии с шаблоном учебника Interlocked.CompareExchange(). Вопрос, конечно, спорный из-за плохой производительности (что меня удивляет, но я знаю о накладных расходах), но мне любопытно, можно ли чему-то научиться из этого случая.

Ваши мысли оценены.


person tethered.sun    schedule 30.03.2016    source источник
comment
Что вы ожидаете? Вы смотрите тонны сравнительных обменных операций, которые ничего не делают. Не здоров.   -  person TomTom    schedule 30.03.2016
comment
Сложение с плавающей запятой не является ассоциативным. Вы не можете просто распараллелить последовательную сумму и ожидать того же результата. Я не понимаю, как ваш конкретный случай вообще должен отличаться от связанного вопроса - то, что частичная сумма сравнивается (не) равна, все еще не гарантирует, что окончательный результат будет таким же, потому что (и это фундаментальный вопрос) вы все еще переставляете порядок терминов.   -  person Jeroen Mostert    schedule 30.03.2016
comment
@TomTom: Вы правы, но вопрос в разнице в стоимости.   -  person tethered.sun    schedule 30.03.2016
comment
@Jeroen Mostert: Сначала я хотел сказать, что они проверяли ассоциативность, а я проверял коммутативность, но, если подумать, вы правы. Всегда задействована ассоциативность, поскольку предыдущая сумма представляет собой группировку, которая отличается при каждом запуске. Спасибо за понимание и извините за глупый вопрос.   -  person tethered.sun    schedule 30.03.2016
comment
Это не глупый вопрос; очень полезно понять, почему это не работает. Если вам нужны точные результаты и ваш диапазон не слишком велик, используйте decimal. И вместо того, чтобы собирать собственное дополнение, вы можете использовать параллельный LINQ: list.AsParallel().Sum().   -  person Jeroen Mostert    schedule 30.03.2016
comment
@Jeroen Mostert: Большое спасибо за ваше предложение. Благодаря вам я узнал кое-что полезное. Я попробовал версию LINQ, и она показывает ту же разницу. Что касается производительности, LINQ примерно в 2-3 раза медленнее, чем последовательный, при первом запуске и в 2-3 раза быстрее при последующих запусках.   -  person tethered.sun    schedule 30.03.2016
comment
@tethered.sun Первый запуск кода часто выполняется медленнее по ряду причин. При тестировании скорости всегда выполняйте несколько итераций (или несколько сотен, если у вас есть время), чтобы позволить JIT и т. д. работать, а затем измеряйте время. В этом случае PLINQ Sum определенно быстрее стандартного LINQ Sum.   -  person Corey    schedule 30.03.2016


Ответы (2)


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

Вы обнаружили, что математика с плавающей запятой коммуникативна, но не ассоциативна. Или, другими словами, a + b == b + a, но a + b + c != a + c + b. В вашем коде подразумевается, что порядок добавления чисел довольно случайный.

Этот вопрос C++ также говорит об этом.

person Hans Passant    schedule 30.03.2016
comment
Спасибо большое. Мне немного стыдно за то, что я не понял, что дело, которое мне удалось найти, на самом деле такое же, как у меня, но я многому научился из комментариев. Пожалуйста, потерпите меня; Я простой физик и в лучшем случае любитель-самоучка в программировании. Но я люблю учиться. - person tethered.sun; 30.03.2016

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

Поскольку вы не показали свой последовательный код, я предполагаю самый простой случай:

double sum = list.Sum();

Это очень простая операция, которая должна работать настолько быстро, насколько это возможно на одном ядре ЦП. С очень большим списком кажется, что можно использовать несколько ядер для суммирования списка. И, как оказалось, вы можете:

double sum = list.AsParallel().Sum();

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

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

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

Предполагая, что операция, которую вы фактически выполняете, более сложна, чем простой Sum(), вы можете обнаружить, что метод Aggregate() более полезен для вас, чем Parallel.For.

Существует несколько перегрузок расширения Aggregate, в том числе одна, которая фактически является шаблоном карты. реализация, схожая с тем, как работают системы больших данных, такие как MapReduce. Документация находится здесь.

Эта версия Aggregate использует начальное значение аккумулятора (начальное значение для каждого потока) и три функции:

  1. updateAccumulatorFunc вызывается для каждого элемента в последовательности и возвращает обновленное значение аккумулятора.

  2. combineAccumulatorsFunc используется для объединения аккумуляторов из каждого раздела (потока) в ваш параллельный перечисляемый

  3. resultSelector выбирает конечное выходное значение из накопленного результата.

Параллельная сумма с использованием этого метода выглядит примерно так:

double sum = list.AsParallel().Aggregate(
    // seed value for accumulators
    (double)0, 
    // add val to accumulator
    (acc, val) => acc + val,
    // add accumulators
    (acc1, acc2) => acc1 + acc2,
    // just return the final accumulator
    acc => acc
);

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

public class avg_acc
{
    public int count;
    public double sum;
}

public double ParallelAverage(IEnumerable<double> list)
{
    double avg = list.AsParallel().Aggregate(
        // accumulator factory method, called once per thread:
        () => new avg_acc { count = 0, sum = 0 },
        // update count and sum
        (acc, val) => { acc.count++; acc.sum += val; return acc; },
        // combine accumulators
        (ac1, ac2) => new avg_acc { count = ac1.count + ac2.count, sum = ac1.sum + ac2.sum },
        // calculate average
        acc => acc.sum / acc.count
    );
    return avg;
}

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

person Corey    schedule 30.03.2016
comment
Я снова поражен, увидев, как мой плохой вопрос привлек такие качественные ответы. Спасибо большое. Как я уже говорил ранее, я физик и не имею формального образования. Хотя я стал чаще использовать LINQ, я не знал о таких утилитах, как AsParallel или Aggregate(), которые кажутся многообещающими для множества задач. Спасибо за уделенное время. - person tethered.sun; 30.03.2016