- 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 ความคิดเห็น
ความคิดเห็นบน Hacker News
ตอนที่ผมเขียนโปรแกรมให้ โปรเซสเซอร์สัญญาณเรดาร์ของ Hughes เมื่อราว 20 ปีก่อน ต้องคำนวณ e^x ในช่วง 0 < x < 1
โปรเซสเซอร์นั้นมีคำสั่งคูณอยู่ จึงสร้างตาราง e^x 4 ตาราง สำหรับค่าที่เป็นไปได้ 256 ค่าในแต่ละบล็อก 8 บิตจากทั้งหมด 4 บล็อกของเวิร์ด 32 บิต แล้วนำมาคูณกันเพื่อให้ได้ค่าสุดท้าย
มันเร็วกว่า routine e^x ที่ดีที่สุดก่อนหน้านั้นประมาณ 5 เท่า และแม้ตอนนี้จะล้าสมัยไปแล้ว แต่ก็เป็นเครื่องที่น่าสนุก ซึ่งช่วงหนึ่งประมวลผลสัญญาณเรดาร์ได้เร็วกว่าโปรเซสเซอร์ที่ตามสเปกแล้วเร็วกว่ามาก
ถ้าพูดให้เคร่งครัด a จะอยู่ในรูปอย่าง
high byte << 24ดังนั้นตาราง e^a จะเป็นการแมป a => e^(a<<24) และไบต์อื่น ๆ ก็จัดการคล้ายกันสงสัยว่าการปรับปรุง silu และ softmax แบบนี้จะส่งผลต่อความเร็วการ inference ของ LLM โดยรวมมากแค่ไหน
ถ้าผมเข้าใจผิดก็ช่วยแก้ด้วย แต่เวลาส่วนใหญ่น่าจะใช้ไปกับการคูณเมทริกซ์ ดังนั้นผลของการเปลี่ยนแปลงนี้น่าจะเล็ก
อาจนอกเรื่องนิดหน่อย แต่ตอนกวาดตาอ่าน ผมคิดว่า “นี่ดูเป็นการ optimize ที่ค่อนข้างบ้าเลยนะ ทั้งซับซ้อนและอยู่ในโค้ดที่คนจำนวนมากดูมาแล้ว” แล้วพอเห็นผู้ร่วมแก้ก็คิดว่า “ว่าแล้ว ต้องเป็น jart นี่เอง วิธีแก้ที่ดีแบบบ้าคลั่งมักเป็น jart เสมอ”
เหมือนหลาย ๆ อย่างในฝั่งนั้น ความเจ็บปวดนี้มีส่วนที่ก่อขึ้นเองไม่น้อย
เท่าที่รู้ก็มีไลบรารี C++ ที่ทำให้ใช้ไวยากรณ์ SIMD และ hardware intrinsic แบบสไตล์ C# ได้ แต่ข้อเสียคือค้น mnemonic จากเอกสารชุดคำสั่งโดยตรงได้ยาก
ไม่ได้ตั้งใจจะลดทอนความสำคัญของงานที่ทำตรงนี้ แค่หมายความว่ามันอาจเข้าถึงผู้อ่านวงกว้างได้ง่ายกว่านี้ได้ อย่างไรก็ตาม ผมยังไม่ได้จะเสนออะไรที่ทุกคนในนี้คงมองว่าน่าขันอย่างการเขียน inference backend ใหม่ด้วย C#
adapted from arm limited optimized routineแปลว่าสุดท้ายก็ยืนอยู่บน บ่าของยักษ์ นั่นแหละนึกถึงอาจารย์คนหนึ่งที่เคยพูดไว้ดัง ๆ ว่า “ค่าคงที่ตัวนั้นที่ทุกคนมองข้าม ในเชิงวิศวกรรมมันกินหัวคุณได้ทั้งหัวเลย”
บอกว่าจะมาแทนที่ lookup table แบบ
short[65536]แต่รู้สึกว่าตั้งแต่แรกก็เป็นตัวเลือกที่ค่อนข้างทื่อไปหน่อยไม่ใช่หรือนั่นเท่ากับมี lookup table ขนาดเท่ากับ L1 cache ทั้งก้อนเลย แต่มันบังเอิญเข้ากันได้ดีทางความน่าจะเป็นจนทำงานได้ดีเกินคาดหรือเปล่า?
ต่อให้ล้าง L1 cache ก็ไม่ค่อยสำคัญ และข้อมูลที่ถูกเบียดออกไปเพราะใส่ LUT ก็แทบไม่มีโอกาสถูกนำกลับมาใช้ซ้ำอยู่แล้ว
ภาระงาน machine learning โดยทั่วไปเป็น streaming load ที่อ่าน dataset ทั้งหมดแบบเชิงเส้นในทุก iteration
จากประสบการณ์อันจำกัดของผม คุณสามารถคำนวณแบบ real-time ได้ค่อนข้างมากก่อนที่จะเริ่มเร็วกว่า lookup
ใน llama.cpp เรื่องนี้สำหรับ CPU
ตอนนี้กำลัง upstream สิ่งนี้เข้าโปรเจกต์ llama.cpp
ยังมีการปรับปรุง performance อื่น ๆ ที่ตอนนี้ได้เฉพาะใน llamafile ด้วย เช่น งานของ Kawrakow ที่ทำให้ K quants เร็วขึ้นมาก
อาจนอกเรื่องเล็กน้อย แต่มีใครรู้ไหมว่าสิ่งอย่าง ggml เป็นอย่างไรเมื่อเทียบกับ runtime อย่าง tensorflow lite, onnxruntime?
สรุปสั้น ๆ คือสำหรับ 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 ยังเหนือกว่าอยู่?
ยังมีความต่างที่สำคัญกว่า เช่น 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...
expโดยตรง ก็ใช้แค่ประมาณ FMA 10~20 ตัว ขึ้นกับความแม่นยำที่ต้องการgather หรือ permutation แข่งกับการคำนวณล้วน ๆ ได้ยาก
ในทำนองเดียวกัน ยังมี tanh ที่เร็วขึ้นด้วย https://github.com/microsoft/onnxruntime/pull/20612
แต่เป้าหมายคืออะไร? ต้องการทำให้ การประมาณ GeLU ตัวนั้นเร็วขึ้นหรือ?
ถ้ากลับไปใช้
erff()น่าจะเร็วขึ้นได้อีกมากสิ่งนี้ช่วยกรณีใช้งาน GPU partial offloading ของ gguf ด้วยไหม?
ฝั่ง CPU ก็เร็วขึ้นด้วยหรือเปล่า?