TRL でデルタ重み同期を実装:トリリオンパラメータをハブバケットで管理
Hugging Face の TRL ライブラリが、超大規模なモデル(1 トリオンパラメータ級)の効率的な同期と配布を可能にする「Delta Weight Sync」機能を導入し、ハイブリッドストレージ戦略による転送コストと時間の大幅削減を実現した。
キーポイント
超大規模モデルの転送課題への解決
1 トリオンパラメータを超えるモデルを従来のフルウェイト同期で転送すると、帯域幅と時間のコストが膨大になる問題を指摘し、差分(Delta)のみを転送する手法の有効性を示している。
Hub Bucket を活用したハイブリッドアーキテクチャ
Hugging Face Hub のストレージ(Bucket)とモデルのバージョン管理を組み合わせ、変更されたウェイトのみを識別して転送する「Delta Weight Sync」メカニズムを導入した。
TRL ライブラリでの実装と自動化
この機能は TRL(Transformer Reinforcement Learning)ライブラリに統合され、トレーニングや微調整後のモデルを自動的に差分として同期・デプロイできるワークフローを提供する。
コスト削減とスケーラビリティの向上
転送データ量を劇的に減らすことで、クラウドストレージコストの削減と、大規模モデルの継続的な更新・展開を現実的な時間枠内で可能にする。
Delta Weight Sync の効果
1T パラメータモデルでも、隣接するチェックポイント間の差分は平均 20.3 GiB(全体の約 2%)に過ぎず、フルスナップショット送信を不要にする。
bf16 演算による自然なスパース性
RL の学習率における Adam の更新幅が bf16 の分解能閾値(|w|/256)を下回る場合、数値は丸められて変化として認識されず、99% の重みが不変となる。
共有バケットによる非同期連携
トレーナーと推論クラスタを直接接続せず、S3 などの共有オブジェクトストアに圧縮された差分(delta)をアップロード・ダウンロードするだけで、異なるリージョン間でも動作可能になる。
影響分析・編集コメントを表示
影響分析
この記事は、LLM のパラメータ数が指数関数的に増加する中で直面している「モデル転送のボトルネック」に対する実用的な解決策を示しています。Hugging Face が提供するインフラと TRL ライブラリを組み合わせることで、超大規模モデルの開発・運用コストを劇的に下げ、業界全体のスケーラビリティ向上に寄与します。特に、企業や研究機関がトリオンパラメータ級モデルを実環境で運用する際の障壁を取り除く重要な技術的進展です。
編集コメント
超大規模モデルの時代において、転送コストと時間は最大のボトルネックの一つです。この技術は単なる機能追加ではなく、モデル開発のワークフローそのものを再定義する重要なインフラ更新と言えます。
**
- 1. The One Terabyte Problem
- 2. Why bf16 RL Weights Are Almost Always Sparse
- 3. HF Buckets and the Architecture 3.1 What is a Bucket?
- 3.2 The Three Boxes
- 4. The Protocol 4.1 Safetensors as the Wire Format
- 4.2 The Trainer Side: a Boolean Mask From an Optimizer Hook
- 4.3 The vLLM Side: a 30 Line Extension
- 5. Standing It Up on Spaces, For Real
- 6. So What Does This Actually Unlock?
- 7. What's Still on Our Plate
- 8. Try It
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.
- さらなる追い風:トレーナーを 1 つのサーバーに、vLLM を Hugging Face Space に、Wordle 環境を別の Space に配置し、重み(weights)を単一の Hub バケット経由で流す完全な分散トレーニングを実行しました。共有クラスタも RDMA も VPN も不要です。
非同期 RL は大幅にコスト削減されました。続きをお読みください。
同じ重みを配送する 2 つの方法。赤色はトークン生成が行われていない間の壁時計時間(wall-clock time)を示します。
1. 1 テラバイトの問題
非同期 RL トレーニングの現状 という以前の投稿をお読みであれば、すでに結論をご存知でしょう。どのライブラリであっても、「アクターモデル」という用語をどう綴ろうと、NCCL バックエンドが何色であろうと、すべての非同期 RL ライブラリは最終的に同じ根本的な問題に直面します:重み同期です。
推論エンジン(inference engine)はステップ N の方策(policy)を実行しています。一方、トレーナーはちょうどステップ N+1 を完了したところです。推論エンジンが方針から完全に逸脱し始める前に、新しい重みを片側から他側に移動させる必要があります。これは同期実行でも非同期実行でもクリティカルパス上に存在します。ブロッキング転送(blocking transfer)は、トークンを生成していない GPU の計算リソースが無駄になることを意味します。スパースデルタ経路(sparse delta path)を使用すれば、このアイドル時間を数秒に圧縮でき、トレーナーが推論エンジンの準備完了を待つ必要さえありません。「重み準備完了」を公開し、オプティマイザステップの終了と同時に共有バケットへ重みをアップロードするだけで、推論エンジン側は各自のタイミングで取得します。
Fireworks は、その投稿 Frontier RL Is Cheaper Than You Think において非常に印象的な数値を示しています。彼らの設定におけるフロンティアレベルの 1T パラメータチェックポイント(fp8 フォーマット)の場合、完全なスナップショットは1024 GiBに達します。これが、ロールアウト・フリートを更新するたびに毎回送信しなければならないという従来の常識です。この数値は、人々がメガクラスターや RDMA ファブリック、専用クロスリージョンリンクを備えた図を描き始めるきっかけとなります。しかし、彼らが測定した隣接するチェックポイント間の平均的な差分(デルタ)は20.3 GiBであり、これは完全なモデルの1.98%に過ぎません。また、「bf16 フォーマットにおける重みの 98% 以上が、連続するチェックポイント間でビット単位で等価である」とも報告されています。
Cursor の Composer 2 report も同様の物語を語っています。彼らはトレーニングと推論を異なるリージョンで実行し、共有 S3 バケット(原文の表現そのまま)を用いてこれらを結合しています。このバケットには、トレーナーがすべてのトレーニングステップにおいて圧縮された重みの差分(デルタ)をアップロードします。各クラスターは独立して、共有される差分チェーンからダウンロードし再構築を行います。「トレーニング・クラスターとの直接的な接続は不要です」というものです。両側はパラメータについて直接対話することはありません。バケットがその通信路となるのです。
両方の論文は 3 つの点で合意しており、私たちはこれらをゆっくりと繰り返したいと考えています。なぜなら、この投稿の残りの部分は本質的に、これらの知見を忠実にオープンソース化して翻訳したものであるからです:
- 隣接する RL(強化学習)ステップの間には、実際には重みの大部分が変更されていません。
- 変更された部分のみを送信すれば、帯域幅の使用料はおよそ2桁(100 分の 1)に激減します。
- これらの微小な差分を共有オブジェクトストア経由でルーティングすれば、トレーニング環境と推論クラスターが同じデータセンター内に存在する必要はなくなります。
- この物語の「pip install」可能なバージョンが存在しないことが唯一の欠点でした。そこで私たちはそれを作成しました。
2. なぜ bf16 RL の重みはほぼ常にスパースなのか
何かを接続する前に、なぜこのゲームが勝利可能なのかを理解しておく価値があります。「98% の重みが変化しない」という主張は、デモでは機能しても実世界では崩壊するような数字のように聞こえるかもしれません。しかし、それは誤りです。これは RL が使用する学習率における bf16 演算の仕組みから自然に導き出される結果なのです。
bf16 数値は 7 ビットの仮数部を持ちます。2 つの連続する 2 のべき乗の間には、正確に 2^7 = 128 の表現可能な値が存在します。したがって、|w| 付近の隣接する bf16 数値間の間隔はおよそ |w| ⋅ 2^-7 です。更新量がこの間隔の半分未満である場合、すなわち |Δw| < |w|/256 の場合、その更新は bf16 へのキャストによって吸収されてしまいます。これが PULSE が Figure 3 でプロットしている「bf16 可視性閾値」です。
さて、Adam オプティマイザが何をしているかを見てみましょう。RL の学習率を例えば 3×10^-6 とすると、単一の重みに対する更新量は以下のようになります:
Δw = -η ⋅ (m̂ / (√v̂ + ε))
正規化されたステップ m^/(v^+ϵ) は概ねオーダー 1 であるため、∣Δw∣≈η≈3×10−6 です。ほとんどの重み ∣w∣ は 10−2 から 10−1 の間に位置します(PULSE では代表的な大規模言語モデルの重みの中央値が 0.019 と報告されています)。この大きさにおける閾値 ∣w∣/256 は、およそ 4×10−5 から 4×10−4 の範囲となり、これは更新量よりも *大きい* です。
つまり、オプティマイザが囁いているのに、bf16(半精度浮動小数点形式)ではそれを聞き取れないのです。更新値は丸めによって吸収され、w のバイト表現は変化しません。推論エンジンから見れば、この重みは移動していないことになります。これを数億パラメータに掛け合わせると、近似を一切行わずに、超過 99% というスパース性という結果が無料で得られるのです。
これはまさに PULSE 論文 (Mihai & Belilovsky, 2026) で形式化された議論です。彼らは 2 つの閾値を定義しています。吸収バウンド 10η は Adam の更新における保守的な最悪ケースであり、有効バウンド η は実際にあなたが存在するレジームです。bf16 可視性閾値は ∣w∣/256 です。更新がこの可視性閾値を下回る場合、それは吸収され、bf16 のバイトは変化しません。彼らの Figure 3 では、両方のバウンドを代表的な LLM(大規模言語モデル)の重みの雲に対してプロットしており、結論は明白です:η=3×10−6 のとき、吸収バウンド自体がモデル内のほぼすべての重みにおいて可視性閾値よりも下に位置します。彼らはこれを Qwen2.5 (0.5B/1.5B/7B)、Llama-3.2-3B、Gemma-3-4B に対して経験的に測定し、400 回のトレーニングステップにわたって平均ステップごとのスパース率が約 99%で、標準偏差が 0.2% から 0.4% であることを一貫して発見しました。最悪ケースのステップでも 98% を上回ります。つまり、1% 未満の変化は偶然の測定結果ではなく、算術によって保証される事実なのです。
これを解析的に予測する必要はありません(実際、Adam の mm と vv の統計量から変化マスクを予測しようと試みましたが、その recall は悲しいことに 30% でした。これについては後ほど詳しく述べます)。私たちが行うべきは、どのバイトが反転したかを観察することだけです。これは最適化ステップの直近で計算される、パラメータごとの小さなブール型テンソルです。
学習率を RL(強化学習)の領域まで下げて、bf16 への戻しマーカーが元のティックにスナップする様子を見てください。左下の 256 要素グリッドは、小さなモデル全体にわたる集約効果です。
3. HF バケットとアーキテクチャ
ここから物語の二つ目のピースが始まり、この投稿が Fireworks/Cursor の翻訳から Hugging Face に特化した内容へと変わります。
3.1 バケットとは何か?
バケットは、ハブ上で高頻度のオブジェクトストレージを目的としたリポジトリタイプです。コミット式典も PR ワークフローも LFS の癖もありません。ファイルを追加し、ファイルをリスト表示し、ファイルをダウンロードするだけです。Python インターフェースは 2 つの関数で構成されます:
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)])これだけです。関数呼び出しが 2 つ行われれば、重みは転送中になります。
内部では、バケットは Hub のコンテンツ定義チャンク化ストレージ層であるXetによってバックアップされています。Xet はアップロードするすべてのファイルを確認し、固定オフセットではなく実際のコンテンツに基づいてチャンクに分割し、バケット内に既に存在するすべてのデータと重複排除を行います。この文脈では特に喜ばしい実用的な結果として、たとえスパース符号化を書くのが面倒で全アンカーを各ステップでアップロードしたとしても、Xet は*依然として*変更されたチャンクのみを転送します。スパース符号化と Xet のスタック:移動した分だけ支払い、かつ一度きりの支払いです。
これは Fireworks と Cursor がともに頼る「共有 S3 バケット」のオープンソース版ですが、ストレージ層がすでにコンテンツハッシュングを知っており、既存の HF トークンに権限が付与されており、さらに Spaces、データセット、モデルなどスタックの他の部分とネイティブに連携する点が異なります。
3.2 三つのボックス
完全なアーキテクチャには、ちょうど三つのボックスと一つの共有基盤があります:
- トレーナー。どこでも構いません。1 つの GPU でも、8 つの GPU でも、USB で接続された H100 を搭載したラップトップでも、私たちは一切批判しません。モデル重みを持ち、オプティマイザを実行し、スパースデルタを生成します。
- HF バケット。単一のリポジトリで、2 つのプレフィックス:稀な完全スナップショット用の anchors/ と、その間のスパースパッチ用の deltas/。これが両側が合意する唯一のものです。
- vLLM ロールアウトサーバー。どこでも構いません。重要なのは、必ずしもトレーナーと同じ場所である必要がないことです。バケットからデータを取得し、デルタを適用してロールアウトを提供します。
- 環境。ロールアウトサーバーに通常の通り(HTTP、関数呼び出し、あるいはご自身の環境が使用するプロトコル)で接続されます。
内部化すべき性質、つまり Cursor の論文が強く主張し、ここではそのまま適用される性質はこれです:トレーナーとロールアウトサーバーは重みについて互いに直接通信しません。両者は repo_id と filename を含むごく小さな POST リクエストを交換するだけで、これが制御プレーンにおけるすべてのやり取りです。実際のバイト転送は、各側がバケットに対して並列で行い、共有ネットワークファブリックは使用されません。
これが実務上でなぜ重要なのか:
- ロールアウトサーバーは別のリージョンや別のクラウドに存在しても、あるいは Hugging Face Space 内の NAT の背後にあっても問題ありません。それは気にしません。
- N 個の推論レプリカが同じバケットから同じデルタ(差分)をプルでき、Xet がそれらすべての間でバイトを重複排除します。
- トレーナーは、推論レプリカがいくつ存在するか、どこにあるか、あるいはそのうちの 1 つが直ちにクラッシュしたかどうかを知る必要はありません。
トレーナーが書き込み、レプリカが読み取り、Hub が配管(パイプライン)を担当します。
4. プロトコル
さて、中身を見ていきましょう。プロトコルは 4 つの部分から構成されます:ワイヤーフォーマット、バケットレイアウト、30 行の vLLM 拡張機能、そしてトレーナー側のチェンジ検出器です。正直に言って、聞こえほど多くのコード量はありません。
4.1 ワイヤーフォーマットとしての Safetensors
オンディスクおよびオンワイヤーのフォーマットには safetensors を採用しました。これはすでに HUB 上の標準的なチェックポイント形式であり、あらゆる合理的なフレームワークで読み込みが可能で、ヘッダーには任意の文字列メタデータを格納できます。このメタデータフィールドこそが、私たちがプロトコルを隠す場所です。
バケット内には 2 種類のファイルが存在します。
アンカー(Anchors) は通常のチェックポイントのように見えます:パラメータごとに 1 つのテンソルを持ち、完全な bf16(半精度浮動小数点)重みを含みます。これは NN 回の同期ごと(デフォルトでは N=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) が興味深い部分です。実際に変更があったパラメータごとに、2 つのエントリを保存します:要素インデックスのフラットな 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 内の実際のコードには、データポインタ (data_ptr()) を介してオプティマイザのパラメータオブジェクトをモデルのパラメータにマッピングするなどの、より多くの下準備処理が含まれていますが(Accelerate がこれらを異なる Python オブジェクトとしてラップするため)、アイデアは手紙の裏側に収まるほどシンプルです:スナップショット取得、ステップ実行、差分計算。
これが真実です。私たちは、Adam の mm および vv 統計量からマスクを予測する、よりエレガントなアプローチを試みました。これは bf16 の ULP(単位最終桁)閾値を直接使用する方法です。原理的には機能しますが、実際には再現率は約 30% でした。つまり、実際の更新の 3 分の 2 を見逃したデルタを送信することになっていたのです。Adam の正規化は十分に複雑であり、解析的な閾値では精度が十分ではありません。そのため、私たちは単にバイト列を比較することにしました。これには、トレーニング側でモデルの bf16 CPU スナップショットを 1 つ作成するコストがかかりますが、私たちはそのコストを支払う用意があります。
新しい _sync_weight フローの 4 つのフェーズは以下の通りです:
- 推論実行中にアップロード。トレーナーはマスクされた要素を safetensors バッファにエンコードし、バケットへプッシュします。このステップ全体を通じて、vLLM は引き続き古いポリシーで喜んでサービスを提供しています。
- vLLM の一時停止。短い HTTP コールで、数百ミリ秒です。
- シグナル /update_weights の送信。バケットの座標を送信し、vLLM がダウンロードして適用し、完了を返します。
- 再開。vLLM は再び稼働状態になります。
ログ行がその物語を語ります:
デルタ:1234567/200000000 要素が変更されました(スパース性=99.38%)
[delta_engine] user/wordle-deltas/deltas/step_000042.safetensors をアップロードしました(27.4 MB、...)
重み同期:完了。合計 9.4 秒(推論一時停止 1.1 秒)
重要なのは括弧内の記述です。推論は1.1 秒間一時停止されました。残りの 9.4 秒はアップロードに費やされ、これはロールアウトサーバーがまだトークンを生成している間に発生しました。NCCL を使用した場合、同期時間の全額を一時停止時間として支払う必要がありました。ここでは、それをバックグラウンド時間として支払っています。
単一の同期をエンドツーエンドで実行します。デルタ・オーバー・バケットと NCCL ブロードキャストを切り替え、レプリカ数トグルを試してファンアウトの仕組みを確認してください。
4.3 vLLM 側:30 行の拡張
vLLM にはこれを指すための明確な抽象化として WeightTransferEngine が用意されています。私たちは DeltaWeightTransferEngine を実装しており、その 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:
# Anchor: feed every tensor and snapshot for future deltas
for name in f.keys():
tensor = f.get_tensor(name)
self._bf16_snapshot[name] = tensor.clone()
load_weights([(name, tensor)])
else:
# Delta: apply (indices, values) to snapshot, hand full tensor to 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])])
vLLM の --worker-extension-cls フラグを通じてこれを登録するため、vLLM のフォークは不要です。vLLM と同じイメージに TRL をインストールし、CLI で当社のクラスを指すだけで完了します。
言及しておくべき点:vLLM 自体も、スパース重みの転送をネイティブに実装する取り組みを進めており、vllm-project/vllm#40096 がその一例です。これは WeightTransferEngine ベースクラスに receive_sparse_weights() と trainer_send_sparse_weights() を直接追加し、パッチを (インデックス,値) の形式でエンコードして index_copy_() 経由でインプレース適用することで、GPU/CPU 間の検証ラウンドトリップを完全に排除します。この PR では、Qwen3-1.7B におけるスパースパッチの転送が 0.40 ms で 0.16 MB であるのに対し、フルデンス送信では 192 ms で 942 MB となることを報告しています。
推論側の実装における正直な注意点を一つ:vLLM の load_weights は現状フルテンソルを期待するため、スパース (インデックス,値) パッチから完全なテンソルを再構築できるように、モデルの CPU bf16 スナップショットを保持しています。#40096(またはその後継)がマージされ、インプレースでのスパース load_weights 経路が公開されたら、インデックスを直接 GPU 上で適用してスナップショットを不要にできます!
5. Spaces 上での本格的な立ち上げ
これが私たちが自慢している部分です。これまでに説明したすべてはあなたのラップトップでも動作しますが、Hub バケットを介して重みをルーティングする意義は、トレーニング側とロールアウトサーバーが互いに近くにある必要がない点にあります。そこで、ネットワークを共有しない 3 つのマシンを用いた完全に分離されたトレーニングを実行しました。
- トレーナーを実行する GPU を 1 基搭載したマシン。
- Hugging Face Space(Docker SDK、L4 GPU)上で、拡張クラス付きの vLLM を実行するもの。
- 256 の同時セッション容量を備えた、Wordle 環境サーバーを実行する Hugging Face Space(CPU)の第 2 实例。
- その中間に Hub バケットが存在します。
このセットアップは、実際には数回の hf CLI コマンドで完了します。vLLM Space の Dockerfile は、元々の vLLM イメージに trl@... を pip install で追加し、エントリーポイントを設定したものです:
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(End of Sequence)の健全性チェックで収束し、その後実際の Wordle ロールアウトでも収束しました:報酬が増加し、デルタペイロードは 20〜35 MB の範囲内に留まり、同期ごとの推論一時停止ウィンドウは約 1 秒程度でした。完全な実行ログは、関連する PR にリンクされています。
6. これは実際に何を可能にするのか?
いくつかのことが可能になりますが、私たちはそれが大きな意味を持つと考えています。
クラスターを必要としない非同期 RL(強化学習)トレーニング。 GPU が 1 つあり Hugging Face のアカウントがあれば、今や本格的な分散型トレーニングが可能になりました。トレーナーは GPU 上にあり、ロールアウト用ファームウェアは Spaces に存在し、環境は別の Space にあります。重みはバケットを介して移動します。これには以前、スループットに関する妥協を伴う共設置設定か、共有ネットワークを持つ本格的なクラスターが必要でした。しかし、もうそうではありません。
無料のマルチレプリカ推論。 vLLM の Spaces を 2 つ、あるいは 10 個立ち上げてください。すべてが同じバケットからデータを取得します。Xet はコンテンツアドレス型ストレージを採用しているため、連続するアンカーは保存時にチャンクを共有し(これによりバケットの肥大化を防ぎます)、Hub のエッジキャッシュにより、同一ファイルの繰り返しダウンロードも低コストで提供可能です。グローバルに分散されたロールアウトファームウェアが必要ですか?それは今や小規模な DevOps 作業であり、研究プロジェクトではありません。
既存のツールでデバッグ可能なワイヤーフォーマット。デルタは safetensors ファイルです。ノートブックから safe_open してキーをリストし、インデックスを検査し、スパースティを自分で計算できます。不明瞭な NCCL ストリーム上で tcpdump を何時間も費やしてきた経験から、この利点を十分に理解しています。
フロンティア規模への道筋。20〜35 MB という数値は Qwen3-0.6B におけるものです。興味深いのは、パラメータを大きくした際に曲線がどうなるかという点です。ここでは簡易計算を行ってみましょう。
Llama-3.1-405B を考えてみましょう。bf16(bfloat16)形式ではディスク上で810 GBになります。PULSE は RL 学習率においてステップごとの平均スパースティが約99%であると測定しており、実際のデルタはパラメータの約1%に相当します。彼らのデプロイメントで測定されたエンコーディングは、7B モデルで108 MBであり、これは PULSE が報告する約130倍の削減率です。これを 405B に線形スケールすると、ステップあたりデルタはおよそ6 GBになります。
これが実時間(wall-clock)において何をもたらすでしょうか?NCCL はクラスター内では確かに高速です。ただし、広義な条件として、集約ブロードキャスト帯域を 100 GB/s(マルチノード、RDMA、その他すべてを含む)と仮定しましょう。フル同期の場合、810 GB / 100 GB/s ≒ 8 秒の推論停止がステップごとに発生します。一方、デルタ経路では、トレーナーは生成処理を継続しながら背景で 6 GB をバケットにストリーミングし、ロールアウトサーバーの実質的な停止ウィンドウは適用ステップのみとなります。この規模ではその時間は数秒程度です。したがって、クラスター外に出る前であっても、デルタにより可視化される停止時間が 4 倍短縮され、ワイヤー上のデータ量が約130倍削減されます。
クラスターから離れてください。NCCL はクラウド間ではまともに機能しません。us-east にロールアウト用ファームを、eu-west に別のファームを、あるいは Hugging Face Space に 1 つ配置したい場合、バケットベースの経路が *唯一* の選択肢となります。利用可能なインターネット帯域幅が 1 GB/s の場合、完全なブロードキャストには 13 分かかりますが、デルタ方式ならわずか 6 秒で完了します。
Fireworks の枠組みにおける 1 TB クラスのモデルの場合、彼ら自身の測定数値によると、20.3 GiB のデルタ対 1024 GiB の完全スナップショットとなり、約 50 倍の削減となります。PULSE のより厳密でスパースな符号化方式であれば、さらにこれを押し上げられるでしょう(デルタあたり約 15 GB と外挿すると、約 65 倍に近づきます)。いずれにせよ、汎用オブジェクトストレージを通じて重みを配送することがハックから、唯一の合理的なアーキテクチャへと移行する領域にあります。
7. まだ課題として残っていること
これが完了したと偽るつもりはありません。ここに正直なリストを示します。
- CPU の bf16(半精度浮動小数点)スナップショットが 2 つあります。1 つ多すぎます。トレーナーは 1 つ保持し(変更検出用)、ロールアウトサーバーも 1 つ保持します(vLLM の load_weights で完全テンソルを再構築するため)。最初のものは、誰かが厳密な解析的マスクを見つけるまで、どうしようもなく残ります。それは見た目よりも難しいです。2 つ目は、vLLM がスパースな load_weights API を獲得したときに消えます。PR(プルリクエスト)が近々提出されます。
- 固定されたアンカーの頻度。現在、NN ステップごとに完全なアンカーをダンプしています。適応型ポリシー(累積ドリフトが X を超えたときにアンカーする)を採用すれば、長時間実行時のアンカーコストを削減できます。
- マルチノード FSDP2 トレーナー。BF16ChangeDetector はプロセスごとのオプティマイザフックを中心に構築されています。FSDP2 に対してはきれいに一般化されるはずですが、マルチノードスケールでの測定はまだ行われていません。この PR には私たちの名前が記載された TODO が含まれています。
- オプティマイザへのフッキング。(m,v) のみからマスクを予測しようとした試みでは再現率が低く、これは解析的な BF16 しきい値が教科書的な公式が示唆するものよりもより微妙な役割を果たしていることを意味しています。この課題を解決した方からの意見をぜひ聞きたいです。
- 転送中の圧縮とのスタッキング。スパース Safetensors とチャンクごとの gzip は直交します。これらを組み合わせた試みはまだ行っていません。ただし、大幅な圧縮効率の向上は期待していません。
8. 実際に試す
- PR: huggingface/trl#5417。ブランチ名は delta-weight-sync です。
- 完全な Wordle の例:examples/scripts/openenv/async_wordle.py。
- Spaces の Dockerfiles: examples/scripts/openenv/vllm_space/ および examples/scripts/openenv/wordle_space/。
- 背景資料:当社の非同期 RL ランドスケープに関する投稿、Fireworks の 1 TB に関する投稿、Cursor Composer 2 のレポート。
原文を表示
1. The One Terabyte Problem 2. Why bf16 RL Weights Are Almost Always Sparse 3. HF Buckets and the Architecture 3.1 What is a Bucket? 3.2 The Three Boxes 4. The Protocol 4.1 Safetensors as the Wire Format 4.2 The Trainer Side: a Boolean Mask From an Optimizer Hook 4.3 The vLLM Side: a 30 Line Extension 5. Standing It Up on Spaces, For Real 6. So What Does This Actually Unlock? 7. What's Still on Our Plate 8. Try It 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.
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:
- Most of the weights have not actually changed between two adjacent RL steps.
- If you send only the parts that changed, your bandwidth bill collapses by roughly two orders of magnitude.
- 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 ∣w∣⋅2−7|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×10−63 \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×10−6|\Delta w| \approx \eta \approx 3 \times 10^{-6}. For most weights, ∣w∣|w| sits somewhere around 10−210^{-2} to 10−110^{-1} (PULSE reports a median of 0.019 for representative LLM weights). The threshold ∣w∣/256|w|/256 at that magnitude is around 4×10−54 \times 10^{-5} to 4×10−44 \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×10−6\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.
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
# Trainer side
batch_bucket_files("my-org/wordle-deltas", add=[(buffer, "deltas/step_000042.safetensors")])
# Inference side
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.
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:
- 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.
- Pause vLLM. A short HTTP call, hundreds of milliseconds.
- Signal /update_weights. Send the bucket coordinates. vLLM downloads, applies, returns.
- 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.
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:
# Anchor: feed every tensor and snapshot for future deltas
for name in f.keys():
tensor = f.get_tensor(name)
self._bf16_snapshot[name] = tensor.clone()
load_weights([(name, tensor)])
else:
# Delta: apply (indices, values) to snapshot, hand full tensor to 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])])
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
- The PR: huggingface/trl#5417. Branch is delta-weight-sync.
- The full Wordle example: examples/scripts/openenv/async_wordle.py.
- The Spaces Dockerfiles: examples/scripts/openenv/vllm_space/ and examples/scripts/openenv/wordle_space/.
- Background reading: our async RL landscape post, the Fireworks 1 TB post, the Cursor Composer 2 report.
関連記事
[AI ニュース] 創業者とフォワード・デプロイエンジニア
Latent Space は、Anthropic の大規模ニュースを踏まえ、世界有数の AI フォワード・デプロイエンジニアを対象に、OpenAI や Anthropic が推進する同様の枠組みに倣った新トラックの募集を開始した。
Amazon SageMaker AI LLM推論における包括的な観測可能性:GPU利用率からLLM品質まで
AWSは、大規模言語モデル(LLM)をAmazon SageMaker AI Inferenceでスケール展開する際、従来のソフトウェアとは異なる不確実な出力に対応するため、GPU利用率やLLMの品質変化を追跡する包括的な観測可能性の重要性について解説した。
Claude Opus 4.8:システムカードの発表
Anthropic は Claude Opus 4.7 からわずか6週間で、より賢く長時間タスクを実行可能な新バージョン「Opus 4.8」を発表し、244ページのシステムカードを公開した。