Perbandingan waktu eksekusi

Mengukur perulangan Python versus kode tervektorisasi.

Daftar isi

Topik ini membahas cara mengukur kinerja kode NumPy secara jujur dan cara membaca hasilnya. Pembahasan mencakup pola pengukuran dengan time.perf_counter, bukti deterministik yang tidak bergantung pada detik, alasan selisih kecepatan antara perulangan Python dan kode tervektorisasi, serta kesalahan pengukuran yang paling sering terjadi. Setelah membacanya, klaim “lebih cepat” dapat disertai angka yang benar sumbernya dan metode yang dapat diulang.

Mengukur waktu dengan time.perf_counter#

Fungsi time.perf_counter() adalah jam beresolusi tertinggi yang disediakan modul time untuk pengukuran durasi, dan nilainya monotonik sehingga tidak pernah mundur selama proses berlangsung. Pola pengukurannya sederhana: catat jam sebelum operasi dijalankan, jalankan operasinya, catat jam setelahnya, lalu selisih keduanya adalah durasi dalam satuan detik. Selisih tersebut biasanya dikalikan seribu agar terbaca sebagai milidetik.

Kode berikut membandingkan penjumlahan satu juta elemen float64 dengan dua cara, yaitu perulangan Python di atas list dan fungsi np.sum atas array. Blok ini tidak disertai keluaran karena angkanya berubah pada setiap eksekusi, dan pola seperti inilah yang dipakai untuk mengukur waktu di sepanjang modul ini.

python
# tidak-dijalankan
# hasilnya berubah pada setiap eksekusi, sehingga tidak ada blok Keluaran
import time
import numpy as np

data = np.arange(1_000_000, dtype=np.float64)
nilai_list = data.tolist()

mulai = time.perf_counter()
total = 0.0
for x in nilai_list:
    total += x
selesai = time.perf_counter()
waktu_perulangan = selesai - mulai

mulai = time.perf_counter()
total_np = float(np.sum(data))
selesai = time.perf_counter()
waktu_np = selesai - mulai

print(f"perulangan Python : {waktu_perulangan*1000:.1f} ms")
print(f"np.sum            : {waktu_np*1000:.3f} ms")

Pada pengujian berulang di lingkungan penulisan, blok tersebut menghasilkan besaran yang stabil dari segi ordonya: perulangan Python memerlukan sekitar 80 milidetik, sedangkan np.sum selesai dalam sekitar 0,7 milidetik. Rasionya bergerak di kisaran 100 hingga 120 kali pada eksekusi berulang. Angka persisnya pasti berbeda di perangkat keras lain, tetapi ordo besarnya, yaitu seratus kali lebih cepat, merupakan besaran khas yang layak diharapkan untuk penjumlahan satu juta elemen.

Satu catatan tentang ukuran data: untuk array yang sangat kecil, np.sum belum tentu lebih cepat, karena pemanggilan fungsi NumPy membawa biaya tetap berorde mikrodetik. Vektorisasi membuahkan hasil yang jelas apabila jumlah elemennya besar. Pengukuran atas ukuran data yang nyata digunakan adalah satu-satunya cara menentukan titik keseimbangannya.

Bukti deterministik: jumlah pemanggilan fungsi#

Waktu tidak dapat direproduksi persis, tetapi ada bukti yang sama sekali tidak bergantung pada jam, yaitu jumlah pemanggilan fungsi Python. Hitungannya dibuat dengan variabel global yang ditambah satu setiap kali fungsi dipanggil, dan hasilnya tetap untuk data yang sama. Untuk nn elemen, perulangan eksplisit melakukan nn panggilan fungsi Python, sedangkan satu pernyataan tervektorisasi melakukan satu panggilan.

Kode berikut mengalikan setiap elemen data dengan tiga menggunakan kedua gaya, sambil menghitung pemanggilannya.

python
import numpy as np

jumlah_panggilan = 0

def kali_tiga(x):
    global jumlah_panggilan
    jumlah_panggilan += 1
    return 3 * x

data = [1.0, 2.0, 3.0, 4.0, 5.0]

hasil_perulangan = []
for x in data:
    hasil_perulangan.append(kali_tiga(x))
panggilan_perulangan = jumlah_panggilan

jumlah_panggilan = 0
hasil_array = kali_tiga(np.array(data))
panggilan_array = jumlah_panggilan

print(f"perulangan Python : {panggilan_perulangan} panggilan fungsi Python")
print(f"operasi array     : {panggilan_array} panggilan fungsi Python")
print(f"hasil perulangan  : {hasil_perulangan}")
print(f"hasil array       : {hasil_array}")
text Keluaran
perulangan Python : 5 panggilan fungsi Python
operasi array     : 1 panggilan fungsi Python
hasil perulangan  : [3.0, 6.0, 9.0, 12.0, 15.0]
hasil array       : [ 3.  6.  9. 12. 15.]

Keluarannya menunjukkan lima pemanggilan berbanding satu pemanggilan, dan hasil kedua gaya bernilai sama. Selisih inilah yang membesar berlipat-lipat pada data besar: setiap panggilan fungsi Python membawa pekerjaan tambahan, sehingga jumlah panggilan yang bertambah linier terhadap nn menjadi sumber utama biayanya. Pola penghitung ini dapat dikembangkan lebih jauh, misalnya menghitung pemanggilan pada dua operasi berantai untuk menunjukkan bahwa rantai operasi array tetap satu panggilan per langkah, bukan per elemen.

Mengapa selisihnya besar#

Alasan utamanya terletak pada pihak yang menjalankan perulangan per elemen. Pada perulangan Python, interpreter bekerja langkah demi langkah untuk setiap elemen: mengambil objek berikutnya, memeriksa tipenya, membuka pembungkus objek angka, menjalankan aritmetika pada level objek, lalu menyimpan hasilnya sebagai objek baru. Rangkaian langkah itu terjadi nn kali. Pada np.sum, perulangan yang setara berjalan di dalam kode C atas penyangga (buffer) angka mentah yang berdampingan, tanpa menyentuh interpreter sama sekali.

Sumber percepatan kedua berada di pustaka yang dipanggil. Fungsi seperti np.dot mendelegasikan pekerjaannya ke BLAS (Basic Linear Algebra Subprograms), yaitu kumpulan rutinitas aljabar linear yang dioptimasi secara intensif. Pengukuran perkalian titik pada sepuluh juta elemen di lingkungan penulisan menghasilkan ordo berikut: perulangan Python dengan zip memerlukan sekitar dua detik, sedangkan np.dot selesai dalam sekitar lima milidetik, atau ratusan kali lebih cepat. Kedua pengukuran ini menegaskan pola yang sama, yaitu biaya per elemen yang dikeluarkan Python berbanding biaya sekali panggil yang dikeluarkan NumPy.

Untuk pengukuran interaktif, IPython dan Jupyter menyediakan alat %timeit, yang menjalankan satu pernyataan berkali-kali lalu melaporkan statistik waktunya. Pendekatannya sama dengan pola perf_counter di atas, tetapi pemanasan dan variasi antar eksekusi diperhitungkan secara otomatis. Dalam skrip biasa, modul timeit bawaan Python menyediakan kemampuan serupa melalui antarmuka fungsinya. Karena keluaran kedua alat itu berupa statistik yang berubah setiap sesi, keduanya dipakai sebagai penunjang eksplorasi, sedangkan bukti yang ditempel di dalam artikel tetap berupa bukti deterministik.

Kesalahan umum#

Dua kesalahan berikut paling sering muncul ketika pengukuran pertama kali dilakukan. Yang pertama menghasilkan angka yang menyesatkan, sedangkan yang kedua menghasilkan error yang membingungkan saat pola penghitung dipakai. Keduanya mudah diperbaiki begitu penyebabnya diketahui.

Mengukur durasi dengan time.time()#

Fungsi time.time() mengembalikan jam dinding (wall clock) sistem, yaitu waktu nyata yang dapat melompat akibat penyetelan jam dan yang resolusinya lebih kasar. Interval yang diukur dengan jam tersebut dapat terdistorsi tanpa terlihat dari angkanya. Fungsi perf_counter dirancang khusus untuk durasi, sehingga pola berikut ini perlu dihindari.

python
# tidak-dijalankan
# pola yang dihindari: mengukur durasi dengan jam dinding
import time

mulai = time.time()
# operasi yang diukur
selesai = time.time()
print(selesai - mulai)

Perbaikannya adalah mengganti kedua pemanggilan dengan time.perf_counter(), sebagaimana contoh lengkapnya pada bagian pertama. Untuk pengukuran yang serius, timeit tetap menjadi pilihan karena statistiknya memperhitungkan variasi antar eksekusi.

Penghitung pemanggilan tanpa global#

Fungsi yang menambah variabel global tanpa deklarasi global memicu UnboundLocalError, karena penugasan membuat Python menganggap nama tersebut sebagai variabel lokal. Error muncul tepat pada pemanggilan pertama, dan pesannya tidak selalu mudah dikaitkan dengan maksud aslinya.

python
import numpy as np

jumlah = 0

def kali_tiga(x):
    jumlah = jumlah + 1
    return 3 * x

try:
    kali_tiga(np.array([1.0, 2.0, 3.0]))
except UnboundLocalError as e:
    print(f"{type(e).__name__}: {e}")
text Keluaran
UnboundLocalError: cannot access local variable 'jumlah' where it is not associated with a value

Perbaikannya adalah menuliskan pernyataan global jumlah di awal isi fungsi, sebagaimana contoh pada bagian bukti deterministik. Dengan deklarasi tersebut, penambahan jumlah kembali menyasar variabel global dan hitungannya dapat dipercaya.

Ringkasan#

  • Durasi diukur dengan time.perf_counter yang monotonik, dengan pola catat jam, jalankan operasi, catat jam, lalu hitung selisihnya.
  • Pada pengujian di lingkungan penulisan, penjumlahan satu juta elemen float64 memerlukan sekitar 80 milidetik melalui perulangan Python dan sekitar 0,7 milidetik melalui np.sum, yaitu orde seratus kali lebih cepat.
  • Bukti deterministiknya berupa jumlah pemanggilan fungsi Python: lima pemanggilan untuk lima elemen melalui perulangan, berbanding satu pemanggilan untuk satu operasi array.
  • Alasannya adalah perulangan per elemen yang berpindah dari interpreter Python ke perulangan C atas penyangga berdampingan, dengan sebagian fungsi mendelegasikan pekerjaan ke BLAS.

Referensi#