Rehber BLAS nedir?

Açılımı Basic Linear Algebra Subprograms, yani Temel Lineer Cebir Altprogramları diyebiliriz. Bununla ilgili detaylara biraz sonra değineceğim, öncelikle bu konseptin ne olduğunu ve nerede karşımıza çıktığına bakalım.

Bir kez bile olsa Python gibi bir dilde matematik işlemleri yapmışsanız NumPy kütüphanesini duymuş veya kullanmışsınızdır. Örnek olarak bu kütüphanenin belirli bir fonksiyonu üzerinden ilerleyeceğim.

Importlarla başlayalım:
Python:
import numpy as np
import time

Arka arkaya 1 milyon tane çarpma işlemi yaptığımızı düşünelim. Öncelikle akla ilk gelen yöntemle yapacak olursak:
Python:
c = 1

for i in range(1, 1_000_000+1):
    c *= i

Bu kod 1'den 1 milyona kadar olan tüm sayıları birbirine çarpacaktır. Bunun ne kadar sürdüğünü de bilsek iyi olur tabii. Bu durumda kodu şu şekilde günceleyip çalıştırıyoruz:
Python:
c = 1
st = time.time()

for i in range(1, 1_000_000+1):
    c *= i

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")

Ben böyle bir çıktı aldım:
Kod:
Tüm işlem 493.20 saniye sürdü.

Şimdi bir de demin bahsini açtığım fonksiyonu kullanalım, ki kendisi np.dot olur:
Python:
c = 1
st = time.time()

for i in range(1, 1_000_000+1):
    c = np.dot(c, i)

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")

Aldığım çıktı:
Kod:
Tüm işlem 1.42 saniye sürdü.

Aradaki fark inanılmaz! Devam edelim. Bu bir scaler çarpımıydı. Şimdi daha zorlayıcı bir şey yapalım, iki vektörün çarpımına bakalım. Bunun için öncelikle iki deneyde de farklı sayılarla işlem yapmamış olmak için baştan bir vektör tanımlıyoruz:
Python:
v = np.random.randint(low=0, high=11, size=10**8)

0'dan 10'a değişiklik gösteren 100 milyon integer barındıran bir vektör var elimizde şu an. Şimdi ikinci vektörü oluşturup ikisinin çarpımını akla gelen ilk yöntemle hesaplayıp süresini ölçelim:
Python:
# np.ones ile aynı boyutta ve sadece 1'lerden oluşan bir vektör oluşturuyoruz
w = np.ones(v.size)
sum = 0

st = time.time()

for i in range(len(v)):
    sum += v[i] * w[i]

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")

Aldığım çıktı:
Kod:
Tüm işlem 33.18 saniye sürdü.

Şimdi bir de np.dot ile deneyelim, kod diğerine nazaran daha basit görünüyor:
Python:
# np.ones ile aynı boyutta ve sadece 1'lerden oluşan bir vektör oluşturuyoruz
w = np.ones(v.size)
sum = 0

st = time.time()

np.dot(v, w)

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")

Çıktı:
Kod:
Tüm işlem 0.33 saniye sürdü.

Arada yine inanılmaz bir fark var! Peki ama neden? Bunu anlamak için NumPy dokümantasyonuna bakmamız yeterli:

It uses an optimized BLAS library when possible (see numpy.linalg).

Yani diyor ki, bu fonksiyon mümkün oldukça optimize edilmiş bir BLAS kütüphanesi kullanıyor. Bunun yanında bir de referans link vermiş, bakıyoruz.

The NumPy linear algebra functions rely on BLAS and LAPACK to provide efficient low level implementations of standard linear algebra algorithms. Those libraries may be provided by NumPy itself using C versions of a subset of their reference implementations but, when possible, highly optimized libraries that take advantage of specialized processor functionality are preferred. Examples of such libraries are OpenBLAS, MKL (TM), and ATLAS...

Çevirecek olursak:

NumPy lineer cebir fonksiyonları, standart lineer cebir algoritmalarının verimli birer düşük seviye uygulamalarını sağlamak için BLAS ve LAPACK (Linear Algebra PACKage) kütüphanelerine dayanmaktadır. Bu kütüphaneler NumPy tarafından C versiyonlarınca da sağlanabilir ancak mümkün oldukça özelleştirilmiş işlemci fonksiyonlarını kullanan ve iyi optimize edilmiş kütüphaneler tercih edilmektedir. Bu kütüphanelere örnek olarak OpenBLAS, MKL (Intel Math Kernel Library) ve ATLAS (Automatically Tuned Linear Algebra Software) verilebilir.

Mesela OpenBLAS adından ve linkten de anlayabileceğiniz üzere açık kaynak kodlu bir kütüphane. Kütüphaneyi incelerseniz Fortran gibi diller kullanılmış olduğunu görürsünüz. Mesela biz demin dot product işlemi yapmıştık, bunun Fortran kodlarından birisi de ddot.f olarak görebileceğiniz bu dosyadır:

Peki, nedir BLAS?​

Açılımını daha önceden de yapmış olduğum Basic Linear Algebra Subprograms; vektör toplamı, skaler çarpımı, matrix çarpımı gibi işlemleri barındıran bir teknik standarttır. BLAS bir standardı belirtiyor olsa da farklı uygulamaları belirli ortamlarda hızlı çalışacak şekilde optimize edildiği için arkadaki örneklerde de görmüş olduğumuz gibi performansta epey bir iyileşme sağlayabilir. Bunu sadece yazıldığı dilin halihazırda performanslı olmasına değil, vector register ve SIMD gibi özel floating point donanımlarına borçludur. Donanım seviyesinde bir uygulamadan bahsediyoruz yani, bunun getireceği avantaj kaçınılmaz.

1979'da yazılmış bir Fortran kütüphanesine dayanmaktadır. Bu kütüphane bir referans uygulaması olup sanılanın aksine şu anda bahsettiğimiz BLAS kütüphanesi olmamakla beraber hız için optimize edilmiş değildir ve kamu malına aittir. Lineer cebir fonksiyonları sunan çoğu kütüphane BLAS arayüzüne uyumludur, bu sayede geliştiriciler kullanılan BLAS kütüphanesinden bağımsız olarak yazılım geliştirebilmektedir.

BLAS fonksiyonların karmaşıklıklarına göre 3 seviyeye ayrılır. Bunlar:
  • Level 1: Linear time.
  • Level 2: Quadratic time.
  • Level 3: Cubic time.
olarak tanımlanmaktadır.

Level 1​

Bu seviye 1979'daki BLAS uygulamasındaki genelleştirilmiş vektör toplamı gibi fonksiyonları barındırmaktadır. Bu fonksiyon aşağıdaki şekilde tanımlanmış olup axpy (a x plus y) şeklinde isimlendiriliyor.

2024-07-02 12_56_42-Basic Linear Algebra Subprograms - Wikipedia.png


Level 2​

Bu seviye genelleştirilmiş matrix-vector çarpımı (gemv (generalized matrix-vector multiplication)) gibi matrix-vector işlemleri barındırmaktadır.

2024-07-02 13_01_12-Basic Linear Algebra Subprograms - Wikipedia.png


Bununla beraber aşağıdaki lineer eşitlikte üçgensel bir T için x'i bulacak bir fonksiyon da barındırmaktadır.

2024-07-02 13_04_27-Basic Linear Algebra Subprograms - Wikipedia.png


Bu seviyenin tasarımı 1984'te başlayıp sonuçları 1988'de yayımlanmıştır. Bu seviyedeki fonksiyonlar özellikle Level 1'in yetersiz kaldığı, vector işlemciler kullanan yazılımlarda performansı artırmak için geliştirilmiştir.

Level 3​

Bu seviye resmi olarak 1990'da yayımlanmış olup genel matrix çarpımı (gemm (generalized matrix multiplication)) gibi matrix-matrix işlemlerini barındırmaktadır.

2024-07-02 13_16_21-Basic Linear Algebra Subprograms - Wikipedia.png


Burada A ve B transposed veya hermitian-conjugated gibi farklı türlerde matrixler olabilir. Standart matrix çarpımı da a'yı 1'e, C'yi de gerekli boyutta 0-matrixe eşitleyerek yapılabilir. Ayrıca T'nin yine üçgensel bir matrix olduğu şekildeki gibi fonksiyonları da barındırmaktadır:

2024-07-02 13_19_18-Basic Linear Algebra Subprograms - Wikipedia.png


Bu bahsettiğim gemm fonksiyonu optimizasyon için hedef haline gelmiş bir fonksiyondur. Çünkü matrix çarpımının pek çok farklı uygulaması var ve pek çok alanda kullabılabildiği için en çok optimize edilmeye çalışılan fonksiyonlardan birisidir. Matrix çarpımı algoritmalarıyla ilgilenmiş olanınız varsa Strassen algoritmasını duymuşsunuzdur. Bu seviyede de gemm3m adında bu fonksiyona benzer bir uygulama kullanılmakta.

Diğer BLAS uygulamalarına buradan erişip inceleyebilirsiniz:
 
Elinize sağlık. Son zamanlarda okuduğum en tatmin edici Türkçe yazıyı okudum diyebilirim. Ufak bir hata var, izninizle düzeltmek isterim:

Fortran esasında pek low-level olarak geçmiyor. Zamanında çok fazla bilimsel formül Fortran ile yazıldığı için bir dönem matematiğin hesaplama dili gibi bir konuma erişiyor. Derlenen bir dil olduğu için doğası gereğince hızlı ancak low-level operasyonlar pek yok.
 
Son düzenleme:
Elinize sağlık. Son zamanlarda okuduğum en tatmin edici Türkçe yazıyı okudum diyebilirim. Ufak bir hata var, izninizle düzeltmek isterim:

Fortran esasında pek low-level olarak geçmiyor. Zamanında çok fazla bilimsel formül Fortran ile yazıldığı için bir dönem matematiğin hesaplama gibi bir konuma erişiyor. Derlenen bir dil olduğu için doğası gereğince hızlı ancak low-level operasyonlar pek yok.
Haklısınız, low-level olarak tanımlamak hatalı kaçıyor. Teşekkür ederim, düzeltiyorum.
 
Cok guzel guide olmus.

Python'in 1m iterasyonu 500 saniyede yapmasina sasirip kendi makinemde denedim sabredemeyip kapattim. Omur torpusu gercekten. C'de yazsan muhtemelen 2ms surmeyecek. Meraklisi deneyip sonucu yazsa super olur, non-scientific benchmark keyifli is.
 
Elinize sağlık.

İlk örnekte süre farkının çok fazla olması, Python testinde sayıların sınırlandırılmayıp (int) NumPy testinde 64 bit ile (np.int64) (?) sınırlandırılmış olmasından kaynaklı. Bunun göstergelerinden biri, NumPy testinde çarpımın sonucunun 0 gelmesi. İlk kez 66!'de 0 olmasından yola çıkarak testte 64 bit ile çalıştığını tahmin ettim.

Python, pahalı işlemler yapmaya daha ilk adımlardan başlıyor ve sonuç bu şekilde oluyor. Benim bilgisayarımda da bayağı uzun sürüyor.

Python testini de NumPy'ınkine şu şekilde benzetmeye çalıştım:

Python:
import time
from ctypes import c_int64

import numpy as np

c = 1
st = time.time()

for i in range(1, 1_000_000 + 1):
    c = c_int64(c * i).value

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")
Kod:
Tüm işlem 0.09 saniye sürdü.

NumPy versiyonunda da 0.94 saniyeyi gördüm bilgisayarımda. İki versiyonda da döngü kullanılıyorken Python öne geçmiş oldu. Döngü yerine np.prod(range(1, 10**6 + 1)) kullanınca 0.04'e düştü bu süre. Sınırı, ikinci örnekteki gibi 10**8'e çıkarınca fark daha da açılıyor tabii NumPy lehinde.



İkinci örnekte de NumPy'da döngü içinde np.dot kullanılınca Python versiyonundan daha yavaş olduğu gözlemlenebiliyor. Tabii bu da döngülerden kaçınıp olabildiğince NumPy'dan yararlanmaya teşvik ediyor.

BLAS'ın yanı sıra NumPy'ın C'den faydalanıp Python'un kendi for döngüsü nedeniyle oldukça yavaş kalması da bir etkendir diye tahmin ediyorum.

Şu C++ testi, bilgisayarımda -O2 optimizasyon flagiyle 0.065 ve flagsiz 0.180 saniyede çalışıyor:
C++:
#include <bits/stdc++.h>
using namespace std;

int main() {
    const int N = 1e8;
    vector<long long> v(N), w(N, 1);
    for (int i = 0; i < N; i++) {
        v[i] = rand() % 11;
    }
    long long sum = 0;
    auto st = chrono::steady_clock::now();
    for (int i = 0; i < N; i++) {
        sum += v[i] * w[i];
    }
    auto en = chrono::steady_clock::now();
    cout << chrono::duration_cast<chrono::milliseconds>(en - st).count() / 1e3 << " " << sum << "\n";
}

Tabii 0.33 değerinde Python'un bir etkisi vardır kesin.
 
Elinize sağlık.

İlk örnekte süre farkının çok fazla olması, Python testinde sayıların sınırlandırılmayıp (int) NumPy testinde 64 bit ile (np.int64) (?) sınırlandırılmış olmasından kaynaklı. Bunun göstergelerinden biri, NumPy testinde çarpımın sonucunun 0 gelmesi. İlk kez 66!'de 0 olmasından yola çıkarak testte 64 bit ile çalıştığını tahmin ettim.

Python, pahalı işlemler yapmaya daha ilk adımlardan başlıyor ve sonuç bu şekilde oluyor. Benim bilgisayarımda da bayağı uzun sürüyor.

Python testini de NumPy'ınkine şu şekilde benzetmeye çalıştım:

Python:
import time
from ctypes import c_int64

import numpy as np

c = 1
st = time.time()

for i in range(1, 1_000_000 + 1):
    c = c_int64(c * i).value

et = time.time()

print(f"Tüm işlem {(et - st):.2f} saniye sürdü.")
Kod:
Tüm işlem 0.09 saniye sürdü.

NumPy versiyonunda da 0.94 saniyeyi gördüm bilgisayarımda. İki versiyonda da döngü kullanılıyorken Python öne geçmiş oldu. Döngü yerine np.prod(range(1, 10**6 + 1)) kullanınca 0.04'e düştü bu süre. Sınırı, ikinci örnekteki gibi 10**8'e çıkarınca fark daha da açılıyor tabii NumPy lehinde.



İkinci örnekte de NumPy'da döngü içinde np.dot kullanılınca Python versiyonundan daha yavaş olduğu gözlemlenebiliyor. Tabii bu da döngülerden kaçınıp olabildiğince NumPy'dan yararlanmaya teşvik ediyor.

BLAS'ın yanı sıra NumPy'ın C'den faydalanıp Python'un kendi for döngüsü nedeniyle oldukça yavaş kalması da bir etkendir diye tahmin ediyorum.

Şu C++ testi, bilgisayarımda -O2 optimizasyon flagiyle 0.065 ve flagsiz 0.180 saniyede çalışıyor:
C++:
#include <bits/stdc++.h>
using namespace std;

int main() {
    const int N = 1e8;
    vector<long long> v(N), w(N, 1);
    for (int i = 0; i < N; i++) {
        v[i] = rand() % 11;
    }
    long long sum = 0;
    auto st = chrono::steady_clock::now();
    for (int i = 0; i < N; i++) {
        sum += v[i] * w[i];
    }
    auto en = chrono::steady_clock::now();
    cout << chrono::duration_cast<chrono::milliseconds>(en - st).count() / 1e3 << " " << sum << "\n";
}

Tabii 0.33 değerinde Python'un bir etkisi vardır kesin.
sum degerini looptan sonra kullanmazsan sonuc nasil degisiyor?
compiler unused variable ilan edip optimize edecek mi onu merak ediyorum.
 
sum degerini looptan sonra kullanmazsan sonuc nasil degisiyor?
Compiler unused variable ilan edip optimize edecek mi onu merak ediyorum.

O2'de optimize ediyordu ve süre sıfır geliyordu, tam olarak bu yüzden yazdırmıştım. : )
 

Technopat Haberler

Yeni konular

Geri
Yukarı