C# Parallel 병렬 분할 알고리즘 변경이 가능한가요?

안녕하세요. Parallel 클래스를 사용한 병렬 프로그래밍에서 최적화를 할 수 있는지 여쭤보려 글을 쓰게 되었습니다.

최적화하고 싶은 이유를 예시를 통해 보여드리겠습니다.
범위를 알 수 있는 List 자료형에 병렬 알고리즘을 적용하면 범위 분할이 되어 병렬 처리가 됩니다. 예를 들어, list에 100개의 원소가 들어있고, 이를 4개의 코어로 병렬 처리하면 0~24, 25~49, 50~74, 75~99로 나뉘어 병렬처리가 됩니다.

여기서 특정 범위의 원소에 작업이 소요되는 시간이 오래걸리는 경우, 처음에는 4개의 스레드로 병렬처리 되다가 3개의 스레드가 먼저 일을 끝내고 1개의 스레드만 작업을 진행하게 됩니다.

이해를 돕기 위한 코드입니다.

var list = new List<int>();
for(var i = 0; i < 100; i++)
{
    list.Add(i);
}

list.AsParallel().WithDegreeOfParallelism(4).ForAll(i =>
{
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}, Value: {i}");
    // 번호가 높을 수록 작업이 오래 걸린다.
    Thread.Sleep(i * 5);
});
  • 결과

    [중간생략]
  • 설명
    • list의 원소값이 높을 수록, 처리에 대한 시간도 오래소요된다.
    • 4개의 스레드가 각각의 범위를 처리한다.
      • 1번스레드: 75~99
      • 3번스레드: 25~49
      • 4번스레드: 50~74
      • 5번스레드: 0~24
    • 5번 스레드가 가장 먼저 종료되고, 3번, 4번, 1번 스레드순으로 작업이 완료된다.
  • 문제점: 시간이 지날수록 병렬처리의 의미가 없어진다.
  • 제가 기대하는 동작: 먼저 자신의 범위에서 작업을 완료한 스레드가 종료되지 않고, 남은 원소의 처리를 돕는다.

위와 같이 먼저 작업을 완료한 스레드가 남아있는 작업을 실행하는 방법이 있을까요?

3개의 좋아요

질문이 있습니다.

여러 스레드가 동일한 작업을 원할하게 할 수 있을까요?

2개의 좋아요

ConcurrentQueue를 활용해서 여러 스레드에게 동일하게 분배할 순 있습니다.

List<int> list = new List<int>();
for (int i = 0; i < 100; i++)
{
    list.Add(i);
}
var queue = new ConcurrentQueue<int>(list);

Parallel.ForEach(Enumerable.Range(0, 4), _ =>
{
    while (queue.TryDequeue(out int p))
    {
        Console.WriteLine($"Id: {Environment.CurrentManagedThreadId}, Value: {p}");
        Thread.Sleep(p * 5);
    }
});

질문자님이 기대하는 동작인지는 모르겠네요^^;

7개의 좋아요

흥미로운 질문이네요.
같은 코드로 테스트 했을 경우 질문자님과 같은 결과를 반환합니다.
혹시 꼭 WithDegreeOfParallelism(4) 코드를 사용해야만 했던 이유가 있을까요?

2개의 좋아요

업무 시간이라 자세한 답변은 할 수 없지만 ^^; 아래의 문서를 보면 도움이 될 것입니다.

How to: Control Ordering in a PLINQ Query - .NET | Microsoft Learn

Custom Partitioners for PLINQ and TPL - .NET | Microsoft Learn

3개의 좋아요
  List<int> list = new List<int>();
  for (int i = 0; i < 100; i++)
  {
      list.Add(i);
  }
  var po = new ParallelOptions { MaxDegreeOfParallelism = 4 };
  Parallel.ForEach(list, po, (item) =>
  {
      Console.WriteLine($"Id: {Environment.CurrentManagedThreadId}, Value: {item}");
      Thread.Sleep(item * 5);
  });

원하시는 조건이 단순히 조건이
동시진행 쓰레드 갯수를 4개로 한정시키시려는게 목적이시라면
위의 코드와 같이 Parallel에 아이템에 대한 액션만 대응시키면 되는게 아닌가요?

아니면 각 쓰레드에 범위 형태로 할당해야하는 목적이 있는건가요?

3개의 좋아요

위 코드로 실행을 하면 전체적인 처리 속도는 빨라지네요. 그러나, 한번의 순회에서 4개의 병렬 처리가 모두 끝나야 하다보니 여전히 대기시간은 존재하는것 같습니다…

제가 원하는 동작은 list의 모든 원소를 처리할 동안 4개의 스레드가 쉬지않고 동작하는 것이였습니다.

1개의 좋아요

단지 예시를 위해 4개로 제한했습니다.
해당 옵션을 주지 않더라도 전체적인 동작은 비슷하던데 … 혹시 다른점이 있나요?

1개의 좋아요

스레드에 범위 형태로 할당해야 하는 목적은 없습니다. 단지 데이터 관점으로 병렬처리 했을 때 처리되는 과정이 궁금해서 질문했습니다.

(부끄럽지만) 보여주신 위 코드와 제 코드의 내부 동작이 이렇게 다른 줄은 몰랐습니다. 액션만 대응시키는 방법이 있었군요. 처리 속도는 빠르게 나오네요.

여전히 궁금한 부분이 있어서 여쭤보고 싶습니다.
Q1. 위 코드에선 분할 알고리즘이 없는 것같은데, 그렇다면 4개의 스레드에 어떤식으로 분산이 되는건가요?
Q2. 위 코드로 실행시에도 마지막 94~99번대 원소는 계속 하나의 스레드에서만 처리가 됩니다. 이유가 뭘까요?

1개의 좋아요

저에게 질문하신 건가요?

1개의 좋아요

네 글을 보는 모두가 해당됩니다.

1개의 좋아요

주제와 다른 이야기라… 별개의 질문 글을 올리시고 이 글을 참조 하는 것이 어떨까요?

1개의 좋아요

감사합니다. 원글에는 적어놓지 않았지만 아래 책에 분할 알고리즘이 여러 개 소개되어 있어서 분할 알고리즘 변경이 가능한건지 질문했던 것입니다.

‘More Effective C# 아이템 35. PLINQ가 병렬 알고리즘을 구현하는 방법을 이해하라’ 를 보면 병렬 프로그래밍 시 분할 알고리즘은 4가지입니다. 궁금하신 분들을 위해 간단히 정리했습니다.

  1. 범위 분할
  • 입력값의 범위를 태스크 수로 나누어 처리
  • List와 같은 자료구조에 적용
  1. 덩어리 분할
  • 태스크에 작은 청크를 할당하고, 작업을 완료하면 추가 청크를 할당하는 방식. 작업을 계속하면서 청크 크기를 늘려나간다.
  • 최종 목적은 모든 태스크가 거의 동시에 끝나도록 하여 전체적인 처리량을 최대로 높이는 것
  1. 줄 단위 분할
  • 작업 태스크가 4개 (a, b, c, d)일 때 a, b, c, d, a, b, c, d, a, … 이런식으로 줄무늬같이 처리하는 방식
  • 각각의 작업 스레드는 N개의 요소를 건너뛴 후 M개의 요소를 처리한다. (반복)
  1. 해시 분할
  • Join, GroupBy 등 특별 쿼리에 사용되는 알고리즘
  • 같은 해시 코드를 생성하는 항목을 같은 태스크가 처리하도록 하여 태스크 간 상호작용을 최소화하는 방식
4개의 좋아요

감사합니다 이해했습니다. :+1: :heart:

제가 다르게;; 이해하고 있엇네요.

1개의 좋아요

주신 답변을 보고 저도 다시 More Effective를 소환해 읽어보았습니다.

설명해 주셨듯이 현재 사용하는 자료구조는 List<T>이므로 PLINQ는 범위 분할 알고리듬을 사용했습니다.

PLINQ는 쿼리를 실행하기 전에 분할 과정을 거친 후 실행 된다는 More Effective의 설명으로 보아
4개의 병렬성으로 분할 된 데이터는 쿼리를 실행할 때 다시 분할되지 않을 것으로 예상됩니다.

그러므로 원하시는대로 먼저 데이터를 처리한 쓰레드에서 아직 처리가 완료 되지 않은 쓰레드의 데이터를 다시 분할해서 작업을 도와주는 최적화를 하셔야 한다면 PLINQ가 아닌 새로운 알고리듬을 작성하셔야 할 것 같습니다.

개인 적인 판단이지만 새로운 알고리듬을 직접 만들어 최적화 하는 방법보다는 WithDegreeOfParallelism(4)를 사용하지 않고 PLINQ가 쿼리를 시작하기 전에 최적의 병렬성을 스스로 판단해서 쿼리를 돌리게 만들면 모든 쓰레드의 작업이 거의 동시에 끝나 말씀해 주신 것과 같이 특정 쓰레드의 작업이 끝나길 기다리는 시간이 최소한으로 줄어들 것 같습니다.

4개의 좋아요

학습이 목적이라면 다음 책을 추천합니다. 동시성 프로그래밍은 결국 패러다임의 전환이 필요해서요.

4개의 좋아요