Vận chuyển một nghìn tỷ tham số với Hub Bucket- Đồng bộ hóa trọng số delta trong TRL

Vận chuyển một nghìn tỷ tham số với Hub Bucket- Đồng bộ hóa trọng số delta trong TRL

  • 23 min read
Vận chuyển một nghìn tỷ tham số với Hub Bucket- Đồng bộ hóa trọng số delta trong TRL
Vận chuyển một nghìn tỷ tham số với Hub Bucket- Đồng bộ hóa trọng số delta trong TRL

Truyền tải một nghìn tỷ tham số với Hub Bucket: Đồng bộ hóa Delta Weight trong TRL

Tóm tắt (TL;DR) dành cho những ai đang bận huấn luyện mô hình:

  • RL bất đồng bộ (Async RL) có một “bí mật” khó chịu: ở mỗi bước, bộ huấn luyện (trainer) phải gửi toàn bộ mô hình sang công cụ suy luận (inference engine). Với mô hình 7B ở định dạng bf16, con số này là 14 GB. Với một checkpoint mô hình 1T (nghìn tỷ tham số), con số này lên tới hàng terabyte. Cho mỗi bước huấn luyện.
  • Thực tế là bạn không cần làm vậy. Giữa hai bước tối ưu hóa RL liên tiếp, khoảng 99% trọng số bf16 là giống hệt nhau (và trong trường hợp tệ nhất cũng không dưới 98%). Phần thay đổi (delta) thực tế là rất nhỏ.
  • Chúng tôi đã triển khai một PR trong TRL để mã hóa chỉ những phần tử thay đổi dưới dạng tệp safetensors thưa (sparse safetensors), tải chúng lên một Hugging Face Bucket, và thông báo cho vLLM để tải về. Trên mô hình Qwen3-0.6B, lượng dữ liệu truyền tải mỗi bước giảm từ 1,2 GB xuống còn 20 đến 35 MB.
  • Điểm đặc biệt nhất: chúng tôi đã chạy một quá trình huấn luyện phân tách hoàn toàn, trong đó bộ huấn luyện nằm trên một máy, vLLM nằm trong một Hugging Face Space, môi trường Wordle nằm trong một Space khác, và trọng số được luân chuyển thông qua một Hub bucket duy nhất. Không cần cụm máy chủ chung, không cần RDMA, không cần VPN.

RL bất đồng bộ vừa trở nên rẻ hơn rất nhiều. Hãy cùng tìm hiểu chi tiết.


1. Bài toán một Terabyte

Nếu bạn đã đọc bài viết trước của chúng tôi về toàn cảnh huấn luyện RL bất đồng bộ, bạn đã biết vấn đề cốt lõi: mọi thư viện RL bất đồng bộ, bất kể cách họ gọi “actor model” hay sử dụng backend NCCL màu gì, cuối cùng đều vấp phải cùng một rào cản: đồng bộ hóa trọng số.

Công cụ suy luận đang sử dụng chính sách của bước N. Bộ huấn luyện vừa hoàn thành bước N+1. Các trọng số mới phải được chuyển từ bên này sang bên kia trước khi công cụ suy luận bị lệch khỏi chính sách (off-policy) một cách nghiêm trọng. Việc này nằm trên đường găng (critical path) dù bạn chạy đồng bộ hay bất đồng bộ: một quá trình truyền tải gây nghẽn là lãng phí tài nguyên tính toán của GPU khi chúng không tạo ra token. Với phương pháp truyền tải delta thưa, bạn giảm thời gian chờ đó xuống còn vài giây, và bộ huấn luyện thậm chí không cần đợi công cụ suy luận sẵn sàng: nó chỉ cần công bố “trọng số đã sẵn sàng” và tải trọng số lên bucket dùng chung ngay khi bước tối ưu hóa kết thúc, còn công cụ suy luận sẽ tự tải về khi nó rảnh.

Fireworks đã đưa ra một con số đáng nhớ trong bài viết Frontier RL Is Cheaper Than You Think: đối với một checkpoint mô hình 1T tham số ở định dạng fp8, một bản snapshot đầy đủ là 1024 GiB, và theo quan niệm thông thường, đó là những gì bạn phải truyền tải mỗi khi cập nhật dàn máy rollout. Con số này khiến mọi người phải vẽ ra những sơ đồ với các siêu cụm máy chủ (mega-clusters), hạ tầng RDMA và các đường truyền chuyên dụng xuyên vùng. Tuy nhiên, delta trung bình mà họ đo được giữa các checkpoint liền kề chỉ là 20,3 GiB, tương đương 1,98% mô hình đầy đủ, và “hơn 98% trọng số ở định dạng bf16 vẫn giữ nguyên giá trị bit giữa các checkpoint liên tiếp”.

Báo cáo Composer 2 của Cursor cũng kể một câu chuyện tương tự. Họ chạy huấn luyện và suy luận ở các vùng khác nhau và kết nối chúng bằng một S3 bucket dùng chung, nơi bộ huấn luyện tải lên các sai biệt trọng số (weight diffs) đã nén sau mỗi bước huấn luyện. Mỗi cụm máy chủ độc lập tải xuống và tái cấu trúc từ chuỗi delta dùng chung, “không yêu cầu kết nối trực tiếp với cụm huấn luyện”. Hai bên không bao giờ trao đổi tham số trực tiếp với nhau. Bucket chính là đường truyền.

Cả hai bài báo đều đồng ý về ba điều, và chúng tôi muốn nhấn mạnh lại vì phần còn lại của bài viết này thực chất là một bản chuyển thể mã nguồn mở trung thành với những ý tưởng đó:

  1. Hầu hết các trọng số không thực sự thay đổi giữa hai bước RL liên tiếp.
  2. Nếu bạn chỉ gửi những phần thay đổi, chi phí băng thông sẽ giảm khoảng hai lần (100 lần).
  3. Nếu bạn định tuyến những sai biệt nhỏ đó thông qua một kho lưu trữ đối tượng (object store) dùng chung, bạn không còn cần bộ huấn luyện và cụm suy luận phải nằm trong cùng một trung tâm dữ liệu.

Điều duy nhất còn thiếu là một phiên bản của câu chuyện này mà bạn có thể cài đặt qua pip install. Vì vậy, chúng tôi đã viết nó.

2. Tại sao trọng số RL định dạng bf16 hầu như luôn thưa?

Trước khi thiết lập, cần hiểu tại sao phương pháp này lại khả thi. Tuyên bố “98% trọng số không thay đổi” nghe có vẻ giống như những con số chỉ đúng trong bản demo và sẽ thất bại trong thực tế. Nhưng không phải vậy. Điều này xuất phát từ cách tính toán bf16 hoạt động với tốc độ học (learning rate) mà RL sử dụng.

Một số bf16 có 7 bit phần phân số (mantissa). Giữa hai lũy thừa của hai liên tiếp, có đúng $2^7 = 128$ giá trị có thể biểu diễn, vì vậy khoảng cách giữa các số bf16 liền kề xung quanh $|w|$ là khoảng $|w| \cdot 2^{-7}$. Một bản cập nhật sẽ bị “hấp thụ” bởi việc ép kiểu bf16 bất cứ khi nào nó nhỏ hơn một nửa khoảng cách đó, tức là khi $|\Delta w| < |w|/256$. Đây là “ngưỡng hiển thị bf16” (bf16 visibility threshold) mà PULSE vẽ trong Hình 3 của họ.

Bây giờ hãy nhìn vào những gì Adam làm. Với tốc độ học RL, ví dụ $3 \times 10^{-6}$, bản cập nhật cho một trọng số duy nhất là:

$$\Delta w = -\eta \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}$$

Bước chuẩn hóa $\hat{m}/(\sqrt{\hat{v}}+\epsilon)$ xấp xỉ bằng 1, vì vậy $|\Delta w| \approx \eta \approx 3 \times 10^{-6}$. Đối với hầu hết các trọng số, $|w|$ nằm trong khoảng $10^{-2}$ đến $10^{-1}$ (PULSE báo cáo trung vị là 0,019 cho các trọng số LLM điển hình). Ngưỡng $|w|/256$ ở mức độ đó là khoảng $4 \times 10^{-5}$ đến $4 \times 10^{-4}$, vốn lớn hơn bản cập nhật.

Nói cách khác: bộ tối ưu hóa đang “thì thầm”, và bf16 không thể nghe thấy. Bản cập nhật bị hấp thụ bởi việc làm tròn, biểu diễn byte của $w$ không thay đổi, và từ góc nhìn của công cụ suy luận, trọng số này không hề di chuyển. Nhân con số đó với vài trăm triệu tham số, bạn sẽ có con số độ thưa >99% một cách tự nhiên, không cần xấp xỉ.

Đây chính là lập luận được chính thức hóa trong bài báo PULSE (Mihai & Belilovsky, 2026). Họ định nghĩa hai ngưỡng. Ngưỡng hấp thụ (absorption bound) $10\eta$ là trường hợp tệ nhất cho một bản cập nhật Adam, và ngưỡng hiệu quả (effective bound) $\eta$ là chế độ bạn thực sự hoạt động. Ngưỡng hiển thị bf16 là $|w|/256$. Bất cứ khi nào bản cập nhật nằm dưới ngưỡng hiển thị, nó sẽ bị hấp thụ và byte bf16 không thay đổi. Hình 3 của họ vẽ cả hai ngưỡng so với một đám mây các trọng số LLM đại diện, và kết luận là rõ ràng: tại $\eta = 3 \times 10^{-6}$, ngay cả ngưỡng hấp thụ cũng nằm dưới ngưỡng hiển thị cho hầu hết mọi trọng số trong mô hình. Họ đo lường điều này thực tế trên Qwen2.5 (0.5B/1.5B/7B), Llama-3.2-3B và Gemma-3-4B, và nhất quán tìm thấy độ thưa trung bình mỗi bước là ~99%, với độ lệch chuẩn từ 0,2 đến 0,4% qua 400 bước huấn luyện. Bước tệ nhất vẫn trên 98%. Vì vậy, việc dưới 1% thay đổi không phải là một phép đo may mắn; đó là điều mà số học đảm bảo.

Chúng tôi không cần dự đoán điều này bằng phân tích (thực tế, chúng tôi đã thử dự đoán mặt nạ thay đổi từ thống kê $m$ và $v$ của Adam, nhưng tỷ lệ thu hồi chỉ đạt 30% đáng buồn). Chúng tôi chỉ cần quan sát xem byte nào đã bị đảo. Đó là một tensor boolean nhỏ cho mỗi tham số, được tính toán ngay sau bước tối ưu hóa.

3. HF Buckets và Kiến trúc

Đây là nơi mảnh ghép thứ hai xuất hiện, và là lúc bài viết này không còn là bản dịch từ Fireworks/Cursor mà trở thành giải pháp của Hugging Face.

3.1 Hub Bucket là gì?

Một Bucket là một loại repo trên Hub được thiết kế để lưu trữ đối tượng tần suất cao. Không có quy trình commit rườm rà, không có quy trình PR, không có những rắc rối của LFS. Bạn thêm tệp, liệt kê tệp, tải tệp. Giao diện Python chỉ gồm hai hàm:

from huggingface_hub import batch_bucket_files, download_bucket_files

# Phía Trainer (Bộ huấn luyện)
batch_bucket_files("my-org/wordle-deltas", add=[(buffer, "deltas/step_000042.safetensors")])

# Phía Inference (Suy luận)
download_bucket_files("my-org/wordle-deltas", files=[("deltas/step_000042.safetensors", local_path)])

Chỉ vậy thôi. Hai lời gọi hàm và trọng số của bạn đã được vận chuyển.

Đằng sau đó, các bucket được hỗ trợ bởi Xet, lớp lưu trữ phân đoạn theo nội dung (content-defined chunking) của Hub. Xet xem xét mọi tệp bạn tải lên, chia nó thành các phân đoạn dựa trên nội dung thực tế (không phải theo offset cố định) và loại bỏ trùng lặp với mọi thứ đã có trong bucket. Kết quả thực tế là, ngay cả khi chúng tôi quá lười để viết mã hóa thưa và chỉ tải lên các anchor đầy đủ mỗi bước, Xet vẫn sẽ chỉ truyền tải các phân đoạn đã thay đổi. Kết hợp mã hóa thưa + ngăn xếp Xet: chúng ta chỉ trả phí cho những gì di chuyển, và chỉ trả một lần.

Đây là phiên bản mã nguồn mở tương đương với “S3 bucket dùng chung” mà cả Fireworks và Cursor sử dụng, ngoại trừ việc lớp lưu trữ đã biết về băm nội dung, token HF hiện có của bạn đã có quyền truy cập, và nó kết hợp tự nhiên với phần còn lại của hệ sinh thái (Spaces, datasets, models).

3.2 Ba chiếc hộp

Kiến trúc đầy đủ có chính xác ba chiếc hộp và một nền tảng dùng chung:

  • Trainer. Bất cứ nơi nào bạn muốn. Một GPU, tám GPU, một laptop gắn H100 qua USB, chúng tôi không phán xét. Nắm giữ trọng số mô hình, chạy bộ tối ưu hóa, tạo ra các delta thưa.
  • HF Bucket. Một repo duy nhất, hai tiền tố: anchors/ cho các bản snapshot đầy đủ thỉnh thoảng và deltas/ cho các bản vá thưa ở giữa. Đây là điều duy nhất cả hai bên cùng thống nhất.
  • vLLM rollout server. Bất cứ nơi nào bạn muốn, và quan trọng là không nhất thiết phải ở cùng nơi với bộ huấn luyện. Tải dữ liệu từ bucket, áp dụng delta và cung cấp kết quả rollout.
  • Environment (Môi trường). Kết nối với rollout server theo cách thông thường (HTTP, gọi hàm, v.v.).

Điểm mấu chốt cần ghi nhớ, điều mà bài báo của Cursor nhấn mạnh và cũng đúng y hệt ở đây: bộ huấn luyện và rollout server không bao giờ nói chuyện trực tiếp với nhau về trọng số. Chúng trao đổi một yêu cầu POST nhỏ chứa {"repo_id": ..., "filename": ...}, và đó là toàn bộ mặt phẳng điều khiển (control plane). Việc truyền tải byte thực tế diễn ra giữa mỗi bên và bucket, song song, không cần hạ tầng mạng dùng chung.

Tại sao điều này lại quan trọng trong thực tế:

  • Rollout server có thể ở một vùng khác, một đám mây khác, hoặc nằm sau NAT bên trong một Hugging Face Space. Nó không quan tâm.
  • N bản sao suy luận có thể tải cùng một delta từ cùng một bucket, và Xet loại bỏ trùng lặp byte cho tất cả họ.
  • Bộ huấn luyện không bao giờ phải biết có bao nhiêu bản sao suy luận tồn tại, ở đâu, hay liệu một trong số chúng vừa bị sập.

Bộ huấn luyện ghi. Các bản sao đọc. Hub làm nhiệm vụ dẫn đường.

4. Giao thức

Bây giờ chúng ta hãy cùng “mở nắp ca-pô”. Giao thức gồm bốn phần: định dạng truyền tải, bố cục bucket, một bản mở rộng vLLM dài 30 dòng và một bộ phát hiện thay đổi phía bộ huấn luyện. Thực ra nó ít mã hơn bạn tưởng.

4.1 Safetensors làm định dạng truyền tải

Chúng tôi chọn safetensors cho định dạng trên đĩa và trên đường truyền. Nó đã là định dạng checkpoint chuẩn trên Hub, mọi framework hợp lý đều có thể đọc được, và phần tiêu đề (header) có thể mang theo siêu dữ liệu chuỗi tùy ý. Trường siêu dữ liệu đó chính là nơi chúng tôi giấu giao thức.

Có hai loại tệp trong bucket.

Anchors trông giống như một checkpoint bình thường: một tensor cho mỗi tham số, trọng số bf16 đầy đủ, được ghi sau mỗi $N$ lần đồng bộ (mặc định $N=10$).

anchors/step_000010.safetensors
  ├── model.layers.0.self_attn.q_proj.weight   (bf16, đầy đủ)
  ├── model.layers.0.self_attn.k_proj.weight   (bf16, đầy đủ)
  └── ...
metadata:
  sparse=False, model_version=10, sparsity=0.0

Deltas là phần thú vị. Đối với mỗi tham số thực sự thay đổi, chúng tôi lưu trữ hai mục: một tensor int32 phẳng chứa các chỉ số của phần tử (indices), và một tensor bf16 chứa các giá trị tại các chỉ số đó.

deltas/step_000011.safetensors
  ├── model.layers.0.self_attn.q_proj.weight.indices   (int32, [số_lượng_thay_đổi])
  ├── model.layers.0.self_attn.q_proj.weight.values    (bf16,  [số_lượng_thay_đổi])
  ├── model.layers.0.mlp.gate_proj.weight.indices
  ├── model.layers.0.mlp.gate_proj.weight.values
  └── ...
metadata:
  sparse=True, model_version=11, sparsity=0.9938, changed_params=[...]

Một vài hệ quả tốt từ lựa chọn này:

  • Một delta là một tệp. Bạn có thể mở nó bằng safe_open(...) trong Python và kiểm tra mọi tensor trong đó. Không có khung đóng gói độc quyền, không có tiền tố độ dài, không có bắt tay phiên bản.
  • Siêu dữ liệu tự mô tả. Bên nhận đọc sparse=True/False và rẽ nhánh xử lý. Không cần một tệp danh mục (manifest) riêng biệt.
  • Nó là zero-copy thông qua mmap ở phía suy luận, điều này rất quan trọng khi bạn thực hiện việc này sau mỗi vài giây.

Nhịp độ rất đơn giản: tạo anchor mỗi bước thứ N, tạo delta cho các bước ở giữa. Cả hai đều nằm trong cùng một bucket dưới tiền tố anchors/deltas/. Mỗi bản sao suy luận mới chỉ cần lấy anchor gần nhất và sau đó tái hiện lại các delta kể từ đó.

4.2 Phía Bộ huấn luyện: Mặt nạ Boolean từ Optimizer Hook

Bộ huấn luyện cần biết phần tử bf16 nào thực sự bị đảo. Chúng tôi thực hiện điều này bằng một BF16ChangeDetector nhỏ, đăng ký một hook trước bước (pre-step) và sau bước (post-step) trên bộ tối ưu hóa:

class BF16ChangeDetector:
    def __init__(self, model, optimizer):
        self._pre_step_bf16: dict[str, torch.Tensor] = {}
        self._validated_masks: dict[str, torch.Tensor] = {}
        optimizer.register_step_pre_hook(self._pre_step_hook)
        optimizer.register_step_post_hook(self._post_step_hook)

    def _pre_step_hook(self, opt, args, kwargs):
        for p in self._params:
            self._pre_step_bf16[name_of(p)] = p.detach().to(torch.bfloat16).cpu().clone()

    def _post_step_hook(self, opt, args, kwargs):
        for p in self._params:
            self._validated_masks[name_of(p)] = (
                p.detach().to(torch.bfloat16).cpu() != self._pre_step_bf16[name_of(p)]
            )

Mã thực tế trong PR có thêm một chút chi tiết kỹ thuật (khớp đối tượng tham số của optimizer với tham số mô hình qua data_ptr()), nhưng ý tưởng rất đơn giản: chụp snapshot $\rightarrow$ thực hiện bước $\rightarrow$ tìm sai biệt.

Đây là sự thật khách quan. Chúng tôi đã thử con đường thanh thoát hơn là dự đoán mặt nạ từ thống kê $m$ và $v$ của Adam, sử dụng trực tiếp ngưỡng ULP của bf16. Về nguyên tắc thì nó hoạt động, nhưng trong thực tế, tỷ lệ thu hồi chỉ khoảng 30%, nghĩa là chúng tôi sẽ gửi một delta thiếu mất hai phần ba các bản cập nhật thực tế. Việc chuẩn hóa của Adam đủ phức tạp khiến ngưỡng phân tích không đủ chặt chẽ. Vì vậy, chúng tôi chỉ so sánh các byte. Việc này tốn một bản snapshot CPU bf16 của mô hình ở phía bộ huấn luyện, và chúng tôi chấp nhận chi phí này.

Bốn giai đoạn của luồng _sync_weight mới là:

  1. Tải lên trong khi suy luận vẫn chạy. Bộ huấn luyện mã hóa các phần tử theo mặt nạ thành một buffer safetensors và đẩy lên bucket. vLLM vẫn vui vẻ phục vụ chính sách cũ trong suốt bước này.
  2. Tạm dừng vLLM. Một lời gọi HTTP ngắn, vài trăm mili giây.
  3. Phát tín hiệu /update_weights. Gửi tọa độ bucket. vLLM tải về, áp dụng, rồi phản hồi.
  4. Tiếp tục. vLLM hoạt động trở lại.

Các dòng log kể lại câu chuyện này:

Delta: 1234567/200000000 elements changed (sparsity=99.38%)
[delta_engine] uploaded user/wordle-deltas/deltas/step_000042.safetensors (27.4 MB, ...)
Weight sync: done. Total 9.4s (inference paused 1.1s)

Dòng quan trọng nhất là phần trong ngoặc. Suy luận bị tạm dừng trong 1,1 giây. 9,4 giây còn lại được dành cho việc tải lên, việc này diễn ra trong khi rollout server vẫn đang tạo token. Với NCCL, chúng tôi phải trả toàn bộ thời gian đồng bộ dưới dạng thời gian tạm dừng. Ở đây, chúng tôi coi đó là thời gian chạy nền.

4.3 Phía vLLM: Bản mở rộng 30 dòng

vLLM có một trừu tượng sạch sẽ cho việc này gọi là WeightTransferEngine. Chúng tôi triển khai một DeltaWeightTransferEngine với phương thức receive_weights có nội dung cốt lõi là:

def receive_weights(self, update_info, load_weights):
    download_bucket_files(update_info.repo_id, files=[(update_info.filename, local_path)])
    with safe_open(local_path, framework="pt", device="cpu") as f:
        meta = PatchMetadata.from_metadata_dict(f.metadata())
        if not meta.sparse:
            # Anchor: nạp mọi tensor và snapshot cho các delta tương lai
            for name in f.keys():
                tensor = f.get_tensor(name)
                self._bf16_snapshot[name] = tensor.clone()
                load_weights([(name, tensor)])
        else:
            # Delta: áp dụng (indices, values) vào snapshot, đưa tensor đầy đủ cho vLLM
            for name in json.loads(meta.changed_params):
                indices = f.get_tensor(f"{name}.indices").long()
                values = f.get_tensor(f"{name}.values")
                snap = self._bf16_snapshot[name].flatten()
                snap[indices] = values
                self._bf16_snapshot[name] = snap.reshape(self._bf16_snapshot[name].shape)
                load_weights([(name, self._bf16_snapshot[name])])

Chúng tôi đăng ký nó thông qua cờ --worker-extension-cls của vLLM, nghĩa là không cần fork vLLM. Bạn chỉ cần cài đặt TRL vào cùng một image với vLLM, trỏ CLI đến lớp (class) của chúng tôi là xong.

Đáng nhắc tới: bản thân vLLM đang có một nỗ lực tích hợp sẵn việc truyền tải trọng số thưa, vllm-project/vllm#40096. Nó thêm receive_sparse_weights()trainer_send_sparse_weights() trực tiếp vào lớp cơ sở WeightTransferEngine, với các bản vá được mã hóa dưới dạng (indices, values) và được áp dụng tại chỗ thông qua index_copy_(), loại bỏ hoàn toàn chu kỳ xác thực GPU/CPU. PR này báo cáo truyền tải 0,16 MB trong 0,40 ms cho một bản vá thưa trên Qwen3-1.7B so với 942 MB trong 192 ms cho việc gửi đầy đủ.

Một lưu ý thành thật về triển khai của chúng tôi ở phía suy luận: chúng tôi giữ một snapshot bf16 trên CPU của mô hình để có thể tái cấu trúc các tensor đầy đủ từ các bản vá thưa (indices, values), vì hàm load_weights trong vLLM hiện nay yêu cầu tensor đầy đủ. Khi #40096 (hoặc phiên bản kế nhiệm) được triển khai và cung cấp đường dẫn load_weights thưa tại chỗ, chúng tôi có thể áp dụng các chỉ số trực tiếp trên GPU và loại bỏ bản snapshot này!

5. Triển khai thực tế trên Spaces

Đây là phần mà chúng tôi cảm thấy tự hào nhất. Mọi thứ chúng tôi mô tả cho đến nay đều chạy được trên laptop, nhưng mục đích của việc định tuyến trọng số qua Hub bucket là để bộ huấn luyện và rollout server không cần phải ở gần nhau. Vì vậy, chúng tôi đã chạy một quá trình huấn luyện phân tách hoàn toàn với ba máy, không máy nào chia sẻ mạng nội bộ:

  • Một máy với một GPU chạy bộ huấn luyện (trainer).
  • Một Hugging Face Space (Docker SDK, L4 GPU) chạy vLLM với lớp mở rộng của chúng tôi.
  • Một Hugging Face Space thứ hai (CPU) chạy server môi trường Wordle với khả năng đáp ứng 256 phiên đồng thời.
  • Một Hub bucket ở giữa.

Việc thiết lập này thực sự chỉ mất vài lệnh gọi hf CLI. Dockerfile của vLLM Space về cơ bản là image vLLM gốc cộng với pip install trl@... và entrypoint:

FROM vllm/vllm-openai:latest
RUN pip install "trl @ git+https://github.com/huggingface/trl.git@delta-weight-sync"
ENV VLLM_SERVER_DEV_MODE=1
EXPOSE 7860
ENTRYPOINT ["vllm", "serve", "Qwen/Qwen3-1.7B", \
    "--host", "0.0.0.0", "--port", "7860", \
    "--worker-extension-cls", "trl.experimental.async_grpo.delta_engine.DeltaWorkerExtension", \
    "--weight-transfer-config", "{\"backend\":\"nccl\"}", \
    "--max-model-len", "32768", \
    "--gpu-memory-utilization", "0.8"]

Triển khai nó dưới dạng một Space:

hf repos create $USER/vllm-wordle-inference \
    --type space --space-sdk docker --flavor l4x1 \
    --secrets HF_TOKEN=$HF_TOKEN
hf upload $USER/vllm-wordle-inference examples/scripts/openenv/vllm_space/ --type space

Và bắt đầu huấn luyện từ bất cứ nơi nào trên hành tinh có thể kết nối HTTPS:

python examples/scripts/openenv/async_wordle.py \
    --vllm-server-url https://$USER-vllm-wordle-inference.hf.space \
    --env-url https://openenv-wordle.hf.space \
    --delta-sync-repo-id $USER/wordle-deltas \
    --model Qwen/Qwen3-1.7B

Bộ huấn luyện không bao giờ mở cổng. Space không bao giờ thấy IP của bộ huấn luyện. Môi trường Wordle không biết sự tồn tại của cả hai. Tất cả chúng đều nói chuyện với Hub. Quá trình huấn luyện đã hội tụ ở bài kiểm tra sanity check “immediate-EOS”, sau đó là trên các bản rollout Wordle thực tế: phần thưởng tăng lên, dữ liệu delta duy trì trong khoảng 20 đến 35 MB, và cửa sổ tạm dừng suy luận mỗi lần đồng bộ duy trì ở mức khoảng một giây. Toàn bộ log chạy được liên kết trong PR đi kèm.

6. Điều này thực sự mở ra khả năng gì?

Một vài điều, và chúng tôi nghĩ chúng rất lớn.

Huấn luyện RL bất đồng bộ không cần cụm máy chủ (cluster). Nếu bạn có một GPU và một tài khoản Hugging Face, giờ đây bạn có thể thực hiện huấn luyện phân tách thực sự. Bộ huấn luyện nằm trên GPU; dàn máy rollout nằm trong Spaces; môi trường nằm trong một Space khác; trọng số di chuyển qua một bucket. Điều này trước đây yêu cầu một thiết lập đặt cạnh nhau (colocated) với tất cả những thỏa hiệp về thông lượng mà nó mang lại, hoặc một cụm máy chủ thực sự với mạng chia sẻ. Bây giờ thì không cần nữa.

Suy luận đa bản sao, miễn phí. Thiết lập hai vLLM Spaces, hoặc mười cái. Tất cả cùng tải từ một bucket. Xet lưu trữ theo địa chỉ nội dung nên các anchor liên tiếp chia sẻ các phân đoạn khi lưu trữ (giúp bucket không bị phình to), và bộ nhớ đệm biên (edge cache) của Hub giúp việc tải lặp lại cùng một tệp trở nên rẻ hơn. Muốn một dàn máy rollout phân phối toàn cầu? Giờ đây đó là một bài tập DevOps nhỏ, không còn là một dự án nghiên cứu.

Một định dạng truyền tải có thể gỡ lỗi bằng các công cụ hiện có. Một delta là một tệp safetensors. Bạn có thể safe_open nó từ một notebook, liệt kê các khóa, kiểm tra các chỉ số, tự tính toán độ thưa. Chúng tôi đã dành đủ số giờ trong tcpdump cho các luồng NCCL mờ mịt để biết trân trọng điều này.

Một con đường tiến tới quy mô frontier. Con số 20 đến 35 MB là dành cho Qwen3-0.6B. Câu hỏi thú vị là đường cong sẽ trông như thế nào khi ta tăng quy mô. Hãy làm một phép tính nhanh.

Lấy Llama-3.1-405B. Ở định dạng bf16, nó nặng 810 GB trên đĩa. PULSE đo được độ thưa trung bình mỗi bước là 99% ở tốc độ học RL, vì vậy delta thực tế nằm ở khoảng 1% tham số. Mã hóa đo được khi triển khai của họ đạt 108 MB trên mô hình 7B, tương ứng với mức giảm 130 lần mà PULSE báo cáo. Tỷ lệ tuyến tính với 405B, delta sẽ nằm ở mức khoảng 6 GB mỗi bước.

Điều đó mang lại lợi ích gì về thời gian thực? NCCL nhanh trong một cụm, chắc chắn rồi. Giả sử băng thông phát sóng tổng hợp hào phóng là 100 GB/s (đa nút, RDMA, đầy đủ). Một lần đồng bộ đầy đủ là 810 GB / 100 GB/s ≈ 8 giây tạm dừng suy luận, mỗi bước. Với đường dẫn delta, bộ huấn luyện truyền 6 GB vào một bucket ở chế độ nền trong khi việc tạo token vẫn tiếp tục, và cửa sổ tạm dừng thực tế của rollout server chỉ là bước áp dụng, ở quy mô này mất vài giây. Vì vậy, ngay cả trước khi rời khỏi cụm, delta cắt giảm thời gian tạm dừng hiển nhiên đi 4 lần và giảm lượng byte trên đường truyền khoảng 130 lần.

Bây giờ hãy rời khỏi cụm. NCCL hoàn toàn không hoạt động xuyên đám mây. Một khi bạn muốn một dàn máy rollout ở us-east, một cái ở eu-west, có thể một cái trong Hugging Face Space, đường dẫn dựa trên bucket là đường dẫn duy nhất. Với băng thông internet khả dụng 1 GB/s, một lần phát sóng đầy đủ sẽ mất 13 phút; delta thực hiện điều đó trong 6 giây.

Đối với một mô hình cấp 1 TB trong khung của Fireworks, số liệu họ đo được cho thấy delta 20,3 GiB so với snapshot đầy đủ 1024 GiB, giảm khoảng 50 lần. Mã hóa thưa chặt chẽ hơn của PULSE sẽ đẩy con số đó đi xa hơn (ngoại suy khoảng 15 GB mỗi delta, gần 65 lần). Dù thế nào, bạn đang ở trong chế độ mà việc truyền tải trọng số thông qua lưu trữ đối tượng thông dụng không còn là một “mẹo” mà trở thành kiến trúc hợp lý duy nhất.

7. Những gì còn lại trên bàn làm việc

Chúng tôi không giả vờ rằng điều này đã hoàn tất. Đây là danh sách thành thật.

  • Hai bản snapshot CPU bf16, thừa một cái. Bộ huấn luyện giữ một bản (cho bộ phát hiện thay đổi) và rollout server giữ một bản (để tái cấu trúc tensor đầy đủ cho load_weights của vLLM). Bản đầu tiên chúng tôi phải chấp nhận cho đến khi ai đó tìm ra một mặt nạ phân tích chặt chẽ, điều này khó hơn vẻ ngoài. Bản thứ hai sẽ biến mất khi vLLM có API load_weights thưa. PR sắp ra mắt.
  • Nhịp độ anchor cố định. Hiện tại chúng tôi đổ một anchor đầy đủ sau mỗi $N$ bước. Một chính sách thích ứng (“tạo anchor khi độ lệch tích lũy vượt quá X”) sẽ cắt giảm chi phí anchor trong các đợt chạy dài.
  • Bộ huấn luyện FSDP2 đa nút. BF16ChangeDetector được xây dựng xung quanh các hook optimizer theo từng tiến trình. Nó sẽ tổng quát hóa sạch sẽ cho FSDP2, nhưng chúng tôi chưa đo lường nó ở quy mô đa nút. Có một mục TODO trong PR mang tên chúng tôi.
  • Kết nối vào bộ tối ưu hóa. Nỗ lực dự đoán mặt nạ chỉ từ $(m, v)$ cho kết quả thu hồi thấp, nghĩa là ngưỡng bf16 phân tích đang làm điều gì đó tinh tế hơn công thức trong sách giáo khoa. Chúng tôi rất muốn nghe từ bất kỳ ai đã giải quyết được vấn đề này.
  • Kết hợp với nén trên đường truyền. Safetensors thưa và gzip theo từng phân đoạn là hai thứ độc lập. Chúng tôi chưa thử kết hợp chúng, mặc dù không mong đợi mức tăng nén khổng lồ.

8. Hãy thử nó

Recommended for You

Harness, Scaffold và các Thuật ngữ về AI Agent Cần Nắm Vững

Harness, Scaffold và các Thuật ngữ về AI Agent Cần Nắm Vững

Tìm hiểu về Harness, Scaffold và các thuật ngữ quan trọng liên quan đến AI Agent

Hai năm sử dụng AI cục bộ trên laptop- Khi các mô hình mở vượt xa định luật Moore

Hai năm sử dụng AI cục bộ trên laptop- Khi các mô hình mở vượt xa định luật Moore

Chia sẻ về trải nghiệm sử dụng AI cục bộ trên máy tính xách tay trong hai năm qua