안녕하세요. 디모이입니다.
최근 Sollang에 stream을 구현해봤습니다.
처음에는 구구단을 map, flatMap, each로 표현해봤는데요. 문법이 간결해진 모습은 잘 보였지만, 사실 구구단 정도로는 stream이 왜 필요한지 충분히 드러나지 않았습니다.
그래서 이번에는 조금 과감하게 10억 개의 센서 데이터를 선언해봤습니다.
1..1000000000
-> map sensorId {
readSensor(sensorId)
}
-> tap reading {
scanned! + 1 => scanned!
}
-> filter reading {
reading.celsius >= 57
}
-> take(5)
-> each alert {
alert -> println
}
이 코드에서 왼쪽의 1..1000000000은 값을 만들어내는 upstream이고, 마지막 each는 값을 실제로 사용하는 downstream입니다.
그 사이의 map, tap, filter, take는 upstream에서 내려오는 값을 받아 다음 downstream으로 넘기는 stage가 됩니다.
upstream downstream
1..1,000,000,000
→ map → tap → filter → take(5) → each
값은 왼쪽에서 오른쪽으로 흐릅니다.
재미있는 부분은 take(5)가 다섯 번째 값을 통과시킨 뒤 stop을 발생시키면, 이번에는 중단 신호가 반대 방향으로 upstream까지 올라간다는 점입니다.
값의 흐름 upstream ───────────────→ downstream
중단 신호 upstream ←─────────────── stop
그래서 10억 개를 선언했다고 해서 10억 개를 모두 처리하지 않습니다.
현재 센서 예제에서는 다섯 개의 이상치를 찾는 데 필요한 54개만 확인하고 원본 Range 루프가 바로 종료됩니다.
C# IEnumerable과는 무엇이 다를까?
C#의 LINQ도 지연 실행을 지원합니다.
source
.Select(Map)
.Where(Filter)
.Take(5);
저도 C#을 주로 사용하기 때문에 이 모델은 익숙하고 상당히 잘 만들어졌다고 생각합니다.
다만 C# IEnumerable<T>의 기본 실행 모델은 consumer가 enumerator의 MoveNext()를 호출하면서 다음 값을 당겨오는 pull 방식입니다. LINQ의 지연 실행도 실제 enumeration이 시작될 때 동작합니다. Microsoft LINQ 문서
개념적으로 보면 이런 모습에 가깝습니다.
foreach
└─ Take.MoveNext()
└─ Where.MoveNext()
└─ Select.MoveNext()
└─ Source.MoveNext()
반면 Sollang의 stream은 이 stage들을 런타임 iterator 체인으로 유지하지 않습니다.
컴파일할 때 upstream의 원본 루프와 downstream의 실행을 continuation으로 연결해서 하나의 루프로 융합합니다.
결과적으로 위의 Sollang 코드는 대략 이런 실행 구조가 됩니다.
원본 Range loop
sensor 읽기
조건 검사
조건이 맞으면 바로 each 실행
다섯 번째라면 원본 loop 종료
중간에 결과 배열을 만들었다가 다시 순회하지 않고, stage마다 iterator 객체를 만들어 연결하지도 않습니다. 이번에 생성된 LLVM도 중간 배열, iterator 객체, 간접 callback 호출 없이 원본 루프와 직접 분기로 내려가는 것을 확인했습니다.
물론 이것이 모든 상황에서 C# LINQ보다 무조건 빠르다는 뜻은 아닙니다. 최신 .NET과 JIT도 많은 최적화를 하고 있으니까요.
다만 Sollang에서는 처음부터 이 비용 모델을 언어의 stream 의미로 고정하고 싶었습니다.
메모리 측면에서도 차이가 있습니다
1..1000000000
이 Range는 10억 개의 정수를 메모리에 저장하지 않습니다. 시작값과 끝값, 두 경계만 가지고 있습니다.
map, filter, take의 결과도 별도의 컬렉션으로 만들지 않습니다. 값 하나가 upstream에서 만들어지면 stage를 거쳐 downstream에서 바로 소비됩니다.
따라서 입력이 100개이든 10억 개이든 stream 파이프라인 자체가 입력 크기에 비례하는 중간 메모리를 요구하지 않습니다.
take가 사용하는 카운터 같은 작은 상태만 유지하면 됩니다.
take는 컴파일러 특수 기능이 아닙니다
이 부분도 꽤 중요하게 생각했습니다.
처음에는 take를 컴파일러 intrinsic으로 만들면 구현이 간단하지 않을까 생각했는데요. 그렇게 하면 take만 특별한 언어가 되어버립니다.
그래서 Sollang에서는 보다 일반적인 state와 stop을 언어에 넣었습니다.
public take<T> value: T, count: Int -> stream T {
state taken! = 0
taken! < count -> if {
taken! + 1 => taken!
taken! >= count -> if {
stop
}
value -> emit
} else {
stop
}
}
state taken! = 0은 stage가 시작될 때 한 번 만들어지고, upstream에서 여러 값이 들어오는 동안 계속 유지됩니다.
stop은 현재 값의 downstream 처리가 끝난 뒤 전체 upstream을 중단합니다. 중첩된 flatMap도 추가 값을 만들지 않고 함께 빠져나옵니다.
즉 take와 skip은 컴파일러가 이름으로 알아보는 기능이 아니라 std.sequence에 Sollang으로 작성된 평범한 함수입니다.
사용자도 같은 방식으로 자신만의 stateful stream stage를 만들 수 있습니다.
소스코드도 실행 구조 그대로 읽힙니다
제가 Sollang에서 계속 중요하게 보는 부분은 코드가 실제 실행 방향과 비슷하게 읽히는가입니다.
source
-> map
-> filter
-> take(5)
-> each
어디에서 값이 시작되고, 어떤 과정을 거쳐, 어디에서 소비되는지가 그대로 보입니다.
성능을 위해 수동 루프로 다시 작성했는데 원래 의도가 사라지는 것도 아니고, 읽기 좋은 코드를 작성했더니 내부적으로 iterator와 callback 계층이 늘어나는 것도 아닙니다.
소스코드는 파이프라인으로 읽히지만, 생성 코드는 직접 작성한 하나의 루프에 가까워지는 것.
이번 stream 작업에서 제가 가장 만들고 싶었던 부분은 바로 이것이었습니다.
아직 더 다듬어야 할 부분은 많지만, Sollang이 추구하는 짧고 자연스러운 코드와 예측 가능한 성능이 꽤 잘 구현된 기능인 것 같아요.