Prefill dan Decode Terpisah
Memisahkan prefill dan decode di berbagai perangkat mempercepat inferensi LLM dan meningkatkan performa
Untuk menghasilkan token keluaran dari prompt masukan, inferensi LLM dibagi menjadi dua tahap: prefill dan decode. Prefill berjalan pada token masukan, mengisi cache KV, sebelum memasuki tahap decode yang menghasilkan token satu per satu. Meskipun satu langkah decode biasanya berjalan selama puluhan milidetik, prefill memakan waktu jauh lebih lama. Jika dijalankan pada perangkat yang sama, mencampur prefill dengan decode akan menurunkan performa decode. Dalam artikel ini, kami mengeksplorasi solusi mapan berupa pemisahan prefill dan decode, menjalankannya pada perangkat terpisah untuk memaksimalkan throughput prefill dan latensi decode.
Performa Prefill vs Decode
Dalam mesin penyajian LLM tipikal, penjadwal batch memilih permintaan untuk diproses di setiap langkah eksekusi model. Saat berjalan pada satu perangkat atau node, permintaan prefill dan decode digabungkan dalam batch yang sama. Biaya atensi, yang diagregasikan di sepanjang panjang sekuens, meningkat untuk prefill dan decode secara proporsional dengan panjang entri dalam cache KV (kv_len). Permintaan decode biasanya meneruskan satu token (qo_len=1), dengan biaya minimal melalui lapisan lain yang beroperasi secara independen pada token-token suatu sekuens. Permintaan prefill meneruskan ribuan atau puluhan ribu token dengan biaya yang signifikan melalui lapisan padat (qo_len besar).
Latensi dari pass penerusan lebih dipengaruhi secara kuat oleh jumlah token independen yang diteruskan melalui lapisan padat (qo_len) daripada jumlah token yang diambil dari cache KV selama atensi (kv_len). Atensi dapat melakukan paralelisasi baik terhadap jumlah permintaan maupun kv_len yang proporsional dengan panjang sekuens, sehingga mencapai utilisasi yang baik. Prefill terikat oleh komputasi (compute bound): karena qo_len yang tinggi, kernel GEMM dapat mengalokasikan blok yang cukup di sepanjang dimensi M untuk sepenuhnya mengutilisasi kapabilitas komputasi GPU modern. Decode terikat oleh memori (memory bound): karena ukuran batch yang umumnya kecil, jumlah masukan di sepanjang M biasanya sedikit, hanya cukup untuk satu blok. Meskipun kernel Split-K GEMM dapat meningkatkan utilisasi SM untuk ukuran batch token yang kecil, cache dan unit perkalian matriks biasanya tetap kurang terutilisasi.
Ketika dicampur bersama, batch yang berisi permintaan untuk prefill menghasilkan latensi yang lebih tinggi melalui pass penerusan, yang berdampak negatif pada throughput decode dari seluruh instans. Meskipun mencampur permintaan prefill dengan permintaan decode atau menerapkan prefill terpotong (chunked prefill) dapat sedikit meningkatkan performa decode, sulit untuk mempertahankan throughput prefill yang memadai untuk memproses cukup permintaan pada suatu instans guna memaksimalkan throughput decode. Dalam kasus model besar dengan panjang keluaran tipikal, untuk mempertahankan ukuran batch yang besar untuk decode, prefill harus sering dilakukan sedemikian rupa sehingga secara signifikan menurunkan latensi rata-rata dan menyebabkan gangguan (stutter) pada keluaran.
Masalah-masalah ini dapat diatasi dengan menggunakan sekumpulan node terpisah untuk melakukan prefill dan decode. Dengan mengaitkan node prefiller dengan beberapa node decoder, permintaan yang memadai dapat dijadwalkan untuk prefill guna memaksimalkan throughput dan mempertahankan jumlah permintaan konkuren yang cukup besar pada node decoder untuk memaksimalkan throughput decode juga. Node prefiller mengisi cache KV, yang kemudian ditransfer ke node decoder. Karena decoder tidak lagi harus berhenti sejenak untuk prefill, latensi menjadi jauh lebih deterministik, karena dampak keseluruhan dari pertumbuhan kv_len pada permintaan aktif menjadi jauh lebih kecil. Biayanya dibayar dengan peningkatan Time to First Token (TTFT), karena transfer cache KV melalui jaringan dapat memakan waktu puluhan hingga ratusan milidetik.
KV Messenger
Di Perplexity, implementasi kami untuk pemisahan prefill dan decode dibangun di sekitar KV messenger yang berinteraksi dengan mesin LLM untuk mengatur transfer cache KV dari node prefiller ke node decoder melalui jaringan. Di sisi prefiller, messenger menerima permintaan dari node decoder, menyerahkannya ke penjadwal batch, dan melacak eksekusi pass penerusan untuk mengirimkan cache KV dengan latensi sekecil mungkin. Di sisi decoder, setelah halaman yang tidak dapat dikeluarkan (un-evictable pages) dialokasikan, messenger memblokir permintaan agar tidak dijadwalkan untuk decode sampai diberi tahu tentang selesainya transfer cache KV dan konteks decoder.
Pemisahan prefill membutuhkan koneksi ber-throughput tinggi dan berlatensi rendah, sehingga implementasi kami disesuaikan untuk RDMA, yang mendukung Network Interface Controller (NIC) EFA dan ConnectX. KV Messenger dibangun di atas libfabric, menggunakan pembungkus fabric-lib kami untuk menyediakan abstraksi tingkat tinggi berlatensi rendah di atas primitif Remote Direct Memory Access (RDMA), mengimplementasikan transfer halaman dan metadata yang efisien, beserta signaling berlatensi rendah. Di latar belakang, fabric-lib mengoordinasikan GPU dan NIC yang terhubung langsung dengannya untuk menyalin data dari node prefiller ke node decoder.
Setelah diterima, node prefiller mengalokasikan seperangkat halaman KV sumber yang sesuai dan menjadwalkan permintaan untuk prefill menggunakan mesin lokalnya. Untuk meminimalkan latensi, transfer tidak menunggu pass penerusan: sebagai gantinya, penyalinan halaman KV dimulai segera setelah model selesai menambahkan entri cache KV ke cache KV untuk masing-masing lapisan. Karena permintaan prefill dapat dipotong, penjadwal batch memberi tahu KV messenger tentang potongan yang sedang dijadwalkan sebelum eksekusi. Untuk mendukung grafik CUDA sekaligus mampu melacak lapisan, messenger mempertahankan utas khusus (dedicated thread) yang melakukan polling pada counter yang bertambah setelah proyeksi keluaran dari atensi. Counter tersebut dipelihara hanya pada node utama dalam lingkungan bershard: meskipun entri cache KV valid setelah penambahan dan sebelum atensi, proyeksi keluaran direduksi di seluruh rank, secara implimèt menyinkronkannya. Begitu perubahan pada counter diamati, messenger diberi tahu dan ia memanggil fabric-lib untuk memulai transfer suatu lapisan.
Setelah transfer potongan terakhir selesai, metadata tambahan apa pun juga disalin: decoding spekulatif atau MTP memerlukan logits dan status tersembunyi (hidden states) untuk dipindahkan ke decoder. Penyalinan ini juga dilakukan melalui RDMA, ke dan dari buffer yang telah dialokasikan sebelumnya.
Setelah selesainya semua transfer yang tertunda dari potongan terakhir, node prefiller membatalkan alokasi (de-allocate) halaman KV dan menyelesaikan permintaan. Node decoder tidak diberi tahu secara eksplisit: sebagai gantinya, node tersebut menggunakan counter langsung untuk melacak jumlah operasi yang telah selesai. Jumlah operasi RDMA di sisi prefiller berbanding lurus dengan jumlah halaman yang ditransfer. Setelah selesainya jumlah salinan halaman dan konteks yang diketahui, fabric-lib memanggil KV messenger untuk menunjukkan bahwa suatu permintaan siap untuk didecode. Messenger membatalkan alokasi konteks apa pun dan menyerahkan permintaan tersebut ke mesin LLM.
Transfer Cache KV Bershard
Jika prefiller dan decoder mengandalkan Tensor Parallelism (TP) dan melakukan sharding atau mereplikasi cache KV secara identik, satu mesin transfer mengoordinasikan beberapa perangkat untuk mengirim dan menerima halaman dari semua replika. Agar dapat menggunakan satu messenger dan mesin transfer meskipun eksekutor model direplikasi di beberapa perangkat dan proses, cuMem dan cuMemImportFromShareableHandle digunakan untuk mengalokasikan memori perangkat yang mendasari cache KV dan memetakannya ke dalam proses utama. Mesin transfer memeriksa topologi node untuk menemukan NIC dan CPU di node NUMA terdekat yang akan digunakan untuk transfer masing-masing slice cache KV.
Jika sumber dan tujuan di-shard secara identik, transfer menjadi sangat mudah karena ada pemetaan satu-ke-satu dari perangkat dan halaman sumber serta tujuan. Dalam situasi ini, sharding secara implisit membantu latensi transfer: dengan menggunakan lebih banyak GPU, lebih banyak NIC terkait yang dapat digunakan, sehingga mendekati utilisasi bandwidth penuh. Namun, jika terjadi ketidakcocokan, mesin transfer harus memecah atau merekonstruksi halaman bergantung pada rasio antara slice sumber dan tujuan.
Jika prefiller memecah cache KV di lebih banyak perangkat, halaman penuh direkonstruksi pada decoder dengan mengirimkan setengah bagian yang sesuai keluar dari perangkat prefiller. Jika decoder memiliki lebih banyak shard, decoder akan menerima halaman dari beberapa sumber. Decoder perlu mengetahui skema sharding dari prefiller agar dapat menghitung jumlah penulisan RDMA yang diharapkan akan diterima. Jika replikasi terlibat, prefiller mengelompokkan perangkat ke dalam set replika yang mereplikasi cache KV penuh di dalam diri mereka sendiri. Set replika tujuan secara acak diberi salah satu set sumber agar dapat menggunakan semua perangkat yang tersedia untuk memulai penulisan RDMA.
Transfer bershard memerlukan sedikit penyesuaian pada cache KV. Secara default, FlashInfer mengandalkan tata letak (layout) NHD, yang mengurutkan token dalam satu halaman di dalam head. Karena cache kemungkinan besar di-shard di sepanjang jumlah attention head, hal ini menciptakan diskontinuitas di dalam head. Transfer RDMA tidak secara implisit mendukung penulisan berjarak (strided writes), yang memerlukan satu operasi per head untuk melakukan transfer. Sebagai gantinya, untuk mengurangi jumlah interaksi dengan libfabric, kami mengatur cache KV menggunakan tata letak HND yang menempatkan dimensi head sebelum jumlah token. Ini memastikan kontinuitas, memungkinkan halaman disalin dengan satu penulisan tunggal.
Decoding Spekulatif
Decoding spekulatif memerlukan sedikit penyesuaian pada prefill-decode terpisah. Dalam implementasi kami, node prefiller tidak diizinkan untuk mengambil sampel token. Karena model Sonar dari Perplexity mendukung keluaran terstruktur, kami tidak ingin menanggung kerumitan sinkronisasi implementasi prosesor skema di seluruh prefiller dan decoder. Dalam mekanisme MTP dan decoding spekulatif, melakukan prefill pada model draf hingga token terakhir melibatkan pengambilan sampel token dari model target.
Untuk mengatasi masalah ini, prefill tidak menyertakan token terakhir dari sekuens masukan. Sebagai gantinya, status tersembunyi (hidden states) atau logits dari prefill sebelum token terakhir ditransfer dan diperlakukan sebagai token decode pada langkah berikutnya di decoder. Meskipun ini sedikit meningkatkan latensi, karena langkah decode penuh harus dilakukan setelah prefill untuk mengeluarkan token pertama, kerumitan implementasi menjadi sangat berkurang.
Penyebaran Terpisah
Kami telah menerapkan atau bereksperimen dengan beberapa konfigurasi terpisah dengan berbagai model, untuk mendukung lalu lintas produksi atau beban kerja evaluasi internal. Berdasarkan ukuran dan mekanisme atensi model, kami memilih skema sharding yang sesuai untuk node prefiller dan decoder guna mengutilisasi GPU secara optimal.
DeepSeek-R1
Dengan DeepSeek, kami mempertimbangkan penyebaran Tensor-Parallel (TP) dan Data-Parallel (DP). Sebagaimana dibahas dalam postingan blog sebelumnya, penyebaran TP memberikan latensi yang lebih baik dengan konsekuensi throughput yang lebih rendah, yang membutuhkan lebih banyak GPU untuk melayani lalu lintas yang padat. Penyebaran DP menskalakan jauh lebih baik dengan beban, namun throughput puncaknya lebih rendah karena biaya komunikasi antar-perangkat atau antar-node.
DeepSeek mengandalkan Multi-Head Latent Attention, mengompresi cache KV. Karena semua head KV dikompresi menjadi satu vektor laten tunggal, TP tidak dapat melakukan sharding pada cache KV, karena ia harus mereplikasi vektor laten pada semua rank. Sharding terjadi setelah dekompresi, karena setiap rank dapat mengekstrak head yang berbeda dari representasi laten yang sama. Akibatnya, semua shard cache KV identik di seluruh shard prefiller maupun decoder.
Dengan penyiapan TP intranode, prefiller dan decoder di-shard secara identik. Transfer dikirimkan dari semua rank untuk mengutilisasi semua NIC yang tersedia secara penuh. Namun, dengan penyebaran DP, di mana ukuran rank TP lebih kecil atau setiap rank DP ditetapkan ke satu GPU, perangkat prefiller apa pun yang memegang salinan replika dari cache KV dapat mengirimkannya. Untuk menyeimbangkan permintaan di semua NIC yang tersedia, kami secara acak memilih GPU dan NIC untuk mengirim cache KV dari prefiller ke decoder.
Dengan prefill-decode campuran, penyebaran R1 kami kesulitan untuk secara konsisten melampaui 50 TPS karena gangguan prefill yang sering terjadi dalam kisaran ratusan milidetik. Sebaliknya, dengan memisahkan prefill, kami mengalami penalti sekitar 100ms pada TTFT untuk setiap permintaan, tetapi satu node prefiller dapat mempertahankan ukuran batch yang konsisten pada 3 node decoder, menghasilkan throughput melebihi 90 TPS sambil menangani beban sekitar 1 QPS per node decoder. Dengan penyebaran paralel data, TPS sedikit lebih rendah di sekitar 50, namun instans tersebut dapat menangani beban 1 QPS per rank, dengan 8 rank ke satu node tunggal.
Qwen3-Coder
Model 480B ini menggunakan Grouped-Query Attention (GQA), sehingga atensi dapat dengan mudah di-shard dan dapat mengambil manfaat dari paralelisme tensor tanpa mengorbankan memori untuk cache KV. Akibatnya, kami dapat melakukan sharding model di 8 GPU untuk prefill maupun decode, memasangkan sekitar 3 node decoder dengan satu node prefiller. Karena atensi di-shard, kami mengandalkan tata letak cache KV HND untuk melakukan sharding pada cache KV prefiller dan decoder, memasangkan rank prefiller dengan rank decoder dan mengutilisasi penuh semua NIC untuk mentransfer slice secara paralel.
Sitasi
@online{perplexity2025DisaggregatedPrefillAndDecode,
author = {{Perplexity} and Nandor Licker and Lequn Chen and Kevin Hu and Erin McKnight and Phi Hoang},
title = {{Disaggregated prefill and decode}},
date = {2025-08-01},
url = {https://perplexity.coolkeypoint.com/hub/blog/disaggregated-prefill-and-decode}
}