GPU 없이 13년 된 Xeon에서 Gemma 4 26B를 초당 5토큰으로 실행하기
5 days ago
12
- 2013년형 듀얼 Xeon E5-2690 v2와 DDR3로 구성된 서버에서 Gemma 4 26B-A4B Q8_0을 CPU만으로 구동해 디코딩 약 5.2토큰/초, 프롬프트 평가 약 16토큰/초를 달성함
- ik_llama.cpp의 고속 경로는 AVX2·FMA3를 전제로 하지만 Ivy Bridge CPU는 AVX1만 지원하므로, 비 AVX2 빌드를 위한 컴파일 수정과 연산 폴백이 필요했음
- 그래프 빌더가 MOE_FUSED_UP_GATE와 FUSED_UP_GATE를 무조건 생성하는 반면 비 AVX2 디스패처에는 처리 경로가 없어, 순전파마다 약 240개 텐서가 계산되지 않은 채 난삽한 다국어 출력으로 이어짐
- Claude가 로짓 계측과 코드 분석으로 오류를 찾아 융합 연산을 두 번의 ggml_mul_mat_id와 ggml_fused_mul_unary로 대체했으며, 사용자는 실험을 실행하고 정상 결과의 기준을 판별함
- 오래된 기업용 서버도 유료 API 장애 시의 로컬 대체 수단이나 느린 배치 작업에 활용할 수 있지만, AVX2 전용 레이아웃을 만드는 --run-time-repack은 사용하면 안 됨
13년 된 스토리지 서버의 실행 환경
- 재활용한 HP StoreVirtual 장비는 2013년형 듀얼 Xeon E5-2690 v2와 DDR3 메모리를 사용하며 GPU가 없음
- Ivy Bridge 세대라 AVX1만 지원하고 AVX2와 FMA3는 지원하지 않음
- 원래 디스크 저장용으로 제작됐으며 구입 비용은 300달러 미만임
- 실행 모델은 Google의 Gemma 4 26B-A4B로, 260억 파라미터를 가진 오픈 가중치 전문가 혼합(MoE) 모델임
- Q8_0 양자화 모델에서 디코딩은 약 5.2토큰/초, 프롬프트 평가는 약 16토큰/초로 측정됨
출발점이 된 2016년형 Xeon 사례
- Hacker News에서 공유된 A 10 year old Xeon is all you need는 GPU 없이 2016년형 단일 Xeon과 128GB DDR3에서 Gemma 4를 실행한 사례임
- 해당 구성은 ik_llama.cpp와 약 25개의 세밀한 실행 플래그를 사용함
- 추측 디코딩
- CPU를 고려한 MoE 라우팅
- CPU용 플래시 어텐션
- 실행 시간 가중치 재패킹
- 같은 접근을 Ivy Bridge 서버에 적용했지만 시작 단계에서 실행이 중단됨
- 2016년형 Broadwell CPU와 달리 E5-2690 v2에는 AVX2와 FMA3가 없음
- 이 명령어 집합은 2014년 Haswell, 즉 Intel의 v3 세대부터 제공됐으며 고속 커널은 이를 전제로 작성돼 있었음
Claude를 이용한 진단과 패치
- 시작 실패 정보를 전달받은 Claude는 CPU 명령어 집합 차이를 원인으로 특정함
- 앞서 무료 모델로 시도했던 미완성 접근을 이어받아, 성능상 중요한 C++ 경로가 AVX2 이전 CPU에서 올바르게 폴백하도록 수정함
- 작업은 단일 fix it 요청으로 끝나지 않았음
- 다른 개발자가 작성한 성능 중심 C++ 코드를 읽음
- 특정 마이크로아키텍처에서 커널이 유효하지 않은 이유를 분석함
- 포크의 기존 최적화를 버리지 않으면서 지원되지 않는 경로를 우회함
- 사용자는 C++ 커널 폴백을 직접 작성하는 대신 실험을 실행하고 출력을 읽으며 다음 질문을 정하고, 정상 결과의 기준을 판별하는 역할을 맡음
- 진단과 패치는 해당 서버에서 실행된 Claude 인스턴스가 수행함
비 AVX2 빌드에서 깨진 연산 경로
- Gemma 4 MoE 추론에 사용한 ik_llama.cpp는 llama.cpp 포크이며, 기본적으로 AVX2를 최소 조건으로 가정함
- 빌드할 때 GGML_USE_IQK_MULMAT를 끄면 대부분의 고속 경로가 제외되고 일반 스칼라/SSE 연산으로 폴백함
- 일반적인 Q8_0 행렬 곱셈에는 이 폴백이 작동함
- 다만 두 그래프 연산에는 폴백이 적용되지 않았음
- Gemma 4의 MoE 피드포워드 네트워크는 다음 연산을 생성함
- MOE_FUSED_UP_GATE: 전문가별 gate·up 행렬 곱셈과 SwiGLU를 결합한 연산
- FUSED_UP_GATE: 비 MoE 계층에서 사용하는 밀집 연산 버전
- 컴퓨트 디스패처의 두 연산은 GGML_USE_IQK_MULMAT 조건으로 보호됐지만, 그래프 빌더는 조건 없이 연산을 생성함
- 비 AVX2 빌드의 디스패처에는 해당 enum을 처리하는 case가 없었음
- 연산이 기본 분기로 빠지면서 모든 전문가 피드포워드 네트워크의 목적지 텐서가 조용히 계산되지 않음
- Gemma 4 26B는 30개 계층에서 토큰당 활성 전문가 8개를 사용하므로, 순전파마다 메모리 버퍼에 남아 있던 값이 담긴 텐서 약 240개를 소비하게 됨
난삽한 출력에서 드러난 단서
- 오류 출력은 유창해 보이지만 의미 없는 다국어 문자열이었음
- 26만 2,000개 어휘 전체에 토큰 ID가 고르게 퍼짐
- 태국어, 한국어, <unused> 센티널, 영어 조각 등이 비슷한 빈도로 생성됨
- 온도 0에서 출력은 결정적이었고, 단일 스레드와 다중 스레드 실행 결과도 바이트 단위로 같았으며 NaN은 발생하지 않음
- 계층마다 큰 상수가 은닉 상태를 밀어 최종 소프트맥스가 평평해지는 형태를 보임
- Claude는 샘플링 전 원시 로짓을 계측해 상위 5개 토큰, 범위, 평균, NaN 수를 출력함
- 첫 예측 토큰의 평균 로짓이 0 근처가 아니라 +16이었음
- 전체 어휘의 약 80%가 양의 로짓을 가졌음
- 무작위 손상과 달리 편향이 일정했기 때문에, 은닉 상태의 큰 부분이 작은 양의 부동소수점 값이 남은 초기화되지 않은 메모리를 사용한다고 범위를 좁힘
세 단계로 구성된 수정
- 패치는 포크의 main 위에 세 개 커밋으로 구성됨
-
비 AVX2 컴파일 수정
- iqk_quantize.cpp의 quantize_row_q8_0_x4와 quantize_row_q8_1_x4_T에 있는 스칼라 #else 분기가 실제로는 hsum_i32_8 등 AVX2 도우미를 참조하고 있었음
- 해당 분기를 이식 가능한 스칼라 루프로 다시 작성함
- ggml.c와 ggml-quants.c로 새어 나온 일부 IQK 호출에 #if GGML_USE_IQK_MULMAT 보호 조건을 추가함
- iqk_cpu_ops.cpp가 독립적으로 컴파일되도록 누락된 include를 추가함
- 이 수정이 없으면 포크 자체가 비 AVX2 하드웨어에서 빌드되지 않음
-
실행 시간 그래프 폴백
- 디스패처를 고치는 대신 그래프 빌더가 해당 빌드에서 이미 계산 경로를 가진 연산을 생성하도록 변경함
- ggml_moe_up_gate에서 GGML_USE_IQK_MULMAT가 꺼져 있을 때 결합된 up_gate_exps 가중치를 처리함
- 텐서 형태는 [n_embd, 2*n_ff, n_experts]이며 앞 절반은 gate, 뒤 절반은 up임
- 두 개의 ggml_view_3d 슬라이스로 분리함
- 각 슬라이스에 ggml_mul_mat_id를 실행함
- ggml_fused_mul_unary(gate, up, SILU)로 두 결과를 결합함
- gate와 up 가중치가 이미 분리돼 있다면 슬라이스 없이 두 번의 ggml_mul_mat_id와 융합 곱셈·단항 연산을 사용함
- 비 MoE 계층에서 사용하는 ggml_fused_up_gate에도 같은 폴백을 적용함
- mul_mat_id는 기본 ggml 구현을 사용하고 fused_mul_unary는 SILU와 곱셈을 한 번에 처리하므로, 모든 구성 연산에 비 IQK 구현이 이미 존재함
- 전체 변경은 #if !GGML_USE_IQK_MULMAT 뒤에 있어 AVX2 빌드 결과는 기존과 비트 단위로 같음
-
CI 스텁 정비
- IQK 소스의 #else 스텁이 iqk_mul_mat.h와 달라 비 AVX2 하드웨어에서 ci/run.sh도 빌드되지 않았음
- <cstdint> include가 빠져 있었음
- 일부 스텁은 불필요한 선행 매개변수를 갖거나 sinks 매개변수가 누락되는 등 시그니처가 달랐음
- 일부 함수는 스텁 자체가 없어 링크 단계에서 정의되지 않은 참조가 발생함
- 스텁을 헤더와 맞추면서 비 AVX2 환경에서도 테스트 스위트 실행이 가능해짐
폴백의 성능 비용과 재패킹 오류
- 폴백은 하나의 융합 커널 대신 두 번의 mul_mat_id를 실행하므로 추가 비용이 발생함
- 해당 CPU는 이미 메모리 대역폭 제약을 받고 있고 원래 융합 커널도 AVX2 전용이어서, 이 환경에서 포기하는 기존 실행 경로는 없었음
- 최종 성능은 26B-A4B MoE에서 디코딩 약 5.2토큰/초, 프롬프트 평가 약 16토큰/초임
- --run-time-repack은 시작할 때 양자화 가중치를 AVX2 전용 인터리브 형식인 Q8_0_R8로 재배열함
- AVX1 환경에서 출력을 다시 손상시키는 별도 버그임
- 현재 패치는 이를 수정하지 않으며 실행 스크립트에서 해당 플래그를 제거함
원인을 좁힌 과정
- 명령어 집합 불일치는 쉽게 확인됐지만, 디스패처가 오류 없이 기본 분기로 빠지는 문제는 찾기 어려웠음
- 코드 검토를 통해 여러 후보를 제거함
- RMSNorm 도우미는 올바르게 보였음
- ggml_vec_dot_q8_0_q8_0의 AVX1 폴백도 올바르게 보였음
- 단일 스레드 결과가 비트 단위로 같아 스레딩 오류를 배제함
- 로짓 평균이 +16에 고정되고 긴 꼬리의 토큰들이 비슷한 값을 가진다는 점에서 잔차 스트림의 큰 부분이 초기화되지 않았다고 판단함
- 디스패처에서 #if GGML_USE_IQK_MULMAT를 검색한 뒤 약 1분 만에 누락된 두 연산 경로를 찾음
재현 조건과 활용 범위
- AVX2 이전 장비의 재현 조건은 다음과 같음
- 하드웨어: 듀얼 Xeon E5-2690 v2, Ivy Bridge, AVX1, DDR3, GPU 없음
- 빌드: 패치 브랜치의 ik_llama.cpp를 GGML_USE_IQK_MULMAT 없이 컴파일
- 모델: Gemma 4 26B-A4B Q8_0
- 실행: 일반적인 ik_llama.cpp CPU 플래그를 사용하되 --run-time-repack은 제외
- 정확한 변경은 ikawrakow/ik_llama.cpp PR #2138에서 확인할 수 있음
- 작성 시점에는 아직 열려 있고 관리자 검토를 기다리는 상태라 브랜치에서 직접 실행해야 함
- 같은 장비에서 발생한 버그는 PR 스레드에 보고할 수 있음
- 오래된 기업용 서버에 로컬 모델을 유지하면 유료 API가 중단됐을 때 대체 수단으로 쓰거나, 토큰당 과금이 맞지 않는 느린 배치 작업을 처리할 수 있음
- 구독 서비스 이용보다 낯선 코드와 오래된 시스템을 직접 파고드는 능력을 중시하며, 이는 15년 된 Rails 애플리케이션이나 담당자가 떠난 데이터베이스를 유지하는 작업에도 같은 방식으로 적용됨
-
Homepage
-
Tech blog
- GPU 없이 13년 된 Xeon에서 Gemma 4 26B를 초당 5토큰으로 실행하기