Memaksimalkan Kecepatan MicroPython¶
Tutorial ini menjelaskan cara-cara untuk meningkatkan performa kode MicroPython. Optimasi yang melibatkan bahasa lain dibahas di tempat lain, yaitu penggunaan modul yang ditulis dalam C dan assembler inline MicroPython.
Proses pengembangan kode berkinerja tinggi terdiri dari tahap-tahap berikut yang harus dilakukan sesuai urutan yang tercantum.
Desain untuk kecepatan.
Kode dan debug.
Langkah-langkah optimasi:
Identifikasi bagian kode yang paling lambat.
Tingkatkan efisiensi kode Python.
Gunakan emitter kode native.
Gunakan emitter kode viper.
Gunakan optimasi khusus perangkat keras.
Mendesain untuk Kecepatan¶
Masalah performa harus dipertimbangkan sejak awal. Ini melibatkan penilaian terhadap bagian-bagian kode yang paling kritis dari sisi performa dan memberikan perhatian khusus pada desainnya. Proses optimasi dimulai ketika kode telah diuji: jika desain sudah benar sejak awal, optimasi akan mudah dilakukan dan mungkin sebenarnya tidak diperlukan.
Algoritma¶
Aspek terpenting dalam merancang rutinitas untuk performa adalah memastikan algoritma terbaik yang digunakan. Ini adalah topik untuk buku teks daripada panduan MicroPython, tetapi peningkatan performa yang luar biasa kadang-kadang dapat dicapai dengan mengadopsi algoritma yang dikenal efisien.
Alokasi RAM¶
Untuk merancang kode MicroPython yang efisien, diperlukan pemahaman tentang cara interpreter mengalokasikan RAM. Ketika sebuah objek dibuat atau bertambah ukurannya (misalnya ketika sebuah item ditambahkan ke dalam list), RAM yang diperlukan dialokasikan dari blok yang dikenal sebagai heap. Ini membutuhkan waktu yang signifikan; lebih jauh lagi, sesekali akan memicu proses yang dikenal sebagai garbage collection yang dapat memakan waktu beberapa milidetik.
Akibatnya, performa suatu fungsi atau metode dapat ditingkatkan jika sebuah objek hanya dibuat sekali dan tidak diizinkan untuk bertambah ukurannya. Ini berarti bahwa objek tersebut bertahan selama masa penggunaannya: biasanya akan di-instantiate dalam konstruktor kelas dan digunakan dalam berbagai metode.
Ini dibahas lebih rinci di Controlling garbage collection di bawah ini.
Buffer¶
Contoh dari hal di atas adalah kasus umum di mana sebuah buffer diperlukan, seperti yang digunakan untuk komunikasi dengan perangkat. Driver tipikal akan membuat buffer di konstruktor dan menggunakannya dalam metode I/O-nya yang akan dipanggil berulang kali.
Library MicroPython biasanya menyediakan dukungan untuk buffer yang telah dialokasikan sebelumnya. Misalnya, objek yang mendukung antarmuka stream (misalnya, file atau UART) menyediakan metode read() yang mengalokasikan buffer baru untuk data yang dibaca, tetapi juga metode readinto() untuk membaca data ke dalam buffer yang sudah ada.
Beberapa kelas berguna untuk membuat objek buffer yang dapat digunakan kembali:
Floating Point¶
Beberapa port MicroPython mengalokasikan bilangan floating point di heap. Beberapa port lain mungkin tidak memiliki koprosessor floating-point khusus, dan melakukan operasi aritmatika padanya dalam "perangkat lunak" dengan kecepatan yang jauh lebih rendah dibandingkan dengan bilangan bulat. Jika performa penting, gunakan operasi bilangan bulat dan batasi penggunaan floating point pada bagian kode di mana performa bukan hal yang utama. Misalnya, ambil pembacaan ADC sebagai nilai bilangan bulat ke dalam array dengan cepat, lalu baru konversi ke bilangan floating-point untuk pemrosesan sinyal.
Array¶
Pertimbangkan penggunaan berbagai jenis kelas array sebagai alternatif dari list. Modul array mendukung berbagai tipe elemen dengan elemen 8-bit yang didukung oleh kelas bawaan Python bytes dan bytearray. Struktur data ini semua menyimpan elemen di lokasi memori yang berdekatan. Sekali lagi untuk menghindari alokasi memori dalam kode kritis, ini harus dialokasikan sebelumnya dan dilewatkan sebagai argumen atau sebagai objek terikat.
Memoryview¶
Saat meneruskan irisan objek seperti instance bytearray, Python membuat salinan yang melibatkan alokasi sebesar ukuran irisan secara proporsional. Ini dapat diatasi menggunakan objek memoryview. memoryview itu sendiri dialokasikan di heap, tetapi merupakan objek kecil berukuran tetap, terlepas dari ukuran irisan yang ditunjuknya. Mengiris memoryview membuat memoryview baru, sehingga ini tidak dapat dilakukan dalam rutinitas layanan interupsi. Selanjutnya, sintaks irisan a:b menyebabkan alokasi lebih lanjut dengan menginstantiasi objek slice(a, b).
ba = bytearray(10000) # big array
func(ba[30:2000]) # a copy is passed, ~2K new allocation
mv = memoryview(ba) # small object is allocated
func(mv[30:2000]) # a pointer to memory is passed
Sebuah memoryview hanya dapat diterapkan pada objek yang mendukung protokol buffer - ini termasuk array tetapi bukan list. Satu catatan kecil adalah bahwa selama objek memoryview masih aktif, objek buffer aslinya juga tetap aktif. Jadi, memoryview bukanlah solusi universal. Misalnya, dalam contoh di atas, jika Anda selesai dengan buffer 10K dan hanya membutuhkan byte 30:2000 darinya, mungkin lebih baik membuat irisan, dan membiarkan buffer 10K dilepas (siap untuk garbage collection), daripada membuat memoryview yang berumur panjang dan membuat 10K terblokir untuk GC.
Meskipun demikian, memoryview sangat diperlukan untuk manajemen buffer yang telah dialokasikan sebelumnya secara canggih. Metode readinto() yang dibahas di atas menempatkan data di awal buffer dan mengisi seluruh buffer. Bagaimana jika Anda perlu menempatkan data di tengah buffer yang ada? Cukup buat memoryview ke bagian buffer yang diperlukan dan teruskan ke readinto().
String vs Bytes¶
MicroPython menggunakan string interning untuk menghemat ruang ketika ada beberapa string yang identik. Setiap kali string baru dialokasikan saat runtime (misalnya, ketika dua string lain digabungkan), MicroPython memeriksa apakah string baru dapat di-intern untuk menghemat RAM.
Jika Anda memiliki kode yang melakukan operasi string yang kritis terhadap performa, pertimbangkan menggunakan objek dan literal bytes (yaitu b"abc"). Ini melewati pemeriksaan interning, dan dapat beberapa kali lebih cepat daripada melakukan operasi yang sama dengan objek string.
Catatan
Performa tercepat selalu dicapai dengan menghindari pembuatan objek baru sama sekali, misalnya dengan buffer yang dapat digunakan kembali seperti dijelaskan di atas.
Mengidentifikasi Bagian Kode yang Paling Lambat¶
Ini adalah proses yang dikenal sebagai profiling dan dibahas dalam buku teks dan (untuk Python standar) didukung oleh berbagai alat perangkat lunak. Untuk jenis aplikasi embedded yang lebih kecil yang kemungkinan berjalan di platform MicroPython, fungsi atau metode yang paling lambat biasanya dapat ditentukan dengan penggunaan grup fungsi ticks yang cermat dari time. Waktu eksekusi kode dapat diukur dalam ms, us, atau siklus CPU.
Berikut ini memungkinkan fungsi atau metode apa pun untuk diukur waktunya dengan menambahkan dekorator @timed_function:
def timed_function(f, *args, **kwargs):
myname = str(f).split(' ')[1]
def new_func(*args, **kwargs):
t = time.ticks_us()
result = f(*args, **kwargs)
delta = time.ticks_diff(time.ticks_us(), t)
print('Function {} Time = {:6.3f}ms'.format(myname, delta/1000))
return result
return new_func
Peningkatan Kode MicroPython¶
Deklarasi const()¶
MicroPython menyediakan deklarasi const(). Ini bekerja dengan cara yang mirip dengan #define dalam C yaitu ketika kode dikompilasi menjadi bytecode, compiler mengganti nilai numerik untuk pengenal. Ini menghindari pencarian kamus saat runtime. Argumen untuk const() bisa berupa apa saja yang, pada waktu kompilasi, dievaluasi menjadi bilangan bulat misalnya 0x100 atau 1 << 8.
Menyimpan Referensi Objek dalam Cache¶
Ketika sebuah fungsi atau metode berulang kali mengakses objek, performa ditingkatkan dengan menyimpan objek dalam variabel lokal:
class foo(object):
def __init__(self):
self.ba = bytearray(100)
def bar(self, obj_display):
ba_ref = self.ba
fb = obj_display.framebuffer
# iterative code using these two objects
Ini menghindari kebutuhan untuk berulang kali mencari self.ba dan obj_display.framebuffer di dalam tubuh metode bar().
Mengendalikan Garbage Collection¶
Ketika alokasi memori diperlukan, MicroPython mencoba menemukan blok berukuran cukup di heap. Ini mungkin gagal, biasanya karena heap penuh dengan objek yang tidak lagi direferensikan oleh kode. Jika terjadi kegagalan, proses yang dikenal sebagai garbage collection merebut kembali memori yang digunakan oleh objek-objek redundan tersebut dan alokasi kemudian dicoba lagi - sebuah proses yang dapat memakan waktu beberapa milidetik.
Mungkin ada manfaat dalam mengantisipasi ini dengan secara berkala mengeluarkan gc.collect(). Pertama, melakukan pengumpulan sebelum benar-benar diperlukan lebih cepat - biasanya sekitar 1ms jika dilakukan sering. Kedua, Anda dapat menentukan titik dalam kode di mana waktu ini digunakan daripada mengalami penundaan yang lebih lama pada titik-titik acak, mungkin dalam bagian yang kritis terhadap kecepatan. Terakhir, melakukan pengumpulan secara teratur dapat mengurangi fragmentasi di heap. Fragmentasi yang parah dapat menyebabkan kegagalan alokasi yang tidak dapat dipulihkan.
Emitter Kode Native¶
Ini menyebabkan compiler MicroPython mengeluarkan opcode CPU native daripada bytecode. Ini mencakup sebagian besar fungsionalitas MicroPython, sehingga sebagian besar fungsi tidak memerlukan adaptasi (tetapi lihat di bawah). Ini dipanggil menggunakan dekorator fungsi:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Ada batasan tertentu dalam implementasi emitter kode native saat ini.
Jika
raisedigunakan, argumen harus diberikan.Penjadwal latar belakang (lihat
micropython.schedule) tidak dijalankan selama eksekusi kode native.Pada target dengan threading dan GIL, GIL tidak dilepaskan selama eksekusi kode native.
Untuk mengurangi dua poin terakhir, fungsi native yang berjalan lama harus memanggil time.sleep(0) secara berkala, yang akan menjalankan penjadwal dan memantulkan GIL.
Pertukaran untuk peningkatan performa (kira-kira dua kali lebih cepat dari bytecode) adalah peningkatan ukuran kode yang dikompilasi.
Emitter Kode Viper¶
Optimasi yang dibahas di atas melibatkan kode Python yang sesuai standar. Emitter kode Viper tidak sepenuhnya sesuai. Ini mendukung tipe data native Viper khusus demi mengejar performa. Pemrosesan bilangan bulat tidak sesuai karena menggunakan kata mesin: aritmatika pada perangkat keras 32 bit dilakukan modulo 2**32.
Seperti emitter Native, Viper menghasilkan instruksi mesin tetapi optimasi lebih lanjut dilakukan, secara substansial meningkatkan performa terutama untuk aritmatika bilangan bulat dan manipulasi bit. Ini dipanggil menggunakan dekorator:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Seperti yang diilustrasikan oleh fragmen di atas, sangat bermanfaat menggunakan type hints Python untuk membantu optimizer Viper. Type hints memberikan informasi tentang tipe data argumen dan nilai kembalian; ini adalah fitur bahasa Python standar yang didefinisikan secara formal di sini PEP0484. Viper mendukung serangkaian tipenya sendiri yaitu int, uint (unsigned integer), ptr, ptr8, ptr16 dan ptr32. Tipe ptrX dibahas di bawah. Saat ini tipe uint memiliki satu tujuan: sebagai type hint untuk nilai kembalian fungsi. Jika fungsi tersebut mengembalikan 0xffffffff Python akan menginterpretasikan hasilnya sebagai 2**32 -1 daripada -1.
Selain batasan yang diberlakukan oleh emitter native, batasan-batasan berikut berlaku:
Nilai argumen default tidak diizinkan.
Floating point dapat digunakan tetapi tidak dioptimasi.
Viper menyediakan tipe pointer untuk membantu optimizer. Ini terdiri dari
ptrPointer ke suatu objek.ptr8Menunjuk ke sebuah byte.ptr16Menunjuk ke half-word 16 bit.ptr32Menunjuk ke kata mesin 32 bit.
Konsep pointer mungkin tidak familiar bagi programmer Python. Ini memiliki kemiripan dengan objek memoryview Python dalam hal menyediakan akses langsung ke data yang tersimpan di memori. Item diakses menggunakan notasi subscript, tetapi irisan tidak didukung: pointer hanya dapat mengembalikan satu item saja. Tujuannya adalah untuk menyediakan akses acak yang cepat ke data yang tersimpan di lokasi memori yang berdekatan - seperti data yang tersimpan dalam objek yang mendukung protokol buffer, dan register periferal yang dipetakan ke memori dalam mikrokontroler. Perlu dicatat bahwa pemrograman menggunakan pointer berbahaya: pemeriksaan batas tidak dilakukan dan compiler tidak melakukan apa pun untuk mencegah kesalahan buffer overrun.
Penggunaan tipikal adalah untuk menyimpan variabel dalam cache:
@micropython.viper
def foo(self, arg: int) -> int:
buf = ptr8(self.linebuf) # self.linebuf is a bytearray or bytes object
for x in range(20, 30):
bar = buf[x] # Access a data item through the pointer
# code omitted
Dalam hal ini compiler "mengetahui" bahwa buf adalah alamat dari array byte; ia dapat mengeluarkan kode untuk menghitung dengan cepat alamat buf[x] saat runtime. Ketika cast digunakan untuk mengkonversi objek ke tipe native Viper, ini harus dilakukan di awal fungsi daripada dalam loop timing yang kritis karena operasi cast dapat memakan waktu beberapa mikrodetik. Aturan untuk casting adalah sebagai berikut:
Operator cast saat ini adalah:
int,bool,uint,ptr,ptr8,ptr16danptr32.Hasil dari cast akan menjadi variabel Viper native.
Argumen ke cast bisa berupa objek Python atau variabel Viper native.
Jika argumen adalah variabel Viper native, maka cast adalah no-op (yaitu tidak ada biaya saat runtime) yang hanya mengubah tipe (misalnya dari
uintkeptr8) sehingga Anda kemudian dapat menyimpan/memuat menggunakan pointer ini.Jika argumen adalah objek Python dan cast adalah
intatauuint, maka objek Python harus bertipe integral dan nilai dari objek integral tersebut dikembalikan.Argumen ke cast bool harus bertipe integral (boolean atau integer); ketika digunakan sebagai tipe kembalian, fungsi viper akan mengembalikan objek True atau False.
Jika argumen adalah objek Python dan cast adalah
ptr,ptr8,ptr16atauptr32, maka objek Python harus memiliki protokol buffer (dalam hal ini pointer ke awal buffer dikembalikan) atau harus bertipe integral (dalam hal ini nilai dari objek integral tersebut dikembalikan).
Menulis ke pointer yang menunjuk ke objek read-only akan menyebabkan perilaku yang tidak terdefinisi.
Catatan
Contoh kode di bawah ini diberikan untuk OpenMV Cam berbasis STM32, yang menyediakan modul stm. Teknik yang dijelaskan berlaku secara umum.
Modul stm mengekspos alamat memori register periferal MCU. Setiap port GPIO memiliki output data register (ODR) yang bit-bitnya memetakan satu-ke-satu ke pin port tersebut: menulis register tersebut menggerakkan pin-pin tersebut secara langsung, tanpa overhead pemanggilan metode machine.Pin, dan XOR-ing sebuah bit akan mengubah keadaan pin tersebut. Pada OpenMV Cam asli, LED biru terhubung ke pin 2 GPIOC, sehingga contoh berikut menggunakan cast ptr16 untuk mengubah keadaan LED biru n kali:
BIT2 = const(1 << 2)
@micropython.viper
def toggle_n(n: int):
odr = ptr16(stm.GPIOC + stm.GPIO_ODR)
for _ in range(n):
odr[0] ^= BIT2
Deskripsi teknis terperinci dari tiga emitter kode dapat ditemukan di Kickstarter di sini Note 1 dan di sini Note 2
Mengakses Perangkat Keras Secara Langsung¶
Ini termasuk dalam kategori pemrograman lebih lanjut dan memerlukan beberapa pengetahuan tentang MCU target. Pertimbangkan contoh mengubah keadaan pin output pada OpenMV Cam. Pendekatan standar adalah dengan menulis
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Ini melibatkan overhead dua panggilan ke metode value() dari instance Pin. Overhead ini dapat dihilangkan dengan melakukan baca/tulis ke bit yang relevan dari register output data port GPIO chip (ODR). Untuk memfasilitasi ini, modul stm menyediakan serangkaian konstanta yang memberikan alamat register yang relevan (stm.GPIOC adalah alamat dasar port GPIOC, stm.GPIO_ODR adalah offset register output data-nya). Seperti di atas, LED biru pada OpenMV Cam asli adalah pin 2 GPIOC, sehingga toggle cepat padanya dapat dilakukan sebagai berikut:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2