Trao đổi về dự án
Trí Tuệ Nhân Tạo

GPU thứ hai có làm AI cục bộ của bạn nhanh hơn không?

AI cục bộ của bạn tạo văn bản: thêm card đồ họa thứ hai có làm nó nhanh hơn? Chạy cùng một mô hình trong hai cấu hình và học cách phân biệt các thời gian tính toán khác nhau.

Même charge : Contexte fixé → 1 ou 2 GPU : Modèle identique → Mesures : Latence et débit.

Ứng dụng của bạn gửi một văn bản đến một mô hình cục bộ, rồi chờ phản hồi. Việc xử lý vẫn hoạt động, nhưng thời gian chờ có vẻ hơi dài. Một ý tưởng tự nhiên xuất hiện: thêm một card đồ họa thứ hai để nhanh gấp đôi.

Liệu đó có thực sự là điều sẽ xảy ra? Chưa chắc. Hai card có thể chia nhau một công việc, nhưng chúng cũng phải trao đổi dữ liệu. Và không phải mọi bước của quá trình sinh văn bản đều hưởng lợi như nhau từ cách phân chia này.

Chúng ta sẽ kiểm chứng điều đó bằng một bộ đo nhỏ với Python và Docker. Nguyên tắc giống một bài kiểm tra hiệu năng API: gửi một tải đã biết, bấm giờ phản hồi, lưu kết quả và so sánh hai cấu hình. Điểm mới là hiểu mô hình làm gì trong lúc chờ đó.

Tải toàn bộ dự án của hướng dẫn. Dự án so sánh cùng một Qwen 7B Q8_0, chạy bằng llama.cpp, ở sáu chế độ: một hoặc hai GPU, với sức chứa ngữ cảnh 512, 2 048 và 4 096 token.

Kịch bản ứng dụng web là điểm xuất phát: tệp nén cung cấp một công cụ đo, không phải tính năng nghiệp vụ sẵn sàng triển khai. Bạn sẽ không huấn luyện mô hình mà dùng một mô hình đã được huấn luyện và quan sát việc thực thi của nó. Biết đọc hàm Python, JSON và lệnh Docker là đủ để theo hướng dẫn; các khái niệm riêng của mô hình ngôn ngữ được giới thiệu bên dưới.

Các kết quả trình bày là của những lần tái lập r3 của dự án. Các ví dụ tính toán nhỏ được ghi rõ là phục vụ giảng giải, còn các phép tính bổ sung trên số đo cũng được nhận diện như vậy: chúng không phải các thí nghiệm GPU mới.

Giao thức so sánh GPU đơn và kép

Những khái niệm cần biết trước khi chạy lệnh

Mô hình, động cơ và GPU không phải là một

Mô hình ngôn ngữ, hay LLM, ở đây là Qwen 7B. Các tham số của nó, còn gọi là trọng số, là những giá trị số có được từ quá trình học. Tên « 7B » chỉ kích thước cỡ bảy tỷ tham số, không phải bảy tỷ từ được lưu trong cơ sở dữ liệu.

llama.cpp là động cơ nạp mô hình này và thực hiện các phép tính cần thiết để tạo văn bản. Trong hướng dẫn này, nó cung cấp một máy chủ HTTP: chương trình Python của chúng ta gọi nó như gọi một dịch vụ khác. Dùng mô hình để tạo đầu ra được gọi là suy luận.

Biến thể Q8_0 chỉ một phương pháp lượng tử hóa: cách biểu diễn trọng số gọn hơn, ở đây dùng các giá trị lượng tử hóa trên 8 bit và các hệ số tỷ lệ. Tên này không có nghĩa toàn bộ bộ nhớ hay mọi phép tính của chương trình đều dùng 8 bit. Giới thiệu về định dạng GGUF và các cách lượng tử hóa bổ sung cho điểm này.

Chúng ta giữ biến thể này giống hệt trong cả sáu chế độ. So sánh mô hình Q4 trên một card với mô hình Q8 trên hai card sẽ trộn hai thay đổi: cách biểu diễn mô hình và cách phân bố phần cứng. Đó không phải thí nghiệm chúng ta muốn thực hiện.

Token không nhất thiết là một từ

Một token, hay jeton, là đơn vị mô hình dùng để biểu diễn văn bản. Tùy cách tách, đơn vị này có thể tương ứng với một từ, một phần từ hoặc một ký hiệu. Vì vậy không thể luôn thay « 64 jeton » bằng « 64 từ » một cách máy móc.

Văn bản gửi đến mô hình là prompt. Ở đây nó gồm một phần đệm, gọi là padding, rồi đến văn bản của một yêu cầu. Phần đệm này thực sự là văn bản phải xử lý: không phải một dãy ô trống mà mô hình sẽ bỏ qua.

Chúng ta sẽ đếm riêng token đầu vào và đầu ra. Điều này cũng quan trọng như phân biệt kích thước của một yêu cầu HTTP với kích thước phản hồi của nó.

Sức chứa ngữ cảnh: chỗ sẵn có, không phải chỗ đã dùng

Sức chứa ngữ cảnh là giới hạn cấu hình cho chuỗi mà máy chủ có thể quản lý. Trong hướng dẫn này, prompt và phần sinh được yêu cầu phải nằm trong giới hạn đó. Sáu chế độ dùng ba sức chứa: 512, 2 048 và 4 096.

Một phép ví von từ web giúp tránh nhầm lẫn: cho phép tệp 10 Mo trong biểu mẫu không có nghĩa mọi tệp tải lên đều nặng 10 Mo. Tương tự, cấu hình 4 096 token không có nghĩa gửi một prompt 4 096 token.

Giao thức tạo một padding cố định cho mỗi sức chứa. Với một sức chứa nhất định, nó áp dụng cùng phần đệm cho cùng 30 yêu cầu ở chế độ đơn và kép. Vì vậy các prompt có thể so sánh giữa hai chế độ; điều đó không có nghĩa cả 30 câu hỏi đều dài chính xác như nhau.

Trường cần đọc là tokens_evaluated, lưu số token của prompt do máy chủ báo cáo. Nhờ đó chúng ta sẽ thấy rằng ở thiết lập 4 096, các prompt được đo có trung bình 2 080 token, chứ không phải 4 096.

Trong lúc chờ có hai giai đoạn khác nhau

Tiền điền, hay prefill, là việc xử lý prompt đã có sẵn. Mô hình phải tính các biểu diễn cần thiết để bắt đầu trả lời.

Giải mã sau đó là quá trình tạo các token mới. Trong sinh tự hồi quy, mỗi token mới được chọn dựa trên những gì đi trước. Vì thế tạo phần tiếp theo không chỉ là chạy song song 64 phép tính độc lập. Tài liệu Hugging Face về suy luận LLM giải thích cơ chế này và các trạng thái trung gian được giữ trong lúc sinh.

Đối với client Python của chúng ta, các giai đoạn này nằm trong cùng một lần chờ HTTP. Máy chủ cung cấp các thời gian nội bộ, nhưng client bấm giờ toàn bộ lời gọi. Cũng như với trình phân tích hiệu năng web, thời gian của một truy vấn SQL và tổng thời gian của một route không phải là hai tên cho cùng một phép đo.

Vì vậy chúng ta sẽ lưu thời lượng HTTP, thời gian tiền điền và thời gian giải mã. Đồng hồ bấm giờ không đo thời gian hiển thị một trang trong trình duyệt: nó quan sát client đo lường do dự án cung cấp.

Vì sao phải yêu cầu đúng 64 token?

Hãy giả sử, chỉ để hiểu vấn đề, lần thử đầu sinh 12 token còn lần thứ hai sinh 64. Dù lần đầu kết thúc nhanh hơn, chúng ta đã không yêu cầu cùng một lượng sinh văn bản.

Do đó dự án yêu cầu n_predict=64 và bật ignore_eos. EOS là dấu kết thúc sinh; bỏ qua nó giúp một điểm dừng tự nhiên không rút ngắn phản hồi. Sau đó chương trình kiểm tra máy chủ thực sự đã tạo 64 token.

Nhờ vậy chúng ta tạo một tải có thể so sánh, không phải đánh giá chất lượng phản hồi. Đầu ra bị ép thành 64 token có thể quá dài cho câu hỏi ngắn hoặc dừng trước khi hết câu. Hành vi này là chủ đích cho phép đo.

Một GPU hay hai GPU: công việc được phân chia thế nào?

VRAM là bộ nhớ của card đồ họa. Nó khác RAM của máy. Ở chế độ mono, mô hình được nạp trên một card. Ở chế độ dual, hướng dẫn mô tả việc chia các lớp của nó trên hai card, với tensor-split 1,1.

Một lớp là một bước tính toán của mạng. Để hình dung, hãy tưởng tượng một chuỗi xử lý mà các bước đầu ở trên một máy còn các bước sau ở máy khác: kết quả trung gian phải chuyển từ máy này sang máy kia. Máy thứ hai không tự động thực hiện mọi bước cùng lúc.

Trong llama.cpp, --split-mode chọn cách phân chia mô hình; --tensor-split cung cấp các tỷ lệ. 1,1 biểu thị các phần bằng nhau, không phải lời hứa thời gian giảm một nửa hay mức sử dụng bộ nhớ hoàn toàn giống nhau. Chế độ đơn của dự án dùng --split-mode none. Tài liệu máy chủ phân biệt các tham số này; hãy giữ các tham số trong ZIP để tái lập hướng dẫn.

Hai VRAM không trở thành một bộ nhớ hợp nhất 48 Gio. Sự hiện diện của NVLink, một liên kết phần cứng giữa các card, không được giả định trong thí nghiệm này. Cũng không nên nhầm việc chia một mô hình này với hai máy chủ độc lập, mỗi máy nhận các yêu cầu riêng.

Cuối cùng, tải là tuần tự: client của chúng ta chờ một phản hồi trước khi gửi yêu cầu tiếp theo. Vì vậy chúng ta chưa tìm hiểu máy chủ có thể phục vụ bao nhiêu người dùng đồng thời.

Mã giúp kiểm soát phép so sánh

Sáu chế độ, nhưng ba so sánh hữu ích

Ma trận thí nghiệm rất đơn giản:

Sức chứa ngữ cảnh Tham chiếu trên một GPU So sánh trên hai GPU
512 mono-512 dual-512
2 048 mono-2048 dual-2048
4 096 mono-4096 dual-4096

Mỗi phép so sánh diễn ra trên một hàng của bảng này, tức ở cùng sức chứa. Đặt mono-512 cạnh dual-4096 sẽ không cô lập được thay đổi về phân bố: thiết lập ngữ cảnh và tải được tạo cho thiết lập đó cũng thay đổi.

Trong cli.py, mỗi lệnh measure-<mode> tạo prompt từ phần đệm dự kiến và 30 yêu cầu. Hàm one() gọi /completion với các tham số cố định: 64 token được yêu cầu, nhiệt độ 0, hạt giống 42, ignore_eos=true và cache_prompt=false.

Nhiệt độ và hạt giống là các thiết lập được giữ lại để hạn chế biến thiên liên quan đến việc sinh. Chúng không biến một thời lượng đo được thành hằng số phổ quát: trạng thái máy và động cơ thực thi vẫn có ảnh hưởng.

Khởi động làm nóng và bộ nhớ đệm trả lời hai câu hỏi khác nhau

Trước các phép đo được giữ lại, chương trình gửi một yêu cầu làm nóng, hay warm-up. Nó đưa máy chủ qua đường sinh, nhưng thời lượng của nó không nằm trong trung bình. Vì vậy việc nạp ban đầu và yêu cầu đầu tiên không bị trộn vào 30 yêu cầu sau.

Điều đó không có nghĩa mọi ảnh hưởng khởi động hay biến động đều biến mất. Làm nóng là một quy tắc của giao thức, không phải bảo đảm trạng thái phần cứng hoàn hảo.

Thiết lập cache_prompt=false liên quan đến việc tái sử dụng một tiền tố đã được xử lý trong yêu cầu trước đó. Chúng ta không muốn một chế độ hưởng lợi từ prompt chung đã tính sẵn trong khi chế độ kia phải đọc lại. Thiết lập này không có nghĩa « tắt mọi bộ nhớ đệm của máy ». Chẳng hạn, bộ nhớ đệm các lớp Docker là một chủ đề khác.

Cũng không nên nhầm nó với các trạng thái trung gian được giữ trong cùng một lần sinh, thường gọi là cache KV. Các trạng thái này tránh phải tính lại một số thành phần của văn bản trước đó cho mỗi token mới. Sự khác biệt giữa tái sử dụng giữa các yêu cầu và tính một phản hồi được trình bày chi tiết trong tài liệu của llama.cpp và Hugging Face.

Lưu các quan sát trước khi tạo trung bình

Với mỗi yêu cầu, mã lưu prompt, văn bản thô được tạo, thời lượng HTTP, các timings của máy chủ, tokens_predicted, tokens_evaluated và chỉ báo cắt bớt.

Tại sao phải giữ nhiều chi tiết như vậy? Vì chỉ một giá trị trung bình không cho thấy một phản hồi quá ngắn hay một ngữ cảnh đã tràn. Các dòng riêng lẻ có vai trò như dấu vết yêu cầu trong ứng dụng web: chúng cho phép quay lại điều đã thực sự được thực thi.

summarize() kiểm tra một số điều kiện trước khi gộp kết quả. Sau đó compare() tính tỷ số temps_mono / temps_dual, ở cùng sức chứa. Tỷ số lớn hơn 1 nghĩa là dual nhanh hơn; tỷ số nhỏ hơn 1 nghĩa là nó chậm hơn. Chúng ta sẽ dành thời gian tính các ví dụ sau khi thực thi.

Một độ trễ không chỉ là một phép đo

Các hàm chính cần đọc trước khi thực thi

Chờ máy chủ, rồi mới khởi động đồng hồ

Trong client.py, ready() truy vấn /health tối đa 180 giây để chờ máy chủ sẵn sàng. request() đặt thời hạn HTTP là 120 giây. Chờ một tiến trình sẵn sàng và giới hạn một lời gọi HTTP là hai trách nhiệm khác nhau.

Với một lập trình viên web, đây là khác biệt giữa « container đã khởi động » và « dịch vụ thực sự phản hồi ». Một máy chủ vẫn đang nạp mô hình không nên bị xem là một yêu cầu suy luận chậm bất thường.

one() : một yêu cầu, một quan sát đầy đủ

Đây là lõi của phép đo. padding, data, mode, capacity, request() và save() do phần còn lại của dự án cung cấp: đoạn trích này phải được đọc trong ngữ cảnh, không phải một script độc lập để sao chép vào thư mục trống.

def one(query):
    prompt = padding + query["query"]
    start = time.perf_counter()
    result = request(
        "/completion",
        {
            "prompt": prompt,
            "n_predict": 64,
            "temperature": 0,
            "seed": 42,
            "ignore_eos": True,
            "cache_prompt": False,
        },
    )
    return {
        **query,
        "prompt": prompt,
        "raw": result["content"],
        "seconds": time.perf_counter() - start,
        "timings": result["timings"],
        "tokens_predicted": result["tokens_predicted"],
        "tokens_evaluated": result["tokens_evaluated"],
        "truncated": result.get("truncated", False),
    }


one(data["queries"][0])
rows = [one(q) for q in data["queries"]]
summarize(rows)
save(mode, {"mode": mode, "context_capacity": capacity, "rows": rows})

Bắt đầu với prompt = padding + query["query"]. Chương trình trước hết tạo văn bản yêu cầu, rồi khởi động đồng hồ bằng time.perf_counter(). Vì vậy việc tạo chuỗi này xảy ra trước phép đo HTTP.

perf_counter() cung cấp một bộ đếm phù hợp để đo thời lượng. Điều quan trọng là chênh lệch giữa hai lần gọi, không phải giá trị tuyệt đối của bộ đếm. Chờ phản hồi là một phần của thời gian trôi qua: chúng ta không chỉ đo thời gian CPU Python hoạt động. Đó là hành vi được mô tả trong tài liệu chính thức của Python.

request() chờ phản hồi từ máy chủ. Giá trị seconds được tính sau khi nó trả về, còn timings lưu các phép đo phía máy chủ. Việc ghi tệp bằng save() đến sau; thời gian của nó không được thêm vào từng thời lượng HTTP.

Ký hiệu **query sao chép các trường của yêu cầu vào từ điển kết quả. Sau đó mã bổ sung những gì đã quan sát. Điều này giúp gắn một thời lượng với đầu vào của nó thay vì chỉ giữ danh sách số vô danh.

Cũng lưu ý result.get("truncated", False): nếu trường thiếu, đoạn trích ghi False. Lựa chọn này phụ thuộc vào hợp đồng với phiên bản máy chủ được dùng; nó không phải cơ chế độc lập có thể phát hiện mọi trường hợp cắt bớt. Để nghiên cứu kết quả r3, hãy giữ máy chủ và các tệp được cung cấp cùng nhau.

Yêu cầu đầu tiên chạy hai lần, nhưng chỉ được đo một lần trong danh sách

Dòng one(data["queries"][0]) gọi câu hỏi đầu tiên để làm nóng và không giữ kết quả trong rows.

List comprehension [one(q) for q in data["queries"]] sau đó duyệt 30 yêu cầu, kể cả yêu cầu đầu tiên. Nó chờ từng kết quả trước khi tiếp tục: ở đây không có đồng thời nào được đưa vào.

Vì vậy phép đếm của vòng lặp này cho 31 lần gọi /completion cho mỗi chế độ: 1 lần làm nóng và 30 dòng được giữ lại. Với sáu chế độ, đó là 180 dòng đo và 6 lần làm nóng bổ sung, chưa kể các lời gọi khác có thể có trong hướng dẫn. Làm nóng thực sự được chạy, nhưng không được trở thành dòng thứ ba mươi mốt trong trung bình.

summarize() : kiểm tra trước khi tính

Đoạn trích thứ hai là hàm tổng hợp:

def summarize(rows):
    if not rows:
        raise ValueError("Mesures vides")
    if any(
        r["tokens_predicted"] != 64
        or r["truncated"]
        or r["seconds"] <= 0
        for r in rows
    ):
        raise ValueError("Longueur, contexte ou temps invalide")
    return {
        "queries": len(rows),
        "mean_ms": 1000 * sum(r["seconds"] for r in rows) / len(rows),
        "tokens_per_second": 64 * len(rows) / sum(r["seconds"] for r in rows),
        "mean_prompt_tokens": sum(r["tokens_evaluated"] for r in rows) / len(rows),
        "mean_prompt_ms": sum(r["timings"]["prompt_ms"] for r in rows) / len(rows),
        "mean_decode_ms": sum(r["timings"]["predicted_ms"] for r in rows) / len(rows),
    }

Điều kiện bảo vệ đầu tiên từ chối danh sách rỗng. Điều kiện thứ hai duyệt các dòng bằng any(...): ngay khi một dòng tạo khác 64 token, báo cắt bớt hoặc có thời lượng nhỏ hơn hay bằng 0, phép tính dừng lại với một lỗi.

Các lần từ chối này có ích rất cụ thể. Một phản hồi dừng sau 8 token không được làm tăng giả tạo thông lượng của một cấu hình. Một prompt tràn ngữ cảnh không được so sánh như thể đã được xử lý trong cùng điều kiện.

Từ điển được trả về sau đó tập hợp số dòng, thời gian HTTP trung bình, thông lượng tính trên tổng thời gian, độ dài prompt trung bình và hai trung bình thời gian phía máy chủ. Chúng ta sẽ giải thích chi tiết các công thức trong phần về chỉ số.

Lưu ý phạm vi chính xác của đoạn trích: summarize() trả về len(rows), nhưng không có kiểm tra bắt buộc len(rows) == 30. Bản thân nó cũng không so sánh prompt của các tệp mono và dual. Ba mươi yêu cầu cùng cách tạo của chúng đến từ hướng dẫn; cần tìm lại chúng trong các đầu ra. Tương tự, hàm này không phải trình xác thực toàn diện cho mọi giá trị số có thể hình dung.

Cuối cùng, compare() thực hiện ba tỷ số từ các tệp đã tạo. Các khác biệt cấu hình vẫn là của Compose được cung cấp: --split-mode none cho mono và phân chia tensor-split 1,1 đã mô tả cho dual.

Chạy sáu phép đo theo thứ tự

Máy được dùng và tài nguyên cần chuẩn bị

Quy trình được thử nghiệm cần Linux x86_64, Docker với Compose, trình điều khiển NVIDIA hoạt động, NVIDIA Container Toolkit và hai RTX 3090.

Mức chuẩn bị công bố là 12 Gio RAM, 25 Gio đĩa và 14 Gio VRAM trống trên mỗi trong hai card. Đây là tài nguyên dự kiến cho quy trình này, không phải mức tối thiểu được chứng minh bằng nghiên cứu ngưỡng. Có tổng lượng bộ nhớ đó trên một card không thay thế điều kiện « trên mỗi card ».

Không có chế độ suy luận CPU nào được xác nhận. Điều đó không có nghĩa CPU của máy không có vai trò: Python, Docker và các trao đổi HTTP vẫn phụ thuộc vào nó.

NVIDIA Container Toolkit cho phép Docker đưa GPU vào container. Nó không thay thế trình điều khiển cài trên máy chủ. Nếu GPU hoạt động trên Linux nhưng Docker không truy cập được, hãy xem hướng dẫn chính thức của NVIDIA trước khi tiếp tục.

Giải nén ZIP vào một thư mục riêng, rồi mở terminal tại gốc dự án. Mọi lệnh bên dưới đều chạy từ thư mục này. Tránh chạy đồng thời công việc khác trên các card đang đo: nó sẽ tạo tải không có trong giao thức bạn muốn tái lập.

1. Xác thực máy chủ và xây dựng môi trường

Preflight là bước kiểm tra trước. Nó phải cho thấy Docker truy cập được cả hai card. Script cấu hình chuẩn bị quyền cho dự án; bản build tạo image sẽ thực hiện các phép đo.

./scripts/preflight.sh
sh scripts/setup.sh
docker compose build

Cần quan sát: preflight và quá trình build phải kết thúc không lỗi. Kiểm tra GPU_DEVICE và GPU_DEVICE_2 thực sự chỉ hai thiết bị khác nhau. Hai biến có tên khác nhau nhưng trỏ đến cùng một card không tạo ra chế độ dual.

2. Nhận mô hình, kiểm thử hợp đồng và chuẩn bị tải

Bước nhận tải về và xác thực mô hình mà dự án quy định. Các kiểm thử xác nhận chỉ số và các lần từ chối dự kiến. Sau đó prepare ghi mô tả tải: 30 yêu cầu cho mỗi chế độ, sáu chế độ và 64 token đầu ra.

docker compose --profile acquisition run --rm --no-deps fetch
docker compose run --rm --no-deps app -m unittest discover -s tests -v
docker compose run --rm --no-deps app -m src.cli prepare

Cần quan sát: bộ kiểm thử công khai phải thành công. Sau đó mở outputs/prepared.json: nó phải chứa queries_per_mode: 30, sáu chế độ và output_tokens: 64.

Việc kiểm tra này giống xác thực fixture trước một kiểm thử API: bạn cần biết đầu vào dự kiến trước khi diễn giải đầu ra. Số lượng chính xác các kiểm thử công khai không được nêu trong bài này; hãy dùng báo cáo của bộ kiểm thử đi kèm, không thay bằng một con số suy đoán.

Trong các lệnh, app thực thi chương trình Python. run --rm tạo một container cho lệnh rồi xóa nó khi kết thúc; --no-deps ngăn tự động khởi động các dịch vụ liên quan. Vì vậy máy chủ được khởi động rõ ràng ở bước tiếp theo. Tài liệu tham chiếu docker compose run giải thích các tùy chọn này.

3. Đo một GPU ở ba ngữ cảnh

Một profile Compose giúp chọn các dịch vụ tương ứng với một kịch bản. Ở đây, mỗi profile mang tên cấu hình đo của nó.

Chúng ta sẽ lặp lại ba lần cùng một chuỗi: khởi động máy chủ, đo, dừng profile. Đừng để máy chủ trước vẫn hoạt động: nó có thể vẫn giữ cổng hoặc VRAM.

docker compose --profile mono-512 up -d server-mono-512
docker compose run --rm --no-deps app -m src.cli measure-mono-512
docker compose --profile mono-512 down

docker compose --profile mono-2048 up -d server-mono-2048
docker compose run --rm --no-deps app -m src.cli measure-mono-2048
docker compose --profile mono-2048 down

docker compose --profile mono-4096 up -d server-mono-4096
docker compose run --rm --no-deps app -m src.cli measure-mono-4096
docker compose --profile mono-4096 down

Cần quan sát: phải có ba tệp outputs/mono-*.json. Mỗi tệp phải chứa 30 dòng, với tokens_predicted bằng 64 và không có cắt bớt.

Các tệp này là tham chiếu trên một GPU. Hãy giữ chúng: mục tiêu không phải chỉ ghi nhớ thời lượng nhỏ nhất, mà so sánh từng chế độ mono với chế độ dual tương ứng.

4. Đo hai GPU ở cùng sức chứa ngữ cảnh

Giữ cùng các yêu cầu, cùng phần đệm cho mỗi sức chứa, cùng nhiệt độ và cùng độ dài đầu ra. Biến được nghiên cứu trong mỗi cặp là cách phân bố mô hình.

docker compose --profile dual-512 up -d server-dual-512
docker compose run --rm --no-deps app -m src.cli measure-dual-512
docker compose --profile dual-512 down

docker compose --profile dual-2048 up -d server-dual-2048
docker compose run --rm --no-deps app -m src.cli measure-dual-2048
docker compose --profile dual-2048 down

docker compose --profile dual-4096 up -d server-dual-4096
docker compose run --rm --no-deps app -m src.cli measure-dual-4096
docker compose --profile dual-4096 down

Cần quan sát: ba tệp outputs/dual-*.json, với cùng kỳ vọng: 30 yêu cầu, 64 token được tạo cho mỗi yêu cầu và không ngữ cảnh nào bị cắt bớt.

Hãy cũng so sánh prompt và tokens_evaluated của chúng giữa các tệp tương ứng. Ở giai đoạn này, sự không nhất quán về tải quan trọng hơn một con số độ trễ đẹp: trước hết cần hiểu điều gì thực sự được so sánh.

5. Tổng hợp sáu kết quả

Lệnh này đọc lại các tệp đo và tính các giá trị tổng hợp cùng các tỷ số. Nó không thêm một chuỗi yêu cầu mới vào bảng.

docker compose run --rm --no-deps app -m src.cli evaluate

Cần quan sát: outputs/evaluation.json phải chứa sáu giá trị tổng hợp. Hãy cũng giữ các tệp theo từng chế độ: chúng vẫn cần thiết để giải thích một giá trị trung bình bất ngờ.

Để lệnh exercise lại sau. Trước tiên chúng ta sẽ học cách đọc kết quả, rồi đưa ra dự đoán trước khi chạy nó.

Đọc các chỉ số trước khi đọc các con số

Con số nào trả lời câu hỏi nào?

Trường Điều nó đo trong dự án này
queries Số dòng dùng cho tổng hợp: dự kiến 30 cho mỗi chế độ.
mean_ms Thời lượng trung bình của lời gọi HTTP, tính bằng mili giây.
tokens_per_second Các token được tạo chia cho tổng các thời lượng HTTP.
mean_prompt_tokens Trung bình của tokens_evaluated, tức độ dài prompt do máy chủ báo cáo.
mean_prompt_ms Thời gian tiền điền trung bình do máy chủ báo cáo.
mean_decode_ms Thời gian giải mã trung bình do máy chủ báo cáo.

Ý tưởng là đặt một thời lượng cạnh phạm vi của nó. mean_ms trả lời « client của chúng ta chờ bao lâu? ». mean_decode_ms trả lời câu hỏi hẹp hơn, về giai đoạn sinh do động cơ báo cáo.

Tự tính lại trung bình và thông lượng

Hãy lấy hai yêu cầu giả định, chỉ để hiểu summarize(). Cả hai đều sinh 64 token mà không bị cắt bớt:

Yêu cầu minh họa Thời lượng HTTP Token được tạo
A 0,5 giây 64
B 1,5 giây 64

Thời lượng trung bình là (0,5 + 1,5) / 2 = 1 seconde, tức 1 000 ms.

Thông lượng do dự án tính là:

tokens_per_second = 64 × nombre de requêtes / somme des durées HTTP

Ở đây, nó cho (64 × 2) / (0,5 + 1,5) = 64 tokens/s.

Đó không phải trung bình cộng của các thông lượng riêng lẻ. Yêu cầu A cho 64 / 0,5 = 128 tokens/s; B cho 64 / 1,5 ≈ 42,67 tokens/s. Trung bình của hai giá trị này sẽ khoảng 85,33 tokens/s, một phép tính khác với mã. Dự án thực sự báo cáo tổng khối lượng tạo ra trên tổng thời gian đo.

Vì mỗi phản hồi chứa đúng 64 token, ta cũng có thể trực tiếp suy ra:

tokens_per_second = 64 000 / mean_ms

Do đó thời gian trung bình và thông lượng này không phải hai bằng chứng độc lập về sự tăng tốc: chúng mô tả cùng các thời lượng, được trình bày bằng hai đơn vị khác nhau.

« tokens/s » không chỉ đo giải mã

Tử số của tokens_per_second đếm token được tạo, nhưng mẫu số của nó gồm toàn bộ thời gian HTTP được đo, bao gồm cả tiền điền.

Hãy lấy một ví dụ minh họa khác: một yêu cầu mất 300 ms để xử lý prompt, 680 ms để giải mã phản hồi, và 1 000 ms tổng cộng phía client. Với 64 token được tạo, thông lượng đầu-cuối là 64 tokens/s. Nếu chỉ chia cho 680 ms giải mã, ta có khoảng 94,12 tokens/s. Cả hai phép tính đều có ý nghĩa, nhưng không áp dụng cho cùng khoảng thời gian.

20 ms còn lại trong ví dụ này không phải một phép đo riêng lẻ của mạng: nó chỉ là chênh lệch giữa các phạm vi bấm giờ. Tương tự, các thời gian nội bộ máy chủ không nên được xem là phân rã phổ quát và đầy đủ của thời gian HTTP.

Vì vậy tiền điền nhanh hơn có thể cải thiện tokens/s của hướng dẫn này mà không tăng tốc giải mã. Chúng ta sẽ chính xác gặp tình huống này trong kết quả.

Đọc một tỷ số mà không đảo ngược kết luận

Tỷ số của dự án là:

ratio = temps_mono / temps_dual

Với các thời lượng giả định 1 200 ms ở mono và 1 000 ms ở dual, tỷ số là 1,2. Dual mất ít thời gian hơn. Nhưng mức giảm thời lượng so với mono là (1 200 - 1 000) / 1 200 ≈ 16,67 %, chứ không phải 20 %.

Với 800 ms ở mono và 1 000 ms ở dual, tỷ số là 0,8. Lần này dual mất nhiều thời gian hơn: (1 000 - 800) / 800 = 25 % thời lượng bổ sung.

Có thể ghi nhớ công thức sau để biểu diễn mức giảm thời lượng so với mono:

diminution relative = 1 - temps_dual / temps_mono = 1 - 1 / ratio

Kết quả dương tương ứng với thời lượng giảm; kết quả âm tương ứng với thời lượng tăng. Việc chọn mẫu số giải thích vì sao « thông lượng cao hơn 20 % » và « thời gian ít hơn 20 % » không đồng nghĩa.

Vì vậy câu hỏi đúng vẫn là: với mô hình này, các prompt này, sức chứa này và tải tuần tự này, thời lượng nào thay đổi và thay đổi bao nhiêu? Chỉ riêng dung lượng VRAM không trả lời được câu hỏi đó.

Kết quả cục bộ đã quan sát

Các giá trị của lần tái lập đầu tiên

Các phép đo sau đến từ lần tái lập được xác thực đầu tiên. Cột « Ngữ cảnh » chỉ sức chứa được cấu hình, không phải số token chính xác của từng prompt.

Trên màn hình nhỏ, hãy cuộn ngang bảng.

Ngữ cảnh Mono: ms trung bình Dual: ms trung bình Tỷ số mono/dual Cách đọc
512 711,80 709,49 1,003 gần như giống nhau, dual nhanh hơn một chút
2 048 886,90 843,31 1,052 dual nhanh hơn với tải này
4 096 1 124,56 1 025,49 1,097 dual nhanh hơn với tải này

Các thông lượng tương ứng là 89,91, 72,16 và 56,91 tokens/s ở mono, so với 90,21, 75,89 và 62,41 tokens/s ở dual. Chúng dùng công thức đầu-cuối đã giải thích trước đó, với 64 token đầu ra cho mỗi yêu cầu.

Ở 512, chênh lệch giữa 711,80 ms và 709,49 ms là 2,31 ms, tức khoảng 0,32 % thời lượng mono. Hai giá trị sau được tính từ các trung bình hiển thị. Có thể mô tả dual nhanh hơn một chút trong bảng này, nhưng không thể kết luận có lợi thế ổn định chỉ từ chênh lệch nhỏ như vậy và các trung bình này.

Ở 2 048 và 4 096, thời lượng trung bình dual cũng thấp hơn. Bảng mô tả điều đã quan sát trên tải này; nó không chứng minh quan hệ tổng quát rằng tăng ngữ cảnh luôn làm hai GPU có lợi hơn.

Ở 4 096: thời gian được tiết kiệm ở đâu?

Các prompt thực sự được đánh giá khi đó có trung bình 2 080 token. Tiền điền trung bình giảm từ 473,17 ms ở mono xuống 365,70 ms ở dual; giải mã tăng từ 648,18 ms lên 656,43 ms.

Hãy xem các con số này trong một bảng đọc. Cột cuối là một phép trừ tính từ các giá trị đã công bố, không phải phép đo mới:

Phép đo ở sức chứa 4 096 Mono Dual Chênh lệch dual − mono
Tiền điền trung bình 473,17 ms 365,70 ms −107,47 ms
Giải mã trung bình 648,18 ms 656,43 ms +8,25 ms
Thời lượng HTTP trung bình 1 124,56 ms 1 025,49 ms −99,07 ms

Dấu âm chỉ thời lượng ngắn hơn ở dual. Dấu dương chỉ thời lượng dài hơn.

Ở đây phần tăng tốc nằm trong tiền điền. Giải mã không cải thiện: nó chậm đi một chút. Điều này làm rõ cách đọc các giá trị ban đầu. Phản hồi hoàn chỉnh vẫn đến nhanh hơn vì phần giảm ở tiền điền bù cho mức tăng nhỏ của giải mã và tổng thời gian HTTP giảm.

99,07 ms tương ứng khoảng 8,81 % thời lượng HTTP ít hơn so với mono, tính từ các trung bình hiển thị. Do đó tỷ số làm tròn 1,097 không có nghĩa « thời gian ít hơn 9,7 % ».

Và thông lượng tổng thể thực sự tăng từ 56,91 lên 62,41 tokens/s. Điều này không mâu thuẫn với giải mã chậm hơn một chút: phép tính của nó bao gồm tiền điền. Đó là lý do cần định nghĩa các chỉ số trước khi nhận xét các con số.

Những gì riêng các giá trị này không giải thích

Phân rã xác định vị trí thay đổi trong các thời gian máy chủ báo cáo. Nó không cho phép quy chính xác mức tăng đó cho một lần truyền, một liên kết hay một thao tác nội bộ cụ thể: bảng không chứa hồ sơ chi tiết của các cơ chế này.

Hai lần chạy hoàn chỉnh đã vượt qua xác thực trên cùng một máy. Bảng trên không phải trung bình của hai lần tái lập, cũng không phải kết quả so sánh trên nhiều máy. Không có đánh giá độc lập của con người hay phép đo trên một nhóm máy không đồng nhất nào được tuyên bố.

Một mô hình khác, liên kết khác, ngữ cảnh khác, tải đồng thời hoặc phiên bản llama.cpp khác có thể thay đổi sự cân bằng. Kết quả hữu ích vì nó mô tả một thí nghiệm có thể nhận diện, không phải vì nó đưa ra quy tắc mua sắm phổ quát.

Những gì chúng ta đã học

Bây giờ chúng ta có thể quay lại ứng dụng web. Thêm một card không tương đương với chia đôi một lần chờ HTTP: cách phân bố tính toán và trọng số của từng giai đoạn đều quan trọng.

Giao thức khiến câu hỏi này có thể quan sát với 30 yêu cầu giống nhau giữa hai chế độ của một cặp, một lần làm nóng bị loại, 64 token đầu ra bắt buộc và số token thực của prompt được lưu lại. Sức chứa máy chủ và kích thước yêu cầu vẫn là hai thông tin khác nhau.

Ở 512, các thời lượng quan sát gần như giống nhau. Ở 2 048 và 4 096, dual nhanh hơn trên máy này. Ở 4 096, cách đọc chi tiết cho thấy tiền điền ngắn hơn và giải mã dài hơn một chút: chỉ nhìn tokens/s sẽ che khuất khác biệt này.

Vì vậy kết luận cần giữ không phải « hai GPU tốt hơn », cũng không phải « hai GPU vô ích ». Đó là: để so sánh hai cấu hình, hãy xác định một tải giống nhau, kiểm tra điều đã được thực thi, rồi xem phần nào của thời gian thay đổi.

Bài tập chủ động: dự đoán, sửa đổi, thực thi, quan sát, giải thích

Dự đoán trước khi xem phản hồi

Trước khi chạy bài tập, hãy ghi câu trả lời cho ba câu hỏi: hai GPU có nhất thiết nhanh hơn không? Tỷ số mono/dual 0,8 có nghĩa gì? Còn tỷ số 1,2?

Đừng chỉ trả lời « tốt hơn » hay « kém hơn ». Với mỗi tỷ số, hãy tạo hai thời lượng nhất quán, rồi chỉ ra cấu hình nào khiến client chờ lâu nhất.

docker compose run --rm --no-deps app -m src.cli exercise

Quan sát và giải thích

Chương trình nhắc rằng tỷ số nhỏ hơn 1 nghĩa là dual chậm hơn với tải này. Hãy đối chiếu lời giải thích đó với hai thời lượng của bạn.

Giả thuyết « một card bổ sung làm giảm cơ học mọi độ trễ » là quá mạnh. Ở 512, chúng ta có 711,80 ms so với 709,49 ms: chênh lệch cực nhỏ trong phép đo này. Ở 4 096, tổng thời gian giảm nhưng giải mã tăng nhẹ. Hành vi của hệ thống chính xác hơn việc chỉ đếm số card.

Để kiểm tra sự hiểu biết của bạn, cũng hãy tự hỏi vì sao 62,41 tokens/s ở dual không chứng minh giai đoạn giải mã đã nhanh hơn. Câu trả lời nằm ở mẫu số của thông lượng: đó là toàn bộ thời lượng HTTP.

Sửa đổi một biến duy nhất trong một thí nghiệm riêng

Giữ các đầu ra tham chiếu, rồi làm việc trên bản sao của dự án. Sửa một sức chứa ngữ cảnh duy nhất, nhất quán cho cặp mono/dual bạn muốn so sánh. Lặp lại các lệnh khởi động, đo và dừng của cặp đó.

Cần giữ cùng prompt được xây dựng cho cả hai chế độ và kiểm tra 64 token vẫn được tạo mà không cắt bớt. Nếu một sửa đổi cũng thay đổi cách tạo padding, hãy ghi lại: bạn không còn chỉ thử một giới hạn cấu hình mà còn thử độ dài đầu vào khác. Đừng ép tokens_evaluated thành giá trị sức chứa để làm các tệp trông nhất quán.

Bài viết không cung cấp tham số lệnh bổ sung để tự động hóa biến thể này. Hãy tìm trong Compose và cli.py của ZIP các định nghĩa tương ứng, rồi ghi lại sửa đổi của bạn thay vì bịa ra một tùy chọn. Lệnh exercise và sửa đổi thủ công này là hai bước riêng biệt.

Trước khi chạy lại, hãy viết dự đoán về độ dài prompt, tiền điền và giải mã. Sau khi chạy, so sánh tokens_evaluated, mean_prompt_ms, mean_decode_ms và mean_ms. Giải thích điều gì thay đổi, đồng thời điều gì vẫn ổn định.

Kết quả mới của bạn là một quan sát cục bộ bổ sung, không phải giá trị r3 để thay thế bảng tham chiếu. Kết quả không có cải thiện cũng hữu ích như nhau để học cách đọc cơ chế.

Khắc phục sự cố và dọn dẹp

Hai GPU không khác biệt. Kiểm tra GPU_DEVICE và GPU_DEVICE_2, rồi chạy lại preflight. Đừng chạy profile dual khi hai biến chưa chỉ đúng các card dự kiến.

Máy chủ không sẵn sàng. Xem nhật ký của dịch vụ liên quan và kiểm tra các điều kiện tiên quyết. Giới hạn chờ sẵn sàng là 180 giây; thời hạn HTTP của một yêu cầu là 120 giây. Lỗi ở bước này không phải phép đo hiệu năng để thêm vào bảng.

Một phản hồi bị cắt bớt hoặc không chứa 64 token. Đừng đưa dòng này vào một so sánh được trình bày là tuân thủ giao thức. Kiểm tra ngữ cảnh, prompt và tham số máy chủ, rồi đo lại trong điều kiện có ghi chép. Đừng chỉ xóa một dòng gây khó chịu để cải thiện trung bình.

Cổng hoặc VRAM vẫn bị chiếm. Một máy chủ trước có thể còn hoạt động. Dùng lệnh down của profile liên quan, như ở trên, xem log của nó và chỉ khởi động lại profile cần đo.

Quy trình đã dừng từng máy chủ bằng profile của nó sau phép đo. Đừng bỏ qua các lần dừng này: tài liệu về profile Compose nêu rõ việc dừng phụ thuộc vào các profile đang hoạt động. Không nên coi docker compose down không có profile là bảo đảm dọn dẹp một máy chủ có profile vẫn hoạt động sau gián đoạn.

Sau các lần dừng profile đã định, hãy kết thúc quy trình bằng lệnh dọn dẹp của dự án:

docker compose down

Các mô hình đã nhận và JSON đầu ra vẫn nằm trong thư mục giải nén. Hãy sao lưu các phép đo hữu ích trước khi chỉ xóa bản giải nén của bạn để lấy lại dung lượng. Đừng xóa sạch volume hay image của các dự án khác để kết thúc hướng dẫn này.

Đi xa hơn

Một trung bình không mô tả mọi lần chờ

Sau đó hãy chạy lại cùng ma trận trên các prompt thực của bạn, trong một thí nghiệm riêng, với nhiều lần lặp hơn. Các phân vị p50, p95 và p99 giúp đọc phân bố thời lượng: p50 là trung vị; p95 và p99 mô tả các ngưỡng mà lần lượt khoảng 95 % và 99 % quan sát nằm dưới.

Lợi ích với ứng dụng web là ngay lập tức: một giá trị trung bình chấp nhận được có thể che giấu một vài yêu cầu rất chậm. Chỉ với 30 phép đo mỗi chế độ, các phân vị cao phụ thuộc mạnh vào vài giá trị cực trị; hiển thị chúng không thay thế một giao thức lặp phù hợp.

Để mở rộng thí nghiệm, bạn cũng có thể xen kẽ hoặc thay đổi thứ tự các lượt mono và dual thay vì luôn đo tất cả mono trước. Đây sẽ là một biến thể mới của giao thức, nhằm xem xét tác động có thể có của thứ tự thực thi; nó không thuộc các lệnh r3 được trình bày ở đây.

Kiểm thử nhiều client là một thí nghiệm khác

Tải đồng thời là gửi nhiều yêu cầu có thể chồng lấp theo thời gian. Nó liên quan đến hàng đợi, giới hạn máy chủ và bộ nhớ sẵn có. Đây không phải cùng câu hỏi với thời lượng của một chuỗi yêu cầu gửi lần lượt.

Hãy đo tải này riêng biệt, với hàng đợi và các giới hạn bộ nhớ có thể quan sát. Đừng chuyển tokens/s tuần tự của bảng thành số người dùng đồng thời: những người dùng đó không được mô phỏng trong quy trình này.

Để các phép đo mới vẫn có thể diễn giải, hãy giữ cùng nhau mô hình, phiên bản llama.cpp, tham số máy chủ, yêu cầu và đầu ra. Thay đổi một trong các yếu tố này mà không ghi lại tương đương với so sánh hai cấu hình API đã mất chi tiết.

Giới hạn cho môi trường production

Giao thức này không đo thông lượng khi có đồng thời, hàng đợi, độ bền trước prompt người dùng, chi phí năng lượng hay chất lượng phản hồi. Một mình nó cũng không cung cấp chính sách triển khai hoặc bảo đảm năng lực.

Tài nguyên máy chủ và đĩa có thể bị ảnh hưởng bởi các tiến trình khác. Các lớp Docker công khai có thể được tái sử dụng. Vì vậy việc loại một lần làm nóng hoặc tắt tái sử dụng prompt giữa các yêu cầu không có nghĩa toàn bộ môi trường là « lạnh ».

Trước khi chọn hạ tầng, hãy dùng lại các prompt đại diện của bạn và xác định tiêu chí: thời gian phản hồi, độ trễ cao, thông lượng dưới mức đồng thời dự kiến, lỗi chấp nhận được và ngân sách VRAM. Thêm các lần lặp, quan sát tài nguyên và đánh giá chất lượng nếu văn bản phải phục vụ một chức năng nghiệp vụ.

Hai card bổ sung thêm các khả năng phần cứng; chỉ một giao thức phù hợp mới cho biết những khả năng đó mang lại gì cho ứng dụng của bạn. Các phép đo của hướng dẫn này là ví dụ cho cách làm đó, không phải kết luận đúng với mọi mô hình hay mọi máy.

Tài liệu tham khảo để tìm hiểu sâu hơn

Tài liệu chính thức của máy chủ llama.cpp mô tả các tham số ngữ cảnh, phân chia, sinh và các trường phản hồi. ZIP lưu các tham số thực sự được dùng cho phép so sánh này.

Các giải thích về GGUF và lượng tử hóa, cũng như hướng dẫn Hugging Face về chi phí suy luận, giúp phân biệt lưu trữ trọng số, xử lý prompt và sinh. Đây là các bổ sung khái niệm; hướng dẫn không dùng Transformers thay cho llama.cpp.

Về mã thực thi, cũng hãy xem tham chiếu time.perf_counter(), hướng dẫn về profile Docker Compose, tham chiếu docker compose run và hướng dẫn NVIDIA Container Toolkit.

Các trang này giải thích khái niệm và công cụ; chúng không thay thế các tệp dự án hay đầu ra của các lần tái lập. Nội dung của chúng có thể thay đổi: để tìm lại quy trình đã trình bày, hãy giữ các phiên bản và thiết lập của ZIP thay vì âm thầm thay động cơ bằng phiên bản hiện tại.

Chia sẻ bài viết