Skip to main content

Penulis

Ashar Mirza - VoicePing Inc.

Masalah

Kami menjalankan perkhidmatan mikro terjemahan menggunakan FastAPI dan vLLM. Di bawah beban berat, kami mengalami isu latensi pelayan yang tidak sepadan dengan apa yang dicadangkan oleh metrik penggunaan GPU kami. Penggunaan GPU menunjukkan corak tersentak: lonjak ke 93%, jatuh ke 0%, lonjak semula. Bukan penggunaan tinggi yang konsisten yang kami jangkakan. Soalannya: jika GPU mempunyai tempoh terbiar, di mana bottlenecknya? Artikel ini meliputi bagaimana kami mengenal pasti isu seni bina dalam persediaan FastAPI + multiprocessing kami yang menghalang penggunaan GPU yang cekap.

Konteks Sistem

Perkhidmatan terjemahan kami berjalan sebagai berbilang pelayan API di belakang pengimbang beban:
Gambaran Keseluruhan Sistem

Rajah 1: Seni bina sistem keseluruhan menunjukkan aplikasi klien, proksi/pengimbang beban, dan berbilang pelayan API

  • Klien: Web, mudah alih, perkhidmatan backend
  • Proksi: Mengarahkan permintaan berdasarkan pasangan bahasa dan kesihatan pelayan
  • Pelayan API: Berbilang contoh FastAPI, setiap satu menjalankan vLLM
Artikel ini memberi tumpuan kepada seni bina dalaman satu pelayan API dan bottlenecknya.

Seni Bina Pelayan API

Berikut adalah struktur dalaman satu pelayan API:
Seni Bina API

Rajah 2: Seni bina pelayan API tunggal menunjukkan FastAPI, baris gilir multiprocessing, proses pekerja, dan contoh vLLM

Komponen

1. Proses Utama FastAPI

# Event loop async single-threaded
@app.post("/translate")
async def translate_endpoint(request: TranslateRequest):
    result = await translation_service.translate(request)
    return result
  • Mengendalikan permintaan HTTP dengan async/await
  • Satu proses Python, satu event loop
  • I/O tidak menyekat untuk pengendalian permintaan serentak

2. TranslationService

class TranslationService:
    def __init__(self, worker: TranslationWorker):
        self.worker = worker

    async def translate(self, request: TranslateRequest) -> TranslateResponse:
        # Cipta tugas terjemahan
        event_task = self.worker.add_translation_task(
            text=request.text,
            source_lang=request.source_lang,
            target_lang=request.target_lang,
            timeout=30
        )

        # Tunggu secara tak segerak untuk keputusan
        await event_task.event.wait()
        return TranslateResponse(translation=event_task.result.translation)
  • Mencipta tugas terjemahan
  • Mengurus objek EventTask dengan asyncio.Event
  • Menjambatani async/await dengan multiprocessing

3. TranslationWorker (Proses Utama)

class TranslationWorker:
    def __init__(self):
        self.ctx = multiprocessing.get_context("spawn")
        self.translation_queue = None  # Dicipta dalam run()
        self.event_queue = None
        self.event_tasks: Dict[str, EventTask] = {}

    def _initialize(self):
        # Cipta baris gilir dalam proses utama
        self.translation_queue = self.ctx.JoinableQueue(maxsize=300)
        manager = self.ctx.Manager()
        self.translation_tasks = manager.dict()  # Keadaan dikongsi
        self.event_queue = self.ctx.Queue()

    def add_translation_task(...) -> EventTask:
        key = "t_" + generate_random_key(10)
        # Simpan dalam dict dikongsi
        self.translation_tasks[key] = TranslationTask(...)

        # Hantar ke pekerja melalui baris gilir
        self.translation_queue.put(key)  # Serialisasi

        # Cipta event untuk menunggu async
        event_task = EventTask(key)
        self.event_tasks[key] = event_task
        return event_task
  • Baris gilir dicipta dalam proses utama (dikongsi dengan pekerja)
  • JoinableQueue untuk pengagihan tugas
  • manager().dict() untuk keadaan tugas dikongsi
  • Baris gilir event untuk keputusan

4. Proses Pekerja

def run(self):
    for worker_id in range(self.num_workers):
        worker = self.ctx.Process(
            target=self.process_queue,
            args=(worker_id, ready_event)
        )
        worker.start()

def process_queue(self, worker_id, ready_event):
    # Setiap pekerja memuatkan contoh vLLM sendiri
    translation_processor = TranslationProcessor(
        worker_id=worker_id,
        model_key=self.model_key,
        gpu_memory_utilization=self.gpu_memory_per_worker
    )

    # Proses dari baris gilir dikongsi
    while True:
        key = self.translation_queue.get()  # Penyahserialisasi
        task = self.translation_tasks[key]

        # Terjemah menggunakan vLLM
        result = translation_processor.translate(
            task.text,
            task.source_lang,
            task.target_lang
        )

        # Hantar keputusan balik
        self.event_queue.put((key, EventType.completed, result))  # Serialisasi
  • Dicipta sebagai proses berasingan (ctx.Process)
  • Setiap satu memuatkan contoh model vLLM sendiri
  • Tarik dari translation_queue dikongsi
  • Kembalikan melalui event_queue dikongsi

5. EventTask (Penyegerakan Async)

class EventTask:
    def __init__(self, key: str):
        self.key = key
        self.event = asyncio.Event()  # Penyegerakan async
        self.event_type = EventType.waiting
        self.result = None

    def update(self, event_type, result):
        self.event_type = event_type
        self.result = result
        self.event.set()  # Bangkitkan coroutine yang menunggu
  • Menjambatani multiprocessing dengan async/await
  • Setiap permintaan mendapat EventTask
  • await event.wait() menyekat coroutine sehingga pekerja selesai

Aliran Permintaan

Berikut adalah apa yang berlaku untuk satu permintaan terjemahan:
Aliran Permintaan

Rajah 3: Aliran permintaan langkah demi langkah menunjukkan titik serialisasi dan menunggu async

Langkah demi langkah:
  1. Klien POST /translate -> FastAPI mencipta coroutine async
  2. async translate() -> TranslationService mengendalikan permintaan
  3. create_task() -> Jana ID, cipta TranslationTask dalam dict dikongsi
  4. queue.put(key) -> Serialisasikan kunci tugas, hantar ke pekerja (overhead IPC)
  5. Pekerja: vllm.translate() -> Pekerja memproses terjemahan
  6. event_queue.put(result) -> Serialisasikan keputusan, hantar balik (overhead IPC)
  7. event.set() -> Kemas kini EventTask, bangkitkan coroutine
  8. await event.wait() tidak disekat -> Dapatkan keputusan
  9. Kembalikan respons -> Hantar ke klien
Titik overhead:
  • Langkah 4: Serialisasi (pickle kunci tugas)
  • Langkah 6: Serialisasi (pickle keputusan)
  • Langkah 8: Menunggu async untuk keputusan multiprocessing
  • Koordinasi IPC sepanjang masa

Prestasi Garis Dasar

Sebelum percubaan pengoptimuman:
Prestasi Garis Dasar

Rajah 4: Prestasi garis dasar menunjukkan penurunan throughput dan peningkatan masa respons di bawah beban

Corak:
  • Masa respons berkembang secara linear (1.4s -> 11.3s)
  • Throughput menurun di bawah beban (3.3 -> 2.2 RPS)
  • Masa terjemahan vLLM sebenar setiap permintaan: 300-450ms
Penggunaan GPU

Rajah 5: Corak penggunaan GPU sebelum (tersentak) dan selepas (konsisten) pengoptimuman

Corak tersentak: GPU berselang-seli antara sibuk dan terbiar. Ini menunjukkan GPU sedang menunggu kerja, bukan terikat pengiraan.

Percubaan 1: Berbilang Pekerja

Hipotesis pertama: lebih banyak pekerja = penselarian lebih baik. Kami meningkatkan dari 1 pekerja ke 2 pekerja.

Konfigurasi

num_workers = 2
gpu_memory_per_model = 0.3
  • Pekerja 1: Model A+B
  • Pekerja 2: Model C
  • Kedua-dua berkongsi GPU yang sama

Keputusan

Perbandingan Dua Pekerja

Rajah 6: Kemerosotan prestasi apabila menambah proses pekerja kedua

Masa terjemahan median juga merosot: 452ms -> 2,239ms. Prestasi menurun merentasi semua tahap beban.

Mengapa Berbilang Pekerja Gagal

Keputusan ini masuk akal apabila anda memahami tingkah laku GPU dan seni bina kami.
Pertembungan GPU

Rajah 7: Berbilang proses pekerja bersaing untuk kapasiti pengiraan GPU

Isu: Pertembungan Pengiraan

Apabila satu pekerja sedang memproses terjemahan:
  • Ia menggunakan ~90% kapasiti pengiraan GPU
  • Pekerja lain tidak dapat menggunakan kapasiti selebihnya secara efektif secara selari
  • Pekerja akhirnya menunggu ketersediaan GPU
Mengapa tiada manfaat selari:
  • Pekerja 1 memulakan penjanaan vLLM -> menggunakan ~90% pengiraan GPU
  • Pekerja 2 cuba memulakan -> hanya ~10% pengiraan GPU tersedia
  • Pekerja 2 berjalan perlahan atau menunggu
  • Pelaksanaan berurutan secara efektif walaupun proses berasingan
Overhead tambahan:
  • Penciptaan dan pengurusan proses
  • Memori GPU dibahagi antara pekerja (setiap satu memuatkan pemberat model)
  • Koordinasi baris gilir IPC
  • Pertukaran konteks antara proses

Bottleneck Dikenal Pasti

Selepas eksperimen ini, kami mengenal pasti isu teras:

1. Overhead Serialisasi IPC

  • Setiap permintaan: serialisasikan tugas -> pekerja, serialisasikan keputusan -> utama
  • Baris gilir multiprocessing Python menggunakan pickle
  • Overhead pada setiap permintaan

2. Pertembungan Pengiraan

  • Satu pekerja menggunakan ~90% pengiraan GPU
  • Pekerja lain tidak dapat berjalan secara efektif secara selari
  • Pelaksanaan berurutan walaupun multiprocessing

3. Jambatan Async/Await + Multiprocessing

  • asyncio.Event menunggu keputusan multiprocessing
  • Pengguna baris gilir event berasaskan thread
  • Overhead koordinasi antara model async dan multiprocess

4. Kitaran GPU Terbuang

  • GPU terbiar sambil menunggu operasi baris gilir
  • Penggunaan tersentak (93% -> 0% -> 93%)
  • Masa terjemahan ~400ms, jumlah masa respons 11+ saat
  • Kebanyakan masa dihabiskan dalam baris gilir, bukan mengira

5. Kerumitan Seni Bina

  • FastAPI (async/await)
  • TranslationService (jambatan)
  • TranslationWorker (koordinasi)
  • JoinableQueue (IPC)
  • Proses pekerja (multiprocessing)
  • Baris gilir event (IPC)
  • EventTask (segerak async)
  • vLLM (kerja sebenar)
Setiap lapisan menambah latensi.

Pandangan Utama

1. Async/Await + Multiprocessing = Overhead

Menjambatani dua model keserentakan ini memerlukan koordinasi:
  • asyncio.Event untuk menunggu async
  • Kumpulan thread untuk menggunakan baris gilir event
  • Serialisasi pada sempadan proses
Jambatan ini mempunyai kos.

2. Berbilang Proses ≠ Penselarian GPU

Menambah proses pekerja tidak secara automatik meningkatkan penggunaan GPU apabila:
  • Satu pekerja menggunakan ~90% pengiraan GPU
  • Kapasiti selebihnya tidak mencukupi untuk kerja selari
  • Pelaksanaan berurutan walaupun overhead multiprocessing

3. Overhead Baris Gilir Mendominasi

Pada 25 permintaan serentak:
  • Masa terjemahan vLLM: ~400ms
  • Jumlah masa respons: 11,258ms
  • Overhead baris gilir: ~97% daripada jumlah masa
Kebanyakan masa dihabiskan dalam baris gilir dan koordinasi, bukan mengira.

4. GPU Tersentak = Isu Seni Bina

  • Penggunaan GPU konsisten (cth. 90-95%) menunjukkan beban kerja terikat pengiraan
  • Corak tersentak (93% -> 0% -> 93%) menunjukkan GPU sedang menunggu kerja - bottleneck di tempat lain (dalam kes kami, baris gilir dan IPC)

Kesimpulan

Bottleneck bukan kapasiti GPU. Ia adalah seni bina multiprocessing kami: Isu dikenal pasti:
  1. Overhead IPC daripada serialisasi baris gilir
  2. Pertembungan pengiraan GPU tanpa penselarian efektif
  3. Overhead koordinasi async/await + multiprocessing
  4. Kebanyakan latensi dari baris gilir, bukan pemprosesan vLLM
Gejala:
  • Penggunaan GPU tersentak
  • Masa respons didominasi oleh menunggu baris gilir
  • Menambah pekerja memburukkan prestasi
Dalam Bahagian 2, kami akan meliputi penyelesaian: menghapuskan multiprocessing, menggunakan AsyncLLMEngine vLLM secara langsung, dan mencapai peningkatan throughput 82% dalam pengeluaran.
Pratonton:
  • Buang seni bina multiprocessing sepenuhnya
  • Gunakan AsyncLLMEngine vLLM dengan FastAPI secara langsung
  • Saiz betul konfigurasi continuous batching
  • Keputusan pengeluaran: Throughput diperbaiki (+82%)