- Menganalisis kinerja Unix pipe yang diimplementasikan di Linux melalui optimasi bertahap
- Bandwidth program pipe sederhana pada awalnya terukur sekitar 3.5GiB/s, lalu dibahas proses peningkatannya hingga lebih dari 20 kali lipat melalui profiling dan perubahan system call
- Menjelaskan berbagai teknik optimasi seperti memanfaatkan system call zero-copy seperti vmsplice dan splice untuk mengurangi penyalinan data yang tidak perlu, serta memperbesar ukuran page
- Mengatasi bottleneck dengan penggunaan Huge Page dan teknik busy loop, hingga mencatat throughput maksimum 62.5GiB/s
- Memberikan insight tentang elemen-elemen penting dalam server berperforma tinggi dan pemrograman kernel seperti pipe, paging, biaya sinkronisasi, dan zero-copy
Gambaran Umum dan Pendahuluan
- Artikel ini membahas bagaimana Unix pipe diimplementasikan di Linux, sambil menulis sendiri program uji yang membaca dan menulis data melalui pipe lalu mengoptimalkan kinerjanya secara bertahap
- Dimulai dari program sederhana dengan bandwidth sekitar 3.5GiB/s, lalu melalui berbagai optimasi berhasil mencapai peningkatan performa sekitar 20 kali
- Optimasi di tiap tahap diputuskan berdasarkan hasil profiling menggunakan tool perf, dan source code terkait dipublikasikan di GitHub - pipes-speed-test
- Inspirasi tulisan ini berasal dari pengamatan terhadap program FizzBuzz berperforma tinggi (36GiB/s) yang memproses data melalui pipe
- Cukup dengan pengetahuan dasar bahasa C, isi tulisan ini dapat dipahami tanpa kesulitan berarti
Mengukur Kinerja Pipe: Versi Pertama yang Lambat
- Dari hasil eksekusi contoh program FizzBuzz berperforma tinggi, terlihat bahwa data dapat diproses melalui pipe hingga 36GiB per detik
- FizzBuzz menghasilkan output dalam blok sebesar ukuran cache L2 (256KiB) untuk menyeimbangkan akses memori dan overhead IO
- Program uji kinerja pipe yang dibuat dalam artikel ini juga berulang kali melakukan output (read/write) dalam blok 256KiB, dan untuk pengukuran kedua ujung read dan write diimplementasikan langsung
write.cpp berulang kali menulis buffer 256KiB yang sama, sedangkan read.cpp membaca 10GiB lalu berhenti dan menampilkan throughput
- Hasil pengujian menunjukkan bahwa read/write melalui pipe hanya mencapai 3.7GiB/s, sekitar 10 kali lebih lambat dibanding FizzBuzz
Bottleneck pada Operasi Write dan Struktur Internal
- Saat program dijalankan dengan tool perf untuk melacak call graph, terlihat bahwa sekitar setengah dari total waktu dihabiskan pada tahap penulisan pipe, yaitu
pipe_write
- Di dalam
pipe_write, sebagian besar waktu dihabiskan untuk penyalinan page memori dan alokasi (copy_page_from_iter, __alloc_pages)
- Pipe Linux diimplementasikan dalam bentuk ring buffer, dan setiap entri mereferensikan page tempat data sebenarnya disimpan
- Ukuran total buffer pipe bersifat tetap, sehingga jika pipe penuh maka write akan memblokir, dan jika kosong maka read akan berada dalam keadaan blocking
- Dalam struktur C (
pipe_inode_info, pipe_buffer), head dan tail masing-masing menunjukkan posisi tulis/baca, serta memuat informasi offset dan panjang tiap page
Logika Baca/Tulis pada Pipe
pipe_write bekerja dengan urutan berikut
- Jika pipe penuh, tunggu sampai tersedia ruang
- Isi terlebih dahulu ruang tersisa pada
head saat ini
- Jika masih ada data, alokasikan page baru, salin data ke buffer, lalu perbarui
head
- Semua operasi dilindungi oleh lock sehingga menimbulkan overhead sinkronisasi
- Pembacaan (
read) menggunakan struktur yang sama dengan menggeser tail dan membebaskan page yang telah dibaca
- Pada dasarnya terjadi dua kali penyalinan, dari memori user ke kernel lalu dari kernel kembali ke ruang user, sehingga menimbulkan overhead yang besar
Zero-Copy: Optimasi dengan Splice/vmsplice
- Pendekatan umum untuk IO cepat adalah melewati kernel (bypass) atau meminimalkan penyalinan
- Linux mendukung system call
splice dan vmsplice agar penyalinan dapat dilewati saat memindahkan data antara pipe dan ruang user
splice: memindahkan data antara pipe dan file descriptor
vmsplice: memindahkan data antara memori user dan pipe
- Kedua system call tersebut dapat bekerja hanya dengan memindahkan referensi tanpa memindahkan data sebenarnya
- Sebagai contoh, dengan
vmsplice, buffer 256KiB dibagi dua lalu masing-masing separuh dimasukkan ke pipe secara bergantian dengan metode double buffering
- Dalam praktiknya, penerapan
vmsplice meningkatkan kecepatan lebih dari 3 kali lipat (sekitar 12.7GiB/s), dan ketika splice juga diterapkan di sisi read, performa naik lagi menjadi 32.8GiB/s
Bottleneck Terkait Page dan Pemanfaatan Huge Page
- Dari hasil analisis perf, bottleneck pada
vmsplice terpusat pada lock pipe (mutex_lock) dan pengambilan page (iov_iter_get_pages)
iov_iter_get_pages bertugas mengubah memori user (virtual address) menjadi page fisik (physical page) nyata lalu menyimpan referensinya di dalam pipe
- Paging Linux tidak hanya menggunakan page berukuran 4KiB, tetapi juga mendukung berbagai ukuran lain seperti 2MiB (huge page), tergantung arsitektur
- Dengan memanfaatkan Huge Page (misalnya 2MiB), overhead translasi page berkurang signifikan karena pengelolaan page table dan jumlah referensi ikut menurun
- Saat huge page diterapkan pada program, throughput maksimum meningkat sekitar 50% lagi menjadi 51.0GiB/s
Penerapan Busy Loop
- Bottleneck yang tersisa adalah proses sinkronisasi seperti menunggu ruang kosong di pipe (
wait) dan membangunkan reader (wake)
- Dengan menggunakan opsi
SPLICE_F_NONBLOCK dan memanggil ulang secara busy loop saat terjadi EAGAIN, overhead scheduling kernel dapat dihilangkan
- Setelah teknik ini diterapkan, throughput maksimum meningkat lagi 25% menjadi 62.5GiB/s
- Busy loop memang menghabiskan 100% sumber daya CPU, tetapi ini adalah pola yang umum pada server berperforma tinggi
Ringkasan dan Hal Lainnya
- Artikel ini menjelaskan cara meningkatkan performa pipe secara dramatis langkah demi langkah melalui analisis perf dan source code Linux
- Isu-isu utama dalam pemrograman berperforma tinggi seperti pipe,
splice, paging, zero-copy, dan biaya sinkronisasi dapat dipahami lewat contoh nyata
- Pada kode nyata juga diterapkan tuning tambahan, seperti mengalokasikan buffer pada page yang berbeda untuk mengurangi refcount contention
- Pengujian dijalankan dengan mengikat tiap proses program ke core yang berbeda menggunakan
taskset
- Keluarga
splice secara desain dapat berisiko dan telah lama menjadi bahan perdebatan bagi sebagian developer kernel
3 komentar
Wow! Seru ya! (meski aku sama sekali tidak paham ini sedang membahas apa… )
|
Komentar Hacker News
Saya masih ingat pengalaman mem-porting aplikasi berbasis pipe Linux ke Windows; karena sama-sama standar POSIX, saya kira performanya tidak akan jauh berbeda, ternyata sangat lambat. Masalahnya sampai ke tingkat di mana seluruh Windows nyaris macet saat menunggu koneksi pipe. Beberapa tahun kemudian saya mengimplementasikan hal yang sama lagi di Win10 dengan C#, kondisinya memang sedikit membaik, tetapi selisih performanya tetap terasa sangat memalukan.
Setahu saya, dalam beberapa tahun terakhir Windows sudah menambahkan socket AF_UNIX. Saya penasaran mana yang performanya lebih baik dibanding pipe Win32; dugaan saya AF_UNIX akan lebih unggul.
Saat mengatakan "performanya berantakan", apakah yang dimaksud I/O setelah pipe sudah terhubung, atau proses sebelum koneksi terjadi? Kalau setelah terhubung, itu cukup mengejutkan, tetapi kalau masalahnya ada pada koneksi/lepas-koneksi yang berulang, saya bisa menerima bahwa OS mungkin tidak mengoptimalkannya, karena memang jarang diperlukan. Jadi penerimaannya bisa berbeda tergantung kasus penggunaan.
Dari yang baru-baru ini saya cek, performa TCP lokal di Windows ternyata jauh lebih baik daripada pipe.
Perlu diingat bahwa POSIX hanya mendefinisikan perilaku, bukan performa, dan tiap platform serta OS punya kekhasan performanya sendiri.
Dulu saya pernah mengalami kebalikannya. Bukan pipe, tetapi saat aplikasi PHP di Linux berkomunikasi dengan SOAP API berbasis .NET, saya ingat implementasi .NET justru memberi waktu respons yang lebih baik.
Sebagai referensi, ada banyak metode seperti readv() / writev(), splice(), sendfile(), funopen(), io_buffer(), dan lain-lain. splice() sangat unggul untuk transfer data besar zero-copy antara pipe dan socket UNIX, tetapi ini khusus Linux. splice() adalah cara tercepat untuk memindahkan data secara langsung tanpa alokasi memori ruang pengguna, tanpa pengelolaan buffer tambahan, tanpa memcpy(), dan tanpa penelusuran iovec saat transfer data. Untuk keluarga BSD, sekaligus ada pertanyaan apakah readv()/writev() memang pilihan optimal untuk pipe. Bagaimanapun, artikel ini dinilai sangat mengesankan.
sendfile() memberikan performa sangat tinggi dengan mekanisme zero-copy file→socket, dan tersedia baik di Linux maupun BSD. Namun, ini hanya mendukung file→socket. sendmsg() tidak bisa dipakai untuk pipe biasa; itu untuk socket UNIX domain/INET/dan socket lain. Sebagai catatan, di Linux sendfile diimplementasikan secara internal dengan splice, sehingga saya pernah benar-benar memakainya juga untuk transfer file→block device.
splice() adalah yang terbaik di Linux untuk transfer data besar supercepat antar-pipe, tetapi jika io_uring digunakan dengan benar, performanya bisa setara atau bahkan melampauinya.
Shared memory seperti shm_open dan metode pengiriman file descriptor pada praktiknya lebih cepat, dan sepenuhnya portabel.
Disebutkan bahwa di HN sebelumnya sudah ada diskusi aktif tentang artikel ini, lalu diarahkan ke https://news.ycombinator.com/item?id=31592934 (200 komentar), https://news.ycombinator.com/item?id=37782493 (105 komentar).
Artikel ini benar-benar keren, dan sangat menyenangkan melihatnya kembali muncul secara berkala.
Ada kesan sayang karena belum ada komentar sama sekali. Saya juga ingin lebih sering memakai splice, tetapi khawatir dengan isu keamanan atau kompatibilitas ABI yang disebut di akhir tulisan. Saya juga penasaran apakah splice akan terus dipertahankan ke depannya, dan seberapa sulit membuat patch agar pipe bawaan selalu memakai splice demi peningkatan performa.
Ditanyakan apakah di Linux modern ada sesuatu yang mirip dengan Doors di SunOS. Konteksnya sedang mencari teknologi yang lebih baik daripada AF_UNIX untuk aplikasi embedded yang membutuhkan pertukaran data kecil dengan sensitivitas latensi yang sangat tinggi.
Shared memory adalah yang paling cepat dari sisi latensi, tetapi perlu mekanisme membangunkan task, biasanya memakai futex. Google pernah mengembangkan system call FUTEX_SWAP yang memungkinkan handoff langsung dari satu task ke task lain, tetapi saya tidak tahu bagaimana kelanjutannya.
Karena 'Doors' adalah kata yang terlalu umum, diminta penjelasan agar lebih mudah dicari.
Ditanyakan lebih lanjut apa sebenarnya masalah dengan AF_UNIX saat ini: apakah ada fitur yang kurang, apakah latensinya lebih tinggi dari yang diinginkan, atau apakah struktur API socket server/klien memang tidak cocok.
Ditambahkan informasi singkat bahwa artikel ini ditulis pada 2022.