Apa yang saya pelajari tentang pengoptimalan dengan Python

Halo semuanya. Hari ini kami ingin membagikan terjemahan lain yang disiapkan menjelang peluncuran kursus Pengembang Python . Ayo pergi!



Saya menggunakan Python lebih sering daripada bahasa pemrograman lain dalam 4-5 tahun terakhir. Python adalah bahasa utama untuk pembuatan di bawah Firefox, pengujian, dan alat CI. Mercurial juga sebagian besar ditulis dalam Python. Saya juga menulis banyak proyek pihak ketiga saya di sana.

Selama bekerja, saya memperoleh sedikit pengetahuan tentang kinerja Python dan alat pengoptimalannya. Dalam artikel ini, saya ingin membagikan pengetahuan ini.

Pengalaman saya dengan Python terutama terkait dengan juru bahasa CPython, terutama CPython 2.7. Tidak semua pengamatan saya bersifat universal untuk semua distribusi Python, atau bagi mereka yang memiliki karakteristik yang sama dalam versi Python yang serupa. Saya akan mencoba untuk menyebutkan ini selama narasi. Perlu diingat bahwa artikel ini bukan gambaran terperinci tentang kinerja Python. Saya hanya akan berbicara tentang apa yang saya temui sendiri.

Memuat karena kekhasan peluncuran dan impor modul


Memulai interpreter Python dan mengimpor modul adalah proses yang agak panjang dalam milidetik.

Jika Anda perlu memulai ratusan atau ribuan proses Python di salah satu proyek Anda, maka penundaan dalam milidetik ini akan berubah menjadi penundaan hingga beberapa detik.

Jika Anda menggunakan Python untuk menyediakan alat CLI, overhead dapat menyebabkan pembekuan yang nyata bagi pengguna. Jika Anda memerlukan alat CLI secara instan, menjalankan juru bahasa Python dengan setiap panggilan akan mempersulit Anda untuk mendapatkan alat yang rumit ini.

Saya sudah menulis tentang masalah ini. Beberapa catatan masa lalu saya membicarakan hal ini, misalnya, pada 2014 , pada Mei 2018 dan Oktober 2018 .

Tidak ada banyak hal yang dapat Anda lakukan untuk mengurangi keterlambatan startup: memperbaiki kasus ini mengacu pada memanipulasi juru bahasa Python, karena dialah yang mengontrol pelaksanaan kode, yang membutuhkan terlalu banyak waktu. Hal terbaik yang dapat Anda lakukan adalah menonaktifkan impor modul situs di panggilan untuk menghindari mengeksekusi kode Python tambahan saat startup. Di sisi lain, banyak aplikasi menggunakan fungsionalitas modul site.py, sehingga Anda dapat menggunakan ini dengan risiko Anda sendiri.

Kami juga harus mempertimbangkan masalah mengimpor modul. Apa bagusnya juru bahasa Python jika tidak memproses kode apa pun? Faktanya adalah bahwa kode ini dibuat tersedia untuk penerjemah lebih sering melalui penggunaan modul.

Untuk mengimpor modul, Anda perlu mengambil beberapa langkah. Dan di masing-masing dari mereka ada potensi sumber beban dan penundaan.

Penundaan tertentu terjadi karena mencari modul dan membaca datanya. Seperti yang saya tunjukkan dengan PyOxidizer , menggantikan pencarian dan pemuatan modul dari sistem file dengan solusi arsitektur yang lebih sederhana, yang terdiri dari membaca data modul dari struktur data dalam memori, Anda dapat mengimpor perpustakaan Python standar untuk 70-80% dari waktu solusi awal untuk tugas ini. Memiliki satu modul per file file sistem meningkatkan beban pada sistem file dan dapat memperlambat aplikasi Python selama milidetik pertama dari eksekusi kritis. Solusi seperti PyOxidizer dapat membantu menghindari hal ini. Saya berharap bahwa komunitas Python melihat biaya dari pendekatan saat ini dan sedang mempertimbangkan transisi ke mekanisme distribusi modul, yang tidak begitu tergantung pada file individual dalam modul.

Sumber lain dari biaya impor tambahan untuk suatu modul adalah eksekusi kode pada modul tersebut selama impor. Beberapa modul berisi bagian dari kode di area di luar fungsi dan kelas modul, yang dieksekusi ketika modul diimpor. Mengeksekusi kode tersebut meningkatkan biaya impor. Penanganan masalah: jangan jalankan semua kode pada saat impor, tetapi hanya jalankan jika perlu. Python 3.7 mendukung modul __getattr__ , yang akan dipanggil jika atribut modul tidak ditemukan. Ini dapat digunakan untuk mengisi atribut modul dengan malas pada akses pertama.

Cara lain untuk menghilangkan perlambatan impor adalah dengan malas mengimpor modul. Alih-alih memuat modul secara langsung selama impor, Anda mendaftar modul impor khusus yang mengembalikan rintisan. Ketika Anda pertama kali mengakses rintisan ini, itu akan memuat modul aktual dan "bermutasi" menjadi modul ini.

Anda dapat menyimpan puluhan milidetik dengan aplikasi yang mengimpor beberapa puluh modul jika Anda mem-bypass sistem file dan menghindari menjalankan bagian-bagian modul yang tidak perlu (modul biasanya diimpor secara global, tetapi hanya fungsi-fungsi modul tertentu yang digunakan).

Impor modul dengan malas adalah hal yang rapuh. Banyak modul memiliki templat yang memiliki hal-hal berikut: try: import foo ; except ImportError: Pengimpor modul yang malas mungkin tidak pernah melempar ImportError, karena jika ia melakukannya, ia harus mencari di dalam sistem file untuk sebuah modul untuk melihat apakah itu ada pada prinsipnya. Ini akan menambah beban tambahan dan menambah waktu yang dihabiskan, jadi importir yang malas tidak melakukan ini pada prinsipnya! Masalah ini sangat mengganggu. Importir modul malas Mercurial memproses daftar modul yang tidak dapat diimpor dengan malas, dan harus memintasnya. Masalah lain adalah sintaks from foo import x, y , yang juga mengganggu impor modul malas, dalam kasus di mana foo adalah modul (bukan paket), karena modul tersebut masih harus diimpor untuk mengembalikan referensi ke x dan y.

PyOxidizer memiliki set modul yang ditransfer ke dalam biner, sehingga dapat efektif dalam meningkatkan ImportError. Modul __getattr__ dari Python 3.7 memberikan fleksibilitas tambahan untuk importir modul malas. Saya berharap untuk mengintegrasikan importir malas yang dapat diandalkan ke dalam PyOxidizer untuk mengotomatisasi beberapa proses.

Solusi terbaik untuk menghindari memulai juru bahasa dan menyebabkan penundaan waktu adalah memulai proses latar belakang dengan Python. Jika Anda memulai proses Python sebagai proses daemon, katakanlah untuk server web, maka Anda dapat melakukannya. Solusi yang ditawarkan Mercurial adalah memulai proses latar belakang yang menyediakan protokol server perintah . hg adalah C executable (atau sekarang Rust), yang terhubung ke proses latar belakang ini dan mengirimkan perintah. Untuk menemukan pendekatan ke server perintah, Anda harus melakukan banyak pekerjaan, itu sangat tidak stabil dan memiliki masalah keamanan. Saya sedang mempertimbangkan ide memberikan server perintah menggunakan PyOxidizer sehingga executable memiliki kelebihan, dan masalah biaya solusi perangkat lunak itu sendiri diselesaikan dengan membuat proyek PyOxidizer.

Function Call Delay


Memanggil fungsi dengan Python adalah proses yang relatif lambat. (Pengamatan ini kurang berlaku untuk PyPy, yang dapat mengeksekusi kode JIT.)

Saya melihat lusinan tambalan untuk Mercurial, yang memungkinkan untuk menyelaraskan dan menggabungkan kode sedemikian rupa untuk menghindari beban yang tidak perlu saat memanggil fungsi. Dalam siklus pengembangan saat ini, beberapa upaya telah dilakukan untuk mengurangi jumlah fungsi yang dipanggil saat memperbarui bilah kemajuan. (Kami menggunakan bilah kemajuan untuk operasi apa pun yang mungkin memerlukan waktu, sehingga pengguna memahami apa yang terjadi). Mendapatkan hasil dari fungsi panggilan dan menghindari pencarian sederhana di antara fungsi menghemat puluhan ratus milidetik ketika dieksekusi, ketika kita berbicara tentang satu juta eksekusi, misalnya.

Jika Anda memiliki loop ketat atau fungsi rekursif dalam Python di mana ratusan ribu atau lebih panggilan fungsi dapat terjadi, Anda harus mengetahui overhead panggilan fungsi individu, karena ini sangat penting. Ingatlah fungsi bawaan yang sederhana dan kemampuan untuk menggabungkan fungsi untuk menghindari overhead.

Atribut lookup overhead


Masalah ini mirip dengan overhead karena pemanggilan fungsi, karena artinya hampir sama!

Menemukan atribut penyelesaian di Python bisa lambat. (Dan lagi, di PyPy, ini lebih cepat). Namun, menangani masalah ini adalah apa yang sering kita lakukan di Mercurial.

Katakanlah Anda memiliki kode berikut:

 obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i] 

Abaikan bahwa ada cara yang lebih efisien untuk menulis contoh ini (misalnya, total = sum(obj.member) ), dan perhatikan bahwa loop perlu mendefinisikan obj.member pada setiap iterasi. Python memiliki mekanisme yang relatif canggih untuk mendefinisikan atribut . Untuk tipe sederhana, ini bisa cukup cepat. Tetapi untuk tipe yang kompleks, akses atribut ini dapat secara otomatis memanggil __getattr__ , __getattribute__ , berbagai metode __getattribute__ dunder dan bahkan @property fungsi yang ditentukan pengguna. Ini mirip dengan pencarian cepat untuk atribut yang dapat membuat beberapa panggilan fungsi, yang akan mengarah pada beban tambahan. Dan beban ini dapat diperburuk jika Anda menggunakan hal-hal seperti obj.member1.member2.member3 , dll.

Setiap definisi atribut menyebabkan beban tambahan. Dan karena hampir semua yang ada di Python adalah kamus, kita dapat mengatakan bahwa setiap pencarian atribut adalah pencarian kamus. Dari konsep umum tentang struktur data dasar, kita tahu bahwa pencarian kamus tidak secepat, katakanlah, pencarian indeks. Ya, tentu saja ada beberapa trik dalam CPython yang dapat menghilangkan overhead karena pencarian kamus. Tetapi topik utama yang ingin saya sentuh adalah bahwa setiap pencarian atribut adalah potensi kebocoran kinerja.

Untuk loop ketat, terutama yang berpotensi melebihi ratusan ribu iterasi, Anda dapat menghindari overhead yang terukur ini untuk menemukan atribut dengan menetapkan nilai ke variabel lokal. Mari kita lihat contoh berikut:

 obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i] 

Tentu saja, ini hanya dapat dilakukan dengan aman jika tidak diganti dalam satu siklus. Jika ini terjadi, iterator akan menyimpan tautan ke elemen lama dan semuanya bisa meledak.
Trik yang sama dapat dilakukan saat memanggil metode objek. Sebaliknya

 obj = MyObject() for i in range(1000000): obj.process(i) 

Anda dapat melakukan hal berikut:

 obj = MyObject() fn = obj.process for i in range(1000000:) fn(i) 

Perlu juga dicatat bahwa dalam kasus ketika pencarian atribut perlu memanggil metode (seperti dalam contoh sebelumnya), maka Python 3.7 relatif lebih cepat daripada rilis sebelumnya. Tapi saya yakin di sini beban berlebihan terhubung, pertama-tama, dengan pemanggilan fungsi, dan bukan dengan beban pada pencarian atribut. Karena itu, semuanya akan bekerja lebih cepat jika Anda meninggalkan pencarian atribut tambahan.

Akhirnya, karena pencarian atribut memanggil fungsi untuk ini, dapat dikatakan bahwa pencarian atribut umumnya lebih sedikit masalah daripada beban karena panggilan fungsi. Biasanya, untuk melihat perubahan kecepatan yang signifikan, Anda harus menghilangkan banyak pencarian atribut. Dalam hal ini, segera setelah Anda memberikan akses ke semua atribut di dalam loop, Anda dapat berbicara tentang 10 atau 20 atribut hanya di loop sebelum memanggil fungsi. Dan loop dengan sedikitnya ribuan atau kurang dari puluhan ribu iterasi dapat dengan cepat memberikan ratusan ribu atau jutaan pencarian atribut. Jadi hati-hati!

Memuat objek


Dari sudut pandang interpreter Python, semua nilai adalah objek. Dalam CPython, setiap elemen adalah struktur PyObject. Setiap objek yang dikendalikan oleh juru bahasa ada di heap dan memiliki ingatannya sendiri yang berisi jumlah referensi, jenis objek, dan parameter lainnya. Setiap benda dibuang oleh pengumpul sampah. Ini berarti bahwa setiap objek baru menambahkan overhead karena penghitungan referensi, pengumpulan sampah, dll. (Dan sekali lagi, PyPy dapat menghindari beban yang tidak perlu ini, karena lebih "hati-hati" tentang masa pakai nilai jangka pendek.)

Secara umum, semakin unik nilai dan objek Python yang Anda buat, hal-hal yang lebih lambat bekerja untuk Anda.

Katakanlah Anda mengulangi koleksi satu juta objek. Anda memanggil fungsi untuk mengumpulkan objek ini dalam sebuah tuple:

 for x in my_collection: a, b, c, d, e, f, g, h = process(x) 

Dalam contoh ini, process() akan mengembalikan tuple 8-tuple. Tidak masalah jika kita menghancurkan nilai kembali atau tidak: tuple ini membutuhkan pembuatan setidaknya 9 nilai dalam Python: 1 untuk tuple itu sendiri dan 8 untuk anggota internalnya. Nah, dalam kehidupan nyata mungkin ada nilai yang lebih sedikit jika process() mengembalikan referensi ke objek yang ada. Atau, sebaliknya, mungkin ada lebih banyak jika tipenya tidak sederhana dan membutuhkan banyak PyObjects untuk diwakili. Saya hanya ingin mengatakan bahwa di bawah kap penerjemah ada juggling nyata benda untuk presentasi penuh dari konstruksi tertentu.

Dari pengalaman saya sendiri, saya dapat mengatakan bahwa overhead ini hanya relevan untuk operasi yang memberikan peningkatan kecepatan ketika diimplementasikan dalam bahasa asli seperti C atau Rust. Masalahnya adalah bahwa juru bahasa CPython tidak dapat mengeksekusi bytecode begitu cepat sehingga beban tambahan karena jumlah benda penting. Sebaliknya, Anda kemungkinan besar akan mengurangi kinerja dengan memanggil fungsi, atau melalui perhitungan yang rumit, dll. sebelum Anda dapat melihat beban tambahan karena benda. Tentu saja ada beberapa pengecualian, yaitu pembangunan tupel atau kamus dengan beberapa nilai.

Sebagai contoh nyata overhead, Anda dapat mengutip Mercurial dengan kode C yang mem-parsing struktur data tingkat rendah. Untuk kecepatan parsing yang lebih besar, kode C menjalankan urutan besarnya lebih cepat daripada CPython. Tetapi begitu kode C membuat PyObject untuk mewakili hasilnya, kecepatan turun beberapa kali. Dengan kata lain, beban melibatkan pembuatan dan pengelolaan elemen Python sehingga mereka dapat digunakan dalam kode.

Cara mengatasi masalah ini adalah menghasilkan lebih sedikit elemen dalam Python. Jika Anda perlu merujuk ke satu elemen, lalu mulai fungsi dan kembalikan, dan bukan tuple atau kamus elemen N. Namun, jangan berhenti memantau kemungkinan muatan karena panggilan fungsi!

Jika Anda memiliki banyak kode yang bekerja cukup cepat menggunakan API CPython C, dan elemen-elemen yang perlu didistribusikan antara modul yang berbeda, lakukan tanpa tipe Python yang mewakili data berbeda sebagai struktur C dan telah menyusun kode untuk mengakses struktur ini bukannya melalui API CPython C. Dengan menghindari API CPython C untuk mengakses data, Anda akan menyingkirkan banyak beban tambahan.

Memperlakukan elemen sebagai data (alih-alih memiliki fungsi untuk mengakses semuanya dalam satu baris) akan menjadi pendekatan terbaik untuk pythonist. Solusi lain untuk kode yang sudah dikompilasi adalah dengan malas instantiate PyObject. Jika Anda membuat tipe khusus dalam Python (PyTypeObject) untuk mewakili elemen kompleks, Anda perlu mendefinisikan bidang tp_members atau tp_getset untuk membuat fungsi C kustom untuk mencari nilai atribut. Jika Anda, misalnya, menulis parser dan tahu bahwa pelanggan hanya akan mendapatkan akses ke subset bidang yang dianalisis, Anda dapat dengan cepat membuat tipe yang berisi data mentah, mengembalikan tipe ini dan memanggil fungsi C untuk mencari atribut Python yang memproses PyObject. Anda bahkan dapat menunda penguraian hingga fungsi dipanggil untuk menghemat sumber daya jika penguraian tidak pernah diperlukan! Teknik ini cukup langka, karena membutuhkan penulisan kode non-sepele, tetapi memberikan hasil positif.

Penentuan awal dari ukuran koleksi


Ini berlaku untuk API CPython C.

Saat membuat koleksi, seperti daftar atau kamus, gunakan PyList_New() + PyList_SET_ITEM() untuk mengisi koleksi baru jika ukurannya sudah ditentukan pada saat pembuatan. Ini akan menentukan terlebih dahulu ukuran koleksi untuk dapat menampung sejumlah elemen di dalamnya. Ini membantu untuk melewatkan memeriksa ukuran koleksi yang cukup saat memasukkan item. Saat membuat koleksi ribuan item, ini akan menghemat beberapa sumber daya!

Menggunakan Zero-copy di C API


API Python C sangat suka membuat salinan objek daripada mengembalikan referensi kepada mereka. Sebagai contoh, PyBytes_FromStringAndSize () menyalin char* ke dalam memori yang disediakan oleh Python. Jika Anda melakukan ini untuk sejumlah besar nilai atau data besar, maka kita dapat berbicara tentang gigabytes memori I / O dan beban terkait pada pengalokasi.

Jika Anda perlu menulis kode kinerja tinggi tanpa C API, maka Anda harus membiasakan diri dengan protokol penyangga dan jenis terkait, seperti memoryview .

Buffer protocol dibangun ke dalam tipe-tipe Python dan memungkinkan penerjemah untuk melemparkan tipe dari / ke byte. Ini juga memungkinkan juru kode C untuk menerima deskriptor void* dari ukuran tertentu. Ini memungkinkan Anda untuk mengaitkan alamat dalam memori dengan PyObject. Banyak fungsi yang bekerja dengan data biner secara transparan menerima objek apa pun yang mengimplementasikan buffer protocol . Dan jika Anda ingin menerima objek apa pun yang dapat dianggap sebagai byte, maka Anda perlu menggunakan satuan format s* , y* atau w* saat menerima argumen fungsi.

Dengan menggunakan buffer protocol , Anda memberi penerjemah peluang terbaik yang tersedia untuk menggunakan operasi zero-copy dan menolak untuk menyalin byte tambahan ke memori.

Dengan menggunakan tipe dalam Python dari form memoryview , Anda juga akan mengizinkan Python untuk mengakses level memori dengan referensi, alih-alih membuat salinan.

Jika Anda memiliki gigabyte kode yang melalui program Python Anda, penggunaan beragam jenis Python yang mendukung zero-copy akan menyelamatkan Anda dari perbedaan kinerja. Saya pernah memperhatikan bahwa python-zstandard ternyata lebih cepat daripada binding Python LZ4 (walaupun seharusnya sebaliknya), karena saya menggunakan terlalu banyak buffer protocol dan menghindari I / O memori yang berlebihan di python-zstandard !

Kesimpulan


Dalam artikel ini, saya berusaha berbicara tentang beberapa hal yang saya pelajari sambil mengoptimalkan program Python saya selama beberapa tahun. Saya ulangi dan mengatakan bahwa ini sama sekali bukan tinjauan komprehensif metode peningkatan kinerja Python. Saya akui bahwa saya mungkin menggunakan Python lebih banyak menuntut daripada yang lain, dan rekomendasi saya tidak dapat diterapkan ke semua program. Anda seharusnya tidak memperbaiki kode Python Anda secara besar-besaran dan menghapus, misalnya, pencarian atribut setelah membaca artikel ini . Seperti biasa, ketika datang ke optimasi kinerja, perbaiki dulu di mana kode sangat lambat. py-spy Python. , , Python, . , , , !

, Python . , , Python - . Python โ€“ PyPy, . Python . , Python , . , ยซ ยป. , , , Python, , , .

;-)

Source: https://habr.com/ru/post/id457942/


All Articles