Bỏ qua

Phục vụ và Gộp Batch

Phục vụ một LLM cho hàng ngàn người dùng đồng thời đòi hỏi nhiều hơn là chỉ nạp mô hình và chạy suy luận. File này bao quát sự phân chia prefill-decode, gộp batch liên tục (continuous batching), PagedAttention và vLLM, các chiến lược lập lịch, kiến trúc phục vụ phân tách (disaggregated serving), phục vụ đa mô hình và LoRA, cùng các thước đo quan trọng

  • Một yêu cầu suy luận LLM đơn lẻ thì đơn giản: đưa vào token, sinh ra token output. Nhưng phục vụ một LLM cho 10,000 người dùng đồng thời với độ trễ thấp và thông lượng cao là một bài toán hệ thống. Cách tiếp cận ngây thơ (xử lý từng yêu cầu một) lãng phí hơn 90% dung lượng GPU. Gộp batch và lập lịch thông minh có thể tăng thông lượng gấp 10-50 lần mà không cần thêm phần cứng.

Prefill so với Decode: Hai Giai đoạn Rất Khác Nhau

  • Suy luận LLM có hai giai đoạn riêng biệt với các đặc tính tính toán hoàn toàn khác nhau:

  • Prefill (xử lý prompt): xử lý tất cả token đầu vào đồng thời. Đây là một phép nhân ma trận lớn duy nhất: \(O(\text{prompt\_length} \times d_{\text{model}}^2)\). Prompt có thể được xử lý song song (tất cả token đã biết). Prefill bị giới hạn bởi tính toán (compute-bound): các ALU của GPU là nút thắt cổ chai.

  • Decode (sinh token): sinh ra từng token một, theo phương thức tự hồi quy (autoregressive). Mỗi token mới cần attend đến tất cả token trước đó thông qua cache KV. Decode bị giới hạn bởi băng thông bộ nhớ (memory-bandwidth-bound): GPU dành phần lớn thời gian để nạp trọng số mô hình và cache KV từ bộ nhớ, chứ không phải để tính toán. Mỗi bước decode chỉ sinh ra một token nhưng phải nạp toàn bộ mô hình (~140 GB cho mô hình 70B ở FP16).

  • Các hệ quả:

Prefill Decode
Token xử lý Toàn bộ cùng lúc (song song) Từng token một (tuần tự)
Nút thắt cổ chai Tính toán (FLOPS) Băng thông bộ nhớ
Cường độ số học (Arithmetic intensity) Cao Rất thấp
Sử dụng GPU Cao (50-80%) Thấp (1-10%) nếu không gộp batch
Thước đo độ trễ Thời gian đến token đầu (TTFT) Thời gian mỗi token output (TPOT)
  • TTFT quan trọng cho trải nghiệm người dùng (bao lâu cho đến khi phản hồi bắt đầu streaming). TPOT quyết định tốc độ sinh cảm nhận được. Người dùng chấp nhận TTFT cao hơn (1-5 giây) nhưng mong đợi TPOT nhanh (30-100 ms mỗi token cho các ứng dụng hội thoại).

Gộp Batch Tĩnh (Static Batching, Ngây thơ)

  • Cách gộp batch đơn giản nhất: thu thập \(B\) yêu cầu, đệm (pad) chúng đến cùng độ dài, xử lý chúng như một batch duy nhất.

  • Vấn đề 1: các yêu cầu có độ dài prompt khác nhau và sinh ra số lượng token output khác nhau. Các yêu cầu ngắn kết thúc sớm nhưng phải đợi yêu cầu dài nhất trong batch trước khi batch tiếp theo có thể bắt đầu. GPU ngồi không trong khi sinh cho yêu cầu dài còn lại.

  • Vấn đề 2: đệm (padding) lãng phí tính toán. Nếu prompt dài nhất là 2000 token và ngắn nhất là 50, batch được đệm đến 2000. GPU xử lý 1950 token đệm cho yêu cầu ngắn — hoàn toàn lãng phí.

Static batching wastes GPU slots while waiting for the longest request; continuous batching fills freed slots immediately

Gộp Batch Liên tục (Continuous Batching)

  • Gộp batch liên tục (còn gọi là iteration-level batching) giải quyết cả hai vấn đề bằng cách vận hành ở mức độ hạt của từng bước decode riêng lẻ, thay vì toàn bộ yêu cầu.

  • Tại mỗi bước decode:

    1. Tất cả các yêu cầu đang chạy sinh một token song song (dưới dạng một batch).
    2. Các yêu cầu hoàn thành (sinh token EOS) được loại bỏ khỏi batch ngay lập tức.
    3. Các yêu cầu mới từ hàng đợi được chèn vào các chỗ trống ngay lập tức.
  • Kích thước batch thay đổi động mỗi bước. GPU không bao giờ ngồi không chờ các yêu cầu chậm, và không có đệm lãng phí (mỗi yêu cầu chỉ dùng đúng số slot nó cần).

  • Tác động: continuous batching thường tăng thông lượng gấp 2-10 lần so với static batching, mà không thay đổi chất lượng mô hình hay tăng độ trễ đáng kể.

PagedAttention và vLLM

  • Cache KV tạo ra một cơn ác mộng quản lý bộ nhớ. Mỗi yêu cầu có một cache KV phát triển với mỗi token được sinh. Các yêu cầu khác nhau ở các giai đoạn khác nhau (kích thước cache khác nhau). Cấp phát bộ nhớ liền kề cho mỗi yêu cầu lãng phí không gian (bạn phải cấp phát cho độ dài tối đa có thể, ngay cả khi yêu cầu chỉ sinh một vài token).

PagedAttention maps virtual KV-cache pages to non-contiguous physical GPU memory, eliminating fragmentation and enabling on-demand allocation

  • PagedAttention (Kwon et al., 2023) áp dụng khái niệm bộ nhớ ảo của hệ điều hành (chương 13) vào cache KV. Cache được chia thành các trang (page) có kích thước cố định (các khối vị trí token). Các trang được cấp phát theo nhu cầu và có thể không liền kề trong bộ nhớ GPU vật lý.

  • Các lợi ích:

    • Không phân mảnh: các trang có kích thước đồng nhất, nên không có "lỗ hổng" bộ nhớ lãng phí giữa các yêu cầu.
    • Cấp phát lười: bộ nhớ chỉ được cấp phát khi token thực sự được sinh ra, không phải cấp phát trước cho độ dài tối đa.
    • Copy-on-write: các yêu cầu chia sẻ một tiền tố chung (ví dụ system prompt) chia sẻ cùng các trang cache KV. Chỉ khi các yêu cầu phân nhánh, các trang mới được sao chép.
  • vLLM là engine suy luận được xây dựng xoay quanh PagedAttention. Nó đạt thông lượng cao gấp 2-4 lần so với phục vụ cấp phát tĩnh (như HuggingFace text-generation-inference không có paged attention) bằng cách loại bỏ gần như hoàn toàn lãng phí bộ nhớ KV-cache.

Chiến lược Lập lịch (Scheduling)

  • Khi nhiều yêu cầu đang chờ và GPU chỉ có thể xử lý một số lượng batch giới hạn, lập lịch quyết định yêu cầu nào được phục vụ trước:

  • Đến trước phục vụ trước (FCFS): xử lý yêu cầu theo thứ tự đến. Đơn giản nhưng không công bằng: một người dùng gửi yêu cầu sinh 10K token sẽ chặn tất cả người dùng phía sau.

  • Công việc ngắn nhất trước (SJF): xử lý yêu cầu sẽ kết thúc sớm nhất. Cực tiểu hóa độ trễ trung bình nhưng phạt các yêu cầu chạy dài (chúng có thể bị bỏ đói - starvation). Trong thực tế, độ dài output ước tính không biết trước, nên SJF dùng các heuristic (độ dài prompt, lịch sử người dùng).

  • Ưu tiên (Preemption): nếu một yêu cầu ưu tiên cao đến, tạm dừng một yêu cầu ưu tiên thấp đang chạy (swap cache KV của nó sang bộ nhớ CPU hoặc SSD), phục vụ yêu cầu ưu tiên cao, rồi tiếp tục yêu cầu đã tạm dừng. vLLM hỗ trợ điều này.

  • Dựa trên ưu tiên: gán mức ưu tiên cho người dùng hoặc loại yêu cầu. Các truy vấn tương tác thời gian thực được ưu tiên cao hơn so với các công việc xử lý batch. Kết hợp với preemption, điều này đảm bảo các SLO về độ trễ cho lưu lượng ưu tiên cao.

  • Ngân sách token (Token budget): giới hạn tổng số token trong batch đang hoạt động. Ngăn một vài yêu cầu dài độc chiếm bộ nhớ GPU và bỏ đói các yêu cầu mới.

Phục vụ Phân tách (Disaggregated Serving)

  • Prefill và decode có các đặc tính tính toán đối lập nhau. Chạy cả hai trên cùng một GPU đồng nghĩa với việc GPU luân phiên giữa bị giới hạn tính toán (prefill) và bị giới hạn băng thông bộ nhớ (decode), không bao giờ tận dụng hoàn toàn một loại tài nguyên nào.

  • Phục vụ phân tách tách chúng ra:

    • Nút Prefill: các GPU tối ưu cho tính toán (FLOPS cao, có thể ít bộ nhớ hơn). Xử lý tất cả prompt đến.
    • Nút Decode: các GPU tối ưu cho băng thông bộ nhớ (dung lượng cache KV lớn, băng thông bộ nhớ cao). Xử lý tất cả việc sinh token.
  • Nút prefill tính toán cache KV ban đầu và gửi nó đến nút decode (qua NVLink hoặc mạng). Nút decode sinh token bằng cách dùng cache đã nhận.

  • Đây là kiến trúc của Mooncake (Moonshot AI) và đang được một số đội nhóm phục vụ LLM khám phá. Lợi ích: mỗi loại GPU được kết hợp với đặc điểm khối lượng công việc của nó, cải thiện tổng thể việc sử dụng tài nguyên.

Phục vụ Đa Mô hình và LoRA

  • Trong sản xuất, bạn thường phục vụ nhiều mô hình (kích thước khác nhau cho các tầng khác nhau, các biến thể tinh chỉnh khác nhau cho các tác vụ khác nhau).

  • Đa kênh mô hình (Model multiplexing): nạp nhiều mô hình trên cùng một GPU và định tuyến yêu cầu đến mô hình phù hợp. Bộ nhớ GPU được chia sẻ: một GPU 40 GB có thể chứa đồng thời mô hình 13B (26 GB) và mô hình 7B (14 GB).

  • Phục vụ LoRA: thay vì triển khai các mô hình tinh chỉnh riêng biệt, triển khai một mô hình nền với nhiều bộ điều hợp LoRA (LoRA adapters, chương 6). Mỗi adapter thêm <1% tham số. Các yêu cầu được định tuyến đến adapter phù hợp tại thời điểm suy luận.

  • S-LoRA (Sheng et al., 2023): phục vụ hàng nghìn bộ điều hợp LoRA từ một mô hình nền duy nhất. Các adapter được lưu trên CPU và nạp vào bộ nhớ GPU theo nhu cầu (paged). Cache KV và trọng số của mô hình nền được chia sẻ; chỉ có các ma trận LoRA nhỏ là khác nhau giữa các yêu cầu.

  • Punica (Chen et al., 2023): gộp batch các yêu cầu trên các bộ điều adapter LoRA khác nhau bằng cách dùng một kernel CUDA tùy chỉnh áp dụng các ma trận LoRA khác nhau cho các yêu cầu khác nhau trong cùng một batch. Điều này tránh chi phí chuyển đổi adapter cho mỗi yêu cầu.

Sinh Có Ràng buộc và Định hướng (Constrained and Guided Generation)

  • Nhiều ứng dụng cần LLM tạo output theo một định dạng cụ thể: JSON hợp lệ, truy vấn SQL, mã trong một ngôn ngữ nhất định, hoặc các phản hồi tuân theo một schema. Sinh có ràng buộc (constrained generation) đảm bảo output tuân thủ một ngữ pháp hoặc schema.

  • Giải mã có ràng buộc ngữ pháp (grammar-constrained decoding): ở mỗi bước giải mã, che giấu (mask) các token vi phạm ngữ pháp. Nếu output cho đến nay là {"name": "Alice", "age": và ngữ pháp yêu cầu một số nguyên tiếp theo, hãy che mọi token trừ các chữ số. Phân bố xác suất của LLM được chuẩn hóa lại trên các token hợp lệ.

  • Outlines (Willard & Louf, 2023): biên dịch một schema JSON hoặc biểu thức chính quy thành một máy trạng thái hữu hạn (FSM). Ở mỗi bước giải mã, FSM xác định những token nào là phần tiếp nối hợp lệ. Các token không hợp lệ nhận xác suất 0. Điều này đảm bảo tuân thủ schema 100% mà không cần thử lại.

  • SGLang tích hợp sinh có ràng buộc một cách tự nhiên: bạn chỉ định cấu trúc output bằng Python, và engine xử lý việc mask token và caching một cách hiệu quả. Điều này kết hợp với RadixAttention (tiền tố caching) sao cho các output có cấu trúc tái sử dụng các tiền tố đã cache.

  • Tại sao nó quan trọng: không có sinh có ràng buộc, bạn tạo tự do và phân tích output, thử lại khi thất bại. Tỷ lệ thử lại 10-30% là phổ biến cho các schema JSON phức tạp, lãng phí tính toán. Sinh có ràng buộc loại bỏ hoàn toàn việc thử lại.

Định tuyến Yêu cầu (Request Routing)

  • Không phải mọi truy vấn đều cần mô hình lớn nhất. Định tuyến yêu cầu hướng các truy vấn đến các mô hình khác nhau dựa trên độ khó ước tính:

  • Thác tác (Cascading): thử một mô hình nhỏ trước. Nếu độ tự tin của mô hình nhỏ dưới ngưỡng (ví dụ xác suất softmax của token hàng đầu < 0.8), leo thang lên mô hình lớn hơn. Các truy vấn dễ (hơn 80% lưu lượng) được phục vụ rẻ bởi mô hình nhỏ; chỉ các truy vấn khó mới dùng mô hình đắt.

  • Định tuyến có học (Learned routing): huấn luyện một bộ phân loại nhẹ (hoặc dùng perplexity của mô hình nhỏ) để dự đoán truy vấn cần tầng mô hình nào. Định tuyến "2+2 bằng mấy?" đến mô hình 3B và "Giải thích nền tảng toán học của vướng víu lượng tử" đến mô hình 70B.

  • Tác động: nếu 80% truy vấn có thể được xử lý bởi một mô hình rẻ hơn 10 lần, chi phí trung bình mỗi truy vấn giảm ~70%. Đây là một trong những tối ưu hóa chi phí có tác động lớn nhất cho triển khai đa mô hình.

  • Định tuyến lai on-device + cloud: Cactus (github.com/cactus-compute/cactus) hiện thực định tuyến yêu cầu ở mức thiết bị. Nó chạy một mô hình nhỏ on-device (điện thoại, laptop, thiết bị đeo) qua các kernel ARM SIMD tùy chỉnh, và tự động định tuyến lên mô hình cloud khi độ tự tin của mô hình local thấp hoặc truy vấn vượt quá khả năng của thiết bị. Ứng dụng dùng một API tương thích OpenAI cho cả hai đường dẫn — việc định tuyến là trong suốt. Đây là thác tác ở mức hạ tầng: tầng đầu tiên miễn phí (on-device), tầng thứ hai tốn tiền (cloud API). Với các ứng dụng mà phần lớn truy vấn đơn giản (trợ lý Q&A, tự động hoàn thành, ghi chép), xử lý on-device bao phủ 70-90% lưu lượng với chi phí biên bằng không.

Các Thước đo Suy luận (Inference Metrics)

  • Các thước đo đúng phụ thuộc vào trường hợp sử dụng:
Thước đo Đo gì Mục tiêu (Hội thoại) Mục tiêu (Batch)
TTFT Thời gian đến token đầu <1 s ít quan trọng
TPOT Thời gian mỗi token output <100 ms ít quan trọng
Thông lượng Token/giây (tổng) ít quan trọng cực đại hóa
Độ trễ p99 1% yêu cầu tồi tệ nhất <5 s <30 s
Chi phí mỗi token $/1M token cực tiểu hóa cực tiểu hóa
Tuân thủ SLO % yêu cầu đạt mục tiêu độ trễ >99% >95%
  • Sự đánh đổi TTFT so với TPOT: gộp batch mạnh mẽ tăng thông lượng (nhiều token/s tổng) nhưng tăng TPOT (mỗi token mất lâu hơn vì GPU xử lý nhiều yêu cầu hơn). Chiến lược lập lịch phải cân bằng thông lượng (doanh thu) với độ trễ (trải nghiệm người dùng).

  • Chi phí mỗi token là thước đo tối thượng cho sản xuất. Nó kết hợp chi phí phần cứng (thuê GPU), thông lượng (token/s), và mức sử dụng. Một hệ thống chạy ở 50% sử dụng GPU tốn gấp 2 lần mỗi token so với hệ thống ở 100%. Đây là lý do gộp batch, lập lịch và PagedAttention lại quan trọng đến thế — chúng tăng mức sử dụng.

Bài tập lập trình (dùng CoLab hoặc notebook)

  1. Mô phỏng gộp batch liên tục so với tĩnh và đo sự khác biệt thông lượng.
import random
import time

def simulate_static_batching(requests, batch_size=8):
    """Process requests in fixed batches. Wait for all to finish."""
    total_tokens = 0
    total_time = 0

    for i in range(0, len(requests), batch_size):
        batch = requests[i:i + batch_size]
        max_len = max(r['output_len'] for r in batch)
        # All requests in the batch take as long as the longest
        batch_time = max_len * 0.01  # 10ms per token
        total_time += batch_time
        total_tokens += sum(r['output_len'] for r in batch)

    return total_tokens / total_time  # tokens per second

def simulate_continuous_batching(requests, max_batch=8):
    """Process with continuous batching. Remove finished, add new."""
    total_tokens = 0
    total_time = 0
    active = []
    queue = list(requests)

    while active or queue:
        # Fill batch
        while len(active) < max_batch and queue:
            active.append({'remaining': queue.pop(0)['output_len']})

        if not active:
            break

        # One decode step: all active requests generate 1 token
        for req in active:
            req['remaining'] -= 1
        total_tokens += len(active)
        total_time += 0.01  # 10ms per step

        # Remove finished requests
        active = [r for r in active if r['remaining'] > 0]

    return total_tokens / total_time

# Generate requests with varied output lengths
random.seed(42)
requests = [{'output_len': random.randint(10, 500)} for _ in range(100)]

static_tps = simulate_static_batching(requests)
continuous_tps = simulate_continuous_batching(requests)

print(f"Static batching:     {static_tps:.0f} tokens/s")
print(f"Continuous batching: {continuous_tps:.0f} tokens/s")
print(f"Speedup: {continuous_tps / static_tps:.1f}x")
  1. Tính tiết kiệm bộ nhớ cache KV từ PagedAttention. So sánh cấp phát trước (trường hợp xấu nhất) so với phân trang (sử dụng thực tế).
def paged_vs_preallocated(n_requests, max_seq_len, avg_seq_len, page_size, kv_per_token_bytes):
    """Compare memory usage: preallocated vs paged KV-cache."""
    # Preallocated: every request gets max_seq_len slots
    preallocated_gb = n_requests * max_seq_len * kv_per_token_bytes / 1e9

    # Paged: allocate only what is used (with page granularity)
    import math
    avg_pages = math.ceil(avg_seq_len / page_size)
    paged_gb = n_requests * avg_pages * page_size * kv_per_token_bytes / 1e9

    waste_preallocated = (max_seq_len - avg_seq_len) / max_seq_len
    waste_paged = (avg_pages * page_size - avg_seq_len) / (avg_pages * page_size)

    print(f"Requests: {n_requests}, Max seq: {max_seq_len}, Avg seq: {avg_seq_len}")
    print(f"  Preallocated: {preallocated_gb:.1f} GB (waste: {waste_preallocated:.0%})")
    print(f"  Paged:        {paged_gb:.1f} GB (waste: {waste_paged:.0%})")
    print(f"  Savings:      {preallocated_gb - paged_gb:.1f} GB ({preallocated_gb/paged_gb:.1f}x)")
    print()

# Llama-70B: ~1.3 KB per token per layer, 80 layers = ~100 KB per token total
kv_bytes = 100_000

# Scenario 1: short requests, large max
paged_vs_preallocated(256, max_seq_len=4096, avg_seq_len=256, page_size=16, kv_per_token_bytes=kv_bytes)

# Scenario 2: varied lengths
paged_vs_preallocated(256, max_seq_len=8192, avg_seq_len=1024, page_size=16, kv_per_token_bytes=kv_bytes)

# Scenario 3: long context
paged_vs_preallocated(64, max_seq_len=131072, avg_seq_len=16000, page_size=16, kv_per_token_bytes=kv_bytes)