1P by GN⁺ | ★ favorite | 댓글 1개
  • GPU 셰이더에서 삼항 연산자나 단순 if로 값을 고르는 코드는 보통 조건부 분기가 아니라 조건부 이동(select)으로 처리됨
  • step()과 산술 마스킹으로 바꿔도 사라질 분기가 없으므로, 이른바 브랜치 제거 최적화라는 전제 자체가 맞지 않음
  • AMD와 Microsoft 컴파일러 출력에는 비교와 조건부 마스크/이동 명령이 나타나며, 점프나 브랜치 명령은 보이지 않음
  • step() 버전은 0.0/1.0 마스크를 만든 뒤 곱셈과 덧셈으로 결과를 합성해, 직접 조건부 이동하는 코드보다 불필요한 연산이 늘어남
  • 조건에 따라 큰 계산 블록을 건너뛰는 GPU 분기는 여전히 유용하지만, 단순 값 선택은 생성된 머신 코드를 확인하는 쪽이 더 안전함

단순 값 선택은 GPU 분기가 아님

  • 예시 함수 snap45()는 입력 벡터에서 x = abs(v.x)를 계산한 뒤, 두 개의 삼항 연산자로 세 가지 vec2 결과 중 하나를 반환함
  • 같은 논리는 일반 if 문으로 작성해도 유지됨
  • 문제가 되는 “최적화”는 삼항 연산자를 step()과 가중치 합성으로 바꾸는 방식임
    • w0, w1, w2step()으로 만듦
    • res0, res1, res2를 각각 계산함
    • w0*res0 + w1*res1 + w2*res2로 최종 결과를 합성함
  • 이 변환은 원래 코드가 조건부 브랜치를 만든다는 오해에서 출발함
  • 단순 레지스터 값 선택은 명령 포인터를 바꾸지 않으며, 예측 실패나 파이프라인 플러시, 명령 캐시 무효화도 일으키지 않음
  • GPU의 실제 분기는 조건에 따라 큰 계산 블록을 건너뛸 때 빠르고 유용할 수 있음
  • 다만 예시처럼 단순 값이나 계산 결과를 고르는 경우에는 생성된 머신 코드에서 브랜치가 생기지 않는다고 볼 수 있음

컴파일러 출력이 보여주는 차이

  • 원래 GLSL 삼항 연산자 코드는 AMD 컴파일러에서 비교와 조건부 마스크 명령으로 변환됨
    • 비교: v_cmp_gt_f32, v_cmp_ngt_f32
    • 조건부 마스크: v_cndmask_b32
  • Microsoft 컴파일러 출력도 같은 구조를 보임
    • 비교: lt
    • 조건부 이동: movc
  • 두 컴파일러 출력 모두 jump/branch 명령이 없음

step() 방식이 더 비싸지는 이유

  • step() 기반 방식은 먼저 조건부 이동으로 0.0 또는 1.0 마스크를 만든 뒤, 여러 후보 결과를 곱셈과 덧셈으로 마스킹함
  • 원래 코드는 필요한 값을 직접 조건부 이동하므로, 마스크 생성과 산술 합성이 추가되는 step() 방식보다 낭비가 적음
  • 다양한 하드웨어에서 step() 기반 버전은 원래 버전보다 훨씬 느리게 측정될 수 있음
  • 예시 코드의 일부 abs() GLSL 호출은 별도 GPU 명령이 아니라 명령 modifier로 들어가며, 이런 경우 abs() 호출은 무료에 가깝게 볼 수 있음
  • float a = mix(b, c, step(y, x));float a = x < y ? b : c;의 최적화로 권장하는 것은 잘못된 접근임

댓글과 토론

Hacker News 의견들
  • TFA의 결론은 맞아 보이지만, 더 좋은 버전의 코드 생성 결과만 보여주지 말고 두 버전의 코드 생성 결과를 모두 보여줬다면 논증이 더 강해졌을 것 같음
    인용문에서는 “최적화했다고 주장하는 버전은 원래 버전보다 훨씬 느리다… 곱셈 두 번과 덧셈 한두 번을 낭비한다… 생성된 머신 코드를 보자”고 해놓고, 실제로는 곱셈이나 덧셈이 없는 좋은 버전만 보여줌
    그건 좋은 버전이 괜찮다는 증명일 뿐, 나쁜 버전이 더 나쁘다는 증명은 아직 아님

    • 핵심은 조건문이 실제 분기를 만들지 않았다는 것임
      다른 버전의 생성 코드를 보여줘도 더 길다는 것만 보였을 가능성이 크고, 그쪽도 분기가 생길 것으로 기대되지는 않아서 큰 가치는 없었을 것 같음
    • RDNA 1용 생성 코드는 여기 있음: https://shader-playground.timjones.io/5d3ece620f45091678dcee...
  • if가 언제 실제 분기를 강제하고 언제 그렇지 않은지 알 수 있는 좋은 방법이 있으면 좋겠음
    사람들이 더 비쌀 수도 있는 mix/lerp를 쓰는 이유는, 약간의 오버헤드는 감수하더라도 분기가 생기는 걸 두려워하기 때문임
    v = x > y ? a : b;처럼 가장 명확한 코드가 실제로 잘 동작하는 건 좋지만, 같은 if 문법이 어떤 때는 분기이고 어떤 때는 아니라는 점은 불안함
    정말 분기하면 안 되는 맥락에서는 branch-if비분기 if가 다른 키워드였으면 싶고, 비분기 키워드는 컴파일러가 분기 없이 만들 수 없으면 컴파일 실패해야 하며, 분기 키워드는 분기 없이 만들 수 있으면 경고를 내면 좋겠음

    • 그 배경에는 NVIDIA와 cg/CUDA 컴파일러의 혼란스러운 문서화가 있음
      처음에는 프로그래머를 겁주고 싶지 않아 실행 모델을 숨기고 “스레드”라는 추상화로 설명했고, 이후 GPU 홍보에도 “CUDA 스레드가 엄청 많다”는 식으로 계속 썼다고 봄
      결과적으로 GPU 코딩에 이상한 미신이 생김
      실제로는 코드에 분기가 있는 게 좋을 때도 많고, 분기 자체는 빠름
      문제는 SIMD lane에서 각자 다른 분기로 벗어날 수 없다는 점이라서, 컴파일러는 분기 대신 양쪽 코드를 모두 내보내고 조건에 따라 결과를 마스킹함
      따라서 셰이더 입력값, 정점, 컴퓨트 셰이더 인덱스 등에 기반한 계산은 실제 분기하지 않고, 마스킹으로 순차 실행됨
      TFA 예시에서도 ? 연산자의 양쪽 값이 모두 계산되고, SIMD 값에 대한 조건문은 일반적으로 마찬가지임
      다만 모든 lane이 같은 값을 가질 때 계산을 빠르게 건너뛰는 단축 분기가 나올 수는 있지만, 일반적으로는 참/거짓 양쪽 모두 계산됨
      오직 스칼라 레지스터, 즉 셰이더 상수나 uniform 값에 기반한 조건문만 실제 분기를 만들고, 이런 분기는 매우 빠름
    • 그건 스칼라 CPU에서도 마찬가지임
      예를 들어 CMOV 명령어는 1995년 P6 코어에서 도입됐음
      분기는 스칼라 아키텍처에서도 비싸고, 컴파일러는 언제 대체 전략을 써야 할지 최대한 판단함
      가끔 틀리지만 아주 자주 틀리지는 않음
    • GPU에서는 오히려 반대로 봐야 함
      조건부 이동이 기본이고, 실제 분기는 workgroup 전체가 같은 방향으로 가는 uniform 분기일 때만 가능한 성능 최적화임
    • 이런 예를 생각해볼 수 있음: a = f(z); b = g(z); v = x > y ? a : b;
      f()g() 호출 비용이 비교적 크다면, 조건부 코드를 낼지 아니면 둘 다 계산한 뒤 선택할지는 절충 문제가 됨
      단순한 선택이 아니며, 결정은 컴파일러가 내림
    • 셰이더 언어에 그런 기능이 있으면 흥미로울 것 같음
      코드의 모든 함수를 분기 가능/비분기로 색칠하듯 구분하고, 비분기라고 표시된 함수는 if가 조건부 이동으로 컴파일되어야 하며 비분기 함수만 호출할 수 있게 만들 수도 있음
  • “GPU에서 분기는 느리다”는 미신의 상당 부분은 오래전 PlayStation 3 시절에는 실제로 꽤 느렸기 때문임
    PS3에는 NVIDIA RSX GPU가 들어갔고, 문서상으로는 분기가 6사이클이었다고 기억하지만 실제 측정은 항상 그보다 느리게 나왔음
    워프의 모든 스레드가 같은 경로를 타는 완전히 coherent한 분기에서도 그랬고, incoherent 분기는 IFEH 명령이 6사이클인 데다 GPU가 양쪽 분기를 모두 실행해야 해서 더 느렸음
    오늘날까지 이어지는 “GPU 분기는 느리다”는 미신은 거기서 시작됐다고 봄
    요즘 GPU 분기, 특히 coherent 분기는 꽤 저렴함

    • 누가 그냥 “분기”라고만 말하면 incoherent 분기를 뜻한다고 봐야 함
      오늘날 분기 메커니즘의 오버헤드는 낮아졌을 수 있지만, 분기 양쪽의 처리량은 활성 스레드 비율만큼 줄어든다는 물리적 제약은 그대로임
      양쪽 분기가 모두 실행되고 명령 길이도 같다면, 양쪽 평균 성능은 최소 절반으로 줄어듦
      그래서 GPU에서 분기가 느리다는 믿음은 오래가고, 실제로도 맞는 말임
      가능하다면 문제를 분기 없이 다시 구성하려고 더 노력할 가치가 있음
    • coherent 분기는 “공짜”에 가깝지만, 추가 명령이 레지스터 압박을 늘림
      동적 분기를 피하는 주된 이유는 분기 자체가 본질적으로 느려서가 아니라 이쪽에 가까움
  • 이런 분기 회피 최적화는 한때 효과가 있었음
    Xbox 360과 오래된 Intel 내장 GPU에서 프로파일링해본 적이 있지만, 이제는 그렇게 하지 않는 편이 맞음
    비트 추출과 다른 정수 연산도 비슷함
    예전에는 부동소수점 수학으로 에뮬레이션하는 편이 더 빨랐지만, 지금은 모든 GPU가 빠른 정수 연산을 갖고 있음

    • “이제 모든 GPU가 빠른 정수 연산을 갖고 있다”는 게 어느 정도까지 사실인지 궁금함
      예를 들어 PS5와 Xbox Series S|X의 아키텍처인 RDNA2 ISA를 보면, 정수용으로는 32비트 스칼라 명령만 보이는 것 같음
      [0] https://www.amd.com/content/dam/amd/en/documents/radeon-tech...
    • 적어도 “큰” GPU에서는 예전만큼 큰 문제가 아니었지만, 이 글은 사실 분기 회피 자체에 관한 글이 아님
      제시된 코드는 이미 분기 없는 코드
      조언하는 사람들은 조건문처럼 보이는 구문이 소스에 있느냐만 보고 분기 코드를 판단하고, 그걸 최적화로 피한다고 생각하는 듯함
  • 이 글도 관련 있음: https://medium.com/@jasonbooth_86226/branching-on-a-gpu-18bf...
    “GPU에서 분기를 작성하는 법을 인터넷에 물어보면, 지옥문을 열어 악마를 들이는 것처럼 말할 수 있다. 어떤 대가를 치르더라도 피해야 하고, 삼항 연산자나 step() 같은 이상한 수학 트릭으로 피할 수 있다고 할 것이다. 이 조언 대부분은 좋게 봐도 낡았고, 그냥 틀린 경우도 많다. 바로잡아보자.”

  • 프로세서도 바뀌고 컴파일러도 바뀜
    이런 세부 사항이 중요하다면 여러 변형을 배포하고 런타임에 가장 빠른 버전을 고르는 게 최선임
    예전에도 몇 번 말했지만, 직접 작성한 어셈블리를 제거하고 평범한 C나 비슷한 코드로 바꿔서 훨씬 빠르게 만든 적이 있음
    그 어셈블리가 10~20년 전에는 더 빨랐을지 몰라도, 지금은 상황이 달라졌음

    • 셰이더의 가장 빠른 버전을 런타임에 알아내는 건 매우 까다롭다고 봄
      실제로 그렇게 하는 게임이나 엔진은 잘 모르겠음
      원칙적으로는 가능할 수 있음
      D3D, GL, Vulkan 같은 대부분의 API가 성능 카운터를 노출하고, 벤더에 따라 신뢰성은 다르지만 대표 테스트 장면을 만들어 여러 번 재생하며 최적화를 측정할 수는 있음
      하지만 많은 게임은 동적으로 생성되는 장면과 동적으로 생성되는 셰이더를 쓰기 때문에, 테스트해야 할 조합 수가 장애물이 될 수 있음
      사용자에게 벤치마크가 끝날 때까지 기다리라고 해야 할 수도 있음
      하드웨어가 있다면 각 벤더의 여러 GPU 세대에서 미리 측정하고 중요한 결정만 하드코딩할 수는 있겠지만, 그런 기존 인프라는 잘 모르겠음
    • 재미있게도 NVIDIA 드라이버가 어느 정도 그런 일을 함
      게임 셰이더를 가로채서 NVIDIA가 최적화한 커스텀 셰이더로 바꿈
      그래서 NVIDIA 드라이버 변경 로그에 “게임 X 최적화, 40% 더 빠르게 실행” 같은 문구가 보임
    • 셰이더 하나를 더 추가하는 정도라면 괜찮겠지만, “현대적” 그래픽 API에서는 같은 셰이더에 수천 개의 순열이 필요할 때가 있고, 변형을 하나 추가할 때마다 그 수가 2배가 됨
      각 셰이더에 무한한 시간을 쓸 수도 없음
      신경 쓰는 하드웨어에서 프로파일링하고, 선택한 방식이 어떤 가상의 미래 프로세서에서 더 느리다면 어쩔 수 없음
      바라건대 그 프로세서가 충분히 빨라서 문제가 안 되길 기대해야 함
  • 이 글이 바로잡으려는 실수와 혼란이 여기서도 반복되는 것 같음
    글은 조건부 분기가 공짜라고 주장하지 않음
    내가 보기엔 분기 코드의 성능 비용에 대해 말하는 글도 아님
    글의 요지는 제시된 형태의 조건 논리가 조건부 분기 코드로 컴파일되지 않는다는 것임
    그리고 눈에 보이는 모든 조건식을 억지로 가리는 해로운 조언을 계속 퍼뜨리면 안 된다는 것임
    실제 분기 코드에 대해서는, 분기 코드를 실행하는 일이 더 복잡하다는 건 자명함
    공짜 분기는 없고, 합리적인 범위에서 분기를 피하면 어떤 코드든 더 빨라질 가능성이 큼
    다행히도 원래 코드는 이미 분기 없는 코드였음
    늘 그렇듯 최적화가 가치 있는지 말해주는 보편적인 척도는 없음
    [0] 여기서 “눈에 보이는”이 중요함. 생성 코드에는 관심이 없고, 소스 코드가 조건문처럼 보이지 않는지만 신경 쓰는 경우를 말함
    [1] 물론 운이 좋았던 건 아님. IQ에게 누군가 셰이더 코드의 뻔해 보이는, 하지만 틀린 개선안을 보냈을 거라고 짐작함

  • 그렇다면 컴파일러는 왜 “최적화된” 버전이 같은 코드라는 걸 알아볼 만큼 똑똑하지 않은 걸까?
    step()을 이해하고 step() = 0.0, step() == 1.0 경우를 따로 최적화할 수 있어야 하지 않나?
    적어도 곱셈 하나는 제거할 수 있으니, 보통은 조건부 로드/스토어나 다른 것으로 바뀌더라도 항상 이득일 것 같음

    • 실제로 그럴 수도 있음
      어떤 컴파일러는 어떤 경우에는 이런 최적화를 할 가능성이 충분히 있지만, 컴파일러가 이해하지 못하는 버전을 작성하는 것도 분명히 가능함
    • 최적화의 다른 문제는 모든 가능성을 시도하는 데 너무 오래 걸리면 안 된다는 점임
      대부분의 최적화는 드라이버 쪽에서 일어나고, 너무 오래 걸리는 작업은 셰이더 컴파일 끊김으로 드러남
      지금 이 최적화가 실제로 되는지 안 되는지는 말할 수 없지만, 항상 고려해야 하는 요소임
  • 문제의 “최적화” 버전이 더 느린 이유는 step() 함수가 실제로는 이런 식으로 구현되기 때문임:
    float step( float x, float y ) { return x < y ? 1.0 : 0.0; }
    OpenGL 함수가 GPU 프리미티브를 호출하는지, 아니면 에뮬레이션되는지 어떻게 알아야 하나?

    • 유일한 방법은 원글처럼 셰이더를 컴파일하고, 역어셈블한 뒤 어셈블리를 읽는 것임
      HLSL 셰이더로 자주 그렇게 해봤고, 가상 명령어 집합에 대해 많이 배웠음
      예를 들어 GPU에는 sincos 명령이 있지만, 역삼각함수는 컴파일 중에 에뮬레이션된다는 점이 흥미로움
    • 왜 알아야 하느냐는 목적에 따라 다름
      성능이 중요해서라면 알아야 할 수 있음
      하지만 step이 전용 명령어가 아니라 조건문 위의 라이브러리 함수로 구현됐다는 사실만으로 전용 명령어 대비 성능을 말해주지는 않으니, 구현 자체에 너무 매달릴 필요는 없음
      GPU 아키텍처가 궁금해서라면 역어셈블, 오픈소스 드라이버 코드, LLVM, ISA 문서를 보면 됨
    • PC식 어셈블리에서 볼 법한 함수 외에 GPU가 특별한 프리미티브를 가진 경우는 본 적이 없음
      디컴파일된 셰이더를 볼 때마다 대체로 C에서 생각하는 것과 비슷했음
      OpenGL 같은 명세는 많은 내장 함수의 동작을 규정하고, 구현은 표준 어셈블리 명령으로 그 명세를 만족시키는 식임
      여러 아키텍처로 디컴파일해주는 온라인 사이트를 찾아보면 됨
    • 프로그래밍 전반에서 자주 보이는 좋은 질문이고, 최적화할 때 먼저 측정해야 하는 핵심 이유이기도 함
      보통은 내장 함수가 어떻게 구현됐는지 알 필요도 없고 신경 쓸 필요도 없음
      신경 쓰고 있다면 아마 최적화를 생각하고 있는 것이고, 그때 답은 “측정해서 뭐가 더 나은지 확인하라”임
    • 내가 헷갈린 지점은 “분기”가 내가 자라며 배운 의미보다 더 하드웨어별로 잘 정의된 의미를 갖는다는 점인 듯함
      내가 배운 의미에서는 조건문은 분기임
      머신 코드 수준에서 제어 흐름이 런타임에 선택되므로, 조건부 점프는 정의상 분기임
      step()을 쓰는 건 논리를 산술로 바꾸는 게 아니라, 라이브러리 함수 호출 안에 논리를 숨기는 것일 뿐이라고 봤음
      step()이 내장 함수이거나 수학 논문에 나오는 함수라고 해서 달라지는 건 없음
      수학에서 step()의 정의도 말 그대로 조건문임
      조건문 없이 제대로 최적화하려면 원하는 결과와 비슷한 연속 함수를 택하고, 매개변수를 조정해 목표에 최대한 가깝게 맞춰야 함
      보통은 다항식을 고르고 표준 반복 근사법을 돌린 뒤, 분기 없이 덧셈·곱셈과 “이상하게 구체적인” 상수만 있는 f(x)를 만들게 됨
      저자가 조건부 이동은 “분기”가 아니라고 강하게 말하는 부분은 잘 이해되지 않음
      abs()가 GPU 명령이 아니라 명령 수정자로 내려가 공짜가 되는 건, 정수의 2의 보수 표현과 IEEE-754 부동소수점 표현 덕분에 부호 비트를 최상위 비트로 다룰 수 있기 때문임
      그래서 abs()는 최상위 비트를 무조건 0으로 만들거나, 읽는 명령에서 마스크하는 정도로 끝남
      하지만 step()이나 임의의 삼항 연산, 그리고 내가 아는 한 조건부 이동 명령은 그런 특수한 경우가 아님
      기본적인 abs(), sqrt(), 삼각함수 정도는 표준 지식에 가깝고, 나머지는 어차피 중요할까 싶음
      step()은 어딘가에서 조건을 가질 수밖에 없고, 직접 하든 라이브러리에 맡기든 하드웨어에 맡기든 근본적인 성격은 바뀌지 않음
  • 이 함정에 걸려본 적이 있음
    Claude나 ChatGPT도 이걸 최적화라고 제안하곤 함
    하지만 매번 측정해보면 성능이 떨어졌고, 때로는 꽤 크게 떨어졌음

    • 이상한 일은 아님
      LLM은 학습 말뭉치에 있는 내용을 반복할 뿐임
      인터넷 대부분이 이런 조건부 이동 “최적화”처럼 틀린 내용을 추천하면, LLM도 그렇게 추천함
    • LLM은 인터넷 사람들이 하는 말을 반복하고, 사람들은 자주 틀림