Stream API 의 병렬스트림을 이용하여, 큰 고민 없이 병렬처리를 할 수 있음을 알 수 있었습니다.



현재 공부하고 있는 책인 [JAVA8 in Action] 에 따르면, 성능을 최적화할 때는 세 가지 황금 규칙이 있다고 합니다. 첫째도 측정, 둘째도 측정, 셋째도 측정!


병렬스트림으로 변경했을 때, 순차스트림보다 빠르다는 것을 보장할 수 있을까요? 

한번 측정을 해보겠습니다.


아래는 0~1000000 에 대한 덧셈 로직과 수행시간 결과입니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final int[] dataSet = IntStream.range(01000000).toArray();
 
// 단순한 for-loop 순차처리
{
    int sum = 0;
    for (int i = 0, size = dataSet.length; i < size; ++i) {
        sum += i;
    }
}
 
// 병렬스트림 사용.
{
    IntStream.of(dataSet).boxed().parallel().reduce(Integer::sum);
}
 
// Misure time!!
// 1.  3msecs.
// 2. 70msecs.
cs


결과는 단순한 for-loop 이 훨씬 빠르게 나왔습니다. 꼭 빠르다고만은 볼 수 없군요. ㅡㅡ^ 


병렬스트림을 사용한 예제에서는 최종연산 reduce 에서 코드를 간결하게 하기 위해(Integer::sum) 박싱(boxed) 과정을 사용하였습니다. 


알게모르게 놓치는 부분이지만 Wrapper 클래스를 사용하기 위해 하는 박싱과정은 비용이 은근히 크다고 볼 수 있습니다. 그렇기 때문에 JAVA8 에서는 원시타입을 위한 함수형인터페이스와 원시타입 전용 Stream 을 제공한다고 포스팅을 했었던 적이 있습니다.



병렬스트림의 예제를 다음과 같이 변경해보겠습니다.


1
2
3
4
5
// 병렬스트림 사용.
IntStream.of(dataSet).parallel().reduce((a, b) -> a + b);
 
// Misure time!!
// 1. 26msecs.
cs


Integer::sum 이란 이미 구현된 메소드레퍼런스 사용을 포기하고 람다로 구현하였으며, 박싱을 하지 않았습니다. 위의 예제보다 분명 시간은 빨라졌는데 단순 for-loop 보다 느립니다. 


이번에는 한 번 덧셈을 해야하는 양을 늘려보겠습니다. 현재 사용하는 IDE 인 이클립스의 힙을 늘리지 않은 상태에서 out of memory 가 나지 않는 최대량인 (0~700000000) 까지 늘려보겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
개수 : 2000000
1. For-loop (4 msecs)
2. Parallel   (43 msecs)
 
개수 : 25000000
1. For-loop (13 msecs)
2. Parallel   (60 msecs)
 
…….
 
개수 : 350000000
1. For-loop (122 msecs)
2. Parallel   (120 msecs)
 
개수 : 400000000
1. For-loop (152 msecs)
2. Parallel   (121 msecs)
 
개수 : 700000000
1. For-loop (243 msecs)
2. Parallel   (174  msecs)
cs


개수가 350000000 부터 비슷한 성능을 보였으며, 그 이후부터는 병렬스트림이 빠른 것을 알 수 있습니다.


즉 이 실험에서 알 수 있는 것은 작업의 분할, 멀티코어 간의 데이터 이동, 결과 병합 등 각 단계 과정의 비용이 꽤 크며, 순차로 하는 작업이 꽤 걸리는 작업일 때만 빛을 볼 수 있다는 것입니다.


두 가지 정도 병렬 처리 시, 주의해야할 문제에 대해서 언급을 했습니다.


- 반복자 내부 박싱에 주의할

(일반 비지니스로직 작성 중에서도 주의할 내용입니다. ㅡㅡ^)


- 병렬로 처리하고자 하는 일이 충분히 오래걸리는 작업인가

    (한 단위의 계산시간이 길다면, 성능개선의 가능성이 있습니다.)


이 외에, [JAVA8 in Action] 에서는 병렬스트림 사용 시, 고려해야할 사항을 다음과 같이 명시하고 있습니다.


1. 확신이 서지 않는다면 측정할 것! 


최적화를 위해서 해야할 세 가지 황금규칙을 생각하세요.


2. limit 나 findFirst 와 같은 순서에 의존하는 연산을 지양할 것.


병렬처리 자체가 반복자의 작업을 분할-정복 하는 알고리즘을 사용하는 데, 순서를 고려해야한다면 그만큼 작업(Thread의 task)간 동기화 시간이 길어지게 될 것입니다.


3. 적절한 자료구조를 사용할 것!


LinkedList 보다 ArrayList 가 좋습니다. 작업 분할을 위해서 LinkedList 는 모든 요소를 탐색해야하지만, ArrayList 는 index의 요소 단위로 탐색이 가능합니다.


작업 분할을 위한 자료구조 선택에 다음 사진을 참고하세요.


출처 : [JAVA8 in Action]


4. Stream 내의 파이프라인의 중간연산에 따라 작업 분할 성능에 영향이 있음!


일정한 크기의 Stream 의 경우 크기가 정확하기 때문에 적절하게 작업을 분리할 수 있지만, 중간에 filter 등 크기를 예측할 수 없게 만드는 중간연산이 있다면 병렬처리를 하는 것에 애로 사항을 꽃피울 수 있습니다


5. 병합과정의 비용도 고려할 것.


기껏 작업을 분할, 계산 열심히 해서 성능을 최적화 했지만, 병합과정에서 오래걸린다면 상쇄될 수 있습니다. 


6. 작업 간의 공유 변수 사용 금지!


예를 들어, 다음의 코드는 문제가 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
    Integer a = 0;
    
    void sum(int number) {
        this.a += number;
    }
}
 
A object = new A();
            
// 공유변수 접근.
IntStream.of(dataSet).boxed().parallel().forEach(object::sum);
 
// Measure time!!
// 526msecs.
cs


A 인스턴스의 멤버변수인 a 로 덧셈결과를 취합하고 있습니다. 이 때, 각 스레드는 공유 변수인 A.a 에 서로 접근하려하는 Race Condition 상황이 벌어질 수 있습니다. 


물론, 한 스레드가 한 변수에 접근가능하도록 내부적으로는 세마포어가 작동하는 것은 보장하지만 실험결과와 같이 비용이 크다는 것을 알 수 있습니다.



병렬스트림 사용 시, 주의사항을 다음과 같이 알아보았습니다. 


JAVA8 에서 멀티스레드 프로그래밍을 하기에 분명 Simple 해진 것은 맞고, 공짜로 병렬성을 얻었다고 하지만 이 세상에 완전한 공짜는 없다는 것을 깨닫고 갑니다.



자바 8 인 액션
국내도서
저자 : 라울-게이브리얼 우르마(RAOUL-GABRIEL URMA),마리오 푸스코(MARIO FUSCO),앨런 마이크로프트(ALAN MYCROFT) / 우정은역
출판 : 한빛미디어 2015.04.01
상세보기


반응형
Posted by N'

JAVA8 을 공부하는 있는 책인 [JAVA8 in Action] 에서 언급되었던 내용 중 한 가지는 "공짜로 병렬성을 얻을 수 있다"는 것이었습니다. Collection 군의 데이터를 새로 추가된 API 인 Stream 의 형태를 parallel 관련 keyword 를 사용하여, 선언형 프로그래밍을 작성하면 병렬처리가 된다는 것이었죠.


아래는 병렬처리를 수행하는 간단한 예제입니다.


1
2
3
4
5
6
7
8
9
10
// Collection 클래스 군의 parallelStream 사용.
List<Integer> boxedDataList = IntStream.of(dataSet).mapToObj(Integer::new).collect(Collectors.toList());
boxedDataList.parallelStream().reduce(Integer::sum).ifPresent(System.out::println);
        
// 순차스트림 (IntStream) 을 병렬스트림으로 변
IntStream.of(dataSet).boxed().parallel().reduce(Integer::sum).ifPresent(System.out::println);
 
// 출력 결과
// 499500
// 499500
cs


위의 예제를 보면 정말 간단하다는 것을 알 수 있습니다. (Too Simple!!)

병렬 keyword 를 제외한 중간, 최종 연산 방법이 생각이 안나면, 이 곳을 참고하세요. 



간단히 리뷰를 해보면, 


1번 선언에서는 일반적으로 Collection 에서 스트림을 구하는 것 대신에 parallelStream 키워드를 통해 병렬스트림으로 변경한 후, 처리를 하고 있습니다. 


2번 선언에서는 이미 순차스트림인 상태를 parallel 중간연산을 사용하여 병렬스트림으로 변경시켰습니다.


단순히 병렬스트림으로 변경하는 것만으로 병렬처리를 할 수 있으니, 책에서 소개한 것과 같이 공짜로 병렬성을 얻었다는 말이 뻥은 아니라는 것을 알 수 있습니다. ㅡㅡ^


즉 병렬처리를 하기 위해 생각해봐야할 고민인 

사용할 스레드 개수경쟁상태(race condition), 계산된 결과들의 동기화 등이 추상화되었습니다.


 

parallel 관련 키워드 메소드를 사용하여 순차스트림을 병렬스트림으로 변경했들이,

병렬스트림을 순차스트림으로 변경할 수도 있습니다. sequencial 키워드로 말이죠. :-) 


하지만, 이러한 중간연산(parallel 과 sequencial)을 아래와 같이 특정 중간연산의 제어를 하겠다는 목적으로 여러번 사용하는 것은 부질 없습니다. ㅡㅡ^


1
2
3
4
5
6
IntStream.of(dataSet).boxed()
            .parallel()
            .filter(n -> n % 2 == 0)
            .map(n -> n + 2)
            .sequential()
            .collect(Collectors.reducing(Integer::sum)).ifPresent(System.out::println);
cs


최종적으로 선택된 sequential 만 적용되며, 위 연산은 순차상태로 계산됩니다.


하지만, 이러한 병렬처리가 무조건 성능을 끌어다 줄까요?


이 이야기는 다음 포스팅에서...




자바 8 인 액션
국내도서
저자 : 라울-게이브리얼 우르마(RAOUL-GABRIEL URMA),마리오 푸스코(MARIO FUSCO),앨런 마이크로프트(ALAN MYCROFT) / 우정은역
출판 : 한빛미디어 2015.04.01
상세보기



반응형
Posted by N'