Monthly Archives: November 2019

Multithreading dan Multiprocessing di Python

Python adalah salah satu bahasa yang sangat saya sukai, tapi bukan berarti bahasa ini tidak memiliki kekurangan, di tulisan ini saya ingin menunjukkan kekurangan Python dalam memproses data kompleks dengan Python murni (pemrosesan tidak dilakukan di modul native). Kompleks di sini maksudnya pemrosesannya CPU bound (batasan programnya adalah kemampuan CPU, bukan memori atau I/O).

Kelebihan python jelas sangat banyak: mudah dipakai, librarynya banyak, sintaksnya bersih dan mudah dibaca. Library Python untuk berbagai hal (terutama AI) sudah sangat banyak dan banyak yang ditulis dalam native code (C/C++). Dengan library eksternal yang ditulis dalam bahasa C/C++ (kebanyakan library AI), kita tidak perlu memikirkan apapun dalam hal pemrosesan data besar dan kompleks, karena itu dilakukan di level native code.

Multithreading dan Global Interpreter Lock

Dalam banyak bahasa (misalnya C atau Java): jika saya punya algoritma yang lambat untuk data besar, dan datanya masih masuk memori, saya biasanya bisa menyelesaikannya dengan thread. Saya hanya perlu mempartisi data, membuat banyak thread dan mengolah tiap potongan data di thread yang berbeda. Partisi data di sini biasanya mudah: jika bentuknya array, tinggal dibagi saja per N element.

Di Python, solusi dengan multithreading tidak akan berjalan baik karena adanya Global Interpreter Lock. Atau tepatnya lagi: bisa jalan, tapi tidak lebih cepat dari single thread. Adanya GIL berarti di kebanyakan kasus beberapa thread di Python tidak bisa jalan berbarengan. Threading di Python hanya berguna untuk proses yang menunggu I/O (akses file, akses socket, dsb). Jadi jika kita punya prosessor 32 core sekalipun, Python tidak bisa optimal memakai semua core tersebut dengan module threading.

Kadang-kadang mengganti interpreter dengan pypy sudah cukup menyelesaikan masalah komputasi yang berat. Pypy adalah implementasi interpreter Python yang memakai teknologi JIT (Just In Time Compiler), jadi lebih efisien walaupun tetap satu thread saja. Tapi Pypy ini tidak selalu berhasil:

  • Pypy tidak 100% kompatibel dengan Python
  • Ada modul-modul yang tidak bisa dipakai di Pypy
  • Versi Pypy ketinggalan dibandingkan versi Python resmi

Solusi lain seperti Cython juga bisa dipakai, tapi lebih rumit dari sekedar mengganti interpreter.

Multiprocessing

Python memiliki modul multiprocessing, artinya kita bisa menjalankan sebuah fungsi di subproses baru. Overhead untuk memulai proses baru cukup besar, jadi ada kita bisa juga memakai process Pool supaya lebih optimal. Di titik ketika kita membuat subprocess, isi memori saat ini di proses utama akan di-clone ke memori yang sifatnya copy-on-write (COW). Artinya: selama memori tersebut tidak diubah, maka tidak akan dibuat salinannya, hanya akan dishare (dipakai bersama).

Lalu bagaimana kita bisa memanggil fungsi di proses baru dan mendapatkan hasilnya di proses utama? jawabannya adalah: setiap parameter akan di-serialize dan dikirim ke subproses baru. Hasil kembalian fungsi diserialize oleh subproses dan dikembalikan ke proses utama. Ini cukup untuk menyelesaikan banyak masalah. Tapi pendekatan ini tidak sempurna. Proses serialization dilakukan menggunakan modul pickle. Ini titik yang harus diwaspadai, perhatikan contoh kecil ini di mana kita memproses XML dengan minidom.

from xml.dom import minidom
import pickle

myxml = """<?xml version="1.0"?>
<test>
	<item id="11">
		<name>red thing </name>
		<price>100</price>
	</item>
	<item id="12">
		<name>blue thing</name>
		<price>200</price>
	</item>
</test>
"""

doc = minidom.parseString(myxml)

items = doc.getElementsByTagName("item")

firstitem = items[0]

print(firstitem.toxml())

with open("test.bin", "wb") as f:
    f.write(pickle.dumps(firstitem))

Hasil dari pickle bisa dilihat di file test.bin dan jika dilihat dengan editor akan terlihat bahwa ada ‘blue thing’, padahal tidak ada di firstitem. Modul pickle akan melihat semua atribut yang dimiliki oleh sebuah node dan mempickle semuanya, dalam hal ini: parent-nya juga, dan karena parent memiliki semua child, maka semua child-nya juga ikut.

Jika kita menggunakan modul multiprocessing dan mengirimkan satu node ke subprocess, maka yang niatnya hanya mengirimkan satu node, ternyata mengirimkan seluruh DOM ke subprocess. Secara umum, ketika mengirimkan objek apapun, maka kita mulai harus memikirkan: ketika serialisasi dilakukan, apa saja yang akan dibawa oleh objek tersebut.

Objek yang diserialize ini tidak masuk ke memori COW, tapi Python akan mengalokasikan memori baru. Bayangkan jika kita mengirimkan copy seluruh DOM ke semua subprocess (misalnya ada 10 subprocess), hasilnya: penggunaan memori jadi sangat besar. Jika DOM-nya besar, proses serialisasi dan deserialisasi ini juga sangat lambat.

Dalam kasus tertentu, cara yang lebih efisien adalah: kita letakkan apa yang ingin kita proses di objek global, objek global ini akan di-clone ke proses baru (tidak melalui serialization). Ketika kita memanggil subprocess, kita hanya mengirimkan string atau bilangan yang berupa ID. Subprocess kemudian bisa mengakses variabel global berdasarkan ID tersebut. Cara ini efisien dalam mentransfer data dari proses utama ke subprocess, tapi tidak bisa dilakukan untuk sebaliknya. Ketika mengembalikan hasil proses, kita tetap harus memikirkan objek yang kita kembalikan. Untuk nilai kembalian ini, tidak ada trik yang mudah, kita harus berhati-hati agar serialisasi tidak berjalan liar.

Modul multiprocessing juga memiliki beberapa kelas khusus supaya kita bisa mentransfer data dengan lebih efisien antar proses (Queue, Pipe, Manager), tapi penggunaanna cukup rumit dan tidak intuitif, dan rawan salah. Ini juga tidak menyelesaikan masalah serialisasi data kompleks.

Mungkin sebagian orang akan bilang: ah kan gampang, cukup kita lihat setiap kelas yang kita miliki dan cek satu persatu membernya supaya jelas apa yang diserialisasi. Ada beberapa masalah:

  • Kode kadang ditulis anggota team lain, dan tidak jelas tipe data sebuah variabel member, lebih sulit lagi jika ada list atau dictionary yang tipe datanya tidak jelas
  • Kadang kita memakai library orang lain (contoh kecil: XML, Graph), yang tidak mudah untuk dimodifikasi. Andaikan bisa dimodifikasi: kita harus memaintain library itu jika ada versi baru

Untuk masalah pertama: Python saat ini memiliki type hint, kita bisa memberikan hint apa tipe sebuah variabel/member. Tipe data tidak akan dicek oleh interpreter python, tapi IDE yang kita pakai bisa membantu mengecek tipe data. Untuk masalah kedua: ini lebih sulit, kita bisa menulis serializer/pickler khusus untuk object tertentu (bisa dibaca di dokumentasi Python), atau menggunakan setstate__ dan __getstate untuk memproses secara khusus member variable tertentu.

Solusi Third Party

Dulu orang menyarankan: pakai saja Jython (implementasi Python dalam Java) atau IronPython (implementasi Python dalam .NET), keduanya tidak memakai Global Interpreter Lock, jadi thread bisa berjalan cepat. Tapi masalah dengan solusi dari pihak lain adalah: maintenance. Sekarang ini baik Jython maupun IronPython tidak dimaintain lagi. Belum lagi dukungan modul yang kurang lengkap.

Saat ini ada beberapa pendekatan lain untuk memudahkan masalah multithread/multiprocess, misalnya Ray, tapi ini masih kurang populer dan kode kita harus diubah untuk mengikuti model komputasi Ray, library ini pun punya masalahnya sendiri. Tentunya saya tetap khawatir dengan library apa saja dari 3rd party bahwa proyek semacam ini tidak bertahan lama.

Data Ukuran Sedang

Masalah yang saya contohkan di atas itu adalah kasus yang sudah saya temui beberapa kali. Jika dari awal diketahui bahwa data akan sangat besar, saya akan memecah data menjadi banyak bagian, lalu membuat proses khusus yang bisa memproses data yang sudah dipecah, atau bahkan menggunakan sistem terdistribusi. Jika data sangat kecil, saya juga tidak akan menggunakan threading sama sekali, cukup satu proses.

Perlu dicatat bahwa jika kita terlalu buru-buru berusaha memakai sistem terdistribusi untuk data yang masih muat di memori, performancenya justru tidak bagus (belum lagi biaya ekstra yang dikeluarkan untuk banyak server). Artikel tahun 2014 ini merupakan contoh yang bagus bahwa kadang command line tools bisa 235 kali lebih cepat dari Hadoop. Ini kasus yang sangat ekstrem, tapi pelajaran yang bisa dipetik adalah: kalau datanya kurang besar, overhead memakai Hadoop atau sistem lain kadang sangat besar.

Contoh yang biasa saya temui adalah: ada program yang hanya memproses sebuah file, biasanya ukurannya puluhan megabyte, tapi kemudian filenya bertumbuh jadi beberapa gigabyte, nilai ini besar (beberapa puluh kali lebih besar dari input biasanya) tapi masih jauh di bawah memori yang tersedia (misalnya server dengan memori 16-256 GB). Program yang tadinya butuh beberapa detik menjadi beberapa menit atau bahkan puluhan menit, sedangkan ketika diubah menjadi multiprocess, tiba-tiba pemakaian memori Python menjadi beberapa puluh gigabyte (karena masalah serialization) dan malah lebih lambat lagi. Memperbaiki skrip seperti ini bisa butuh waktu yang cukup lama.

Secara umum saya tidak suka dengan pendekatan multiprocessing ini: saya memakai bahasa yang managemen memorinya otomatis (contoh: java, C#, Python, Javascript) karena ingin memori diatur otomatis oleh sistem. Kalau memang ingin mengatur memori manual sendiri, lebih baik saya memakai C/C++ sekalian.

Penutup

Python tetap merupakan salah satu bahasa yang saya sukai dan saya tetap akan memakainya untuk berbagai hal. Posting ini hanya untuk menunjukkan bahwa Python juga memiliki keterbatasan. Dengan memahami batasan ini, kita bisa mempertimbangkan:menggunakan pendekatan lain dalam memproses data dengan Python atau memakai bahasa lain yang lebih cocok untuk masalah tersebut.

Perlu dicatat juga bahwa tiap bahasa bisa berubah, baik sintaksnya maupun implementasinya. Contohnya: NodeJS baru punya Worker Thread di Node JS 10 sebagai fitur experimental dan harus diaktifkan dengan flag khusus. Baru pada Node JS 12 yang baru dirilis April tahun 2019 API Worker Thread dianggap stabil dan layak dipakai produksi. Jadi mungkin saja suatu saat masalah multithread ini akan berubah di Python.