#07장 병렬 데이터 처리와 성능

Raoul-Gabriel Urma 저, 우정은 역 「Modern Java in Action, 2019」를 읽고 정리하였습니다.

  • 이 장에서는

    • 병렬 스트림으로 데이터를 병렬 처리하기

    • 병렬 스트림의 성능 분석

    • 포크/조인 프레임워크

    • Spliterator로 스트림 데이터 쪼개기

7.1 병렬 스트림

  • parallelStream(): 스트림 요소를 청크로 분할된 병렬 스트림 생성된다.

  • 사례: n까지의 숫자 합계

    • 무한 스트림 생성

    • n으로 스트림 크기 제한

    • 더하는 리듀싱 작업 수

public long sequentialSum(long n) {
    return Stream.iterate(1L, i -> i + 1)
        .limit(n)
        .reduce(0L, Long::sum);
}
  • 전통적인 방식

7.1.1 순차 스트림을 병렬 스트림으로 변환하기

  • 이런 걱정 NO

    • 결과 변수를 어떻게 동기화해야?

    • 몇개의 스레드를 사용해야?

    • 숫자는 어떻게 생성?

    • 생성된 숫자는 누가 더할까?

  • parallel() 메소드 추가

    • 여러 청크로 분할

  • sequential() 도 있다.

  • 번갈아 사용할 경우, 마지막 호출이 전체 파이프라인에 영향을 미친다.

    • pararellel이 마지막 호출됬으니.

7.1.2 스트림 성능 측정

  • 3가지 기법 성능 비교

    • 반복형, 순차리듀스형, 병렬 리듀싱

  • 도구: JMH (Java Microbenchmark Harness)

    • 어노테이션 방식으로 측정 심플

  • 설정

    • XML 설정

    • 어노테이션 추가

  • 실행 환경

    • i7-4600u 2.1GHz 쿼드 코어

기존 반복형 (iterativeSum)

  • 시간: 3.278 ms

순차리듀스형 (sequentialSum)

  • 시간: 121.843 ms

병렬 리듀싱 (parallelSum)

  • 시간: 604.059 ms

결과를 보면,

  • for문이 가장 빠르게 나왔다.

  • 의외의 결과가 나온 이유

    • 박싱된 객체가 만들어지므로, 더하려면 언박싱을 해야한다.

    • 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기가 어렵다.

  • 이처럼 병렬 프로그래밍은 함정이 숨어있다.

더 특화된 메서드 사용

  • LongStream.rangeClosed() 장점

    • 기본형 long을 직업 사용하므로, 박싱과 언박싱 오버헤드 x

    • 쉽게 청크로 분할할 수 있는 숫자 범위를 생성 해준다.

      • 예, 1~20을 1-5, 6-10, 11-15, 16-20 로 분할

  • 아래 코드 걸린 시간: 5.315 ms

    • 기존 iterate 메서드 보다 속도가 빠르다.

    • 어떤 알고리즘 병렬화를 선택하는 것보다 적절한 자료구조 선택이 중요하다.

  • 하지만, 병렬 스트림을 적용하면 어떻게 될까?

  • 아래 코드 걸린 시간: 2.677 ms

    • 드디어 빠른 병렬 리듀싱을 만들었다.

주의할 점

  • 서브스트림을 서로 다른 스레드의 리듀싱 연산을 할당하고 다시 합치는데, 즉, 멀티코어간 데이터 이동은 비싸다. 이부분을 주의해야 한다.

7.1.3 병렬 스트림의 올바른 사용법

  • 퀴즈: 다음 코드의 문제는 무엇일까?

    • 답: total이 공유된 상태이기에 데이터 레이스가 발생 할 수 있어, 아래 코드는 병렬 스트림을 사용할 수 없는 코드이다.

7.1.4 병렬 스트림 효과적으로 사용하기

  • 1000개 이상 요소일 때 병렬 스트림 사용 -> 이런 기준은 없다

  • 그러나, 수량적 힌트 사용은 도움

    • 벤치마크로 직접 측정

    • 박싱 주의

      • IntStream, LongStream, DoubleStream 사용 추천

    • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산

      • 예: limit(), findFirst()

    • 파이프라인 연산 비용 고려

      • N*Q

        • N: 처리할 요소수

        • Q: 하나 요소 처리에 드는 비용

      • Q가 높을 경우, 병렬 스트림 사용시 개선될 확률이 높다.

    • 소량 데이터에서 병렬 스트림은 도움 x

    • 자료구조가 적절한지 확인

      • LinkedList 보단 ArrayList가 효율적으로 분할 가능. 왜? LL은 모든 요소 탐색해야 하니.

    • 스트림의 특성과 파이프라인의 중간연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정 성능이 달라질 수 있다.

      • 쉽게 말해, SIZED 스트림은 같은 크기로 분할 가능한데, 필터연산이 있으면 스트림 길이를 예측할 수 없으므로 병렬 처리 할수있을지 알 수 없다.

    • 최종 연산의 병합 과정 비용을 살펴보라.

7.2 FORK/JOIN 프레임 워크

  • 병렬 스트림이 수행내되는 내부 인프라 구조를 알기위해 이 프레임워크를 알아야 한다.

  • 과정: 재귀적으로 작은 작업으로 분할 -> 서브 태스크 결과를 합친다.

  • 포크 의미: 다른 스레드를 만드는것.

7.2.1 RecursiveTask 활용

image
  • 필요한 과정은 아래 코드와 같다

  • 포크/조인 프레임워크를 이용한 병렬 합계 수행

ForkJoinSumCalculator 실행

  • ForkJoinPool은 스레드 풀로 작업자 스레드에 분산 할당하는 ExecutorService를 구현하고 있다.

  • N: 100,000 경우

    • 10개로 쪼갠 후

    • 시작점은 1~10,000 ...

  • 이런식으로 작동되겠습니다.

    • ForkJoinSumCalculator 100,000

      • Left: ForkJoinSumCalculator 1~50,000

        • Left: ...

        • Right: ...

      • Right: ForkJoinSumCalculator 50,001~100,000

        • Left: ...

        • Right: ...

TIP: 일반적으로 애플리케이션에서는 ForkJoinPool을 1개의 싱글턴으로 만들고 재사용한다.

7.2.2 포크/조인 프레임워크 제대로 사용하기

  • 퀴즈: 왜 한쪽에서만 fork()를 쓰고, 다른 한쪽은 compute()를 사용했을까?

    • 각각 사용이 효율적이다. -> 두 서브태스크의 한 태스크에 같은 스레드를 사용할 수 있음으로 불필요한 태스크 할당의 오버헤드 방지한다.

  • 포크/조인에서 병렬 계산시 디버깅이 어려운 이유는?

    • fork 라 불리는 다른 스레드에서, compute를 호출함으로 스택 트레이스가 도움되지 않는다.

  • 팁, 효율적 성능을 위해, 서브태스크의 실행시간 > forking 시간

7.2.3 작업 훔치기 (work stealing)

  • N = 1000만 경우 -> 1000개 이상 서브 태스크로 쪼개질것

  • 퀴즈: 코어가 이렇게 많지도 서브태스크를 많이 쪼개는게 좋은걸까?

    • 답: 코어수와 관계없이 적절한 크기로 분할된 많은 태스크를 포킹하는 것은 바람직하다.

  • 작업 훔치기는 분할 기법이 효율적이지 않을때 발생하는 문제를 해결해준다.

    • 역할: ForkJoinPool의 모든 스레드를 공정하게 분할

    • 작동과정

      • Doubly LinkedList 사용

      • 하나의 스레드 작업이 끝나면, 링크드리스트 큐에 기다리는 다른 작업에게 넘겨준다.

  • 한 작업자가 태스크를 분할했을때, 다른 작업자가 훔치기 가능 image

7.3 Spliterator 인터페이스

  • Spliterator (spliter + iterator): 자동으로 스트림을 분할해준다. (자바 8에서 등장)

  • 링크arrow-up-right

    • 결론: ~ "기존 Stream으로는 번거로운 작업들을 좀 더 쉽게 처리 할 수 있어, 잘 쓰기만 한다면 많이 활용 할 수 있을것 같습니다."

7.3.1 분할 과정

  • null은 더이상 분할 불가 의미

image

7.3.2 커스텀 Spliterator 구현하기

  • 기존 반복형을 이용해 단어수를 구할 경우

WordCounter 병렬로 수행

  • 아래 코드 병렬 X, 그냥 스트림 처리시

  • 266~277쪽이 없네요...ㅜㅜ

  • 아래 코드 병렬 O

  • 기대했던 19 words 가 아닌 다른 값...

    • 이유: 원래의 문자열을 원래의 위치에서 둘로 나누다보니 예상치 못하게 하나의 단어를 둘로 계산하는 상황이 왔다. 문슨말?????? 즉, 순차 스트림을 병렬 스트림으로 바꿀때 분할 위치에 따라 잘못된 결과가 나올 수 있다.

  • 해결법: 단어를 중간이 아닌 끝나는 위치를 기준으로 분할하자.

해결하기 위해, Spliterator 를 사용할때가 왔다!

7.4 마치며

  • 내부 반복을 이용하면, 명시적으로 다른 스레드를 사용하지 않고도 스트림을 병렬 처리 가능

  • 항상 병렬 처리가 빠른것은 아님. (직접 성능 체크 필수)

  • 처리할 데이터가 많거나 각 요소 처리 오랜시간 걸릴때 병렬스트림 추천

  • 성능을 위해, 기본형 특화 스트림 사용 권장

  • 포크/조인은 서브태스크로 분할 후 각 스레드에서 처리후 머지

  • Spliterator는 탐색하려는 데이터를 포함하는 스트림을 어떻게 병렬화할 것인지 정의

Last updated