4P by GN⁺ | ★ favorite | 댓글 1개

Go의 동시성과 병렬성 활용

  • Go의 동시성과 병렬성을 활용하여 숫자 계산 능력을 향상시키고자 하는 프로젝트에 대한 소개.
  • SIMD(Same Instruction Multiple Data) 명령어를 사용하여 하드웨어 수준에서 병렬 계산을 수행할 수 있음.
  • Go의 컴파일러는 SIMD를 활용하지 않으며, 적합한 일반 목적 SIMD 패키지를 찾지 못해 직접 패키지를 개발하기로 결정함.

Plan9 어셈블리 언어

  • Go는 Plan9이라는 자체 어셈블리 언어를 사용하며, 이는 특정 플랫폼의 명령어와 레지스터를 약간 수정하여 사용함.
  • x86 Plan9과 ARM Plan9은 서로 다름.
  • Plan9의 간단한 예제를 통해 기본적인 사용법을 설명함.

Plan9 예제

  • AddInts_amd64.s 파일과 main.go 파일을 통해 Plan9의 기본적인 함수 선언 및 사용법을 설명함.
  • Go의 호출 규칙에 따라 함수 인자와 반환값을 스택에 저장하는 방법을 설명함.

패키지 설계 계획

  • 산술 및 비트 연산 SIMD 작업에 대한 얇은 추상화 계층을 제공하는 패키지를 설계함.
  • 아키텍처별 Plan9 구현을 포함하는 내부 패키지를 생성하고, 초기화 함수를 통해 이를 설정함.

SIMD 예제

  • x86 SIMD Plan9 함수의 예제를 통해 SIMD의 사용법을 설명함.
  • Supported_amd64.sAddFloat32_amd64.s 파일을 통해 SSE 지원 여부 확인 및 float32 덧셈 연산을 수행하는 방법을 설명함.

성능 및 미래

  • Go 소프트웨어 구현과 Plan9 SIMD 구현 간의 성능 차이를 보여주는 차트를 통해 약 200-450%의 속도 향상을 확인함.
  • 이 메모가 Plan9과 SIMD를 사용한 프로젝트에 영감을 주기를 바람.

# GN⁺의 정리

  • 이 글은 Go의 동시성과 병렬성을 활용하여 성능을 극대화하는 방법을 소개함.
  • Plan9 어셈블리 언어와 SIMD 명령어를 사용하여 하드웨어 수준에서 병렬 계산을 수행할 수 있는 방법을 설명함.
  • 이 글은 Go 개발자들에게 Plan9과 SIMD의 활용 가능성을 제시하며, 성능 향상을 위한 새로운 접근 방식을 탐구하는 데 유용할 수 있음.
  • 유사한 기능을 가진 프로젝트로는 Rust의 SIMD 지원 라이브러리나 C++의 SIMD 관련 라이브러리를 추천함.

댓글과 토론

Hacker News 의견들
  • Go 어셈블리에 대해 몇 가지 짚자면, amd64에서 저 int들은 실제로 64비트
    int32를 쓰면 매개변수 목록에서 워드 정렬이 되지만, 함정이 있음. 64비트 시스템에서는 반환값이 항상 더블워드 정렬 오프셋에서 시작함
    NOSPLIT은 Go 컴파일러가 자동 제공하는 textflag.h에 정의되어 있음. 다만 읽어본 바로는 NOSPLITruntime.XX 함수에서만 존중되는 듯해서 여기서는 아무 일도 하지 않고, 필요하지도 않음
    NOSPLIT은 스택이 넘칠 수 있어 분할이 필요한지 확인하는 코드를 컴파일러가 삽입하지 말라는 뜻임. 스택 공간이 필요 없는 함수라면 기술적으로 불필요하고, 기본적으로 스택 분할을 검사하는 함수 자신 안에 그 검사 코드가 주입되는 걸 막기 위해 존재함

  • 4가 “NOSPLIT”을 나타내며, 왜인지 필요하다고 되어 있는데 궁금한 사람을 위해 덧붙이면: 일반적으로 프레임 크기(NOSPLIT 뒤의 매개변수) 뒤에는 인자 크기가 오고, 둘은 빼기 기호로 구분됨
    이는 뺄셈이 아니라 독특한 문법일 뿐임. 프레임 크기 $24-8은 함수가 24바이트 프레임을 가지고, 호출자 프레임에 있는 8바이트 인자로 호출된다는 뜻임
    TEXTNOSPLIT을 지정하지 않으면 인자 크기를 반드시 제공해야 함. Go 프로토타입이 있는 어셈블리 함수의 경우 go vet이 인자 크기가 올바른지 검사함
    출처: https://go.dev/doc/asm

  • “Go가 Plan9라는 자체 내부 어셈블리 언어를 쓴다”는데, 그 언어가 정말 그렇게 불리는 건가?

    • 아님. 그냥 Go 어셈블리
      Plan 9에서 유래한 문법이긴 하지만, 우리는 Go 어셈블리라고 부름
      https://go.dev/doc/asm 참고
    • 타당한 질문임. 처음엔 나도 그냥 맞다고 생각했음. 이 주제를 조사한 사람이 이런 걸 틀릴 리 없다고 봤고, 프로젝트를 조금 안다면 그 이름도 어느 정도 말이 된다고 느껴졌기 때문임
      그런데 더 찾아볼수록 이건 LLM 환각 같다는 생각이 듦
      어셈블리 형식 문서는 제대로 된 이름을 붙이지 않고 그냥 go assembler라고 부름
      이 환각의 출처는 아마 첫 문단인 듯함: “어셈블러는 Plan 9 어셈블러의 입력 스타일을 기반으로 하며… 현재 문서는 그 문법 요약과 차이점, Go와 상호작용하는 어셈블리 코드를 작성할 때의 특이점을 설명한다”
    • 이름이 따로 있는 건 아님. Plan 9은 운영체제이고, 이 어셈블리 문법 스타일은 그 운영체제에서 쓰던 어셈블러에서 유래함
      “GNU Compiler Collection이 Unix라는 자체 내부 어셈블리 언어를 쓴다”고 말하는 것과 비슷함
  • Go 팀이 왜 이런 전용 어셈블리 형식을 택했는지 궁금하다면, Rob Pike가 2016년 발표에서 Go 어셈블러 설계를 다룬 적이 있음 [1][2]
    핵심은 대부분의 어셈블리 언어가 대체로 비슷하니, “기계의 가장 낮은 수준과 대화하면서도 새 문법을 배울 필요가 없게” 해주는 공통 어셈블리 언어를 만들자는 관찰이었던 것 같음
    또한 새 아키텍처의 명령어 매뉴얼 PDF를 입력으로 받아 동작하는 어셈블러를 자동 생성할 수도 있게 해줌
    [1]: https://www.youtube.com/watch?v=KINIAgRpkDA
    [2]: https://go.dev/talks/2016/asm.slide#1

    • 실제로 효과가 있었음. Go는 새 프로그래밍 언어라면 교차 컴파일을 기본 역량으로 갖춰야 한다는 기대치를 만들었고, 당시에는 이를 잘하는 언어가 거의 없었음
    • 참고로 SpiderMonkey도 약 25년 동안 거의 비슷한 일을 해왔고, 다른 JavaScript 가상 머신들도 그럴 거라고 봄
  • “슬라이스에 SIMD 연산을 수행할 함수들이 필요했다”고 했는데, 실제로는 어떻게 쓰는 건지 궁금했음
    글 전체를 검색해도 슬라이스에 수행되는 연산이 어디에도 없었음
    수정: 연결된 문서에서 찾았음: https://pkg.go.dev/github.com/pehringer/simd#pkg-index
    기본적으로 더하고 싶은 슬라이스 2개가 있다면, for 루프 대신 SIMD로 병렬 처리해서 simd.AddInt32(slice1, slice2, result)처럼 쓸 수 있음

  • Go가 지원하는 프로세서와 관련해서는 이 섹션이 관련 있음 (1). 기본 x64 지원에는 SSE와 SSE2가 포함됨
    다만 Go 컴파일러가 실제로 그것을 생성하는지는 모르겠음. 성능을 최우선으로 삼는 gcc 같은 매우 복잡한 컴파일러와 달리, Go 컴파일러는 Wirth식(2)으로 단순하고 빠른 컴파일러를 선호함
    (1) https://go.dev/wiki/MinimumRequirements#amd64
    (2) https://irreal.org/blog/?p=7075
    https://smartgo.blog/2024/01/06/niklaus-wirth/

    • 실제로는 2008년쯤 이후의 모든 칩이 SSE4.1을 지원한다고 봄
  • 작성자가 이 부분을 혼동한 것 같아서 참고 링크를 남김: https://en.wikipedia.org/wiki/Plan_9_from_Bell_Labs

  • 나도 이게 코드를 LLM이 잘못 해석한 결과 같다고 말하려던 참이었음
    Plan 9이라는 용어를 알고 어셈블리로 파고들면서도 자신이 어떤 물에 들어와 있는지 모른다는 게 다른 방식으로는 잘 상상이 안 됨. 다른 사람들도 같은 생각을 했다는 걸 보고 알게 됨
    만약 이게 맞다면, 작성자가 부끄럽거나 “들켰다”고 느끼지 말고 솔직했으면 함. 그래야 우리도 배울 수 있음. 이런 “LLM 노출” 유형에 대한 확신을 얻고 싶은데, 아무리 명백해 보여도 사람들이 인정하는 경우가 거의 없어 보임
    물론 여기서는 명백한 건 아니고, 매우 성급하고 판단적인 추측일 뿐임

    • 솔직히? 아니. LLM이 제대로 해내야 할 작업을 하지 않아도 되는 지름길이라고 생각한 그 오만함은 부끄러워해야 함
      이런 게 정말 화남
  • 내 수준을 조금 넘는 내용이었지만, 글이 독자를 같이 데리고 가는 방식은 즐거웠음
    이런 시도가 처음일 리는 없지 않나? SIMD에 목마른 Gopher가 말 그대로 수십 명은 있을 것 같음. 더 흔한 패턴은 CGO를 쓰는 걸까?

    • cgo의 문제는 함수 호출 오버헤드가 크다는 점임. 꽤 큰 작업 덩어리에만 쓰는 게 좋음
      Go에서 어셈블리 함수를 호출하는 편이 훨씬 저렴함
      https://pkg.go.dev/github.com/grailbio/base/simd에 내가 이 방향으로 작업한 것들이 있음
    • 예전부터 사람들이 분명 시도해왔을 거라고 봄. 거의 10년 전쯤 형제가 Go로 SIMD 라이브러리를 만들려 할 때 (Skype였나?) 통화하던 기억이 있음
      기억이 맞다면 그 당시에는 여러 AVX 명령어가 Go의 Plan 9 어셈블러에서 인코딩조차 안 돼서, 바이트로 직접 인코딩해야 했음 [0]
      내가 본 가장 완성도 높은 라이브러리는, 써본 적은 없지만 CGO를 부분적으로 사용하면서 그 오버헤드를 피하는 깔끔한 해킹을 씀 [1]
      [0]: https://github.com/slimsag/rand/blob/f1e8d464c0021a391d5cd64...
      [1]: https://github.com/alivanz/go-simd/
    • 이런 걸 표준 라이브러리에 추가하자는 제안은 거절됐지만, 배경을 이해하는 데 도움이 됨: https://github.com/golang/go/issues/53171
  • Go 어셈블리 프로그램을 작성하려면 Avo(https://github.com/mmcloughlin/avo)를 확인해보길 권함
    타입 안전성을 제공하고, 유효한 어셈블리를 출력하는지 몇 가지 검사를 해줌. 레지스터를 동적으로 할당해줄 수 있고, 스택과 프레임 크기 같은 것도 직접 계산하지 않아도 되게 해줌
    호출 규약 세부사항도 처리해줄 수 있어서, 인자를 원하는 레지스터나 위치로 쉽게 로드할 수 있음
    최근 Go의 crypto 라이브러리에 있는 amd64 어셈블리를 전부 Avo로 포팅했는데, 이런 작업에 매우 유용한 라이브러리임