Ускорение работы в PLINQ

Эта статья содержит сведения, которые помогут вам создавать максимально эффективные запросы PLINQ, обеспечивая правильность результатов.

Основное назначение PLINQ — ускорять обработку запросов LINQ to Objects на компьютерах с многоядерными процессорами, выполняя делегаты запроса в параллельном режиме. Преимущества PLINQ проявляются лучше всего, когда обработка каждого элемента в исходной коллекции не зависит от других элементов и отдельные делегаты не используют общее состояние. Такие операции достаточно часто встречаются в LINQ to Objects и PLINQ. Они являются параллельными, так как легко поддаются разделению на несколько потоков. Однако не все запросы состоят полностью из восхитительно параллельных операций. В большинстве случаев запрос включает в себя некоторые операторы, которые не могут быть параллелизированы или замедляют параллельное выполнение. И даже для параллельных запросов PLINQ приходится выполнять дополнительную работу: разделять источники данных, распределять работу между потоками и (чаще всего) объединять результаты после обработки запроса. Все эти дополнительные операции привносят накладные расходы, то есть повышают вычислительную стоимость параллелизации. Чтобы добиться оптимальной производительности запросов PLINQ, нужно применять как можно больше параллельных элементов и свести к минимуму элементы, повышающих накладные расходы.

Факторы, влияющие на производительность запросов PLINQ

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

  1. Вычислительная стоимость общей работы.

    Чтобы ускорить обработку запросов PLINQ, нужен достаточный объем параллельных операций, позволяющий компенсировать накладные расходы. Работу можно оценить как произведение вычислительной стоимости каждого делегата на число элементов в исходной коллекции. Если операция допускает параллелизацию, потенциал ускорения напрямую зависит от ее вычислительной стоимости. Например, если функция выполняется за одну миллисекунду, последовательный запрос к 1000 элементов будет выполняться около одной секунды. Параллельное выполнение этого же запроса на компьютере с четырьмя ядрами можно произвести всего за 250 миллисекунд. Таким образом, ускорение может составить 750 миллисекунд. Если же выполнение функции для каждого элемента занимает одну секунду, общее ускорение составит 750 секунд. Если делегат является очень затратным, PLINQ может обеспечить значительное ускорение даже при небольшом размере исходной коллекции. И наоборот, небольшие исходные коллекции в сочетании с элементарными делегатами не будут хорошими кандидатами для использования PLINQ.

    Запрос queryA из следующего примера можно считать хорошим кандидатом для PLINQ, если его функция Select предусматривает много работы. Запрос queryB вряд ли хорошо подходит для параллелизации, так как в его инструкции Select выполняется мало работы, и накладные расходы перевесят все возможное ускорение или значительную его часть.

    Dim queryA = From num In numberList.AsParallel()  
                 Select ExpensiveFunction(num); 'good for PLINQ  
    
    Dim queryB = From num In numberList.AsParallel()  
                 Where num Mod 2 > 0  
                 Select num; 'not as good for PLINQ  
    
    var queryA = from num in numberList.AsParallel()  
                 select ExpensiveFunction(num); //good for PLINQ  
    
    var queryB = from num in numberList.AsParallel()  
                 where num % 2 > 0  
                 select num; //not as good for PLINQ  
    
  2. Число логических ядер в системе (степень параллелизма).

    Это очевидное следствие всего, что мы обсуждали в предыдущем разделе — параллельные запросы работают быстрее на компьютерах, которые имеют больше ядер, так как работу можно разделить между большим числом параллельных потоков. Общий эффект ускорения зависит от того, какой процент работы поддается распараллеливанию. Но не следует полагать, что на компьютере с восемью ядрами все запросы будут выполняться в два раза быстрее,чем на компьютере с четырьмя ядрами. При оптимизации производительности запросов важно измерить фактические результаты на компьютерах с разным количеством ядер. Этот аспект напрямую связан с аспектом № 1: увеличение вычислительных ресурсов принесет больше пользы для крупных наборов данных.

  3. Число и типы операций.

    Язык PLINQ предоставляет оператор AsOrdered для ситуаций, в которых важно поддерживать исходный порядок элементов в последовательности. Упорядочение требует определенных затрат, но обычно они не очень велики. Операции GroupBy и Join требуют затрат. PLINQ работает лучше всего, если есть возможность обрабатывать элементы исходной коллекции в любом порядке и передавать результаты следующему оператору сразу по мере готовности. Дополнительные сведения см. в разделе Сохранение порядка в PLINQ.

  4. Форма выполнения запроса.

    Если вы сохраняете результаты запроса вызовом ToArray или ToList, все результаты из всех параллельных потоков необходимо объединять в одну структуру данных. С этим процессом связаны неизбежные вычислительные затраты. Если же результаты просматриваются в цикле foreach (For Each в Visual Basic), результаты из рабочих потоков нужно сериализовать в поток-перечислитель. Но если вам нужно лишь выполнить некоторую операцию над результатами каждого потока, вы можете использовать метод ForAll, поддерживающий многопоточное выполнение.

  5. Тип параметров слияния.

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

  6. Тип секционирования.

    В некоторых случаях запрос PLINQ по индексируемой исходной коллекции может привести к несбалансированности рабочей нагрузки. В этом случае производительность запросов можно увеличить, добавив пользовательский модуль разделения. Дополнительные сведения см. в разделе Пользовательские разделители для PLINQ и TPL.

В каких случаях PLINQ выбирает последовательный режим

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

Но иногда, оценив производительность конкретного запроса, вы заметите, что он все таки выполняется быстрее в параллельном режиме. В таких случаях можно использовать флаг ParallelExecutionMode.ForceParallelism в методе WithExecutionMode, чтобы принудительно указать для PLINQ параллельный режим выполнения запроса. Дополнительные сведения см. в разделе Практическое руководство. Указание режима выполнения в PLINQ.

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

  • Запросы, содержащие предложение Select, а также индексированные инструкции Where, SelectMany или ElementAt после оператора упорядочивания или фильтрации, который удаляет или изменяет исходные индексы.

  • Запросы, содержащие оператор Take, TakeWhile, Skip или SkipWhile, в которых индексы исходной последовательности не сохраняют исходный порядок.

  • Запросы, которые содержат Zip или SequenceEquals, за исключением случаев, когда один из источников данных содержит изначально упорядоченный индекс, а другой источник данных можно проиндексировать (например, массив или IList(T)).

  • Запросы, которые содержат оператор Concat, если он не применяется к индексируемым источникам данных.

  • Запросы, содержащие оператор Reverse, если он не применяется к индексируемым источникам данных.

См. также