2P by GN⁺ | ★ favorite | 댓글 1개
  • 1BRC의 병목은 CSV 온도값 10억 개를 극단적으로 빠르게 파싱하는 일이었고, Quân Anh Mai의 merykitty SWAR 코드if 없이 고정 ALU 연산으로 온도를 정수화해 주목받음
  • 이 코드는 long 하나에 담긴 8바이트를 한꺼번에 다루는 SWAR(SIMD Within A Register) 방식으로, 일반 CPU 레지스터에서 여러 문자를 병렬처럼 처리함
  • 처리 흐름은 마이너스 부호 감지, 부호 제거, 소수점 위치 탐지, XY.Z 정렬, ASCII 숫자 변환, 매직 곱셈, 부호 적용으로 이어짐
  • 입력 형식은 -XX.X, -X.X, X.X, XX.X 네 가지이며, 소수점 위치를 기준으로 바이트를 이동해 서로 다른 길이를 같은 비트 배치로 맞춤
  • 분기와 반복을 줄이는 대신 ASCII 코드 특성, 2의 보수, 비트 마스크, 곱셈의 시프트-덧셈 성질을 촘촘히 이용해 고성능 파싱을 구현함

1BRC에서 병목이 된 온도 파싱

  • One Billion Row Challenge(1BRC)에서는 CSV 파일의 온도값을 매우 빠르게 파싱하는 일이 핵심 병목으로 떠오름
  • 이전 최적화만으로도 관용적인 병렬 Java 코드는 71초에서 1.7초까지 빨라짐
  • 온도 형식은 단순하지만, 10억 개를 1초 미만에 파싱하려면 작은 비용도 크게 누적됨
    • 가능한 형식은 -XX.X, -X.X, X.X, XX.X
  • 초기 참가자들은 Double.parseDouble()을 사용했지만, 이후 루프 없는 커스텀 파서들이 등장함
  • Quân Anh Mai의 @merykitty 솔루션 일부는 if 없이 단일 파일 읽기로 처리해 1BRC 상위 솔루션의 표준 요소처럼 퍼짐
  • 우승자인 Thomas Wuerthinger는 자신의 솔루션에 기여한 팀 일부로 Quân Anh을 명시함

merykitty 코드가 하는 일

  • 코드는 8바이트 CSV 입력이 담긴 long을 받아 실제 온도의 10배인 정수 온도값을 반환함
  • 입력은 mmap된 CSV 파일에서 직접 네이티브 메모리 읽기로 들어오며, 해당 부분은 별도 관심사로 분리됨
  • 연산은 고정된 순서의 18개 ALU 작업으로 구성됨
    • 비트 시프트, AND, NOT, XOR
    • 덧셈, 뺄셈, 곱셈
    • Long.numberOfTrailingZeros()
  • numberOfTrailingZeros()는 JDK 컴파일러 intrinsic을 통해 특수 CPU 명령을 사용함
  • 일반 SIMD 전용 명령이 아니라 일반 CPU 레지스터와 명령으로 여러 바이트를 다루기 때문에 SWAR 방식에 해당함
  • 예시 코드는 원본을 읽기 쉽게 약간 변형한 것이며, 원본은 CalculateAverage_merykitty.java에 있음

전체 처리 단계

  • 코드는 다음 순서로 온도를 파싱함
    • 첫 문자가 -인지 확인해 음수 여부를 감지함
    • 부호 문자가 있으면 해당 바이트를 0으로 만듦
    • 소수점 .의 위치를 찾음
    • 숫자들이 XY.Z 템플릿에 맞도록 long 내부 비트를 이동함
    • ASCII 문자를 실제 숫자값으로 바꿈
    • 각 자리 숫자에 1x, 10x, 100x 가중치를 곱해 합산함
    • 마지막에 부호를 적용함
  • 겉보기에는 고수준 파싱 문제지만, 각 단계는 ALU 연산만으로 구현됨

1단계: 마이너스 부호 감지

  • 부호 감지는 다음 코드로 시작함
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
  • 설명상 순서를 바꾸면 ( ~(inputData << 59) ) >> 63처럼 볼 수 있음
  • ASCII에서 마이너스 -비트 4가 0이고, 숫자 문자는 해당 비트가 1이라는 성질을 이용함
  • 입력을 왼쪽으로 59비트 이동하면 첫 문자의 구분 비트가 최상위 비트로 이동함
  • NOT으로 비트를 뒤집은 뒤 산술 오른쪽 시프트를 63비트 수행하면 최상위 비트가 전체 long에 퍼짐
  • 결과인 broadcastSign은 마이너스가 있으면 모든 비트가 1, 없으면 모든 비트가 0이 됨

2단계: 부호 문자 제거

  • 음수 여부는 broadcastSign에 저장됐으므로, 입력 데이터에서는 부호 문자를 제거함
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
  • broadcastSign이 모두 1이면 broadcastSign & 0xFF는 최하위 8비트만 1이 됨
  • 이를 NOT하면 최하위 8비트만 0인 마스크가 만들어짐
  • inputData와 AND하면 최하위 바이트의 -가 제거됨
  • 마이너스가 없으면 broadcastSign이 0이므로 마스크는 모든 비트가 1이 되어 숫자 바이트가 유지됨

3단계: 소수점 위치 찾기

  • 소수점 위치는 다음 코드로 계산함
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
  • . 문자도 마이너스와 마찬가지로 비트 4가 0이라는 특성을 가짐
  • 가능한 소수점 위치의 비트 4만 확인하기 위해 DOT_DETECTOR = 0x10101000 마스크를 사용함
  • 원본 입력을 반전한 negatedInput에서는 소수점 위치의 해당 비트가 1이 됨
  • Long.numberOfTrailingZeros()는 이 1비트의 위치를 반환함
  • 예시 -10.8에서는 소수점이 비트 위치 28에 있어 dotPos = 28이 됨

4단계: 고정 템플릿으로 정렬

  • 소수점 위치를 기준으로 입력을 왼쪽으로 이동해 항상 같은 템플릿에 맞춤
long alignedToTemplate = withSignRemoved << (28 - dotPos);
  • 목표 템플릿은 다음과 같음
0 0 0 Z . Y X 0
  • 여기서 X는 십의 자리, Y는 일의 자리, Z는 소수 첫째 자리임
  • 0은 ASCII "0"이 아니라 값이 0인 바이트를 뜻함
  • 부호 제거 후 입력은 네 가지 배치 중 하나일 수 있음
    • 0 0 0 Z . Y X 0
    • 0 0 0 0 Z . Y 0
    • 0 0 0 0 Z . Y X
    • 0 0 0 0 0 Z . Y
  • -10.8은 이미 dotPos = 28이므로 이동량이 0임
  • -7.7은 소수점 위치가 비트 20이라 8비트, 즉 한 바이트 왼쪽으로 이동해 X 자리에 0이 놓임

5단계: ASCII 숫자를 값으로 바꾸기

  • 정렬 후에는 ASCII 문자에서 숫자값만 남김
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
  • ASCII 숫자 0부터 9는 16진수로 0x30부터 0x39
  • 하위 4비트만 남기면 문자 코드가 실제 숫자값이 됨
  • 템플릿의 숫자 위치에만 F가 있는 마스크를 적용함
0 0 0 Z . Y X 0
000000F000F0F00
  • 예시 -10.8은 마스크 적용 후 Z=8, Y=0, X=1을 나타내는 값만 남음

6단계: 매직 곱셈으로 자리값 합산

  • 최종 절댓값은 100 * X + 10 * Y + Z로 계산해야 함
  • 곱셈이 시프트와 덧셈의 조합이라는 성질을 이용해 여러 자리의 가중치 계산을 한 번의 곱셈으로 처리함
  • 먼저 X + Y + Z를 생각하면, digits를 0, 16, 24비트 위치로 시프트한 값을 더해 특정 비트 구간에 합을 모을 수 있음
  • 이 시프트-덧셈 조합은 다음과 같은 곱셈으로 표현됨
0x1 + 0x10000 + 0x1000000
  • 실제로는 각 자리의 가중치가 다르므로 MAGIC_MULTIPLIER는 다음처럼 구성됨
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
  • 계산식은 다음과 같음
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
  • 0x3FF는 10비트 폭 결과만 분리하는 마스크임
  • 100 * X가 10비트까지 커져 인접 비트와 겹칠 수 있지만, Y * 100의 오른쪽 두 비트가 0이 되는 성질 때문에 필요한 비트 공간이 확보됨
  • merykitty는 이 부분에 // That was close :)라는 주석을 남김

7단계: 분기 없이 부호 적용

  • 이 시점에는 절댓값 absValue와 부호 정보 broadcastSign이 있음
  • broadcastSign은 양수면 0, 음수면 -1로 동작함
  • 2의 보수에서 음수는 다음 식으로 표현됨
-n = NOT(n) + 1
  • XOR는 조건부 NOT처럼 사용할 수 있음
    • n XOR -1NOT(n)
    • n XOR 0n
  • 선택적인 +1-broadcastSign으로 처리함
temperature = (absValue ^ broadcastSign) - broadcastSign;
  • 결과적으로 if 없이 양수는 그대로, 음수는 2의 보수 음수값으로 변환됨

보너스: 다음 CSV 행 시작 위치 계산

  • 전체 1BRC 솔루션에서는 다음 CSV 라인의 시작 위치도 저렴하게 계산해야 함
  • 소수점 뒤에는 항상 소수 한 자리와 개행 문자가 이어지므로, 소수점 위치를 기준으로 다음 행 시작 위치를 구함
  • dotPos는 비트 단위 위치이므로 8로 나누기 위해 3비트 오른쪽 시프트를 사용함
nextLineStart = (dotPos >>> 3) + 3;
  • +3은 소수점, 소수 한 자리, 개행 이후의 첫 바이트를 가리키기 위한 값임

결론

  • merykitty의 SWAR 코드는 고정된 비트 연산만으로 네 가지 온도 문자열 형식을 통일해 파싱함
  • 핵심은 ASCII 코드의 비트 특성, 소수점 위치 기반 정렬, 마스크를 통한 숫자 추출, 곱셈을 이용한 자리값 합산, 2의 보수 기반 부호 적용임
  • 단계별로 나누면 동작을 따라갈 수 있지만, 이를 온라인 챌린지 며칠 안에 조합한 점이 인상적인 부분으로 남음

댓글과 토론

Hacker News 의견들
  • 단계별 설명이 정말 훌륭함
    2년 넘게 전에 byte array view var handle이 Java/Scala에서 효율적인 SWAR 루틴을 만들기에 꽤 적합하다는 걸 알게 됐음
    Base16/64 문자열 파싱, java.time.*, 숫자 값을 바이트 배열에서 직접 파싱하는 등 SWAR 활용 예시는 여기에도 많음: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...
  • 글도 좋고 코드 맥락상 훌륭한 해법이지만, 이 방식은 데이터가 올바른 형식이라고 가정함
    실전에서 단련된 파서의 큰 가치는 효율적인 오류 검사와 복구에 있음
    • 잘못된 입력이 출력에 어떤 식으로 영향을 줄 수 있는지 분해해서 보면 흥미로울 듯함
      그리고 현재 코드 스타일처럼 어떤 센티널 오류 값을 반환하도록 감지하려면 얼마나 많은 작업이 필요한지도 궁금함
      직접 해볼 만큼 흥미롭지는 않지만 ;-)
  • 숫자 비트필드에 각 자리의 10의 거듭제곱을 곱하고 MUL로 시프트/덧셈하는 기법은 꽤 알려진 방식임
    Lemire 글 참고: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
  • 글에 따르면 SWAR는 SIMD Within A Register
  • 이런 내용을 좋아한다면 simdjson 논문도 비슷한 기법을 쓰고, 글이 매우 잘 쓰여 있으며 예시도 좋음
    논문: https://arxiv.org/abs/1902.08318
    Github: https://github.com/simdjson/simdjson
    • 이건 SWAR는 아니지만, 왜 흥미로울지는 알겠음
  • BRC가 왜 입출력 병목에 걸리지 않는지 설명해줄 수 있나? CPU가 병목이라는 점이 이해가 안 됨
    • 최신 시스템에서는 로컬 디스크 입출력이 더 이상 병목이 아님: https://benhoyt.com/writings/io-is-no-longer-the-bottleneck/
      게다가 공식 1BRC는 입출력 속도를 완전히 배제하려고 결과를 RAM 디스크에서 평가한다고 명시했음: https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
      “Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)”
    • 배경으로, Daniel Lemire와의 인터뷰가 있음. 그는 입출력이 항상 병목은 아니다라는 관찰을 바탕으로 커리어 전체를 쌓은 사람임: https://corecursive.com/frontiers-of-performance-with-daniel...
    • 이 문제를 자세히 보지는 않았지만, 반대로 시작해볼 수 있음. 왜 메모리 입출력이 병목이라고 생각하나?
      제한적으로 이해한 바로는 큰 텍스트 파일을 순차적으로 L1에 가져오고 각 값마다 한 번 읽음. 대부분의 프로세서에서는 이런 읽기를 사이클당 두 번 할 수 있음. 느린 부분은 RAM에서 L1으로 가져오는 것이겠지만, 순차 읽기는 꽤 빠름
      그다음 각 읽기에 대해 처리를 함. 얼핏 보면 최적화된 버전에서는 대략 4사이클 정도일 듯함. 이후 결과를 어딘가에 써야 하고, 아마 그 전에 무작위 읽기 한 번 또는 두 번이 필요할 것임. 이 부분을 입출력 병목으로 보는 건가?
      CPU 제한이 명백하다는 뜻은 아니지만, 그렇지 않다는 것도 명백해 보이지는 않음
      수정: “디스크 입출력”을 뜻했을 수도 있다는 점을 고려하지 못했음. 다른 사람들이 말했듯이 여기서는 사실상 요인이 아님
    • 테스트는 memfs로 실행됨. 파일과 모든 것이 시작 시점부터 RAM에 있음
    • 데이터셋이 Linux 커널 페이지 캐시에 들어갈 만큼 작고, 벤치마크가 5번 연속 반복되므로 첫 번째 반복은 디스크 입출력 병목이 될 수 있지만 나머지 4번은 그렇지 않음
      즉 모든 데이터가 RAM, 더 정확히는 페이지 캐시에 있게 됨
  • 68000에서 SWAR를 꽤 효과적으로 쓰곤 했음. 한 명령으로 4바이트를 병렬 처리했음
    기억이 맞다면 오버플로 처리가 까다로웠음. 이 글이 정말 마음에 듦
  • “혼자 일하는 한 사람이 티셔츠와 커피 머그가 보상인 온라인 챌린지를 며칠 가볍게 하면서 이 모든 걸 만들어낸 게 진짜 미스터리다”라고 했는데, 왜 그게 미스터리인가?
    아직도 CPU를 실제로 프로그래밍할 줄 알고 자신이 뭘 하는지 이해하는 사람들이 있음
    진짜 미스터리는 스스로를 프로그래머라고 부르는 대다수가 깊은 이해가 부족하고, 심지어 자신이 심각하게 부족하다는 사실도 모르는 듯하다는 데 있음
  • C#에서는 이런 SWAR 꼼수를 쓸 필요가 없음. 대신 일급 크로스플랫폼 SIMD API를 제공함
    실제로도 잘 동작한다는 건 지금까지 공개된 1BRC 중 가장 빠른 것으로 보이는 C# 해법에서 확인할 수 있음: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
  • 이걸 SSE로 벡터화할 수 있나? 대부분의 핵심 처리는 32비트 정수 4개짜리 벡터로 가능해 보임
    문제는 초기 벡터를 구성하고 결과를 추출하는 비용이 과도하지 않은지임
    • 가능하고, 다른 여러 1BRC 구현도 그렇게 했음
      다만 HotSpot이 스스로 해낼지는 의심스럽고, 대부분의 1BRC 제출물이 시작 오버헤드를 줄이려고 Graal로 실행됐다는 점도 별개로 있음
      기본 SSE2에는 32비트나 64비트 곱셈이 없어서 32×32→64비트 곱셈이 문제가 되지만, SSE4.1에는 정확히 필요한 pmuldq가 추가됨. 다만 결과가 64비트라서 32비트 정수 전체 벡터를 처리하려면 이런 연산이 두 번 필요함
    • 온도 필드가 이름 필드와 섞여 있어서 SSE로 추가 이득을 얻기는 어려워 보임
      또 온도 필드는 길이가 가변이라, 열 단위로 저장돼 있더라도 이득이 나지 않을 가능성이 큼
      다만 이름과 온도 사이의 구분자 찾기에는 SSE가 성공적으로 적용됐음
    • 이런 코드는 처음부터든 HotSpot이 핫스팟을 감지한 뒤든 자동 벡터화될 것 같음