[ODOP:11]Java 함수형 프로그래밍 - 08. 스트림을 활용한 병렬 데이터 처리

벤 바이디히 의 'A Functional Approach to Java' 를 읽고 정리 한 내용입니다 스트림 병렬처리에 관해 다룹니다

동시성과 병렬성

모르는 사람 없겠지...?

병렬 스트림 활용

import java.io.IOException;  
import java.nio.file.Files;  
import java.nio.file.Paths;  
import java.util.Arrays;  
import java.util.Map;  
import java.util.function.Function;  
import java.util.regex.Pattern;  
import java.util.stream.Collectors;  
import java.util.stream.Stream;  
  
public class ParserWithStream {  
    public static void main(String[] args) {  
       var fileLocation = Paths.get("../war-and-peace.txt");  
  
       // CLEANUP PATTERNS  
       var punctionaction = Pattern.compile("\\p{Punct}");  
       var whitespace = Pattern.compile("\\s+");  
       var words = Pattern.compile("\\w+");  
  
       Map<String, Integer> wordCount = null;  
  
       try {  
          // LOAD CONTENT  
          var content = Files.readString(fileLocation);  
          wordCount = Stream.of(content)  
             // CLEAN CONTENT  
             .map(punctionaction::matcher)  
             .map(matcher -> matcher.replaceAll(""))  
  
             // SPLIT TO WORDS  
             .map(whitespace::split)  
             .flatMap(Arrays::stream)  
             .filter(word -> words.matcher(word).matches())  
  
             // COUNTING  
             .map(String::toLowerCase)  
             .collect(Collectors.toMap(Function.identity(),  
                word -> 1,  
                Integer::sum));  
       } catch (IOException e) {  
          // ...  
       }  
  
       System.out.println("word count = " + wordCount);  
    }  
}

아래의 코드에서 스트림은 사실 앞서 많이 다루었지만 Pattern 은 다루지 않았다

보자

Pattern.compile("...");

인데, 해당 함수는 개발자가 원하는 ==정규식 패턴을 입력하여 특정 패턴을 인식시킬 수 있는 함수==이다

책에서 나오고 있는 패턴은 총 3가지 인데, 설명을 포함하였다

/*
\p{} : 유니코드 패턴 인식
  - Punct: 구두점 인식 (\\p{P} 로도 작성 가능 하나, 작가의 배려인듯)
*/
Pattern.compile("\\p{Punct}");

/*
\s : 이스케이프 시퀀스 중 하나. 공백 문자를 나타냄
+ : 바로 전의 정규식이 1회 이상 반복됨을 의미
*/
Pattern.compile("\\s+");  

/*
\w : 이스케이프 시퀀스 중 하나. 문자를 나타냄
+ : 바로 전의 정규식이 1회 이상 반복됨을 의미
*/
Pattern.compile("\\w+");

더 자세한 설명은 본인의 노션 링크에 일부 수록 되어 있다

[!NOTE]
이외에는 책에서 충분히 설명하고 있으므로 생략한다

위의 코드는 작성자 기준 140ms 가 소요된다고 한다

이를 병렬 처리로 개선하면 아래와 같은 코드가 나오게 되며, 속도는 25ms 로 줄어들게 된다

import java.io.IOException;  
import java.nio.file.Files;  
import java.nio.file.Paths;  
import java.util.Arrays;  
import java.util.Map;  
import java.util.function.Function;  
import java.util.regex.Pattern;  
import java.util.stream.Collectors;  
import java.util.stream.Stream;  
  
public class WarAndPeaceParallel {  
  
    public static void main(String... args) {  
  
        var location = Paths.get("other/war-and-peace.txt");  
  
        // CLEANUP PATTERNS  
        var punctionaction = Pattern.compile("\\p{Punct}");  
        var whitespace = Pattern.compile("\\s+");  
        var words = Pattern.compile("\\w+");  
  
        Map<String, Integer> wordCount = null;  
  
        // LOAD CONTENT  
        try (Stream<String> stream = Files.lines(location)) {  
  
            wordCount = stream.parallel()  
                    // CLEAN LINES  
                    .map(punctionaction::matcher)  
                    .map(matcher -> matcher.replaceAll(""))  
  
                    // SPLIT TO WORDS  
                    .map(whitespace::split)  
                    .flatMap(Arrays::stream)  
                    .filter(word -> words.matcher(word).matches())  
  
                    // COUNTING  
                    .map(String::toLowerCase)  
                    .collect(Collectors.toConcurrentMap(Function.identity(),  
                             word -> 1,  
                             Integer::sum));  
        } catch (IOException e) {  
            // ...  
        }  
  
        System.out.println(wordCount);  
    }  
}

모든 병렬 처리가 반드시 빠르다고 단정지을 수는 없다

특히나 우리는 Web 개발자이기 때문에 항상 멀티 스레드 환경에서의 동작을 생각하고 적용해야 한다

이러한 직업적 특성은 이거 왜 공부함 이라는 의문을 가지게 될 수 있으나, 여기서 얻는 또다른 인사이트는 그것을 과감히 무시 할 정도가 된다고 생각한다

Data locality (데이터 지역성)

프로세스가 메모리 내에서 데이터를 얼마나 효율적으로 접근 할 수 있는지를 나타낸다

보통 CPU 캐시와 연관성이 깊은데, 좋은 Data locality 를 가지면 CPU 가 필요한 데이터를 더 빠르게 접근 할 수 있음을 의미 한다

책에서는 좀 더 자세한 이야기가 나왔는데, 조금 더 이해 해보자

이름 동작 위치 속도 용량 구조
L1 Cache CPU 가장 근접 CPU 클럭 속도에 근접
(CPU 1~4 cycle)
약 0.5~1.5 ns
주로 몇 32KB ~ 64KB - L1 데이터 캐시(L1d)
- L1 명령어 캐시(L1i)
L2 Cache CPU 근접* L1 > L2 >>> RAM
(CPU 3~10 cycle)
약 1~5 ns
주로 수 백 KB ~ 수 MB 데이터와 명령어 캐시가
통합 되어 있다
RAM RAM L1, L2 에 비해 매우 느림
(CPU 30~50 cycle)
약 10~15ns
주로 수백 MB ~ 수 TB JVM 에서 핸들링
물리적 메모리와
가상 메모리로 나뉨

위 표에 대한 좀 더 자세한 지식을 원한다면 이 링크를 참조 해 보자
https://norvig.com/21-days.html#answers

  • CPU 근접*
    • 일부 아키텍쳐 에서는 각 코어마다 L2 캐시가 독립적으로 존재하기도 하고 여러 코어가 L2 캐시를 공유 하기도 함
추가 설명
  • 메모리 계층 구조
    • L1, L2 캐시 외에도 L3 캐시 나 그 이상의 계층이 존재 할 수 있다
  • 캐시 일관성 문제(Cache Coherence)
    • 다중 코어 시스템 에서는 각 코어의 캐시가 다른 코어의 캐시와 일관성을 유지해야 한다
    • 다양한 캐시를 일관화 하기 위한 프로토콜이 사용된다
[!NOTE]
L1, L2, L3 캐시는 JVM 이 직접 컨트롤 할 수는 없는 영역이다
하지만 해당 캐시 기법은모든 프로세스에 적용 되는 기술이므로 알고 있다면 성능 최적화에 이점을 가져갈 수 있다

[[08-01. JSR-133]]

병렬 처리에 적합한 연산

[!NOTE]
모든 스트림 연산이 병렬 처리에 적합한 것은 아니다

연산이 얼마나 적합한지 판단하는 가장 직관적인 방법은
스트림 요소에 대한 접촉 순서에 의존도하는 정도를 알아보는 것이다
(의존하는 정도가 높다면 적합하지 않음)

예를 들어 limit, skip, distinct 와 같은 중간 연산은 순차 스트림에서 일관되고 안정된 결과를 보장하기 위해 요소의 접촉 순서에 많은 의존성을 갖게 된다

병렬 스트림 에서는 이러한 연산의 안정성을 확보하기 위해 추가적인 비용을 사용하게 된다. 모든 스레드간의 동기화가 바로 그것이다

예를 들어 병렬 상황에서 limit 연산이 순차 스트림에서의 결과를 동일하게 보장하려면, 요소의 순서에 따라 모든 연산이 완료될 때 까지 기다려야 하고 요소가 필요한지 확인 될 때 까지 버퍼에 저장해야 한다

이러한 UNORDERED 특성을 갖는 연산들에 의해 순차 스트림이 불안정 해 질 수 있다

collect vs reduce

  • collect: 가변 방식의 집계
  • reduce : 불변 방식의 집계
[!NOTE]
어떤 방법의 집계가 멀티 스레드 환경에서 더 적합할까?

collect : 가변 방식의 집계

collect 를 코드로 나타내면 아래와 같다

// 크리티컬 섹션
var numbers = List.of(1, 2, 3, 4, 5...);

int total = 0;

for (int value: numbers) {
	total += value;
}

이런 방식은 순차적으로 문제를 해결 할 때 직관적이다
하지만 병렬 처리 환경에서는 적합하지 않다

위 방식을 지원하기 위해 스레드 동기화 및 추가적인 리소스를 소모하게 된다

reduce : 불변 방식의 집계

reduce 는 스트림의 요소를 복제하며 축소하게 된다

간단하게 생각 해 보면 당연하게 불변한 reduce 가 병렬 처리에 적합하다고 생각 할 수 있다

물론 이것은 틀린 답변은 아니다

하지만 불변한 축소 방식이 병렬 처리에 적합하다고 해도 그것만이 축소의 유일한 방법은 아니라는 것이다

요구사항에 따라 각 누적 단계 마다 새로운 불변 결과를 생성하는 것이 비용이 들 수 있기 때문이다

특정 상황에서는 가변 축소가 더 적절한 해결책이 될 수 있다

암달의 법칙 (병렬 처리의 가속도 계산)

우리가 병렬 처리를 수행한다고 했을 때 성능적 이점을 계산 해 본다면 아래의 이론이 도움이 될 것이다

1976년, 컴퓨터 과학자 진 암달(Gene Amdahl) 에 의해 제시된 계산 방식이다

일정한 작업의 부하를 가진 병렬 실행에서 이론적인 지연 시간의 가속도를 계산하는 방법을 제공한다

![[AmdahlsLaw.svg.png]]

  • 가로: 병렬 작업 수
  • 세로: 가속
  • 지표: 작업의 병렬 퍼센트

보다시피 동시에 실행 가능한 병렬 작업의 수에 따라 최대 성능 향상에는 한계가 있다

자원의 부족으로 인해 실제로 병렬 작업을 싱행하지 못하고 작업을 교대로 수행해야 한다면 병렬화 가능한 작업에도 이점을 얻을 수 없다

병렬 스트림 체크 리스트

책 에서는 병렬 스트림 사용의 적절성을 빠르게 판단하기 위해 아래의 체크리스트를 제시하고 있다

기준 고려 사항
데이터 소스 - 분해 가능성에 따른 비용
- 분할 덩어리들의 균등성/예측 가능성
- 각 요소의 데이터 지역성
데이터의 규모 - 전체 요소의 수
- NQ 모델
중간 연산 - 연산 간의 상호의존성
- 공유 상태의 필요성
- 병렬 처리에 적합한 연산
- 데이터 처리 순서
최종 연산 - 최종 결과를 합치는 데 필요한 비용
- 가변적 또는 불변적 감소
사용 가능한 자원 - 사용 가능한 CPU 수
- 메모리 용량
- 공용 또는 사용자 정의 ForkJoinPool