다음을 통해 공유


PLINQ 및 TPL에 대한 사용자 지정 파티셔너

데이터 원본에 대한 작업을 병렬화하기 위해 중요한 단계 중 하나는 여러 스레드에서 동시에 액세스할 수 있는 여러 섹션으로 원본을 분할 하는 것입니다. PLINQ 및 TPL(작업 병렬 라이브러리)은 병렬 쿼리 또는 ForEach 루프를 작성할 때 투명하게 작동하는 기본 파티셔너를 제공합니다. 고급 시나리오의 경우 사용자 고유의 파티셔너를 연결할 수 있습니다.

분할의 종류

데이터 원본을 분할하는 방법에는 여러 가지가 있습니다. 가장 효율적인 방법에서 여러 스레드는 원본을 여러 하위 시퀀스로 물리적으로 분리하는 대신 원래 소스 시퀀스를 처리하기 위해 협력합니다. 길이가 미리 알려진 컬렉션과 같은 IList 배열 및 기타 인덱싱된 원본의 경우 범위 분할 은 가장 간단한 분할 종류입니다. 모든 스레드는 고유한 시작 및 끝 인덱스를 수신하므로, 다른 스레드에 의해 덮어쓰이거나 다른 스레드를 덮어쓰지 않도록 원본 범위를 처리할 수 있습니다. 범위 분할과 관련된 유일한 오버헤드는 범위를 만드는 초기 작업입니다. 그 후에는 추가 동기화가 필요하지 않습니다. 따라서 워크로드가 균등하게 분할되는 한 좋은 성능을 제공할 수 있습니다. 범위 분할의 단점은 한 스레드가 일찍 완료되는 경우 다른 스레드가 작업을 완료하는 데 도움이 될 수 없다는 것입니다.

연결된 목록 또는 길이를 알 수 없는 다른 컬렉션의 경우 청크 분할을 사용할 수 있습니다. 청크 분할에서 병렬 루프 또는 쿼리의 모든 스레드 또는 태스크는 한 청크에서 일부 원본 요소를 사용하고 처리한 다음 다시 돌아와 추가 요소를 검색합니다. 파티셔너는 모든 요소가 분산되고 중복이 없도록 합니다. 청크는 모든 크기일 수 있습니다. 예를 들어 방법: 동적 파티션 구현 에 설명된 파티셔너는 하나의 요소만 포함하는 청크를 만듭니다. 청크가 너무 크지 않은 한, 스레드에 요소를 할당하는 것이 미리 결정되지 않기 때문에 이러한 종류의 분할은 본질적으로 부하 분산입니다. 그러나 파티셔너는 스레드가 다른 청크를 가져와야 할 때마다 동기화 오버헤드가 발생합니다. 이러한 경우 발생하는 동기화 양은 청크의 크기에 반비례합니다.

일반적으로 범위 분할은 대리자의 실행 시간이 작고 원본에 많은 요소가 있고 각 파티션의 총 작업이 거의 동일한 경우에만 더 빠릅니다. 따라서 청크 분할은 일반적으로 대부분의 경우 더 빠릅니다. 적은 수의 요소가 있거나 대리자의 실행 시간이 긴 원본에서는 청크 및 범위 분할의 성능이 거의 같습니다.

TPL 파티셔너도 동적 수의 파티션을 지원합니다. 즉, ForEach 루프가 새 작업을 생성할 때와 같이, 동적으로 파티션을 즉시 생성할 수 있습니다. 이 기능을 사용하면 파티셔너가 루프 자체와 함께 크기를 조정할 수 있습니다. 동적 파티셔너도 기본적으로 부하 분산됩니다. 사용자 지정 파티셔너를 만들 때는 루프에서 사용할 수 있도록 ForEach 동적 분할을 지원해야 합니다.

PLINQ에 대한 부하 분산 파티셔너 구성

메서드의 Partitioner.Create 일부 오버로드를 사용하면 배열 또는 IList 원본에 대한 파티셔너를 만들고 스레드 간에 워크로드의 균형을 맞출지 여부를 지정할 수 있습니다. 파티셔너가 부하를 분산하도록 구성된 경우 청크 분할이 사용되고 요청 시 요소가 작은 청크로 각 파티션에 전달됩니다. 이 방법을 사용하면 전체 루프 또는 쿼리가 완료될 때까지 모든 파티션에 처리할 요소가 있는지 확인할 수 있습니다. 추가 오버로드를 사용하여 모든 IEnumerable 원본의 부하 분산 분할을 제공할 수 있습니다.

일반적으로 부하 분산을 사용하려면 파티션이 파티셔너에서 상대적으로 자주 요소를 요청해야 합니다. 반면 정적 분할을 수행하는 파티셔너는 범위 또는 청크 분할을 사용하여 요소를 한 번에 모두 각 파티셔너에 할당할 수 있습니다. 이렇게 하려면 부하 분산보다 오버헤드가 적지만 한 스레드가 다른 스레드보다 훨씬 많은 작업으로 끝나는 경우 실행하는 데 시간이 더 오래 걸릴 수 있습니다. 기본적으로 IList 또는 배열이 전달되면 PLINQ는 항상 부하 분산 없이 범위 분할을 사용합니다. PLINQ에 부하 분산을 사용하도록 설정하려면 다음 예제와 같이 메서드를 사용합니다 Partitioner.Create .

// Static partitioning requires indexable source. Load balancing
// can use any IEnumerable.
var nums = Enumerable.Range(0, 100000000).ToArray();

// Create a load-balancing partitioner. Or specify false for static partitioning.
Partitioner<int> customPartitioner = Partitioner.Create(nums, true);

// The partitioner is the query's data source.
var q = from x in customPartitioner.AsParallel()
        select x * Math.PI;

q.ForAll((x) =>
{
    ProcessData(x);
});
' Static number of partitions requires indexable source.
Dim nums = Enumerable.Range(0, 100000000).ToArray()

' Create a load-balancing partitioner. Or specify false For  Shared partitioning.
Dim customPartitioner = Partitioner.Create(nums, True)

' The partitioner is the query's data source.
Dim q = From x In customPartitioner.AsParallel()
        Select x * Math.PI

q.ForAll(Sub(x) ProcessData(x))

지정된 시나리오에서 부하 분산을 사용할지 여부를 결정하는 가장 좋은 방법은 대표적인 부하 및 컴퓨터 구성에서 작업을 완료하는 데 걸리는 시간을 실험하고 측정하는 것입니다. 예를 들어 정적 분할은 코어가 몇 개뿐인 다중 코어 컴퓨터에서 상당한 속도 향상을 제공할 수 있지만 상대적으로 많은 코어가 있는 컴퓨터에서 속도가 느려질 수 있습니다.

다음 표에서는 메서드의 사용 가능한 오버로드를 Create 나열합니다. 이러한 파티셔너에는 PLINQ 또는 Task.에서만 사용하도록 제한되지 않습니다. 사용자 지정 병렬 구문과 함께 사용할 수도 있습니다.

오버로드 부하 분산 사용
Create<TSource>(IEnumerable<TSource>)
Create<TSource>(TSource[], Boolean) 부울 인수가 true로 지정된 경우
Create<TSource>(IList<TSource>, Boolean) 부울 인수가 true로 지정된 경우
Create(Int32, Int32) 절대
Create(Int32, Int32, Int32) 절대
Create(Int64, Int64) 절대
Create(Int64, Int64, Int64) 절대

Parallel.ForEach에 대한 정적 범위 파티셔너 구성

For 루프에서 루프 본문은 메서드에 대리자로 제공됩니다. 해당 대리자를 호출하는 비용은 가상 메서드 호출과 거의 동일합니다. 일부 시나리오에서는 병렬 루프 본문이 작아서 각 루프 반복에서 대리자 호출 비용이 크게 발생할 수 있습니다. 이러한 상황에서는 오버로드 중 Create 하나를 사용하여 원본 요소를 범위에 따라 파티션하는 IEnumerable<T>을 생성할 수 있습니다. 그런 다음 이 범위 컬렉션을 일반 ForEach 루프로 구성된 for 메서드의 본문에 전달할 수 있습니다. 이 방법의 이점은 대리자 호출 비용이 요소당 한 번이 아니라 범위당 한 번만 발생한다는 것입니다. 다음 예제에서는 기본 패턴을 보여 줍니다.

using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {

        // Source must be array or IList.
        var source = Enumerable.Range(0, 100000).ToArray();

        // Partition the entire source array.
        var rangePartitioner = Partitioner.Create(0, source.Length);

        double[] results = new double[source.Length];

        // Loop over the partitions in parallel.
        Parallel.ForEach(rangePartitioner, (range, loopState) =>
        {
            // Loop over each range element without a delegate invocation.
            for (int i = range.Item1; i < range.Item2; i++)
            {
                results[i] = source[i] * Math.PI;
            }
        });

        Console.WriteLine("Operation complete. Print results? y/n");
        char input = Console.ReadKey().KeyChar;
        if (input == 'y' || input == 'Y')
        {
            foreach(double d in results)
            {
                Console.Write("{0} ", d);
            }
        }
    }
}
Imports System.Threading.Tasks
Imports System.Collections.Concurrent

Module PartitionDemo

    Sub Main()
        ' Source must be array or IList.
        Dim source = Enumerable.Range(0, 100000).ToArray()

        ' Partition the entire source array. 
        ' Let the partitioner size the ranges.
        Dim rangePartitioner = Partitioner.Create(0, source.Length)

        Dim results(source.Length - 1) As Double

        ' Loop over the partitions in parallel. The Sub is invoked
        ' once per partition.
        Parallel.ForEach(rangePartitioner, Sub(range, loopState)

                                               ' Loop over each range element without a delegate invocation.
                                               For i As Integer = range.Item1 To range.Item2 - 1
                                                   results(i) = source(i) * Math.PI
                                               Next
                                           End Sub)
        Console.WriteLine("Operation complete. Print results? y/n")
        Dim input As Char = Console.ReadKey().KeyChar
        If input = "y"c Or input = "Y"c Then
            For Each d As Double In results
                Console.Write("{0} ", d)
            Next
        End If

    End Sub
End Module

루프의 모든 스레드는 지정된 하위 범위의 시작 및 끝 인덱스 값을 포함하는 자체 Tuple<T1,T2>를 받습니다. 내부 for 루프는 fromInclusive 값과 toExclusive 값을 사용하여 배열이나 IList를 직접 반복합니다.

오버로드 중 Create 하나를 사용하면 파티션의 크기와 파티션 수를 지정할 수 있습니다. 이 오버로드는 요소당 작업이 너무 낮아 요소당 하나의 가상 메서드 호출도 성능에 눈에 띄는 영향을 주는 시나리오에서 사용할 수 있습니다.

사용자 지정 파티셔너

일부 시나리오에서는 사용자 고유의 파티셔너를 구현하는 데 가치가 있거나 필요할 수도 있습니다. 예를 들어 클래스의 내부 구조에 대한 지식에 따라 기본 파티셔너가 할 수 있는 것보다 더 효율적으로 분할할 수 있는 사용자 지정 컬렉션 클래스가 있을 수 있습니다. 또는 원본 컬렉션의 여러 위치에서 요소를 처리하는 데 걸리는 시간에 대한 지식에 따라 다양한 크기의 범위 파티션을 만들 수 있습니다.

기본 사용자 지정 파티셔너를 만들려면 다음 표에 설명된 대로 클래스를 System.Collections.Concurrent.Partitioner<TSource> 파생시키고 가상 메서드를 재정의합니다.

메서드 설명
GetPartitions 이 메서드는 주 스레드에서 한 번 호출되고 IList(IEnumerator(TSource))를 반환합니다. 루프 또는 쿼리의 각 작업자 스레드는 목록에서 GetEnumerator을 호출하여 고유한 파티션에 대한 IEnumerator<T>을 검색할 수 있습니다.
SupportsDynamicPartitions 구현할 때 true을 반환하고, 그렇지 않으면 GetDynamicPartitions를 반환합니다.
GetDynamicPartitions SupportsDynamicPartitionstrue인 경우, 이 메서드는 선택 사항으로 GetPartitions 대신 호출할 수 있습니다.

결과를 정렬할 수 있어야 하거나 요소에 대한 인덱싱된 엑세스가 필요한 경우, 다음 표에 설명된 대로 System.Collections.Concurrent.OrderablePartitioner<TSource>에서 파생하고 가상 메서드를 재정의합니다.

메서드 설명
GetPartitions 이 메서드는 주 스레드에서 한 번 호출되며 IList(IEnumerator(TSource))을 반환합니다. 루프 또는 쿼리의 각 작업자 스레드는 목록에서 GetEnumerator을 호출하여 고유한 파티션에 대한 IEnumerator<T>을 검색할 수 있습니다.
SupportsDynamicPartitions true을(를) 구현하면 GetDynamicPartitions을(를) 반환하고, 그렇지 않으면 false입니다.
GetDynamicPartitions 일반적으로 이것은 GetOrderableDynamicPartitions만 호출합니다.
GetOrderableDynamicPartitions SupportsDynamicPartitionstrue인 경우, 이 메서드는 선택 사항으로 GetPartitions 대신 호출할 수 있습니다.

다음 표에서는 세 종류의 부하 분산 파티셔너가 OrderablePartitioner<TSource> 클래스를 구현하는 방법에 대한 추가 세부 정보를 제공합니다.

메서드/속성 부하 분산이 없는 IList/배열 부하 분산을 위한 IList/배열 IEnumerable
GetOrderablePartitions 범위 분할 사용 지정된 partitionCount에 따라 목록에 최적화된 청크 분할을 사용합니다. 정적 파티션 수를 만들어 청크 분할을 사용합니다.
OrderablePartitioner<TSource>.GetOrderableDynamicPartitions 지원되지 않는 예외를 발생시킵니다. 목록 및 동적 파티션에 최적화된 청크 분할 사용 동적 파티션 수를 만들어 청크 분할을 사용합니다.
KeysOrderedInEachPartition true를 반환합니다. true를 반환합니다. true를 반환합니다.
KeysOrderedAcrossPartitions true를 반환합니다. false를 반환합니다. false를 반환합니다.
KeysNormalized true를 반환합니다. true를 반환합니다. true를 반환합니다.
SupportsDynamicPartitions false를 반환합니다. true를 반환합니다. true를 반환합니다.

동적 파티션

메서드에서 ForEach 파티셔너를 사용하려는 경우 동적 수의 파티션을 반환할 수 있어야 합니다. 즉, 파티셔너는 루프 실행 중에 언제든지 요청 시 새 파티션에 대한 열거자를 제공할 수 있습니다. 기본적으로 루프는 새 병렬 작업을 추가할 때마다 해당 작업에 대한 새 파티션을 요청합니다. 데이터를 순서대로 정렬해야 하는 경우, 각 파티션의 항목마다 고유한 인덱스가 할당되도록 System.Collections.Concurrent.OrderablePartitioner<TSource>로부터 파생하십시오.

자세한 내용 및 예제는 방법: 동적 파티션 구현을 참조하세요.

파티셔너를 위한 계약

사용자 지정 파티셔너를 구현하는 경우 PLINQ 및 ForEach TPL에서 올바른 상호 작용을 보장하기 위해 다음 지침을 따릅니다.

  • GetPartitionspartitionsCount에 대해 0 이하의 인수로 호출되면, ArgumentOutOfRangeException를 throw합니다. PLINQ 및 TPL은 0으로 partitionCount가 전달되지 않지만, 그 가능성에 대비할 것을 권장합니다.

  • GetPartitionsGetOrderablePartitions는 항상 partitionsCount개의 파티션을 반환해야 합니다. 파티셔너가 데이터가 부족하여 요청된 만큼 파티션을 만들 수 없는 경우 메서드는 나머지 파티션 각각에 대해 빈 열거자를 반환해야 합니다. 그렇지 않으면 PLINQ와 TPL이 모두 InvalidOperationException를 던집니다.

  • GetPartitions, GetOrderablePartitions, GetDynamicPartitions, 및 GetOrderableDynamicPartitionsnull을(를) 반환해서는 절대 안 됩니다 (Visual Basic의 경우 Nothing). 그렇다면 PLINQ/TPL은 예외를 발생시킵니다 InvalidOperationException.

  • 파티션을 반환하는 메서드는 항상 데이터 원본을 완전하고 고유하게 열거할 수 있는 파티션을 반환해야 합니다. 파티셔너 디자인에 특별히 필요한 경우가 아니면 데이터 원본 또는 건너뛴 항목에 중복이 없어야 합니다. 이 규칙을 따르지 않으면 출력 순서가 뒤섞일 수 있습니다.

  • 다음 불린 값 반환 함수는 출력 순서가 뒤섞이지 않도록 항상 다음 값을 정확하게 반환해야 합니다.

    • KeysOrderedInEachPartition: 각 파티션은 키 인덱스가 증가하는 요소를 반환합니다.

    • KeysOrderedAcrossPartitions: 반환되는 모든 파티션에 대해, 파티션 i의 키 인덱스는 파티션 i-1의 키 인덱스보다 높습니다.

    • KeysNormalized: 모든 키 인덱스는 0부터 시작하여 간격 없이 단조적으로 증가합니다.

  • 모든 인덱스는 고유해야 합니다. 중복 인덱스가 없을 수 있습니다. 이 규칙을 따르지 않으면 출력 순서가 뒤섞일 수 있습니다.

  • 모든 인덱스는 음수여야 합니다. 이 규칙을 따르지 않으면 PLINQ/TPL에서 예외를 throw할 수 있습니다.

참고하십시오