Mengungguli perkalian matriks NumPy dengan 150 baris kode C
(salykova.github.io)- Perkalian matriks NumPy bergantung pada library BLAS eksternal, tetapi implementasi ini bertujuan membawa performa single-thread dan multithread hingga setara BLAS hanya dengan C murni serta FMA3·AVX2
- Inti performanya adalah struktur yang membagi $C$ menjadi blok-blok kecil, lalu microkernel 16×6 mengulang rank-1 update di dalam register
YMMuntuk mengurangi akses memori - Pada matriks berukuran arbitrer, penanganan batas mudah menjadi bottleneck, sehingga implementasi ini menggabungkan mask store dan buffer zero padding untuk menghindari penurunan performa akibat mask load
- Reuse cache diperoleh melalui blocking
k_c,m_c,n_c, dan performa puncak aktual sangat bergantung pada tuning jumlah thread, ukuran kernel, dan ukuran tile - AVX-512 dikecualikan demi dukungan CPU yang lebih luas, sehingga pada CPU AVX-512 BLAS bisa lebih cepat; perbandingan dengan OpenBLAS juga dilakukan dengan AVX-512 dimatikan
Tujuan implementasi dan pembanding
- Kode implementasinya dipublikasikan di sgemm.c, dan mengoptimalkan perkalian matriks FP32 multithread pada prosesor modern
- NumPy bergantung pada library BLAS eksternal untuk operasi aljabar linear seperti perkalian matriks
- Contohnya Intel MKL, Accelerate, BLIS, GotoBLAS, dan OpenBLAS
- OpenBLAS, GotoBLAS, dan BLIS ditulis dalam C/FORTRAN/Assembly, serta mencakup implementasi perkalian matriks yang dioptimalkan manual untuk tiap mikroarsitektur CPU
- Tujuannya adalah implementasi perkalian matriks yang ditulis dalam C murni tanpa assembly tingkat rendah, tetapi tetap memenuhi syarat berikut
- Berjalan pada ukuran matriks arbitrer
- Berjalan pada prosesor x86-64 modern
- Bersaing dengan library BLAS yang ada
- Kodenya sederhana dan mudah diperluas
- Referensinya adalah Fast Multidimensional Matrix Multiplication on CPU from Scratch oleh Simon Boehm, Matrix Multiplication oleh Sergey Slotin, Can you multiply a matrix? oleh Geohot, serta makalah terkait GotoBLAS dan BLIS
Kondisi benchmark dan perhitungan FLOPS
- Lingkungan pengujian adalah AMD Ryzen 7 9700X, 32GB DDR5 6000 MHz CL36, OpenBLAS 0.3.26, GCC 13.3, Ubuntu 24.04.1 LTS
- Flag kompilasi yang digunakan adalah
-O3 -march=native -mno-avx512f -fopenmp - Untuk perbandingan yang adil, saat menginstal OpenBLAS perlu menetapkan
TARGETyang sesuai dan menonaktifkan instruksi AVX-512- Prosesor Zen4/5 dikompilasi dengan
make TARGET=ZEN - Jika tidak, OpenBLAS akan menggunakan instruksi AVX-512 secara default
- Prosesor Zen4/5 dikompilasi dengan
- Perkalian matriks FP32 OpenBLAS dijalankan melalui API
cblas_sgemm - Benchmark menargetkan matriks persegi
- Dievaluasi dari
m=n=k=200hinggam=n=k=10000dengan interval200 - Perkalian matriks diulang
n_iterkali, dan waktu eksekusi median digunakan untuk pengukuran performa
- Dievaluasi dari
- Jika matriks $A$ berukuran $M \times K$ dikalikan dengan matriks $B$ berukuran $K \times N$, total jumlah operasinya adalah $2MNK$ FLOP
- Performa dihitung dengan
FLOPS=(2*m*n*k)/exec_time
- Performa dihitung dengan
Batas teoretis dan basis SIMD
- CPU x86-64 modern memproses banyak data secara paralel dengan ekstensi SIMD
- Instruksi utamanya adalah AVX2 dan FMA
- Keduanya menggunakan register
YMM256-bit - Setiap register
YMMdapat menampung 8 float 32-bit
- Keduanya menggunakan register
- Instruksi FMA
VFMADD231PSmenjalankan operasi packed single berbentukYMM1 = YMM2 * YMM3 + YMM1 - Pada Ryzen 9700X, throughput fused multiply-add adalah 0,5 cycle/instruction, yakni 2 instruksi per siklus
- Secara teoretis, Ryzen 9700X dapat menjalankan 32 FLOP per siklus pada satu core
- Rumusnya adalah
8 floats × 2(add+mul) × 2(1/TP) - Dengan asumsi clock berkelanjutan 4,7GHz pada 8 core, peak teoretis multithread diperkirakan 1203 FLOPS
- Rumusnya adalah
Implementasi dasar dan microkernel
- Matriks disimpan dalam urutan column-major
A[row][col]diakses pada pointer C sebagaiptr[col*M + row]
- Implementasi paling sederhana menelusuri semua baris dan kolom $C$, lalu menghitung dot product baris $A$ dan kolom $B$ untuk tiap elemen
- Inti implementasi berperforma tinggi adalah microkernel yang membagi $C$ menjadi submatriks $m_R \times n_R$ dan menghitung tiap submatriks secara efisien
- Kernel menginisialisasi $\bar{C}$ di register dengan 0, lalu beriterasi sepanjang dimensi $K$
- Mengambil vektor kolom $\bar{A}$ dan vektor baris $\bar{B}$ ke register
- Menghitung outer product dari kedua vektor dan menambahkannya ke akumulator $\bar{C}$
- Tiap langkah adalah rank-1 update
- Cara ini mengurangi jumlah elemen yang diambil ke register menjadi $(m_R+n_R)K$, dibandingkan akses memori metode naive sebesar $2K m_R n_R$
- CPU AVX memiliki 16 register YMM, sehingga ukuran kernel harus memenuhi batasan berikut
- $(m_R/8) \cdot n_R + m_R/8 + 1 \le 16$
- $m_R$ harus kelipatan 8
- Secara teoretis, makin besar dan makin setara nilai $m_R$ dan $n_R$, makin besar pengurangan akses memori; tetapi pada Ryzen 9700X aktual, kernel 16×6 menunjukkan performa terbaik
- Implementasi menggunakan intrinsic dari
immintrin.h__m256adalah tipe vektor 256-bit dan merepresentasikan isi registerYMM_mm256_loadu_psmemuat vektor kolomA_mm256_broadcast_ssmelakukan broadcast nilai skalarBmenjadi vektor berisi 8 float_mm256_fmadd_psmemperbarui akumulator_mm256_storeu_psmenyimpan hasil ke memori
- Assembly yang dihasilkan mencakup instruksi SIMD FMA seperti
vfmadd231psdanvbroadcastss
Padding untuk matriks berukuran arbitrer
- Kernel dasar 16×6 langsung bekerja saat $M$ dan $N$ masing-masing merupakan kelipatan 16 dan 6
- Jika jumlah kolom $n$ di area batas kurang dari 6, loop penyimpanan hanya dijalankan hingga
j < n - Jika jumlah baris $m$ kurang dari 16,
_mm256_storeu_psmenyimpan 8 elemen sekaligus, sehingga diperlukan mask store_mm256_maskstore_pshanya menyimpan elemen memori yang bit mask-nya aktif- Mask dibuat berdasarkan jumlah baris yang bertumpang tindih, yaitu $m$
- Jika load di area batas juga ditangani dengan
_mm256_maskload_ps, performa kernel bisa turun besar- Instruksi tambahan untuk menghitung mask menambah overhead
- Karena $n$ bukan konstanta compile-time, compiler sulit melakukan unroll loop secara efisien
- Sebagai gantinya, jika $m \neq m_R$, $\bar{A}$ disalin ke buffer dan diberi padding 0; jika $n \neq n_R$, $\bar{B}$ juga disalin ke buffer dan diisi 0
- Implementasi terkait ada di matmul_pad.h
Cache blocking dan reuse data
- Di antara register dan DRAM terdapat hierarki cache CPU, dan CPU desktop modern biasanya menggunakan cache L1, L2, dan L3
- Cache lebih cepat daripada DRAM tetapi kapasitasnya terbatas, sehingga tidak mungkin memasukkan seluruh $A$, $B$, dan $C$ ke cache
- Cara membagi matriks menjadi blok-blok kecil, menaruhnya di cache, lalu menggunakan ulang data yang sama pada banyak rank-1 update disebut cache blocking atau tiling
- Cache blocking single-thread berbentuk loop 5 tingkat yang mirip struktur BLIS
- Loop terluar membuat blok $C_j$ dan $B_j$ sepanjang dimensi $N$
- Loop berikutnya membuat blok $A_j$ dan $B_p$ sepanjang dimensi $K$
- $B_p$ dipacking menjadi $\tilde{B}_p$, dan jika perlu diberi padding 0 untuk reuse cache L3
- Loop berikutnya membuat blok $C_i$ dan $A_j$ sepanjang dimensi $M$, dan $A_j$ dipacking menjadi $\tilde{A}_j$
- Dua loop terakhir membagi blok cache menjadi panel $m_R \times k_c$ dan $k_c \times n_R$, lalu meneruskannya ke kernel
- $\tilde{A}_j$ dan $\tilde{B}_p$ yang sudah dipacking disimpan secara berbeda
- Panel internal $\tilde{A}_j$ disimpan secara column-major
- Panel internal $\tilde{B}_p$ disimpan secara row-major
- Parameter cache blocking perlu disesuaikan dengan ukuran cache tiap model CPU
- $k_c \times n_c$ menjadi titik awal untuk mengisi cache L3
- $m_c \times k_c$ menjadi titik awal untuk mengisi cache L2
- $k_c \times n_R$ menjadi titik awal untuk mengisi cache L1
- Dalam praktiknya, nilai yang lebih besar daripada nilai teoretis sering memberi performa lebih baik, dan karena CPU mengelola penempatan cache secara otomatis, loop dan pola akses perlu dirancang pada level algoritme
- Implementasinya ada di matmul_cache.h
Optimisasi mikro kernel
- Alih-alih mendefinisikan akumulator sebagai array seperti
__m256 C_buffer[6][2], variabel akumulator dideklarasikan dengan diuraikan secara eksplisit - Cara ini membantu GCC mengoptimalkan kode dengan lebih baik dan menghindari register spilling
- Perhitungan mask juga diubah agar menggunakan instruksi vektor
- Menggunakan array statis
mask[32]serta_mm256_cvtepi8_epi32dan_mm_loadu_si64
- Menggunakan array statis
- Implementasi tersebut ada di matmul_micro.h
Strategi multithreading
- Target paralelisasi adalah operasi aritmetika dan packing
- Loop ke-5, ke-4, dan ke-3 di luar microkernel beriterasi dalam satuan ukuran blok cache
- Agar semua thread tetap sibuk, jumlah iterasi harus setidaknya sama dengan jumlah thread
- Dimensi matriks input kira-kira harus minimal
jumlah thread × ukuran blok cache
- Ukuran blok cache yang menunjukkan performa baik pada single-thread Ryzen 9700X adalah $n_c=1535$, $m_c=1024$
- Untuk memanfaatkan seluruh 8 core, diperlukan dimensi ukuran minimal $\max(m_c,n_c) \times 8 = 1535 \times 8 = 12280$
- Sebaliknya, dua loop terakhir mengiterasi blok kecil $m_R$, $n_R$, sehingga cocok untuk paralelisasi
- Umumnya $m_R$ dan $n_R$ kurang dari 20
- Jika $m_c$ dan $n_c$ dipilih sebagai kelipatan jumlah core, pekerjaan dapat dibagi merata
- Pada Ryzen 9700X, pendekatan yang memparalelkan dua loop internal bersama dengan
#pragma omp parallel for collapse(2) num_threads(NTHREADS)menghasilkan performa terbaik - Pada prosesor dengan banyak core, terutama lingkungan dengan lebih dari 16 core, paralelisme bertingkat dan paralelisasi 2–3 loop dapat dipertimbangkan
- Packing $\tilde{A}$ dan $\tilde{B}$ juga diparalelkan dengan OpenMP
pack_blockAdiparalelkan dengan menelusurimcdalam satuanMRpack_blockBdiparalelkan dengan menelusurincdalam satuanNR
- Parameter yang menunjukkan performa baik pada implementasi multithread di Ryzen 9700X adalah sebagai berikut
- $m_c = m_R \times \text{number of threads} \times 5$
- $n_c = n_R \times \text{number of threads} \times 50$
- Implementasi multithread final ada di matmul_parallel.h
1 komentar
Komentar Hacker News
Jika inti tulisan ini adalah bahwa biasanya masih ada ruang untuk peningkatan performa, maka justru besarnya peningkatan yang mungkin terjadi cenderung diremehkan. Ini tetap berlaku meski upaya yang masuk ke library perkalian matriks jauh lebih besar daripada kebanyakan software lainnya.
Jika kodenya belum benar-benar sangat dioptimalkan, peningkatan 10–1000x atau lebih dari kode yang ada sering terjadi tanpa usaha besar. Secara kasar menurut urutan pentingnya, yang paling utama adalah apakah pilihan algoritmanya tepat dan apakah pekerjaannya sendiri bisa dihilangkan; setelah itu, besar juga dampak dari mengurangi kerja berat seperti bolak-balik ke kernel atau
malloc.Vektorisasi memang bisa memakai intrinsic vektor eksplisit, tetapi sering kali cukup dengan menyusun ulang data dari array of structs menjadi struct of arrays/arrays agar menghasilkan kode mesin yang sama. Efisiensi cache juga penting, dan pada kode paralel ini menjadi lebih rumit ketika isolasi data per thread tidak terjaga, misalnya karena false sharing. Terakhir, optimasi spesifik perangkat keras seperti intrinsic atau assembly yang ditulis tangan juga dimungkinkan
Begitu kuerinya diubah agar join terjadi di server jarak jauh dan hanya 5–10 baris yang dikirim lewat jaringan, performanya langsung naik. Overhead tetap dan latensi memang selalu ada, tetapi jika Anda mengirim data jauh lebih banyak daripada yang dibutuhkan melalui koneksi jaringan, performa pada akhirnya akan hancur. Tulisan “It's the latency, stupid” tentang dampak latensi juga layak dibaca: http://www.stuartcheshire.org/rants/latency.html
Secara keseluruhan saya setuju dengan pertimbangan-pertimbangan di atas dan urutannya secara kasar
Banyak wawancara kerja akhirnya berubah menjadi kuis hafalan algoritma yang obscure dengan alasan “karena Google melakukannya”, alih-alih melihat apakah kandidat bisa menalar mengapa implementasinya lambat, mem-benchmark, lalu memperbaikinya
Pola coding yang umum sering tidak cukup terspesialisasi untuk perangkat keras sehingga menyisakan banyak performa. Tulisan ini adalah contoh yang menarik, dan demonstrasi klasik lainnya adalah “There's plenty of room at the top”.
https://www.science.org/doi/10.1126/science.aam9744
Untuk memahami hal ini, makalah-makalah di repositori BLIS adalah rujukan yang paling mendekati sumber utama. Saya tidak tahu mengapa ada yang mengira BLAS yang dioptimalkan tidak menghasilkan performa bagus; untuk matriks yang cukup besar, Anda seharusnya bisa mengharapkan lebih dari 90% puncak CPU.
Terakhir kali saya melihat, OpenBLAS serial umumnya mirip dengan MKL, dan BLAS mengimplementasikan GEMM, bukan
matmul, sebagai blok dasar aljabar linear. Saya juga kurang paham mengapa memakai numpy alih-alih framework benchmark, dan di Zen menurut saya perbandingannya seharusnya dengan BLAS milik AMD, yaitu implementasi berbasis BLIS. Dulu BLIS tampaknya punya cerita yang lebih baik soal paralelisasi dibanding OpenBLAS, dan AMD BLIS juga punya pergantian implementasi untuk dimensi “kecil”, meski saya tidak tahu apakah itu sekarang ada di OpenBLAS.Vektorisasi micro-kernel tidak selalu membutuhkan intrinsic SIMD; compiler C yang bagus bisa melakukan vektorisasi penuh sekaligus unrolling loop. Micro-kernel C murni di BLIS, dengan ukuran blok yang sesuai, mencapai lebih dari 80% performa dibanding implementasi Haswell yang dioptimalkan manual. Perbedaannya mungkin karena prefetch, tetapi saya sendiri tidak memahaminya dengan tepat
Compiler modern pun masih belum bisa melakukan auto-vectorization dan loop unrolling dengan benar dengan tingkat keberhasilan 100%
Tulisannya dan implementasinya terlihat bagus, tetapi saya penasaran apa sebenarnya “rahasianya”. OpenBLAS sudah dioptimalkan dengan assembly+C selama puluhan tahun tepat untuk masalah ini, jadi bagaimana bisa dikalahkan?
Penjelasannya banyak membahas caching dan semacamnya; saya penasaran apakah BLAS memang tidak memanfaatkan hal-hal seperti itu, atau apakah ini lebih disetel untuk prosesor tertentu
Perbedaan performanya jauh lebih menonjol pada throughput puncak daripada throughput rata-rata, padahal hampir tidak ada aplikasi yang benar-benar mementingkan nilai puncak. Kode benchmark yang ditampilkan tampaknya membuat sisi numpy melewati allocator Python sementara implementasi C tidak, jadi tempat pertama yang perlu dicek adalah kemungkinan kesalahan atau ketidaksesuaian microbenchmark. Banyak rutin numpy mendukung operasi in-place, jadi sepertinya perlu melihat benchmark versi in-place secara eksplisit di kedua sisi.
Numpy juga punya pengecekan batas dan penanganan error yang berjalan terlepas dari implementasi di bawahnya, dan ini menjadi alasan mengapa untuk matriks kecil ia bisa jauh lebih lambat bahkan dibanding list Python murni. Jika ditambah beberapa ribu siklus overhead murni, memang sulit membuatnya cepat.
Implementasi ini adalah pendekatan yang cukup disiplin untuk menjenuhkan cache yang relevan, dan dalam arti tertentu memang sudah jelas, tetapi perbaikan engineering yang gamblang seperti ini tetap layak ditekankan dalam diskusi seperti ini. OpenBLAS memang sudah mengerahkan banyak tenaga, tetapi kecil kemungkinan mereka sudah memikirkan semuanya. Untuk menjelaskannya dengan benar, perlu analisis mendalam terhadap kode di kedua sisi
Untuk implementasi C++ dan C, lihat saja pendekatan metaprogramming [2], [3]. Yang benar-benar mengejutkan adalah banyak bahasa modern seperti Matlab, Julia, dan Mojo masih bergantung pada OpenBLAS, walaupun tentu masing-masing punya alasannya
[1] Numeric age for D: Mir GLAS is faster than OpenBLAS and Eigen (2016):
http://blog.mir.dlang.io/glas/benchmark/openblas/2016/09/23/...
[2] Vastly outperforming LAPACK with C++ metaprogramming (2018):
https://wordsandbuttons.online/vastly_outperforming_lapack_w...
[3] Outperforming LAPACK with C metaprogramming (2018):
https://wordsandbuttons.online/outperforming_lapack_with_c_m...
-march=nativebisa memberi keuntungan karena mengompilasi sesuai model CPU yang tepat. Numpy kemungkinan besar dikompilasi untuk target x86-64 yang lebih umum dan lebih lama.Pada CPU Ryzen,
-march=nativemungkin memakai v4, sedangkan numpy tampaknya menargetkan v1 atau v2.https://en.wikipedia.org/wiki/X86-64#Microarchitecture_level...
Tulisannya juga bagus dan keputusan untuk membuat benchmark-nya mudah direproduksi sangat patut diapresiasi. Pada Xeon W-2245 16-core 3.90GHz saya,
matmul.cmenjalankan perkalian matriks 8192x8192 dalam 1,41 detik dengangcc -O3, 1,47 detik denganclang -O2, sedangkan NumPy mencatat 1,07 detik.Menurut saya kernel AVX-512 akan jauh lebih cepat. Alasan lain performanya kurang maksimal mungkin OpenMP; berdasarkan pengalaman saya, mengelola thread pool secara eksplisit dengan
pthreadsbisa mengurangi overhead. Juga lebih baik memakaisysconf(_SC_NPROCESSORS_ONLN)daripada hardcode jumlah CPUTidak ada alasan memberi beban yang berbeda jika yang satu Python dan yang lain C. Keduanya bisa saja ditulis dalam C, satu memanggil library BLAS dan yang lain memanggil implementasi ini, sehingga perbandingannya benar-benar apple-to-apple
Overhead-nya memang tidak terlalu besar, tetapi seperti juga disebut orang lain di thread ini, yang penting adalah memanggilnya dengan benar. Mempertemukan kode numpy yang naif dengan kode C yang sudah dituning jelas bukan perbandingan yang adil
Ini bukan jalur panas, tetapi inefisiensi pembuatan mask, yaitu penggunaan
bit_mask, cukup mengganggu. Cara yang lebih efisien adalah membuat array konstanta global berbentuk{-1,-1,...,0,0,...}lalu memuatnya pada offset elemen16-m,8-m, atau membandingkan vektor konstanta{0,1,2,3,4,...}denganmyang dibroadcast sertam-8Namun ini hanya berlaku untuk satu kolom matriks, dan loop
maskload/maskstoresesudahnya memakan waktu jauh lebih lama, jadi ini cuma kritik kecil. Terutama penyimpanan masih lambat bahkan di Zen 4[1], dan instruksi AVX-512 6 kali lebih cepat meskipun perbedaannya hanya menerima mask dari register mask. clang toh akan mengauto-vektorisasi shift, jadi kemungkinan hanya sekitar 2~3 kali lebih lambat daripada usulan saya[1]: https://uops.info/table.html?search=vmaskmovps&cb_lat=on&cb_...
Saya sangat menghargai masukan yang memberi sudut pandang baru. Untuk “membuat dan memuat array konstanta global”, kalau saya ingat saat diuji hasilnya sedikit lebih lambat daripada shift bit mask, tetapi untuk memastikannya saya akan mengujinya lagi. Metode “membandingkan vektor konstanta
{0, 1, 2, 3, 4, ...}denganmyang dibroadcast danm-8” adalah ide bagus, jadi akan saya cobaint8_t, lalu saat memuat byte-nya bisa di-sign-extend menjadiint32_t. Kombinasi_mm_loadu_si64/_mm256_cvtepi8_epi32akan dikompilasi menjadi satu instruksivpmovsxbdyang menggunakan operan memoriDengan begitu, jika disejajarkan dengan benar memakai
alignas(32), seluruh array konstanta akan muat dalam satu cache line. Pada kasus penggunaan di artikel asli diperlukan dua mask, jadi instruksivpmovsxbdkedua pasti menjadi cache hit L1D sehingga cocok sekaliBagaimana dengan tinyBLAS buatan jart
https://hacks.mozilla.org/2024/04/llamafiles-progress-four-m...
dan https://justine.lol/matmul/
Selain untuk benchmark, apa alasan memultithread perkalian matriks itu sendiri. Dalam praktik nyata, bukankah lebih menguntungkan jika algoritme yang memakai perkalian itu yang menggunakan multithreading
Tetapi kode HPC biasanya GEMM bukan bottleneck-nya
Saya baru sempat membaca sekilas, tetapi tulisan ini penuh detail dan penjelasan. Kelihatannya ini artikel yang cukup bagus dalam menjelaskan bagaimana perkalian matriks cepat diimplementasikan dengan mempertimbangkan aspek arsitektur, jadi saya masukkan ke daftar bacaan