AVX-512 ZMM 직접 짜본 후기 — 왜 Zen 4에서 안 빨라졌나

AVX-512 ZMM 직접 짜본 후기 — 왜 Zen 4에서 안 빨라졌나

주말에 둘째 재워놓고 한 시간 만에 코드 200줄을 짰는데, 그 결과가 이전보다 느려졌어요. 솔직히 처음엔 좀 허탈했거든요. 분명히 더 빠른 게 정답이라고 책에 나와 있는데 왜 안 빨라진 거지? 며칠 동안 측정하고 또 측정한 끝에 진짜 이유를 알게 됐어요.

이 글은 제가 사이드 프로젝트로 짜고 있는 LLM 추론 컴파일러 “Lumen” 얘기예요. 거창한 이름 같지만 그냥 개인 취미 프로젝트로 시작한 거예요. AI 모델을 더 빠르게 돌리는 코드를 직접 만들어보는 거죠. 그 와중에 AVX-512라는 CPU 신기능을 써봤는데, 이론상 2배 빨라야 하는 게 실제로는 4.5% 더 느려지더라구요. 왜 그런지 정리해봤어요.

저도 이번에 처음 알았는데, AMD Zen 4 CPU가 AVX-512를 “내부적으로 두 번 나눠서” 실행하더라고요. 이거 알고 나서 진짜 허탈했어요. 미리 알았으면 시간 덜 썼을 텐데.

한눈에 보기

  • 사이드 프로젝트로 LLM 추론 컴파일러 직접 만들고 있어요
  • v0.1에서 v0.4까지 65/4.43 ≈ 14.7배 빨라졌어요
  • v0.5로 가려고 AVX-512 ZMM(더 큰 SIMD 폭)을 시도했어요
  • 결과: Zen 4에서 -4.5% 회귀. AMD가 AVX-512를 진짜 512비트가 아니라 256비트 두 번으로 구현
  • 이 한 줄을 사전에 알았으면 좋았겠지만, 직접 측정해서 확인한 게 진짜 배움이었어요
컴퓨터 회로 보드 클로즈업

사이드 프로젝트로 컴파일러 만든다는 게

먼저 짧게 설명할게요. 회사에서 정보보안 일을 하다 보면 본업이랑은 결이 다른 걸 만들어보고 싶을 때가 있거든요. 머리를 좀 식히는 거죠. 저는 그게 컴파일러였어요.

“LLM 추론 컴파일러”라는 게 거창하게 들리는데, 한 줄로 하면 이거예요: ChatGPT 같은 AI 모델이 답을 생성할 때 그 계산을 더 빠르게 해주는 코드. 보통은 PyTorch나 llama.cpp 같은 기존 도구를 쓰지만, 저는 처음부터 직접 짜보고 싶었어요. CPU가 어떤 명령어로 곱셈을 하는지, 메모리를 어떻게 읽는지, 그런 걸 한 단계씩 직접 짜보면 이해도가 다르거든요.

도와주는 도구는 Claude Code예요. 개인 사이드 프로젝트라 자유롭게 같이 작업할 수 있어요. 회사 코드는 외부 노출 우려가 있으니까 이런 데는 절대 못 들고 가지만, 취미 프로젝트는 다르거든요. 그리구 컴파일러는 결과물이 명확해요 — 빠르거나 느리거나, 정답이거나 틀리거나. 둘 다 측정으로 확인 가능.

지금까지 4번 release를 냈어요. v0.1, v0.2, v0.3, v0.4. 각 release마다 측정 가능한 성능 향상이 있었구요. 토큰 생성 속도가 v0.1 4.43에서 v0.4 65 정도로 14배 가까이 빨라졌어요. 이번 v0.5 시도가 처음 실패한 release예요.

처음엔 진짜 막막했어요

이건 좀 다른 얘기인데, 처음 이 프로젝트 시작할 때 솔직히 막막했어요. CPU 어셈블리 한 번도 안 짜본 사람이 컴파일러를 짜겠다고 했으니까요. “MOV”, “ADD” 같은 단어는 알지만 실제로 그걸 바이트 코드로 어떻게 인코딩하는지는 몰랐거든요.

예를 들어 단순한 곱셈 명령어 하나 짜는 데도 이런 게 필요해요:

  • “VEX prefix”라는 2~3바이트 헤더를 만들어야 함
  • 레지스터 번호(0~15)를 비트 위치마다 분산 배치해야 함
  • 각 비트는 반전(inverted)되어 있어서 실수하기 쉬움
  • 오프셋 계산도 직접 해야 함

처음 한 달은 그냥 Intel 매뉴얼 PDF 들여다보고 헤매기만 했어요. 근데 한 번 한 명령어를 동작시키니까 다음은 쉬워지더라구요. 핵심은 “VEX 인코더 한 번 제대로 짜기”였어요. 그게 되니까 그 위에 명령어 30개를 같은 패턴으로 쌓을 수 있었거든요.

v0.1에서 v0.4까지 14배 빨라진 비결

이건 길게 쓰면 끝이 없어서 핵심만 정리해 볼게요. 각 release가 측정 데이터를 보고 다음 결정을 했어요.

v0.1.0 (4.43 토큰/초) — 처음 동작한 버전. 가장 기본적인 매트멀(매트릭스 곱셈) 커널 한 개. llama.cpp의 9.3배 느림. 그래도 동작하는 게 어디예요.

v0.2.0 (17.97 토큰/초) — Q8 양자화. 모델 가중치를 32비트가 아니라 8비트로 압축해서 저장하면 메모리 대역폭이 4분의 1로 줄어요. 측정해보니 진짜 4배 빨라지더라고요. v0.1 대비 4.05배.

v0.3.0 (60 토큰/초) — 멀티스레드. CPU 코어 8개를 다 쓰기 시작. 자체 thread pool도 짜서 rayon 라이브러리 의존성 제거. 이때 단일 스레드 ggml(41.32 토큰/초)을 처음 추월했어요. 이거 알고 약간 흥분했거든요. “내가 만든 게 ggml보다 빠르다고?” 하는 순간.

v0.4.0 (65 토큰/초) — 매트멀 형상별로 다른 커널 자동 선택. 짧은 매트멀은 VNNI 정수 곱셈, 긴 매트멀은 FP32 4-accumulator. 이게 좀 흥미로웠어요. 처음엔 “VNNI 켜면 다 빨라야지” 했는데, 측정해보니 한 형상은 27%나 회귀하더라고요. 그래서 형상별 디스패치로 갔어요.

여기까지 5일 동안 진행했어요. 5일에 14배. 매번 측정이 다음 결정의 근거였어요.

컴퓨터 메인보드 컴포넌트 상세

v0.5 도전: AVX-512로 더 빠르게?

여기서 v0.5 시작. v0.4까지 측정해서 보니 ggml과의 격차가 1.39배 정도였어요. 이 격차의 가장 유력한 가설은 SIMD 폭이에요. SIMD는 한 명령어로 여러 데이터를 동시에 처리하는 기술인데, ggml은 ZMM(512비트)으로 16개 fp32를 한 번에 처리하고, 저는 YMM(256비트)으로 8개씩 처리하고 있었거든요.

이론상 단순해요. 한 번에 2배 처리 → 2배 빠름. ggml과 격차 메우기.

그래서 v0.5는 ZMM으로 가자고 결정했고, 네 단계로 작업했어요.

Phase 7.R — EVEX 인프라 만들기

AVX-512는 VEX와 다른 EVEX라는 4바이트 prefix를 써요. 더 복잡한데 더 많은 정보를 담을 수 있어요. 일반화된 EVEX 인코더 헬퍼 만들고, ZMM 레지스터 타입 정의하고, 기본 ZMM 명령어 5개를 추가했어요. 기존 vpdpbusd EVEX 형식(v0.4에서 이미 만든 거)을 헬퍼로 리팩토링해서 바이트 단위 회귀 테스트로 검증했어요.

Phase 7.S — ZMM Q8×F32 커널

“down_matmul”이라고, FFN(Feed-Forward Network)의 down projection 매트멀이에요. 가장 큰 K(=4864)를 갖고 있어서 가장 오래 걸리는 매트멀 중 하나. 여기에 ZMM 16-lane 처리하는 커널을 새로 짰어요. 기존 YMM 8-lane 4-accumulator였는데, ZMM 16-lane 2-accumulator로 바꾼 거예요.

이론상: 인스트럭션 수 절반, 같은 컴퓨트. 메모리 대역폭이 제약이라 2배는 안 나와도 1.5배는 나와야 하는 게 정상이에요.

Phase 7.T — ZMM 4-acc Q8×Q8 (VNNI 확장)

이게 가장 까다로웠어요. Q8×Q8(가중치도 8비트, 활성화도 8비트) 매트멀을 ZMM으로 확장하려고 했는데, 문제가 하나 있었어요. vpsignb 명령어가 AVX-512에 없어요. 이건 부호 처리에 쓰는 명령어인데, AVX-512가 byte 단위 부호 조작은 EVEX 형식으로 안 확장해 놨더라고요. 진짜 짜증났어요. 이거 발견하는 데만 하루 걸렸어요.

다른 길을 찾았어요. vpsignb 안 쓰고 vpmovsxbw(8비트→16비트 부호확장) + vpmaddwd(16비트 부호 곱셈 합)로 우회. vpmaddwd는 양쪽 모두 부호 있는 정수로 처리하니까 vpsignb의 부호 처리 트릭이 필요 없거든요. 우회 발견하니 갑자기 길이 보였어요.

왜 안 빨라졌나 — Zen 4의 비밀

코드 다 짜고 측정했어요. 결과가 진짜 충격이었어요.

구성tg32 평균 (토큰/초)v0.4 대비
v0.4.0 baseline (전부 YMM)65.3
v0.5 7.S만 적용 (down에 ZMM)63.6-2.6%
v0.5 7.T 추가 (Q8×Q8도 ZMM)62.4-4.5%

두 번 다 회귀. 8번씩 측정한 평균값이에요. 처음엔 “내가 코드 잘못 짠 건가?” 의심했는데, 모든 단위 테스트는 통과. 토큰 출력도 비트 단위로 동일. 코드는 맞아요.

며칠 동안 perf counter 보고 인스트럭션 분석한 끝에 알게 된 진짜 이유:

AMD Zen 4가 AVX-512를 “double-pumped 256-bit”로 구현했어요. 무슨 말이냐면, ZMM(512비트) 명령어를 CPU 내부에서 두 개의 256비트 micro-op으로 쪼개서 실행하는 거예요. 그러니까 명령어 하나 보내도 실제로는 두 번에 나눠서 처리. YMM 두 번 쓰는 거랑 같은 성능이에요.

그런데 EVEX prefix는 더 길어요(6바이트 vs VEX 3바이트). dependency chain도 더 길어요(vpmovsxbw + vpmaddwd가 vpdpbusd 한 줄보다 latency 길어요). 같은 성능에 오버헤드 추가니까 결국 더 느려지는 거였어요.

이게 맞는 건지 저도 100% 확신은 못하겠는데, 측정 데이터 + AMD 내부 구조 자료 종합해 보면 거의 확실해요. 진짜 native 512비트 실리콘 — Intel Sapphire Rapids나 AMD Zen 5 — 에서는 다를 거예요. 거기선 한 cycle에 512비트 실제 처리하니까 lane width 2배 효과가 진짜로 날 것.

직관만 믿으면 안 되는 이유

이번이 측정으로 가설을 부정한 4번째 사례예요. v0.4 작업하면서 비슷한 게 세 번 더 있었거든요.

  • “Q8 native int dot product가 fp32 dequant보다 빠르다” → 측정해보니 거의 동률
  • “VNNI single-accumulator도 4-accumulator만큼 빨라야 한다” → -3.6% 회귀
  • “VNNI 4-accumulator는 fp32 4-accumulator보다 빠를 것이다” → -2.7% 회귀

네 번 다 “이론적으로는 더 빨라야 한다”는 직관이 있었고, 네 번 다 측정이 부정. 근데 흥미로운 건 이 인프라들이 헛수고가 아니었어요.

예를 들어 v0.4에서 형상별 디스패치(7.P)는 위의 세 번 시도가 만든 인프라(Q8×Q8 IR 패턴, VNNI 디스패치, 4-accumulator 커널)를 모두 활용해요. 측정에서 부정된 시도가 다른 phase에서 살아나는 거예요. 인프라는 살리고, 활성화 정책만 끄는 것 — 이게 우리 패턴이 됐어요.

저는 솔직히 이 패턴이 제일 마음에 들어요. 측정해보고 안 빠르면 그냥 끄는 거예요. 코드는 살려두고. 다른 하드웨어나 다른 phase에서 켤 수 있게.

그래도 인프라는 살아있다

v0.5는 release를 못 했어요. main 브랜치는 v0.4.0 성능 그대로(~65 토큰/초) 유지. ZMM 코드는 들어가 있지만 default-off예요. host_supports_avx512_q8_kernel()이라는 함수에서 false 반환하게 해놨어요.

이 함수 한 줄만 바꾸면 활성화돼요. native 512비트 silicon에서 측정해서 win이면 그때 켜기. 직접 짜본 코드 자산:

  • 12개 ZMM EVEX 인코더 (모두 바이트 단위 unit test 통과)
  • Q8×F32 ZMM 2-accumulator 커널
  • Q8×Q8 ZMM 4-accumulator 커널 (vpmovsxbw+vpmaddwd 경로)
  • 4-way codegen 디스패치 (ZMM / VNNI 4-acc / VNNI single-acc / AVX2)
  • 강제-ZMM unit test로 CI에서 ZMM 코드 경로 계속 검증

나중에 회사에서 사용하는 서버 CPU(Intel Sapphire Rapids 같은) 만나면 거기서 측정해 보고 싶어요. 거기선 진짜로 빠를 것 같거든요. 미리 알았으면 좋았겠지만, 실측이 답이에요.

컴퓨터 메인보드 디테일

대신 질문 해드릴게요

이거 실제로 쓸 수 있는 건가요?

아직 개인 사이드 프로젝트 단계예요. Qwen2.5-0.5B라는 작은 모델만 돌려본 상태고, 더 큰 모델은 다음 마일스톤. GitHub에 코드는 다 공개해 놨어요(github.com/redchupa/lumen). Apache-2.0 라이선스라 누구나 가져다 쓸 수 있긴 해요. 다만 production 용도로 쓰기엔 아직 부족해요.

llama.cpp 대신 이거 쓰면 안 돼요?

현재로선 llama.cpp가 8 스레드 기준 1.39배 빨라요. 같은 코어 수에서. 단일 스레드만 비교하면 제가 빠르긴 한데, 실제 사용 시나리오는 멀티스레드니까 의미가 적어요. 격차 메우려면 native AVX-512 환경에서 ZMM 켜거나, 메모리 액세스 패턴 추가 최적화가 필요해요.

왜 직접 만들었어요? 기존 거 쓰면 되잖아요

그게 가장 자주 받는 질문이에요. 솔직히 효율로만 따지면 기존 거 쓰는 게 맞아요. 근데 직접 짜보면 CPU 마이크로아키텍처가 어떻게 동작하는지 진짜로 이해하게 돼요. 책 100번 읽는 거랑 한 번 직접 짜보는 거랑 차이가 너무 커요. 그리고 컴파일러는 결과가 명확해서 학습 피드백이 빨라요. 빠르거나 느리거나, 정답이거나 틀리거나.

Claude Code랑 작업하는 게 어때요?

좋아요. 특히 측정 기반 의사결정 패턴이 잘 맞아요. “이거 더 빠를 것 같은데?” 하고 시도 → 측정 → 안 빠르면 인프라 살리고 끄기. 이걸 빠르게 반복할 수 있거든요. 혼자였으면 어셈블리 매뉴얼 보다가 지쳐서 한 달은 더 걸렸을 것 같아요. 단, 개인 사이드 프로젝트 한정이에요. 회사 코드는 외부 노출 우려 때문에 절대 못 들고 가요. 본업 보안 일은 손으로만 하고, 이런 취미 코드만 같이 작업.

ZMM이 진짜 native 512비트면 얼마나 빨라요?

이건 측정해 봐야 알아요. 이론상 lane width 2배니까 컴퓨트 바운드 부분은 2배 가능. 근데 메모리 바운드 비중도 있으니까 실제로는 1.4~1.7배 정도 예상. 추측이에요. 진짜 silicon에서 측정 전까지는 직관일 뿐이고, 직관은 4번 부정당했죠.

다음 작업은 뭐예요?

몇 가지 후보가 있어요. (1) native AVX-512 환경에서 ZMM 측정, (2) ARM64 백엔드 추가, (3) 더 큰 모델 지원, (4) prefill 배치 처리. 어떤 게 가장 ROI 있는지 다음 호흡 정리하면서 결정할 예정. 측정 가능한 win이 있어야 진행해요.

📚 같이 보면 좋은 책 (쿠팡 파트너스)

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

레추의 총평

5일 동안 14배 빠르게 만들었고, 6일째에 처음 회귀했어요. 그리고 그 회귀로부터 가장 큰 배움이 있었어요 — “내 CPU가 AVX-512를 어떻게 구현했는지” 같은 마이크로아키텍처 디테일은 데이터시트 깊은 곳에 묻혀 있어서 직접 측정하기 전엔 모른다는 것.

혹시 비슷한 사이드 프로젝트 하시는 분 있으시면, 측정 인프라부터 단단히 만들어 두세요. 저는 v0.3.0에서 step별 시간 측정하는 StepTimer를 추가했는데, 그게 v0.4부터 모든 의사결정의 근거가 됐어요. 이게 없었으면 v0.5 ZMM이 왜 안 빠른지 영원히 몰랐을 거예요.

실천 체크리스트:

  • 새 최적화 시도 전, 측정 baseline 8회 평균 기록
  • “이론상 더 빠르다”는 직관은 무조건 측정으로 검증
  • 회귀 발견하면 인프라는 살리고 활성화 정책만 끄기
  • CPU 마이크로아키텍처 디테일(double-pumped, port allocation 등) 무시 못 함
  • Claude Code 같은 도구는 측정-기반 반복 워크플로우에 잘 맞음 (개인 프로젝트 한정)

코드 궁금하신 분들은 GitHub에서 redchupa/lumen 검색하시면 돼요. v0.4.0 태그가 현재 안정 버전이고, ZMM 인프라는 main 브랜치에 들어가 있어요. 질문 있으시면 댓글로 남겨주세요. 다음 v0.5 다시 도전할 때 또 정리해서 올릴게요.

컴퓨터 메인보드 흑백 사진

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤