1P by GN⁺ | ★ favorite | 댓글 1개
  • llama.cpp PR #7154는 GGML의 CPU용 SiLU와 SoftMax 계산을 llamafile의 벡터화된 expf() 기반 구현으로 다시 작성해 2024년 5월 17일 master에 병합됨
  • 기존 GGML은 속도를 위해 short[65536] 조회 테이블을 사용했지만, 새 구현은 aarch64와 SSE2+에서 최악의 반올림 오차를 2 ULP로 유지하며 더 정확한 계산을 목표로 함
  • SOFT_MAX CPU 성능 테스트에서 SSE2+FMA는 1.5배, AVX2+FMA는 1.9배, AVX512는 2.1배 빨라졌고, AMD Ryzen 9 5950X와 M2 Ultra에서도 master 대비 약 1.5배 빠른 결과가 확인됨
  • 변경에는 ggml_v_expf(), ggml_v_silu() 추가, ggml_vec_soft_max_f32()로 중복 코드 추출, GGML_SILU_FP16 관련 함수 제거, SSE2 또는 ARM NEON 조건부 SiLU 경로 조정이 포함됨
  • 병합 후 >1 slots 서버 실행에서 비결정적 결과가 재현됐고, 이후 -ffinite-math-only가 원인으로 좁혀져 -fno-finite-math-only가 필요하다는 빌드 차원의 제약으로 이어짐

PR의 변경 목표와 병합 상태

  • PR #7154ggml : rewrite silu and softmax for cpu라는 제목으로, llama.cpp의 GGML CPU 경로에서 SiLU와 SoftMax 계산을 다시 작성함
  • 변경은 llamafile의 벡터화된 expf() 함수를 upstream하는 형태로 시작됨
  • PR은 2024년 5월 17일 ggml-org:master에 병합됐고, 병합 커밋은 934266c로 표시됨
  • 작성자는 기존 GGML이 속도를 위해 사용하던 short[65536] 조회 테이블보다 새 방식이 SoftMax와 SiLU를 더 정확하게 계산할 수 있다고 밝힘

정확도와 지원 범위

  • expf() 기반 경로는 aarch64와 SSE2+ 를 지원하며, 최악의 반올림 오차는 2 ULP로 제시됨
  • 초기 설명에서는 AVX2와 AVX512 구현도 작성됐지만, SSE2+FMA 대비 코드 복잡도를 감수할 만큼의 이점이 크지 않아 포함하지 않았다고 함
  • 이후 벤치마크 결과를 바탕으로 AVX2와 AVX512 코드도 포함됨
  • 별도 테스트 출력에서는 4294967296 numbers tested successfully가 제시됐고, 여러 입력값에 대해 exp와 llamafile 구현의 결과 비교가 포함됨

코드 변경 범위

  • 리뷰어가 정리한 주요 변경 사항은 다음과 같음
    • 주석 처리된 #define 제거
    • 중복된 5줄을 ggml_vec_soft_max_f32()로 추출
    • GGML_SILU_FP16 관련 여러 함수 제거
    • ggml_v_expf() 추가
    • ggml_v_silu() 추가
    • ggml_vec_silu_f32()SSE2 또는 __ARM_NEON 플래그에 따라 다른 함수를 쓰도록 전처리문 조정
  • 변경 파일 수는 GitHub 메타데이터상 1개로 표시됨
  • PR에는 refactoringReview Complexity : High 라벨이 붙었고, 후자는 LLM 또는 GPU에 대한 깊은 지식이 필요할 수 있다는 설명을 포함함

벤치마크와 성능 결과

  • ggerganov는 AMD Ryzen 9 5950X와 M2 Ultra에서 SOFT_MAXmaster보다 약 1.5배 빠르다고 확인함
  • 사용한 테스트 명령은 다음과 같음
make -j tests && ./tests/test-backend-ops -o SOFT_MAX -b CPU perf
  • 이후 작성자는 같은 명령에서 성능 이점이 다음과 같이 증가한다고 밝힘
    • SSE2+FMA: 1.5배
    • AVX2+FMA: 1.9배
    • AVX512: 2.1배
  • 별도 개발용 스크립트에서는 다음 수치가 제시됨
    • run_expf(): 2.98601 ns
    • run_llamafile_expf_sse2(): 1.35154 ns
    • run_llamafile_expf_avx2(): 1.16659 ns
    • run_llamafile_expf_avx512(): 1.18844 ns
  • GitHub Actions의 llama.cpp server 벤치마크는 Standard_NC4as_T4_v3에서 phi-2 q4_0 구성으로 543 iterations를 기록함
    • 동시 사용자: 8
    • duration: 10분
    • HTTP 요청 평균: 8626.19ms
    • p95: 21696.44ms
    • Prompt processing 평균: 94.59 tk/s
    • Token generation 평균: 33.43 tk/s

AVX512 최적화 논의

  • chriselrod는 AVX512에서 vscalefps 사용을 제안함
    • vscalefpszmm0 = zmm1 * 2^{zmm2}를 계산함
    • overflow와 underflow를 적절히 처리해 checks와 blends를 제거할 수 있다고 함
  • Julia 구현 예시와 어셈블리 루프가 공유됐고, 테스트가 맞다면 x=47.483456f에서 최대 오차가 1 ULP 미만이었다고 함
  • vscalefps 접근은 lookup table을 쓰지 않으며, Float64/double 구현에서는 vpermi2pd를 통한 16원소 lookup table을 사용한다고 설명됨
  • 이후 C++ 구현 링크도 공유됨
    • ExpAVX512
    • 소스는 include/ExpAVX512.hpp에 있음
    • README에는 벤치마크가 포함됐지만, 다른 구현과 비교 벤치마크는 하지 않았다고 함

병합 후 비결정성 문제

  • 병합 후 서버에서 >1 slots 사용 시 비결정적 결과가 나온다는 재현 사례가 보고됨
  • 최소 재현 절차는 다음과 같음
make clean && make server
./server -m models/opt/llama_2-7b-q4_0.gguf --parallel 2 --threads 1
  • 다른 셸에서 실행한 요청은 다음과 같음
curl --request POST --url http://localhost:8080/completion --header "Content-Type: application/json" --data '{"prompt": "", "n_predict":10, "n_probs": 2, "temperature": -1}' | python3 -m json.tool
  • 마지막 토큰의 token probabilities가 curl 호출마다 두 값 사이를 순환했고, 4 slots를 쓰면 네 가능한 값 사이를 순환한다고 함

-ffinite-math-only와 빌드 제약

  • 이후 관련 커밋들은 -ffinite-math-only를 문제 원인으로 좁힌 내용을 참조함
  • 해당 문제는 SiLU가 작은 값을 0으로 flush하는 대신 NaN 또는 다른 garbage 값을 반환하는 것으로 추정됐다고 기록됨
  • fix는 -fno-finite-math-only가 설정됐는지 확인하고, 컴파일 모드가 finite math 모드가 아니어야 한다는 검사를 강제함
  • 에러 메시지는 GGML 일부 루틴이 non-finite math arithmetic을 필요로 하며, 컴파일러에 -fno-finite-math-only를 전달하라고 안내함
  • 이후 사용자들은 -Ofast 또는 -ffast-math-ffinite-math-only를 포함해 빌드를 깨뜨릴 수 있다는 경험을 공유함
    • GCC 13.2까지는 -Ofast를 사용할 수 있었지만 GCC 14부터 결과가 garbage가 됐다는 보고가 있음
    • 일부 테스트에서는 -fno-finite-math-only 외에 -fmath-errno도 필요했다고 함
    • -ffast-math를 제거하거나 -fno-finite-math-only를 명시해 ggml 컴파일 오류를 해결한 후속 커밋들이 여러 저장소에서 참조됨

댓글과 토론

Hacker News 의견들
  • 20년쯤 전 Hughes 레이더 신호 프로세서용으로 프로그래밍할 때, 0 < x < 1 범위의 e^x를 계산해야 했음
    그 프로세서에는 곱셈이 있어서 32비트 워드의 4개 8비트 블록 각각에 대해 가능한 값 256개의 e^x 테이블 4개를 만들고, 이들을 곱해 최종 값을 얻었음
    이전 최고 e^x 루틴보다 약 5배 빨랐고, 지금은 구식이지만 한동안 명목상 훨씬 빠른 프로세서들보다 레이더 신호를 더 빠르게 처리하던 재미있는 기계였음

    • 따라가기 어려웠다면 아이디어는 대략 e^x = e^(a+b+c+d)이고, 여기서 a/b/c/d는 x의 각 바이트이며, e^a * e^b * e^c * e^d로 바꾼 뒤 각각의 e^a, e^b lookup table을 만드는 방식으로 보임
      엄밀히는 a가 high byte << 24 같은 형태라서, e^a 테이블은 a => e^(a<<24) 매핑이 되고 다른 바이트도 비슷하게 처리하는 셈임
  • 이런 silu와 softmax 개선이 LLM 추론 속도 전체에는 얼마나 영향을 주는지 궁금함
    틀렸다면 고쳐주면 좋겠지만, 대부분의 시간이 행렬 곱셈에 쓰이니 이 변경의 효과는 작을 것 같음

    • 부동소수점 연산 대부분이 행렬 곱셈에 쓰이는 건 맞지만, softmax는 메모리 대역폭을 불균형하게 많이 쓰기 때문에 연산량만 보고 예상하는 것보다 보통 훨씬 오래 걸림
  • 살짝 딴얘기지만, 훑어보다가 “이건 꽤 미친 최적화 같네. 복잡하고 이미 많은 사람이 본 코드인데”라고 생각했는데 기여자를 보고 “역시 jart구나. 미친 듯이 좋은 해법은 늘 jart네” 싶었음

    • 주로 무서워 보이는 이유는 C/C++의 intrinsics 문법이 원래 그렇기 때문임
      그쪽의 많은 일처럼 이 고통도 꽤 자기초래적인 면이 있음
      C# 스타일의 SIMD와 하드웨어 intrinsic 문법을 가능하게 해주는 C++ 라이브러리도 있는 것으로 알고 있지만, 명령어 집합 문서에서 니모닉을 바로 찾아보기 어렵다는 단점이 있음
      여기서 한 작업의 중요성을 깎아내리려는 건 아니고, 더 넓은 독자가 접근하기 쉬울 수도 있었다는 뜻임. 다만 아직 추론 백엔드를 C#으로 다시 쓰자는, 여기 모두가 황당하게 여길 제안을 하려는 건 아님
    • adapted from arm limited optimized routine이라니, 결국 거인의 어깨 위에 선 셈임
    • 이런 건 점근적 분석 강의에서 가르치는 내용은 아니지 않나 싶음
      “모두가 무시하는 그 상수가 공학적으로는 머리를 통째로 먹어치울 수 있다”고 유명하게 말한 교수가 떠오름
  • short[65536] lookup table을 대체한다는데, 처음부터 좀 둔한 선택 아닌가 싶음
    L1 캐시 전체 크기의 lookup table을 두는 셈인데, 확률적으로 대충 맞아떨어져서 의외로 잘 동작하는 건가?

    • lookup table이 의외로 잘 되는 이유는 워크로드 자체가 극도로 캐시 비우호적이기 때문임
      L1 캐시를 날려도 별로 상관없고, LUT를 넣느라 밀려난 데이터는 어차피 재사용될 일이 거의 없었음
      머신러닝 부하는 대체로 매 반복마다 전체 데이터셋을 선형으로 읽는 스트리밍 로드
    • lookup table을 왜 아마 쓰지 말아야 하는지에 대한 글 https://specbranch.com/posts/lookup-tables/이 일반적으로 언제 적절한지 다룸
      좁은 경험으로는, lookup보다 빨라지기 전까지 실시간 계산을 꽤 많이 할 수 있음
  • llama.cpp에서, CPU용 이야기임

    • 원래는 llamafile용으로 개발했고, 최근 두 릴리스에 포함됐음: https://github.com/Mozilla-Ocho/llamafile/releases/tag/0.8.2
      이제 이를 llama.cpp 프로젝트로 upstream하는 중임
      현재 llamafile에서만 얻을 수 있는 다른 성능 개선도 있는데, 예를 들면 Kawrakow가 K quants를 훨씬 빠르게 만든 작업이 있음
  • 약간 딴얘기일 수 있지만, ggml 같은 것이 tensorflow lite, onnxruntime 같은 런타임과 비교해 어떤지 아는 사람이 있나?

    • ONNX와 llama.cpp Flutter 라이브러리를 6개 True Platforms 전부에서 유지하고 있어서 꽤 잘 알고 있음
      짧게 말하면 LLM에는 llama.cpp가 맞고, 핵심 의존성인 GGML로 whisper도 가능함
      그 외에는 ONNX를 쓰면 됨
      TF는 머신러닝계의 Apple 같아서 Google ML 생태계에 완전히 묶여 있으면 훌륭하지만, 그 밖에서는 사실상 죽은 편임. HF 모델의 말도 안 되는 비율, 대략 94%가 PyTorch임
      직접 추론 성능 비교를 해볼 만한 건 ONNX의 Whisper와 GGML 정도인데, 누군가 내 llama.cpp 라이브러리를 Whisper와 함께 돌렸고 의미 있는 성능 차이는 보고하지 않았음
    • 정확히 어떤 하드웨어 기준인지가 중요함
  • 현시점에서 CUDA 장치의 비배치 추론에는 gguf/llama.cpp가 더 성능 좋은 해법인가, 아니면 여전히 exllamav2+flashattention이 우위인가?

    • 2x 4090에서는 차이가 무시할 만함
      4비트 KV 캐시 같은 더 중요한 차이가 있음
  • LUT도 벡터화할 수 있음
    https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
    예전에 LUT로 가능한 것들에 대해 쓴 적도 있음 https://darkcephas.blogspot.com/2018/10/validating-utf8-stri...

    • 맞지만 직접 exp를 구현해도 원하는 정확도에 따라 FMA 10~20개 정도면 됨
      gather나 permutation이 순수 계산과 경쟁하기는 어려움
  • 비슷한 맥락으로, 더 빠른 tanh도 있음 https://github.com/microsoft/onnxruntime/pull/20612

    • 훌륭한 작업임
      그런데 목표가 뭘까? 저 GeLU 근사를 더 빠르게 만들려는 건가?
      erff()로 돌아가면 아마 훨씬 더 빨라질 것 같음
  • 이게 gguf의 GPU 부분 오프로딩 사용 사례에도 도움이 되나?
    CPU 쪽도 더 빨라지는 건가?