1 คะแนน โดย GN⁺ 2024-05-16 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • llama.cpp PR #7154 เขียนการคำนวณ SiLU และ SoftMax สำหรับ CPU ใน GGML ใหม่ โดยใช้ implementation expf() แบบเวกเตอร์จาก llamafile และถูกรวมเข้า master เมื่อวันที่ 17 พฤษภาคม 2024
  • เดิม GGML ใช้ ตาราง lookup short[65536] เพื่อความเร็ว แต่ implementation ใหม่นี้มุ่งให้คำนวณได้แม่นยำกว่า โดยคงค่าคลาดเคลื่อนการปัดเศษกรณีแย่ที่สุดไว้ที่ 2 ULP บน aarch64 และ SSE2+
  • ในการทดสอบประสิทธิภาพ SOFT_MAX บน CPU พบว่า SSE2+FMA เร็วขึ้น 1.5 เท่า, AVX2+FMA เร็วขึ้น 1.9 เท่า, และ AVX512 เร็วขึ้น 2.1 เท่า อีกทั้งยังยืนยันผลว่าเร็วขึ้นราว 1.5 เท่าเมื่อเทียบกับ master บน AMD Ryzen 9 5950X และ M2 Ultra
  • การเปลี่ยนแปลงประกอบด้วยการเพิ่ม ggml_v_expf() และ ggml_v_silu(), แยกโค้ดซ้ำออกเป็น ggml_vec_soft_max_f32(), ลบฟังก์ชันที่เกี่ยวข้องกับ GGML_SILU_FP16, และปรับเส้นทาง SiLU แบบมีเงื่อนไขสำหรับ SSE2 หรือ ARM NEON
  • หลัง merge พบว่าสามารถทำซ้ำปัญหา ผลลัพธ์ไม่เป็น deterministic ได้เมื่อรันเซิร์ฟเวอร์ด้วย >1 slots และภายหลังตีวงสาเหตุได้ว่าเกี่ยวกับ -ffinite-math-only จนนำไปสู่ข้อกำหนดระดับการ build ว่าต้องใช้ -fno-finite-math-only

เป้าหมายของการเปลี่ยนแปลงใน PR และสถานะการ merge

  • PR #7154 มีชื่อว่า ggml : rewrite silu and softmax for cpu โดยเป็นการเขียนการคำนวณ SiLU และ SoftMax ใหม่ในเส้นทาง CPU ของ GGML ใน llama.cpp
  • การเปลี่ยนแปลงเริ่มต้นจากการ upstream ฟังก์ชัน expf() แบบเวกเตอร์ ของ llamafile
  • PR นี้ถูก merge เข้า ggml-org:master เมื่อวันที่ 17 พฤษภาคม 2024 และคอมมิตที่ merge แสดงเป็น 934266c
  • ผู้เขียนระบุว่าวิธีใหม่นี้สามารถคำนวณ SoftMax และ SiLU ได้แม่นยำกว่าตาราง lookup short[65536] ที่ GGML เดิมใช้เพื่อความเร็ว

ความแม่นยำและขอบเขตการรองรับ

  • เส้นทางใหม่ที่อิง expf() รองรับ aarch64 และ SSE2+ และระบุค่าคลาดเคลื่อนการปัดเศษกรณีแย่ที่สุดไว้ที่ 2 ULP
  • ในคำอธิบายช่วงแรก มีการเขียน implementation สำหรับ AVX2 และ AVX512 ด้วย แต่บอกว่ายังไม่ได้รวมเข้าไปเพราะประโยชน์ยังไม่มากพอเมื่อเทียบกับความซับซ้อนของโค้ดเหนือกว่า SSE2+FMA
  • ต่อมาจากผล benchmark จึงได้รวม โค้ด AVX2 และ AVX512 เข้าไปด้วย
  • ในผลทดสอบแยกต่างหาก มีข้อความ 4294967296 numbers tested successfully และมีการเปรียบเทียบผลลัพธ์ของ exp กับ implementation ของ llamafile สำหรับค่า input หลายชุด

ขอบเขตของการเปลี่ยนแปลงโค้ด

  • ประเด็นเปลี่ยนแปลงหลักที่ reviewer สรุปมีดังนี้
    • ลบ #define ที่ถูกคอมเมนต์ทิ้งไว้
    • แยกโค้ด 5 บรรทัดที่ซ้ำกันออกเป็น ggml_vec_soft_max_f32()
    • ลบหลายฟังก์ชันที่เกี่ยวข้องกับ GGML_SILU_FP16
    • เพิ่ม ggml_v_expf()
    • เพิ่ม ggml_v_silu()
    • ปรับ preprocessor ใน ggml_vec_silu_f32() ให้เลือกใช้ฟังก์ชันต่างกันตามแฟลก SSE2 หรือ __ARM_NEON
  • จำนวนไฟล์ที่เปลี่ยนแปลงแสดงใน metadata ของ GitHub ว่ามี 1 ไฟล์
  • PR นี้ติดป้าย refactoring และ Review Complexity : High โดยป้ายหลังมีคำอธิบายว่าอาจต้องใช้ความรู้เชิงลึกเกี่ยวกับ LLM หรือ GPU

Benchmark และผลลัพธ์ด้านประสิทธิภาพ

  • ggerganov ยืนยันว่า SOFT_MAX บน AMD Ryzen 9 5950X และ M2 Ultra เร็วกว่า master ราว 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
  • benchmark ของ llama.cpp server บน GitHub Actions บน Standard_NC4as_T4_v3 ด้วยคอนฟิก phi-2 q4_0 บันทึกได้ 543 iterations
    • ผู้ใช้พร้อมกัน: 8
    • duration: 10 นาที
    • ค่าเฉลี่ย HTTP request: 8626.19ms
    • p95: 21696.44ms
    • ค่าเฉลี่ยการประมวลผลพรอมป์ต์: 94.59 tk/s
    • ค่าเฉลี่ยการสร้างโทเคน: 33.43 tk/s

การอภิปรายเรื่องการปรับแต่ง AVX512

  • chriselrod เสนอให้ใช้ vscalefps บน AVX512
    • vscalefps คำนวณ zmm0 = zmm1 * 2^{zmm2}
    • ระบุว่าสามารถจัดการ overflow และ underflow ได้อย่างเหมาะสม ทำให้ตัด checks และ blends ออกได้
  • มีการแชร์ตัวอย่าง implementation ใน Julia และลูป assembly และระบุว่าหากการทดสอบถูกต้อง ค่าคลาดเคลื่อนสูงสุดที่ x=47.483456f จะน้อยกว่า 1 ULP
  • แนวทาง vscalefps นี้ไม่ใช้ lookup table และอธิบายว่าสำหรับ implementation แบบ Float64/double จะใช้ lookup table ขนาด 16 องค์ประกอบผ่าน vpermi2pd
  • ต่อมามีการแชร์ลิงก์ implementation ใน C++ ด้วย
    • ExpAVX512
    • ซอร์สอยู่ที่ include/ExpAVX512.hpp
    • README มี benchmark รวมอยู่ด้วย แต่ไม่ได้ทำ benchmark เปรียบเทียบกับ implementation อื่น

ปัญหา non-determinism หลังการ merge

  • หลัง merge มีรายงานกรณีทำซ้ำได้ว่าบนเซิร์ฟเวอร์เมื่อใช้ >1 slots จะเกิด ผลลัพธ์ไม่เป็น deterministic
  • ขั้นตอนทำซ้ำขั้นต่ำมีดังนี้
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 และข้อจำกัดในการ build

  • คอมมิตที่เกี่ยวข้องในภายหลังอ้างถึงการตีวงสาเหตุว่า -ffinite-math-only เป็นต้นตอของปัญหา
  • มีบันทึกว่าปัญหานี้คาดว่าเกิดจาก SiLU คืนค่าเป็น NaN หรือค่า garbage อื่น แทนที่จะ flush ค่าขนาดเล็กให้เป็น 0
  • วิธีแก้คือเช็กว่าตั้งค่า -fno-finite-math-only ไว้หรือไม่ และบังคับตรวจสอบว่าโหมดคอมไพล์ต้องไม่ใช่ finite math mode
  • ข้อความ error ระบุว่า routine บางส่วนของ GGML ต้องการ non-finite math arithmetic และแนะนำให้ส่ง -fno-finite-math-only ให้คอมไพเลอร์
  • ต่อมาผู้ใช้หลายคนแชร์ประสบการณ์ว่า -Ofast หรือ -ffast-math ซึ่งรวม -ffinite-math-only อาจทำให้การ build พังได้
    • มีรายงานว่าถึง GCC 13.2 ยังใช้ -Ofast ได้ แต่ตั้งแต่ GCC 14 ผลลัพธ์กลายเป็นค่า garbage
    • ในบางการทดสอบ นอกจาก -fno-finite-math-only แล้ว ยังต้องใช้ -fmath-errno ด้วย
    • มีคอมมิตติดตามในหลาย repository ที่อ้างถึงการแก้ error ระหว่างคอมไพล์ ggml โดยการเอา -ffast-math ออก หรือระบุ -fno-finite-math-only อย่างชัดเจน

1 ความคิดเห็น

 
GN⁺ 2024-05-16
ความคิดเห็นบน Hacker News
  • ตอนที่ผมเขียนโปรแกรมให้ โปรเซสเซอร์สัญญาณเรดาร์ของ Hughes เมื่อราว 20 ปีก่อน ต้องคำนวณ e^x ในช่วง 0 < x < 1
    โปรเซสเซอร์นั้นมีคำสั่งคูณอยู่ จึงสร้างตาราง e^x 4 ตาราง สำหรับค่าที่เป็นไปได้ 256 ค่าในแต่ละบล็อก 8 บิตจากทั้งหมด 4 บล็อกของเวิร์ด 32 บิต แล้วนำมาคูณกันเพื่อให้ได้ค่าสุดท้าย
    มันเร็วกว่า routine e^x ที่ดีที่สุดก่อนหน้านั้นประมาณ 5 เท่า และแม้ตอนนี้จะล้าสมัยไปแล้ว แต่ก็เป็นเครื่องที่น่าสนุก ซึ่งช่วงหนึ่งประมวลผลสัญญาณเรดาร์ได้เร็วกว่าโปรเซสเซอร์ที่ตามสเปกแล้วเร็วกว่ามาก

    • ถ้าอ่านตามยาก ไอเดียน่าจะประมาณว่า e^x = e^(a+b+c+d) โดย a/b/c/d คือแต่ละไบต์ของ x แล้วแปลงเป็น e^a * e^b * e^c * e^d จากนั้นทำ lookup table ของ e^a, e^b แต่ละตัว
      ถ้าพูดให้เคร่งครัด a จะอยู่ในรูปอย่าง high byte << 24 ดังนั้นตาราง e^a จะเป็นการแมป a => e^(a<<24) และไบต์อื่น ๆ ก็จัดการคล้ายกัน
  • สงสัยว่าการปรับปรุง silu และ softmax แบบนี้จะส่งผลต่อความเร็วการ inference ของ LLM โดยรวมมากแค่ไหน
    ถ้าผมเข้าใจผิดก็ช่วยแก้ด้วย แต่เวลาส่วนใหญ่น่าจะใช้ไปกับการคูณเมทริกซ์ ดังนั้นผลของการเปลี่ยนแปลงนี้น่าจะเล็ก

    • จริงที่การคำนวณ floating point ส่วนใหญ่ใช้ไปกับ การคูณเมทริกซ์ แต่ softmax ใช้ bandwidth หน่วยความจำมากผิดสัดส่วน จึงมักใช้เวลานานกว่าที่คาดจากจำนวนการคำนวณเพียงอย่างเดียวมาก
  • อาจนอกเรื่องนิดหน่อย แต่ตอนกวาดตาอ่าน ผมคิดว่า “นี่ดูเป็นการ optimize ที่ค่อนข้างบ้าเลยนะ ทั้งซับซ้อนและอยู่ในโค้ดที่คนจำนวนมากดูมาแล้ว” แล้วพอเห็นผู้ร่วมแก้ก็คิดว่า “ว่าแล้ว ต้องเป็น jart นี่เอง วิธีแก้ที่ดีแบบบ้าคลั่งมักเป็น jart เสมอ”

    • เหตุผลหลักที่มันดูน่ากลัวก็เพราะไวยากรณ์ intrinsics ของ C/C++ เป็นแบบนั้นอยู่แล้ว
      เหมือนหลาย ๆ อย่างในฝั่งนั้น ความเจ็บปวดนี้มีส่วนที่ก่อขึ้นเองไม่น้อย
      เท่าที่รู้ก็มีไลบรารี C++ ที่ทำให้ใช้ไวยากรณ์ SIMD และ hardware intrinsic แบบสไตล์ C# ได้ แต่ข้อเสียคือค้น mnemonic จากเอกสารชุดคำสั่งโดยตรงได้ยาก
      ไม่ได้ตั้งใจจะลดทอนความสำคัญของงานที่ทำตรงนี้ แค่หมายความว่ามันอาจเข้าถึงผู้อ่านวงกว้างได้ง่ายกว่านี้ได้ อย่างไรก็ตาม ผมยังไม่ได้จะเสนออะไรที่ทุกคนในนี้คงมองว่าน่าขันอย่างการเขียน inference backend ใหม่ด้วย C#
    • บอกว่า adapted from arm limited optimized routine แปลว่าสุดท้ายก็ยืนอยู่บน บ่าของยักษ์ นั่นแหละ
    • เรื่องแบบนี้คงไม่ใช่สิ่งที่สอนกันในวิชา การวิเคราะห์เชิงเส้นกำกับ เท่าไร
      นึกถึงอาจารย์คนหนึ่งที่เคยพูดไว้ดัง ๆ ว่า “ค่าคงที่ตัวนั้นที่ทุกคนมองข้าม ในเชิงวิศวกรรมมันกินหัวคุณได้ทั้งหัวเลย”
  • บอกว่าจะมาแทนที่ lookup table แบบ short[65536] แต่รู้สึกว่าตั้งแต่แรกก็เป็นตัวเลือกที่ค่อนข้างทื่อไปหน่อยไม่ใช่หรือ
    นั่นเท่ากับมี lookup table ขนาดเท่ากับ L1 cache ทั้งก้อนเลย แต่มันบังเอิญเข้ากันได้ดีทางความน่าจะเป็นจนทำงานได้ดีเกินคาดหรือเปล่า?

    • เหตุผลที่ lookup table ใช้ได้ดีเกินคาดคือ workload เอง ไม่เป็นมิตรกับ cache อย่างสุด ๆ
      ต่อให้ล้าง L1 cache ก็ไม่ค่อยสำคัญ และข้อมูลที่ถูกเบียดออกไปเพราะใส่ LUT ก็แทบไม่มีโอกาสถูกนำกลับมาใช้ซ้ำอยู่แล้ว
      ภาระงาน machine learning โดยทั่วไปเป็น streaming load ที่อ่าน dataset ทั้งหมดแบบเชิงเส้นในทุก iteration
    • บทความว่าทำไมโดยทั่วไปคุณอาจไม่ควรใช้ lookup table https://specbranch.com/posts/lookup-tables/ อธิบายว่าเมื่อไรถึงเหมาะสม
      จากประสบการณ์อันจำกัดของผม คุณสามารถคำนวณแบบ real-time ได้ค่อนข้างมากก่อนที่จะเริ่มเร็วกว่า lookup
  • ใน llama.cpp เรื่องนี้สำหรับ CPU

    • เดิมพัฒนาขึ้นสำหรับ llamafile และรวมอยู่ในสอง release ล่าสุด: https://github.com/Mozilla-Ocho/llamafile/releases/tag/0.8.2
      ตอนนี้กำลัง upstream สิ่งนี้เข้าโปรเจกต์ llama.cpp
      ยังมีการปรับปรุง performance อื่น ๆ ที่ตอนนี้ได้เฉพาะใน llamafile ด้วย เช่น งานของ Kawrakow ที่ทำให้ K quants เร็วขึ้นมาก
  • อาจนอกเรื่องเล็กน้อย แต่มีใครรู้ไหมว่าสิ่งอย่าง ggml เป็นอย่างไรเมื่อเทียบกับ runtime อย่าง tensorflow lite, onnxruntime?

    • ผมดูแลไลบรารี ONNX และ llama.cpp Flutter บน True Platforms ทั้ง 6 ตัว จึงค่อนข้างรู้เรื่องนี้ดี
      สรุปสั้น ๆ คือสำหรับ LLM ให้ใช้ llama.cpp และด้วย GGML ซึ่งเป็น dependency หลักก็ใช้กับ whisper ได้ด้วย
      นอกเหนือจากนั้นก็ใช้ ONNX
      TF เหมือน Apple ของวงการ machine learning คือถ้าคุณผูกอยู่กับ ecosystem ของ Google ML อย่างเต็มตัวมันก็ยอดเยี่ยม แต่ข้างนอกนั้นแทบตายไปแล้ว สัดส่วนที่บ้าบอของโมเดลบน HF ประมาณ 94% เป็น PyTorch
      สิ่งที่พอจะเปรียบเทียบ performance การ inference โดยตรงได้คือ Whisper ของ ONNX กับ GGML แต่มีคนเอาไลบรารี llama.cpp ของผมไปรันกับ Whisper แล้วไม่ได้รายงานความต่างด้าน performance ที่มีนัยสำคัญ
    • สำคัญว่าพูดถึง ฮาร์ดแวร์ แบบไหนกันแน่
  • ณ ตอนนี้ สำหรับ non-batched inference บนอุปกรณ์ CUDA นั้น gguf/llama.cpp เป็นทางออกที่ performance ดีกว่าหรือยัง หรือ exllamav2+flashattention ยังเหนือกว่าอยู่?

    • บน 2x 4090 ความต่างเล็กจนมองข้ามได้
      ยังมีความต่างที่สำคัญกว่า เช่น 4-bit KV cache
  • LUT ก็ vectorize ได้เหมือนกัน
    https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
    เคยเขียนไว้ก่อนหน้านี้เกี่ยวกับสิ่งที่ทำได้ด้วย LUT https://darkcephas.blogspot.com/2018/10/validating-utf8-stri...

    • ถูก แต่แม้จะ implement exp โดยตรง ก็ใช้แค่ประมาณ FMA 10~20 ตัว ขึ้นกับความแม่นยำที่ต้องการ
      gather หรือ permutation แข่งกับการคำนวณล้วน ๆ ได้ยาก
  • ในทำนองเดียวกัน ยังมี tanh ที่เร็วขึ้นด้วย https://github.com/microsoft/onnxruntime/pull/20612

    • เป็นงานที่ยอดเยี่ยม
      แต่เป้าหมายคืออะไร? ต้องการทำให้ การประมาณ GeLU ตัวนั้นเร็วขึ้นหรือ?
      ถ้ากลับไปใช้ erff() น่าจะเร็วขึ้นได้อีกมาก
  • สิ่งนี้ช่วยกรณีใช้งาน GPU partial offloading ของ gguf ด้วยไหม?
    ฝั่ง CPU ก็เร็วขึ้นด้วยหรือเปล่า?