AI·News
뒤로

허브 버킷을 이용한 1조 파라미터 배포: TRL의 델타 가중치 동기화

Shipping a Trillion Parameters With a Hub Bucket: Delta Weight Sync in TRL

Hub 버킷으로 조 개의 파라미터 배포하기: TRL의 델타 웨이트 동기화

TL;DR, 학습할 모델이 많고 시간을 존중하니까:
  • 비동기 RL에는 더러운 비결이 있다: 매 스텝마다 트레이너가 전체 모델을 추론 엔진으로 보내야 한다. 7B 모델을 bf16으로 표현하면 14GB다. 프론티어급 1조 파라미터 모델 체크포인트라면 대략 테라바이트 규모다. 매 스텝마다.
  • 그럴 필요가 없다는 게 밝혀졌다. 연속된 두 RL 옵티마이저 스텝 사이에, 대략 99%의 bf16 가중치가 비트 단위로 동일하다(최악의 경우도 98% 이상). 실제 변화는 매우 작다.
  • 우리는 변경된 요소만을 희소 safetensors 파일로 인코딩하고 Hugging Face 버킷에 업로드한 후 vLLM에 다운로드하라고 알려주는 TRL PR을 완성했다. Qwen3-0.6B에서 스텝당 페이로드가 1.2GB에서 20~35MB로 떨어진다.
  • 게다가: 트레이너가 한 머신에, vLLM이 Hugging Face Space에, Wordle 환경이 다른 Space에 있는 완전히 분산된 학습을 실행했고, 가중치는 단일 Hub 버킷을 통해 흘렀다. 공유 클러스터도, RDMA도, VPN도 없었다.

비동기 RL이 훨씬 저렴해졌다. 계속 읽어보자.

같은 가중치를 배포하는 두 가지 방법. 빨간색은 토큰이 생성되지 않는 벽시계 시간이다.

1. 1테라바이트 문제

비동기 RL 학습의 환경에 관한 이전 글을 읽었다면 이미 알고 있을 것이다. 모든 비동기 RL 라이브러리는 "액터 모델"을 어떻게 쓰든, NCCL 백엔드가 무슨 색이든 결국 같은 근본적인 문제에 부딪힌다: 가중치 동기화.

추론 엔진은 스텝 N의 정책으로 말한다. 트레이너는 방금 스텝 N+1을 완료했다. 새 가중치가 한쪽에서 다른 쪽으로 이동해야 추론 엔진이 정책에서 심하게 벗어나기 시작하기 전에. 동기식이든 비동기식이든 관계없이 이것이 임계 경로에 있다: 블로킹 전송은 토큰을 생성하지 않는 GPU의 낭비된 유휴 계산이다. 희소 델타 경로를 사용하면 그 유휴 시간을 초 단위로 줄일 수 있고, 트레이너는 추론 엔진이 준비될 때까지 기다릴 필요도 없다: 옵티마이저 스텝이 끝나자마자 "가중치 준비됨"을 발행하고 공유 버킷에 가중치를 업로드할 수 있고, 추론 엔진은 자신의 속도대로 다운로드한다.

Fireworks는 그들의 글 Frontier RL은 생각보다 저렴하다에서 이에 대해 매우 인상적인 수치를 제시했다: 프론티어급 1조 파라미터 체크포인트를 fp8로(그들의 설정) 표현하면 전체 스냅샷은 1024GB이고, 이것이 롤아웃 플릿을 업데이트할 때마다 배송해야 한다고 기존 지혜가 말한다. 이런 수치면 사람들이 메가 클러스터, RDMA 패브릭, 전용 크로스 리전 링크로 다이어그램을 그리기 시작한다. 그들의 측정된 평균 델타는 인접한 체크포인트 간에 20.3GB, 즉 전체 모델의 1.98%이고, "bf16 형식의 가중치 중 98% 이상이 연속된 체크포인트 간에 비트 동등성을 유지한다".

Cursor의 Composer 2 보고서는 유사한 이야기를 한다. 그들은 학습과 추론을 다른 지역에서 실행하고 공유 S3 버킷(그들의 정확한 말)으로 연결하며, 트레이너가 매 학습 스텝마다 압축된 가중치 델타를 업로드한다. 각 클러스터는 공유 델타 체인으로부터 독립적으로 다운로드하고 재구성하며, "학습 클러스터에 대한 직접 연결이 필요 없다". 두 측은 파라미터에 대해 직접 말한 적이 없다. 버킷이 선이다.

두 논문은 세 가지에 동의하고, 이것들을 천천히 반복하고 싶다. 이 글의 나머지는 본질적으로 충실한 오픈소스 번역이기 때문이다:

  1. 대부분의 가중치는 실제로 두 인접한 RL 스텝 사이에 변하지 않았다.
  2. 변경된 부분만 보내면 대역폭 비용이 대략 두 자릿수 만큼 줄어든다.
  3. 그 작은 델타를 공유 객체 저장소를 통해 라우팅하면 트레이너와 추론 클러스터가 같은 데이터 센터에 있을 필요가 없다.

유일한 빠진 것은 이 이야기를 pip install할 수 있는 버전이었다. 그래서 우리가 만들었다.

2. bf16 RL 가중치가 거의 항상 희소인 이유

무엇이든 연결하기 전에, 이 전체 게임이 왜 승리할 수 있는지 이해할 가치가 있다. "98%의 가중치가 변하지 않는다"는 주장은 데모에서는 작동하고 현장에서 무너진다는 그런 수치 같다. 그렇지 않다. bf16 산술이 RL이 사용하는 학습률에서 어떻게 작동하는지에서 나온다.

bf16 숫자는 7비트 가수를 가진다. 연속된 2의 거듭제곱 사이에 정확히 27=1282^7 = 128개의 표현 가능한 값이 있으므로, w|w| 근처의 인접한 bf16 숫자 사이의 간격은 대략 w27|w| \cdot 2^{-7}다. 업데이트는 Δw<w/256|\Delta w| < |w|/256일 때 그 간격의 절반보다 아래에 있을 때마다 bf16 캐스트에 흡수된다. 이것이 PULSE가 그들의 그림 3에서 그리는 "bf16 가시성 임계값"이다.

이제 Adam이 하는 것을 보자. RL 학습률이 3×1063 \times 10^{-6}라고 하면, 단일 가중치에 대한 업데이트는: Δw=ηm^v^+ϵ\Delta w = -\eta \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}이다.

정규화된 스텝 m^/(v^+ϵ)\hat{m}/(\sqrt{\hat{v}}+\epsilon)는 대략 1 정도이므로, Δwη3×106|\Delta w| \approx \eta \approx 3 \times 10^{-6}이다. 대부분의 가중치에 대해, w|w|는 대략 10210^{-2}에서 10110^{-1} 사이에 있다(PULSE는 대표적인 LLM 가중치에 대해 중앙값 0.019를 보고한다). 임계값 w/256|w|/256는 그 규모에서 대략 4×1054 \times 10^{-5}에서 4×1044 \times 10^{-4} 사이이고, 이것은 업데이트보다 크다.

다시 말해: 옵티마이저가 속삭이고 있고 bf16이 듣지 못한다. 업데이트는 반올림에 의해 흡수되고, ww의 바이트 표현은 변하지 않고, 추론 엔진의 관점에서 이 가중치는 움직이지 않았다. 이것을 몇 억 개의 파라미터로 곱하면 >99% 희소성 수치를 얻는다, 공짜로, 근사 없이.

이것은 정확히 PULSE 논문(Mihai & Belilovsky, 2026)에서 공식화된 주장이다. 그들은 두 개의 임계값을 정의한다. 흡수 경계 10η10\eta는 Adam 업데이트의 보수적인 최악의 경우이고, 효과적 경계 η\eta는 실제로 살고 있는 체제다. bf16 가시성 임계값w/256|w|/256이다. 업데이트가 가시성 임계값 아래에 있을 때마다 흡수되고 bf16 바이트는 변하지 않는다. 그들의 그림 3은 대표적인 LLM 가중치의 구름에 대해 두 경계를 그리고, 결론은 명확하다: η=3×106\eta = 3 \times 10^{-6}에서, 흡수 경계 자체는 이미 모델의 거의 모든 가중치에 대해 가시성 임계값 아래에 있다. 그들은 Qwen2.5(0.5B/1.5B/7B), Llama-3.2-3B, Gemma-3-4B에 걸쳐 경험적으로 측정하고 400 학습 스텝 동안 표준 편차가 0.2~0.4%인 ~99%의 일정한 스텝별 희소성을 일관되게 찾는다. 최악의 경우 스텝은 98% 이상을 유지한다. 따라서 <1%가 변경되었다는 것은 운이 좋은 측정이 아니다; 이것이 산술이 보장하는 것이다.

우리는 이것을 분석적으로 예측할 필요가 없다(그리고 실제로 우리는 Adam의 mmvv 통계로부터 변경 마스크를 예측하려고 시도했지만 재현율이 슬픈 30%였다, 나중에 더 자세히). 우리는 단지 어느 바이트가 뒤집혔는지 관찰할 필요가 있다. 이것은 파라미터당 매우 작은 부울 텐서이며, 옵티마이저 스텝 근처에서 계산된다.

학습률을 RL 영역으로 끌어내리고 bf16으로 캐스트백 마커가 원래 눈금으로 스냅하는 것을 지켜보자. 왼쪽 아래의 256 요소 그리드는 작은 모델에 걸친 집합 효과다.

3. HF 버킷과 아키텍처

여기가 이야기의 두 번째 부분이 들어오는 곳이고, 이 글이 Fireworks/Cursor의 번역을 멈추고 Hugging Face 것이 되는 곳이다.

3.1 버킷이란?

버킷은 고주파 객체 저장소를 위해 설계된 Hub의 리포 유형이다. 커밋 의식도, PR 워크플로도, LFS의 이상함도 없다. 파일을 추가하고, 파일을 나열하고, 파일을 다운로드한다. Python 인터페이스는 두 함수다:

from huggingface_hub import batch_bucket_files, download_bucket_files


batch_bucket_files("my-org/wordle-deltas", add=[(buffer, "deltas/step_000042.safetensors")])


download_bucket_files("my-org/wordle-deltas", files=[("deltas/step_000042.safetensors", local_path)])

이게 다다. 두 함수 호출로 가중치가 비행 중이다.

후드 아래에서 버킷은 Xet, Hub의 콘텐츠 정의 청킹 저장소 계층으로 지원된다. Xet는 업로드하는 모든 파일을 보고, 고정 오프셋이 아닌 실제 콘텐츠를 기반으로 청크로 자르고, 버킷에 이미 있는 모든 것에 대해 중복을 제거한다. 실질적인 결과는 이 문맥에서 기쁜데, 우리가 희소 인코딩을 쓰기에 너무 게으르고 매 스텝 전체 앵커를 업로드했더라도 Xet가 여전히 변경된 청크만 전송할 것이다. 희소 인코딩 + Xet 스택: 우리는 이동한 것에 대해 비용을 지불하고 한 번 비용을 지불한다.

이것은 Fireworks와 Cursor가 모두 도달하는 "공유 S3 버킷"의 오픈소스 동등물이지만, 저장소 계층이 이미 콘텐츠 해싱을 알고 있고, 기존 HF 토큰이 이미 허가를 가지고 있으며, 스택의 나머지와 자연스럽게 작성된다(Spaces, 데이터셋, 모델).

3.2 세 개의 상자

전체 아키텍처는 정확히 세 개의 상자와 하나의 공유 기판을 가지고 있다:

  • 트레이너. 원하는 곳 어디든. 하나의 GPU, 8개의 GPU, USB 연결 H100이 있는 노트북, 우리는 판단하지 않을 것이다. 모델 가중치를 소유하고, 옵티마이저를 실행하며, 희소 델타를 방출한다.
  • HF 버킷. 단일 리포, 두 개의 접두사: 가끔 전체 스냅샷을 위한 anchors/와 그 사이의 희소 패치를 위한 deltas/. 이것이 유일한 것으로 양쪽이 동의한다.
  • vLLM 롤아웃 서버. 원하는 곳 어디든, 그리고 매우 중요하게는 반드시 트레이너가 있는 곳에 있을 필요는 없다. 버킷에서 끌어오고, 델타를 적용하며, 롤아웃을 제공한다.
  • 환경. 일반적인 방식으로 롤아웃 서버를 매달고(HTTP, 함수 호출, 무엇이든 env가 말하는 것).

내재화할 속성, Cursor의 논문이 열심히 판매하는 것이고 여기서 정확하게 보유한다: 트레이너와 롤아웃 서버는 가중치에 대해 절대 말하지 않는다. 그들은 {"repo_id": ..., "filename": ...}를 포함하는 작은 POST를 교환하고, 그것이 전체 제어 평면이다. 실제 바이트 전송은 각 측과 버킷 사이에서 병렬로 공유 네트워크 패브릭 없이 발생한다.

그것이 실제로 중요한 이유:

  • 롤아웃 서버는 다른 리전, 다른 클라우드, 또는 Hugging Face Space 내에서 NAT 뒤에 있을 수 있다. 신경 쓰지 않는다.
  • N 추론 복제본이 같은 버킷에서 같은 델타를 당길 수 있고, Xet는 모두에 걸쳐 바이트를 중복 제거한다.
  • 트레이너는 존재하거나, 어디에 있거나, 그 중 하나가 방금 충돌했는지 추론 복제본 수를 알 필요가 없다.

트레이너가 쓴다. 복제본이 읽는다. Hub가 배관을 한다.

4. 프로토콜

이제 후드를 열 수 있다. 프로토콜은 네 부분을 가진다: 와이어 포맷, 버킷 레이아웃, 30줄 vLLM 확장, 트레이너 측 변경 검출기. 듣는 것보다 실제로 더 적은 코드다.

4.1 와이어 포맷으로 Safetensors

우리는 온디스크 및 온와이어 포맷을 위해 safetensors를 선택했다. 이미 Hub의 정규 체크포인트 포맷이고, 모든 합리적인 프레임워크가 읽을 수 있으며, 헤더가 임의 문자열 메타데이터를 가진다. 그 메타데이터 필드가 프로토콜을 숨기는 곳이다.

버킷에는 두 종류의 파일이 있다.

앵커는 정상적인 체크포인트처럼 보인다: 파라미터당 하나의 텐서, 전체 bf16 가중치, 매 NN 동기마다 작성된다(기본값은 N=10N=10).

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

델타는 흥미로운 부분이다. 실제로 변경된 각 파라미터에 대해 두 개의 항목을 저장한다: 요소 인덱스의 평면 int32 텐서와 해당 인덱스에서의 bf16 텐서 값.

deltas/step_000011.safetensors
  ├── model.layers.0.self_attn.q_proj.weight.indices   (int32, [num_changed])
  ├── model.layers.0.self_attn.q_proj.weight.values    (bf16,  [num_changed])
  ├── 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=[...]

이 선택의 좋은 결과들이 몇 가지:

  • 델타는 파일이다. Python에서 safe_open(...)로 열 수 있고 그 안의 모든 텐서를 검사할 수 있다. 독점적인 프레이밍, 길이 접두사, 버전 악수 없음.
  • 메타데이터는 자기 설명적이다. 수신자가 sparse=True/False를 읽고 분기한다. 별도 매니페스트가 없다.
  • 추론 측에서 mmap을 통해 제로 복사인데, 이것이 중요한데 수 초마다 이것을 할 때이기 때문이다.

케이던스는 간단하다: 매 N번째 스텝에서 앵커, 그 사이에 델타. 둘 다 anchors/deltas/ 접두사 아래의 같은 버킷에 끝난다. 각 새로운 추론 복제본은 가장 최근의 앵커를 잡은 후 그 이후의 델타를 재생하기만 하면 된다.

10 학습 스텝. 스텝 1과 6에서 앵커(전체 스냅샷), 다른 모든 스텝에서 희소 델타. 시켜하면서 파일이 버킷에 착지한다.

4.2 트레이너 측: 옵티마이저 후크로부터의 부울 마스크

트레이너는 실제로 어떤 bf16 요소가 뒤집혔는지 알아야 한다. 우리는 옵티마이저에 사전 스텝 및 스텝 후 후크를 등록하는 작은 BF16ChangeDetector로 이것을 한다:

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)]
            )

PR의 실제 코드는 약간 더 많은 배관을 가진다(Accelerate가 다른 Python 객체로 래핑하기 때문에 data_ptr()를 통해 옵티마이저 파라미터 객체를 모델 파라미터와 매칭), 하지만 아이디어는 냅킨에 들어간다: 스냅샷, 스텝, 차이.

이것이 사실의 근거다. 우리는 Adam의 mmvv 통계에서 마스크를 예측하는 더 우아한 경로를 시도했다, bf16 ULP 임계값을 직접 사용한다. 원칙적으로는 작동한다. 실제로는 재현율이 약 30%였는데, 이는 우리가 실제 업데이트의 3분의 2를 놓친 델타를 배송했을 것임을 의미한다. Adam의 정규화는 혼란스러워서 분석 임계값이 타이트하지 않다. 그래서 우리는 단지 바이트를 비교한다. 트레이너 측에서 모델의 하나의 bf16 CPU 스냅샷 비용이 든다, 우리는 지불할 의사가 있다.

새로운 _sync_weight 흐름의 네 단계는:

  1. 추론이 계속 실행되는 동안 업로드한다. 트레이너가 마스크된 요소를 safetensors 버퍼로 인코딩하고 버킷으로 푼다. vLLM은 이 전체 스텝 동안 여전히 행복하게 구 정책을 제공하고 있다.
  2. vLLM을 일시 중지한다. 짧은 HTTP 호출, 수백 밀리초.
  3. /update_weights를 신호한다. 버킷 좌표를 보낸다. vLLM이 다운로드하고, 적용하고, 반환한다.
  4. 다시 시작한다. vLLM이 다시 공중에서.

로그 라인이 이야기를 말한다:

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)

중요한 라인은 괄호다. 추론이 1.1초 일시 중지되었다. 남은 9.4초는 업로드에 소비되었고, 이는 롤아웃 서버가 여전히 토큰을 생성하는 동안 발생했다. NCCL의 경우 우리는 전체 동기 시간을 일시 중지 시간으로 지불했다. 여기서 우리는 배경 시간으로 지불한다.

단일 동기, 끝에서 끝까지. 델타 오버 버킷과 NCCL 브로드캐스트 사이를 전환하고, 복제본 수 토글을 시도해서 팬아웃 이야기를 본다.

4.3 vLLM 측: 30줄 확장

vLLM은 WeightTransferEngine이라고 불리는 이것을 위한 깨끗한 추상화를 가진다. 우리는 receive_weights 메서드가 정신으로:

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:
            
            for name in f.keys():
                tensor = f.get_tensor(name)
                self._bf16_snapshot[name] = tensor.clone()
                load_weights([(name, tensor)])
        else:
            
            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])])

우리는 vLLM의 --worker-extension-cls 플래그를 통해 등록하는데, 이는 vLLM의 포크가 필요 없다는 의미다. vLLM과 같은 이미지에 TRL을 설치하고, 우리 클래스에 CLI를 가리키고, 완료다.

언급할 가치가 있다: vLLM 자체는 희소 가중치 전송을 기본적으로 착지시키는 진행 중인 노력이 있다, vllm-project/vllm#40096. 이것은 receive_sparse_weights()trainer_send_sparse_weights()WeightTransferEngine 베이스 클래스에 직접 추가하고, 패치를 (indices, values)로 인코딩하고 index_copy_()를 통해 제자리에 적용하며, GPU/CPU 검증 왕복을 전적으로 제거한다. PR은 Qwen3-1.7B에서 희소 패치의 0.16MB를 0.40ms에 대해 전체 조밀 전송의 942MB를 192ms에 보고한다.

추론 측의 우리 구현의 한 가지 정직한 주의: 우리는 CPU bf16 스냅샷을 모델의 유지하므로 우리는 희소 (indices, values) 패치로부터 전체 텐서를 재구성할 수 있는데, vLLM에서 load_weights가 오늘 전체 텐서를 기대하기 때문이다. #40096 (또는 그 후속물)이 착지하고 제자리 희소 load_weights 경로를 드러내면, 우리는 GPU에 지수를 직접 적용할 수 있고 스냅샷을 떨어뜨린다!

5. Spaces에 실제로 세우기

이것이 자랑스러워하는 부분이다. 지금까지 우리가 설명한 모든 것이 노트북에서 작동하지만, 가중치를 Hub 버킷을 통해 라우팅하는 이유는 트레이너와 롤아웃 서버가 서로 가까이 있을 필요가 없다는 것이다. 그래서 우리는 네트워크를 공유하지 않는 세 머신으로 완전히 분산된 학습을 실행했다:

  • 하나의 GPU로 트레이너를 실행하는 상자.
  • Hugging Face Space(Docker SDK, L4 GPU)는 우리의 확장 클래스와 함께 vLLM을 실행한다.
  • 두 번째 Hugging Face Space(CPU)는 256개의 동시 세션 용량으로 Wordle 환경 서버를 실행한다.
  • 중간에 Hub 버킷.

이것을 세우는 것은 정말 몇 가지 hf CLI 호출이다. vLLM Space의 Dockerfile은 본질적으로 업스트림 vLLM 이미지 더하기 pip install trl@... 더하기 진입점:

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"]

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

그리고 지구의 어느 곳에서든 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

트레이너는 포트를 열지 않는다. Space는 트레이너의 IP를 보지 못한다. Wordle 환경은 둘 다 존재하는지 모른다. 그들은 모두 Hub로 말한다. 학습은 즉시-EOS 제안 확인에서 수렴했고, 그 다음 실제 Wordle 롤아웃: 보상이 올라가고, 델타 페이로드는 20~35MB 밴드에 머물렀고, 동기당 추론 일시 중지 창은 약 1초로 유지되었다. 전체 실행 로그는 동반 PR에 링크된다.

6. 그래서 이것이 실제로 무엇을 잠금 해제하는가?

몇 가지이고, 우리는 그들이 크다고 생각한다.

클러스터 없는 비동기 RL 학습. 하나의 GPU와 Hugging Face 계정이 있으면 이제 진짜 분산된 학습을 할 수 있다. 트레이너는 GPU에 있고; 롤아웃 플릿은 Spaces에 산다; 환경은 다른 Space에 산다; 가중치는 버킷을 통해 이동한다. 이것은 공동 위치 설정(그것이 가져오는 모든 처리량 손실)이나 공유 네트워킹이 있는 실제 클러스터를 필요로 했다. 더 이상 아니다.

다중 복제본 추론, 공짜로. 두 개의 vLLM Space를 세우거나, 10개. 그들은 모두 같은 버킷에서 끌어온다. Xet 콘텐츠 주소 저장소가 연속된 앵커가 쉬고 있는 청크를 공유하고(버킷이 폭발하지 않도록 유지), Hub의 모서리 캐시가 같은 파일의 반복 다운로드를 배치하기 싸게 만든다. 전 지구적으로 분산된 롤아웃 플릿을 원하는가? 이제 작은 DevOps 연습이다, 연구 프로젝트 아니다.

기존 도구로 디버깅할 수 있는 와이어 포맷. 델타는 safetensors 파일이다. 노트북에서 safe_open할 수 있고, 키를 나열하고, 지수를 검사하고, 희소성을 자신이 계산할 수 있다. 우리는 불투명한 NCCL 스트림에서 충분한 시간을 tcpdump 화했으므로 이것을 감사한다.

프론티어 규모로의 경로. 20~35MB 수치는 Qwen3-0.6B를 위한 것이다. 흥미로운 질문은 한 번 다이얼을 올린 후 곡선이 어떻게 보이는지 무엇인가. 냅킨 수학을 하자.

Llama-3.1-405B를 가져라. bf16에서 이것은 디스크에 810GB다. PULSE는 RL 학습률에서 ~99% 평균 스텝별 희소성을 측정하므로 실제 델타는 파라미터의 약 1%에 앉는다. 그들의 배포 측정 인코딩은 7B 모델에서 108MB를 명중하는데, 이것이 PULSE가 보고하는 ~130× 감소다. 405B로 선형으로 확장하면, 델타는 스텝당 대략 6GB에 착지한다.

그것이 벽시계에서 무엇을 사는지? NCCL은 클러스터 내에서 빠르다, 확실히. 관대한 100GB/s 집합 브로드캐스트 대역폭을 가정한다(다중 노드, RDMA, 전부). 전체 동기는 810GB / 100GB/s ≈ 8초 추론 일시 중지, 매 스텝. 델타 경로에서, 트레이너가 생성이 계속하는 동안 배경에서 버킷에 6GB를 흐르게 하고, 롤아웃 서버의 실제 일시 중지 창은 단지 적용 스텝이고, 이 규모에서는 수 초 정도에 착지한다. 그래서 우리가 클러스터를 떠나기도 전에, 델타는 보이는 일시 중지를 4× 줄이고 와이어의 바이트를 ~130×.

이제 클러스터를 떠난다. NCCL은 클라우드를 가로질러 직선적으로 작동하지 않는다. 일단 us-east에 롤아웃 플릿을 원하면, 다른 하나는 eu-west에, 아마도 Hugging Face Space에 하나, 버킷 기반 경로가 유일한 경로다. 1GB/s의 사용 가능한 인터넷 대역폭에서, 단일 전체 브로드캐스트는 13분이 걸릴 것이다; 델타는 6초에 한다.

Fireworks 프레이밍에서 1TB급 모델의 경우, 그들 자신의 측정된 수치는 20.3GiB 델타 vs 1024GiB 전체 스냅샷, 약 50× 감소를 보여준다. PULSE의 더 타이트한, 희소 인코딩은 그것을 더 멀리 밀 것이다(외삽 ~15GB 스텝당 델타, ~65×에 더 가깝다). 어쨌든, 너는 상품 객체 저장소를 통해 가중치를 배송하는 것이 해킹에서 유일한 합리적인 아키텍처로 시작하는 체제에 있다.

7. 아직 우리 판 위에 있는 것

우리는 이것이 끝났다고 가장하지 않는다. 정직한 목록이다.

  • 두 개의 CPU bf16 스냅샷, 하나 너무 많다. 트레이너는 하나를 유지한다(변경 검출기를 위해) 그리고 롤아웃 서버는 하나를 유지한다(vLLM의 load_weights를 위해 전체 텐서를 재구성하기). 첫 번째는 누군가가 타이트 분석 마스크를 찾을 때까지 우리가 붙어 있는데, 보이는 것보다 더 어렵다. 두 번째는 vLLM이 희소 load_weights API를 얻을 때 사라진다. PR이 나올 예정이다.
  • 고정된 앵커 케이던스. 우리는 현재 매 NN스텝마다 전체 앵커를 덤프한다. 적응적 정책("누적 드리프트가 X를 초과할 때 앵커")은 긴 실행의 앵커 비용을 줄일 것이다.
  • 다중 노드 FSDP2 트레이너. BF16ChangeDetector는 프로세스당 옵티마이저 후크 주위에 구축된다. FSDP2로 깨끗하게 일반화되어야 하지만 우리는 다중 노드 규모에서 측정하지 않았다. PR의 우리 이름과 함께 TODO가 있다.
  • 옵티마이저로 후킹하기. 우리의 마스크를 (m,v)(m, v) 단독에서 예측하려는 시도는 낮은 재현율을 주었는데, 분석적 bf16 임계값이 교과서 공식보다 더 미묘한 것을 하고 있음을 의미한다. 이것을 깬 누구에게서든 들어보고 싶다.
  • 온 와이어 압축으로 적층하기. 희소 safetensors과 청크당 gzip은 직교한다. 우리는 아직 그들을 결합하려고 시도하지 않았다. 비록 우리는 거대한 압축 이득을 기대하지 않지만.

8. 시도해보기

Shipping a Trillion Parameters With a Hub Bucket: Delta Weight Sync in TRL

TL;DR, because you have models to train and we respect that:
  • Async RL has a dirty secret: every step, the trainer has to ship the whole model to the inference engine. For a 7B in bf16 that is 14 GB. For a frontier 1T model checkpoint that is on the order of a terabyte. Per step.
  • It turns out you do not have to. Between two consecutive RL optimizer steps, roughly 99% of bf16 weights are bit-identical (and never less than 98% in the worst case). The actual delta is tiny.
  • We landed a TRL PR that encodes just the changed elements as a sparse safetensors file, uploads it to a Hugging Face Bucket, and tells vLLM to fetch it. On Qwen3-0.6B, the per-step payload drops from 1.2 GB to 20 to 35 MB.
  • The cherry on top: we ran a full disaggregated training where the trainer was on one box, vLLM lived in a Hugging Face Space, the Wordle environment lived in another Space, and weights flowed through a single Hub bucket. No shared cluster, no RDMA, no VPN.

Async RL just got a lot cheaper. Read on.

Two ways to ship the same weights. Red is wall-clock time during which no tokens are being generated.

1. The One Terabyte Problem

If you read our previous post on the landscape of async RL training, you already know the punchline. Every async RL library, regardless of how it spells "actor model" or which color its NCCL backend is painted, eventually trips over the same root: weight synchronization.

The inference engine speaks the policy of step N. The trainer just finished step N+1. The fresh weights have to get from one side to the other before the inference engine starts drifting hopelessly off-policy. This sits on the critical path whether you are running sync or async: a blocking transfer is wasted idle compute of GPUs not generating tokens. With a sparse delta path you collapse that idle time into seconds, and the trainer does not even have to wait for the inference engine to be ready: it just publishes "weights ready" and uploads the weights to the shared bucket the moment its optimizer step finishes, while the inference engine fetches on its own time.

Fireworks put a very memorable number on this in their post Frontier RL Is Cheaper Than You Think: for a frontier 1T-parameter checkpoint at fp8 (their setting), a full snapshot is 1024 GiB, and that is what conventional wisdom says you have to ship every time you update your rollout fleet. That is the kind of number that gets people to start drawing diagrams with mega-clusters, RDMA fabrics, and dedicated cross-region links. Their measured average delta between adjacent checkpoints lands at 20.3 GiB, or 1.98% of the full model, and "more than 98% of weights in bf16 format remain bit-equivalent between consecutive checkpoints".

Cursor's Composer 2 report tells a parallel story. They run training and inference in different regions and stitch them together with a shared S3 bucket (their exact words), into which the trainer uploads compressed weight diffs every training step. Each cluster independently downloads and reconstructs from the shared delta chain, "requiring no direct connectivity to the training cluster". The two sides never speak to each other about parameters directly. The bucket is the wire.

Both papers agree on three things, and we want to repeat them slowly, because the rest of this post is essentially a faithful open source translation:

  1. Most of the weights have not actually changed between two adjacent RL steps.
  2. If you send only the parts that changed, your bandwidth bill collapses by roughly two orders of magnitude.
  3. If you route those tiny diffs through a shared object store, you no longer need the trainer and the inference cluster to live in the same data center.

The only thing missing was a version of this story that you can pip install. So we wrote one.

2. Why bf16 RL Weights Are Almost Always Sparse

Before we wire anything up, it is worth understanding why this whole game is even winnable. The "98% of weights do not change" claim sounds suspiciously like one of those numbers that works in the demo and falls apart in the wild. It is not. It falls out of how bf16 arithmetic works at the learning rates RL uses.

A bf16 number has 7 mantissa bits. Between two consecutive powers of two, there are exactly 27=1282^7 = 128 representable values, so the spacing between adjacent bf16 numbers around w|w| is roughly w27|w| \cdot 2^{-7}. An update gets absorbed by the bf16 cast whenever it sits below half of that spacing, i.e., when Δw<w/256|\Delta w| < |w|/256. This is the "bf16 visibility threshold" PULSE plots in their Figure 3.

Now look at what Adam does. At an RL learning rate of, say, 3×1063 \times 10^{-6}, the update to a single weight is: Δw=ηm^v^+ϵ\Delta w = -\eta \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}

The normalized step m^/(v^+ϵ)\hat{m}/(\sqrt{\hat{v}}+\epsilon) is roughly order one, so Δwη3×106|\Delta w| \approx \eta \approx 3 \times 10^{-6}. For most weights, w|w| sits somewhere around 10210^{-2} to 10110^{-1} (PULSE reports a median of 0.019 for representative LLM weights). The threshold w/256|w|/256 at that magnitude is around 4×1054 \times 10^{-5} to 4×1044 \times 10^{-4}, which is bigger than the update.

In other words: the optimizer is whispering, and bf16 cannot hear it. The update gets absorbed by rounding, the byte representation of ww does not change, and from the inference engine's perspective, this weight did not move. Multiply that by a few hundred million parameters, and you get the >99% sparsity number, for free, with zero approximation.

This is exactly the argument made formal in the PULSE paper (Mihai & Belilovsky, 2026). They define two thresholds. The absorption bound 10η10\eta is the conservative worst case for an Adam update, and the effective bound η\eta is the regime you actually live in. The bf16 visibility threshold is w/256|w|/256. Whenever the update sits below the visibility threshold, it gets absorbed, and the bf16 byte does not change. Their Figure 3 plots both bounds against a cloud of representative LLM weights, and the conclusion is unambiguous: at η=3×106\eta = 3 \times 10^{-6}, the absorption bound itself already sits below the visibility threshold for almost every weight in the model. They measure this empirically across Qwen2.5 (0.5B/1.5B/7B), Llama-3.2-3B, and Gemma-3-4B, and consistently find a mean per-step sparsity of ~99%, with a standard deviation of 0.2 to 0.4% over 400 training steps. The worst-case step stays above 98%. So <1% changed is not a lucky measurement; it is what the arithmetic guarantees.

We do not have to predict this analytically (and indeed, we tried predicting the change mask from Adam's mm and vv statistics, but recall was a sad 30%, more on that later). We just need to observe which bytes flipped. That is a tiny boolean tensor per parameter, computed right around the optimizer step.

Drag the learning rate down to RL territory and watch the cast-back-to-bf16 marker snap to the original tick. The 256-element grid on the bottom left is the aggregate effect across a tiny model.

3. HF Buckets and the Architecture

Here is where the second piece of the story comes in, and where this post stops being a translation of Fireworks/Cursor and starts being a Hugging Face thing.

3.1 What is a Bucket?

A Bucket is a repo type on the Hub designed for high-frequency object storage. No commit ceremony, no PR workflow, no LFS quirks. You add files, you list files, you download files. The Python interface is two functions:

from huggingface_hub import batch_bucket_files, download_bucket_files


batch_bucket_files("my-org/wordle-deltas", add=[(buffer, "deltas/step_000042.safetensors")])


download_bucket_files("my-org/wordle-deltas", files=[("deltas/step_000042.safetensors", local_path)])

That is it. Two function calls and your weights are in flight.

Under the hood, buckets are backed by Xet, the Hub's content-defined chunking storage layer. Xet looks at every file you upload, slices it into chunks based on its actual content (not fixed offsets), and deduplicates against everything already in the bucket. The practical upshot, which is delightful in this context, is that even if we were too lazy to write the sparse encoding and just uploaded full anchors every step, Xet would still only transfer the changed chunks. Sparse encoding + Xet stack: we pay for what moved, and we pay for it once.

This is the open source equivalent of the "shared S3 bucket" both Fireworks and Cursor reach for, except the storage layer already knows about content hashing, your existing HF token already has permission, and it composes natively with the rest of the stack (Spaces, datasets, models).

3.2 The Three Boxes

The full architecture has exactly three boxes and one shared substrate:

  • Trainer. Wherever you want. One GPU, eight GPUs, a laptop with a USB-attached H100, we will not judge. Owns the model weights, runs the optimizer, emits sparse deltas.
  • HF Bucket. A single repo, two prefixes: anchors/ for occasional full snapshots and deltas/ for the sparse patches in between. This is the only thing both sides agree on.
  • vLLM rollout server. Wherever you want, and crucially not necessarily where the trainer is. Pulls from the bucket, applies the delta, and serves rollouts.
  • Environment. Hangs off the rollout server in the usual way (HTTP, function calls, whatever your env speaks).

The property to internalize, the one Cursor's paper sells hard and that holds verbatim here: the trainer and the rollout server never talk to each other about weights. They exchange a tiny POST containing {"repo_id": ..., "filename": ...}, and that is the entire control plane. The actual byte transfer happens between each side and the bucket, in parallel, with no shared network fabric.

Why that matters in practice:

  • The rollout server can be in another region, another cloud, or behind NAT inside a Hugging Face Space. It does not care.
  • N inference replicas can pull the same delta from the same bucket, and Xet deduplicates the bytes across all of them.
  • The trainer never has to know how many inference replicas exist, or where, or whether one of them just crashed.

The trainer writes. Replicas read. The Hub does the plumbing.

4. The Protocol

Now we can open the hood. The protocol has four parts: a wire format, a bucket layout, a 30 line vLLM extension, and a trainer side change detector. It is honestly less code than it sounds.

4.1 Safetensors as the Wire Format

We picked safetensors for the on-disk and on-wire format. It is already the canonical checkpoint format on the Hub, every reasonable framework can read it, and the header carries arbitrary string metadata. That metadata field is where we hide the protocol.

There are two kinds of files in the bucket.

Anchors look like a normal checkpoint: one tensor per parameter, full bf16 weights, written every NN syncs (we default to N=10N=10).

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

Deltas are the interesting bit. For each parameter that actually changed, we store two entries: a flat int32 tensor of element indices, and a bf16 tensor of values at those indices.

deltas/step_000011.safetensors
  ├── model.layers.0.self_attn.q_proj.weight.indices   (int32, [num_changed])
  ├── model.layers.0.self_attn.q_proj.weight.values    (bf16,  [num_changed])
  ├── 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=[...]

A few nice consequences of this choice:

  • A delta is a file. You can open it with safe_open(...) in Python and inspect every tensor in it. No proprietary framing, no length prefixes, no version handshake.
  • The metadata is self-describing. The receiver reads sparse=True/False and branches. There is no separate manifest.
  • It is zero-copy via mmap on the inference side, which matters when you are doing this every few seconds.

The cadence is straightforward: anchor every Nth step, delta in between. Both end up in the same bucket under anchors/ and deltas/ prefixes. Each new inference replica only needs to grab the most recent anchor and then replay the deltas since.

Ten training steps. Anchor (full snapshot) on step 1 and step 6, sparse delta on every other step. Files land in the bucket as you watch.

4.2 The Trainer Side: a Boolean Mask From an Optimizer Hook

The trainer needs to know which bf16 elements actually flipped. We do this with a tiny BF16ChangeDetector that registers a pre-step and post-step hook on the optimizer:

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)]
            )

The actual code in the PR has a bit more plumbing (matching optimizer param objects to model params via data_ptr(), because Accelerate wraps them as different Python objects), but the idea fits on a napkin: snapshot, step, diff.

This is ground truth. We tried the more elegant path of predicting the mask from Adam's mm and vv statistics, using the bf16 ULP threshold directly. It works in principle. In practice, recall was around 30%, which means we would have shipped a delta missing two thirds of the actual updates. Adam's normalization is messy enough that the analytical threshold is not tight. So we just compare bytes. It costs one bf16 CPU snapshot of the model on the trainer side, which we are willing to pay.

The four phases of the new _sync_weight flow are:

  1. Upload while inference keeps running. The trainer encodes the masked elements into a safetensors buffer and pushes it to the bucket. vLLM is still happily serving the old policy during this whole step.
  2. Pause vLLM. A short HTTP call, hundreds of milliseconds.
  3. Signal /update_weights. Send the bucket coordinates. vLLM downloads, applies, returns.
  4. Resume. vLLM is back on the air.

The log lines tell the story:

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)

The line that matters is the parenthesis. Inference was paused for 1.1 seconds. The remaining 9.4 seconds were spent uploading, which occurred while the rollout server was still generating tokens. With NCCL, we were paying the full sync time as pause time. Here we are paying for it as background time.

A single sync, end to end. Switch between delta-over-bucket and NCCL broadcast, and try the replica count toggle to see the fan-out story.

4.3 The vLLM Side: a 30 Line Extension

vLLM has a clean abstraction for this called WeightTransferEngine. We implement a DeltaWeightTransferEngine whose receive_weights method is, in spirit:

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:
            
            for name in f.keys():
                tensor = f.get_tensor(name)
                self._bf16_snapshot[name] = tensor.clone()
                load_weights([(name, tensor)])
        else:
            
            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])])

We register it via vLLM's --worker-extension-cls flag, which means no fork of vLLM is required. You install TRL into the same image as vLLM, point the CLI at our class, and you are done.

Worth mentioning: vLLM itself has an in-flight effort to land sparse weight transfer natively, vllm-project/vllm#40096. It adds receive_sparse_weights() and trainer_send_sparse_weights() directly on the WeightTransferEngine base class, with patches encoded as (indices, values) and applied in place via index_copy_(), removing the GPU/CPU validation roundtrip entirely. The PR reports a transfer of 0.16 MB in 0.40 ms for a sparse patch on Qwen3-1.7B versus 942 MB in 192 ms for a full dense send.

One honest caveat in our implementation on the inference side: we keep a CPU bf16 snapshot of the model so we can reconstruct full tensors from sparse (indices, values) patches, because load_weights in vLLM today expects full tensors. Once #40096 (or its successor) lands and exposes an in-place sparse load_weights path, we can apply the indices directly on the GPU and drop the snapshot!

5. Standing It Up on Spaces, For Real

This is the part we are smug about. Everything we have described so far works on your laptop, but the point of routing weights through a Hub bucket is that the trainer and the rollout server do not have to live anywhere near each other. So we ran a fully disaggregated training with three machines, none of which share a network:

  • A box with one GPU running the trainer.
  • A Hugging Face Space (Docker SDK, L4 GPU) running vLLM with our extension class.
  • A second Hugging Face Space (CPU) running the Wordle environment server with 256 concurrent session capacity.
  • A Hub bucket in the middle.

Setting this up is genuinely a few hf CLI calls. The vLLM Space's Dockerfile is essentially the upstream vLLM image plus pip install trl@... plus the 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"]

Deploy it as a 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

And kick off training from anywhere on the planet that can talk 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

The trainer never opens a port. The Space never sees the trainer's IP. The Wordle environment does not know either of them exists. They all talk to the Hub. Training converged on the immediate-EOS sanity check, then on real Wordle rollouts: reward went up, delta payloads stayed in the 20 to 35 MB band, and the inference-paused window per sync stayed around a second. The full run logs are linked in the companion PR.

6. So What Does This Actually Unlock?

A few things, and we think they are big.

Async RL training without a cluster. If you have one GPU and a Hugging Face account, you can now do real disaggregated training. Your trainer is on the GPU; your rollout fleet lives in Spaces; your environment lives in another Space; weights move through a bucket. This used to require either a colocated setup (with all the throughput compromises that brings) or a real cluster with shared networking. It does not anymore.

Multi-replica inference, for free. Stand up two vLLM Spaces, or ten. They all pull from the same bucket. Xet content-addresses storage so consecutive anchors share chunks at rest (which keeps your bucket from blowing up), and the Hub's edge cache makes repeated downloads of the same file cheap to serve. Want a globally distributed rollout fleet? That is now a small DevOps exercise, not a research project.

A wire format you can debug with your existing tools. A delta is a safetensors file. You can safe_open it from a notebook, list its keys, inspect the indices, compute the sparsity yourself. We have spent enough hours in tcpdump on opaque NCCL streams to appreciate this.

A path to frontier scale. The 20 to 35 MB number is for Qwen3-0.6B. The interesting question is what the curve looks like once you turn the dial up. Let us do the napkin math.

Take Llama-3.1-405B. In bf16 that is 810 GB on disk. PULSE measures ~99% mean per-step sparsity at RL learning rates, so the actual delta sits around 1% of the parameters. Their deployment-measured encoding hits 108 MB on a 7B model, which is the ~130× reduction PULSE reports. Scaled linearly to 405B, the delta lands at roughly 6 GB per step.

What does that buy you in wall-clock? NCCL is fast inside a cluster, sure. Assume a generous 100 GB/s aggregate broadcast bandwidth (multi-node, RDMA, the works). A full sync is 810 GB / 100 GB/s ≈ 8 seconds of inference pause, every step. With the delta path, the trainer streams 6 GB to a bucket in the background while generation keeps running, and the rollout server's actual paused window is just the apply step, which on this scale lands at a couple of seconds. So even before we leave the cluster, delta cuts the visible pause by 4× and the bytes on the wire by ~130×.

Now leave the cluster. NCCL straight up does not work across clouds. Once you want a rollout fleet in us-east, another in eu-west, maybe one in a Hugging Face Space, the bucket-based path is the only path. At 1 GB/s of usable internet bandwidth, a single full broadcast would take 13 minutes; the delta does it in 6 seconds.

For a 1 TB-class model in the Fireworks framing, their own measured numbers show 20.3 GiB deltas vs the 1024 GiB full snapshot, a ~50× reduction. PULSE's tighter, sparse encoding would push that further (extrapolating ~15 GB per delta, closer to ~65×). Either way, you are in a regime where shipping weights through commodity object storage stops being a hack and starts being the only sensible architecture.

7. What's Still on Our Plate

We are not pretending this is finished. Here is the honest list.

  • Two CPU bf16 snapshots, one too many. The trainer keeps one (for the change detector) and the rollout server keeps one (to reconstruct full tensors for vLLM's load_weights). The first one we are stuck with until someone finds a tight analytical mask, which is harder than it looks. The second one goes away when vLLM gains a sparse load_weights API. PR forthcoming.
  • Fixed anchor cadence. We currently dump a full anchor every NN steps. An adaptive policy ("anchor when cumulative drift exceeds X") would cut anchor cost on long runs.
  • Multi-node FSDP2 trainers. The BF16ChangeDetector is built around per-process optimizer hooks. It should generalize cleanly to FSDP2, but we have not measured it at multi-node scale yet. There is a TODO in the PR with our name on it.
  • Hooking into the optimizer. Our attempt at predicting the mask from (m,v)(m, v) alone gave low recall, which means the analytical bf16 threshold is doing something more subtle than the textbook formula suggests. We would love to hear from anyone who has cracked this.
  • Stacking with on-the-wire compression. Sparse safetensors and per-chunk gzip are orthogonal. We have not tried combining them yet. Although we don't expect huge compression gains.

8. Try It

원문 보기 https://huggingface.co/blog/delta-weight-sync