병렬처리(2) - 병렬스트림 잘 알고 사용하기!
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(0, 1000000).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 을 제공한다고 포스팅을 했었던 적이 있습니다.
2016/07/28 - [개발이야기/함수형 프로그래밍] - 원시타입을 위한 함수형 인터페이스
2017/01/25 - [개발이야기/함수형 프로그래밍] - Stream API 활용편5 (숫자형 스트림, 스트림 생산)
병렬스트림의 예제를 다음과 같이 변경해보겠습니다.
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 해진 것은 맞고, 공짜로 병렬성을 얻었다고 하지만 이 세상에 완전한 공짜는 없다는 것을 깨닫고 갑니다.
|
'개발이야기 > 함수형 프로그래밍' 카테고리의 다른 글
병렬처리(3) - Spliterator (0) | 2017.02.16 |
---|---|
병렬처리(1) - Stream API 를 이용해 간단히 병렬화 하기. (0) | 2017.02.15 |
Collectors! 데이터를 수집해보자. (0) | 2017.02.02 |
Stream API 활용편5 (숫자형 스트림, 스트림 생산) (0) | 2017.01.25 |
Stream API 활용편4 (리듀싱) (0) | 2017.01.23 |