- Cara membangun mesin inferensi LLM menggunakan C++ dan CUDA tanpa library
- Melalui ini, kita bisa memahami seluruh stack inferensi LLM dan merasakan secara langsung bagaimana berbagai optimasi memengaruhi kecepatan inferensi
- Tujuan: mengimplementasikan model agar dapat melakukan inferensi cepat dengan batch tunggal pada satu server CPU + GPU dan mencapai kecepatan pemrosesan token yang lebih cepat daripada llama.cpp
1. Gambaran umum arsitektur dan inferensi LLM
- Sebagian besar LLM utama mengikuti arsitektur yang sama, menggunakan blok transformer yang berurutan.
- Pemuatan model dilakukan dengan mendefinisikan kelas blok transformer yang dapat dikustomisasi, menyusunnya dalam sebuah urutan, lalu menginisialisasinya dengan bobot safetensors.
- Inferensi terutama dilakukan dalam batch tunggal, dan "tahap decode" memakan sebagian besar eksekusi.
1.1 Gambaran umum inferensi
- Inferensi dibagi menjadi tahap prefill, yang mengirim token prompt ke model untuk mengisi cache KV, dan tahap decode, yang berulang kali menjalankan model untuk menghasilkan token
- Tahap Prefill: memproses token prompt sambil menginisialisasi cache KV
- Tahap Decode: menghasilkan satu token dalam satu waktu
- Cache KV: menyimpan pasangan key/value sebelumnya agar attention terhadap konteks masa lalu dapat dihitung dengan cepat
- Forward pass model memetakan ID token ke vektor embedding menggunakan tabel embedding, lalu mentransformasikan state melalui urutan blok transformer
1.2 Bottleneck dan benchmark
- Bottleneck: pada hardware modern, bandwidth memori adalah faktor pembatas
- Saat inferensi model, untuk menghasilkan setiap token perlu membaca seluruh model, sehingga bandwidth memori menjadi kendala yang lebih besar daripada komputasi
- Kuantisasi model efektif untuk meningkatkan kecepatan inferensi
- Throughput token maksimum teoretis berbeda-beda tergantung hardware, dan performa nyata dapat dilihat melalui berbagai mesin inferensi
- Batas kecepatan teoretis:
- AMD EPYC 7702P: maksimum 13.6 tok/s (berdasarkan FP16)
- RTX 4090: maksimum 67.1 tok/s (berdasarkan FP16)
- Benchmark:
- llama.cpp: CPU 8.7 tok/s, GPU 61 tok/s
- calm: GPU 66 tok/s
2. Inferensi berbasis CPU
- Implementasi awal di CPU berjalan single-thread dan hanya mendukung bobot FP32
- Paralelisasi kode dapat dimulai melalui multithreading, dan performa bisa ditingkatkan menggunakan SIMD
2.1 Multithreading
- Menggunakan OpenMP untuk memparalelkan perkalian matriks-vektor (matmul) dan multi-head attention guna meningkatkan performa
- Hasil optimasi: kecepatan meningkat dari 0.6 tok/s → 4.4 tok/s
2.2 Kuantisasi bobot dan optimasi SIMD
- Kuantisasi: bobot FP32 dikuantisasi ke FP16 sehingga penggunaan memori berkurang setengah dan performa meningkat
- SIMD: dioptimalkan dengan AVX2 agar dapat memproses 8 nilai FP32 sekaligus
- Hasil: mencapai 8.4 tok/s
3. Inferensi berbasis GPU
- Model dapat dikuantisasi ke FP16, dimuat ke RTX 4090, lalu implementasi inferensi GPU dapat dimulai
- Dengan CUDA, fungsi C++ (kernel) dapat dijalankan secara paralel di GPU
3.1 Porting sederhana ke CUDA
- Backend GPU dapat diimplementasikan dengan mengonversi operasi CPU secara 1-1 menjadi kernel CUDA
- Kernel CUDA berjalan secara asinkron, tetapi dalam stream yang sama tetap dieksekusi secara berurutan
- Masalah: karena inefisiensi thread, sumber daya GPU tidak termanfaatkan sepenuhnya → lambat di 2.9 tok/s
3.2 Perkalian matriks (matmul) yang lebih baik
- Perkalian matriks memakan runtime besar di CPU, dan dapat dioptimalkan melalui OpenMP
- Di GPU, utilisasi thread dapat ditingkatkan dengan membuat setiap blok memproses 1 baris
- Metode optimasi:
- Satu blok memproses satu baris, dan thread dalam blok berkolaborasi untuk menghitungnya
- Menerapkan warp reduction
- Hasil: kecepatan meningkat menjadi 51.7 tok/s
3.3 Fusi kernel dan optimasi tambahan
- Performa dapat ditingkatkan dengan menggabungkan kernel
- Fusi kernel: menggabungkan operasi yang berurutan ke dalam satu kernel untuk meminimalkan akses memori dan waktu komputasi
- Melalui optimasi pola akses memori dan pemakaian ulang ruang, dicapai 56.1 tok/s
3.4 Optimasi attention dan penanganan konteks panjang
- Masalah: pada konteks panjang, kernel attention menjadi bottleneck performa
- Solusi:
- Optimasi akses memori: didesain ulang agar membaca blok memori yang berurutan
- Menggunakan memori bersama alih-alih atomicAdd untuk mengatasi masalah nilai floating-point yang terlewat
- Hasil optimasi:
- Konteks pendek: 63.8 tok/s (lebih cepat daripada 61.0 tok/s milik llama.cpp)
- Konteks panjang: mencapai 58.8 tok/s
3.5 Kuantisasi cache KV dan masalah optimasi compiler
- Kuantisasi cache KV ke FP16 menyebabkan penurunan performa (karena kurangnya optimasi compiler)
- Solusi: melakukan loop unrolling secara manual dan menerapkan prefetching memori
- Hasil: sekitar 2x lebih cepat dibanding FP32 dan performa konteks panjang tetap 58.8 tok/s
4. Arah peningkatan berikutnya
- Optimasi prefill prompt: memproses beberapa token sekaligus untuk memperpendek waktu hingga token pertama dihasilkan
- Fusi kernel attention: menerapkan teknik optimasi seperti FlashAttention
- Kuantisasi lebih tinggi: menerapkan FP8, INT8, INT4 serta kuantisasi aktivasi/cache
- Optimasi kernel: mengadopsi teknik lanjutan untuk memaksimalkan bandwidth memori dan efisiensi komputasi
- Penggunaan library: memanfaatkan library seperti cuDNN dan cuBLAS untuk mempersingkat waktu optimasi
Ringkasan hasil:
- Melalui berbagai optimasi pada CPU dan GPU, dicapai kecepatan 63.8 tok/s
- Mencatat performa yang mendekati atau lebih baik daripada llama.cpp dan calm
- Mengimplementasikan mesin inferensi LLM berperforma tinggi hanya dengan C++ dan CUDA tanpa library
1 komentar
Komentar Hacker News