Оптимизация запросов Parallel Linq

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

Я спрашиваю, потому что недавно я реорганизовал какой-то неловко параллельный код, изменив некоторые методы и нагромождая AsParallel в определенных ключевых местах. Время работы сократилось с 2 минут до 45 секунд, но из монитора производительности стало ясно, что в некоторых местах все ядра на процессоре полностью не используются. После нескольких ложных запусков я заставил некоторые из запросов выполнить с помощью ToArray, а время выполнения еще больше снизилось до 16 секунд. Было приятно сократить время выполнения кода, но оно также немного сбивало с толку, потому что было неясно, где в кодовых запросах необходимо принудительно с помощью ToArray. Ожидание до последней минуты выполнения запроса не было оптимальной стратегией, но было не совсем ясно, в каких точках кода некоторые из подзапросов необходимо принудительно использовать, чтобы использовать все ядра ЦП.

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

Здесь пример псевдокода:

var firstQuery = someDictionary.SelectMany(FirstTransformation);
var secondQuery = firstQuery.Select(SecondTransformation);
var thirdQuery = secondQuery.Select(ThirdTransformation).Where(SomeConditionCheck);
var finalQuery = thirdQuery.Select(FinalTransformation).Where(x => x != null);

FirstTransformation, SecondTransformation, ThirdTransformation все связаны с ЦП и с точки зрения сложности они представляют собой несколько матричных умножений 3x3 и некоторые ветки if. SomeConditionCheck - это почти проверка null. FinalTransformation - это большая часть кода, требующая большей части кода, потому что она будет выполнять целую кучу пересечений линейных плоскостей и будет проверять локализацию многоугольника для этих пересечений, а затем извлечь пересечение, которое ближе всего к определенной точке на линии.

Я понятия не имею, почему места, где я положил AsParallel, сократили время выполнения кода столько же, сколько и сделали. Теперь я достиг локального минимума с точки зрения времени выполнения, но я понятия не имею, почему. Мне просто не повезло, что я наткнулся на него. Если вам интересно, места, где положить AsParallel - это первая и последняя строки. Ввод AsParallel в другое место будет только увеличивать время выполнения, иногда на 20 секунд. В первой строке скрывается скрытый ToArray.

Ответ 1

Здесь происходит пара вещей:

  • PLINQ более эффективно распараллеливает коллекции, чем несанкционированные IEnumerables. Если у вас есть массив, он делит длину массива на ваше количество ядер процессора и выполняет их задачи равномерно. Но если у вас есть IEnumerable с неизвестной длиной, он делает тупой экспоненциальный тип разгона, где задачи будут обрабатывать элементы 1, 2, 4, 8 и т.д., Пока он не достигнет конца IEnumerable.
  • Распараллеливая все ваши запросы, вы разбиваете работу на крошечные куски. Если у вас есть M параллельных запросов по N элементам, вы получаете задачи M * N. В этом больше накладных расходов, чем если бы вы просто распараллеливали последний запрос, и в этом случае у вас будет только N задач.
  • PLINQ лучше всего подходит, когда каждая задача занимает примерно одинаковое количество времени для обработки. Таким образом, он может разделить их равномерно между ядрами. Распараллеливая каждый из ваших запросов с разными характеристиками производительности, у вас есть задачи M * N, которые занимают разные промежутки времени, а PLINQ не может запланировать их оптимально (поскольку он не знает заранее, сколько времени может пройти каждый).

Итак, общее руководство здесь: убедитесь, что перед запуском у вас есть массив, если это возможно, и только перед тем, как оценивать, поместите только AsParallel по самому последнему запросу. Итак, что-то вроде следующего должно работать очень хорошо:

var firstQuery = someDictionary.SelectMany().ToArray().Select(FirstTransformation);
var secondQuery = firstQuery.Select(SecondTransformation);
var thirdQuery = secondQuery.Select(ThirdTransformation).AsParallel().Where(SomeConditionCheck).ToArray();
var finalQuery = thirdQuery.Select(FinalTransformation).AsParallel().Where(x => x != null);

Ответ 2

Почти невозможно сказать, не видя фактического кода. Но в качестве общего руководства вы должны учитывать, чтобы избежать P/LINQ во время сложного числа хруста, потому что делегат и IEnumerable накладные расходы слишком высоки. Скорость, которую вы получаете с помощью потоков, очень вероятно съедена удобными абстракциями, которые предоставляет LINQ.

Вот некоторый код, который вычисляет сумму из 2 целых списков, делает некоторое int для сравнения с плавающей точкой, а затем вычисляет cos из него. Довольно простые вещи, которые можно хорошо сделать с помощью LINQ.Zip-оператора... или старомодным способом с циклом for.

Обновление 1 с обновленным ParallelLinq на моей основной машине Haswell 8

  • Linq 0,95s
  • Linq Parallel 0,19s
  • Оптимизированный 0,45s
  • Оптимизированный параллельный 0,08 с

Обновить 1 конец

  • LINQ 1,65s
  • Оптимизировано 0,64 с
  • Оптимизированный параллельный 0,40 с

Разница во времени - это почти фактор 3 из-за IEnumerable лень и вызов вызова метода (я использовал режим Release x32 Windows 7,.NET 4 dual core). Я попытался использовать AsParallel в версии LINQ, но он действительно стал медленнее (2,3s). Если вы управляете данными, вы должны использовать конструкцию Parallel.For, чтобы получить хорошую масштабируемость. IEnumerable сам по себе является плохим кандидатом для распараллеливания, поскольку

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

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

    class Program
    {
        static void Main(string[] args)
        {
            var A = new List<int>(Enumerable.Range(0, 10*1000*1000));
            var B = new List<int>(Enumerable.Range(0, 10*1000*1000));

            double[] Actual = UseLinq(A, B);
            double[] pActual = UseLinqParallel(A, B);

            var other = Optimized(A, B);
            var pother = OptimizedParallel(A, B);
        }

        private static double[] UseLinq(List<int> A, List<int> B)
        {
            var sw = Stopwatch.StartNew();
            var Merged = A.Zip(B, (a, b) => a + b);
            var Converted = A.Select(a => (float)a);

            var Result = Merged.Zip(Converted, (m, c) => Math.Cos((double)m / ((double)c + 1)));

            double[] Actual = Result.ToArray();
            sw.Stop();

            Console.WriteLine("Linq {0:F2}s", sw.Elapsed.TotalSeconds);
            return Actual;
        }

    private static double[] UseLinqParallel(List<int> A, List<int> B)
    {
        var sw = Stopwatch.StartNew();
        var x = A.AsParallel();
        var y = B.AsParallel();

        var Merged = x.Zip(y, (a, b) => a + b);
        var Converted = x.Select(a => (float)a);

        var Result = Merged.Zip(Converted, (m, c) => Math.Cos((double)m / ((double)c + 1)));

        double[] Actual = Result.ToArray();
        sw.Stop();

        Console.WriteLine("Linq Parallel {0:F2}s", sw.Elapsed.TotalSeconds);
        return Actual;
    }        

        private static double[] OptimizedParallel(List<int> A, List<int> B)
        {
            double[] result = new double[A.Count];
            var sw = Stopwatch.StartNew();
            Parallel.For(0, A.Count, (i) =>
            {
                var sum = A[i] + B[i];
                result[i] = Math.Cos((double)sum / ((double)((float)A[i]) + 1));
            });
            sw.Stop();

            Console.WriteLine("Optimized Parallel {0:F2}s", sw.Elapsed.TotalSeconds);
            return result;
        }

        private static double[] Optimized(List<int> A, List<int> B)
        {
            double[] result = new double[A.Count];
            var sw = Stopwatch.StartNew();
            for(int i=0;i<A.Count;i++)
            {
                var sum = A[i] + B[i];
                result[i] = Math.Cos((double)sum / ((double)((float)A[i]) + 1));
            }
            sw.Stop();

            Console.WriteLine("Optimized {0:F2}s", sw.Elapsed.TotalSeconds);
            return result;
        }
    }
}