From 30ee41580e6508f5cf375bc60b37eed756a5a96e Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sat, 19 Sep 2026 01:18:19 +0000 Subject: [PATCH 01/58] sync: update from Slime through #2393 Signed-off-by: aoshen02 --- docs/en/examples/qwen3-4B.md | 8 +- docs/en/examples/qwen3-4b-base-openhermes.md | 2 - docs/zh/examples/qwen3-4B.md | 8 +- docs/zh/examples/qwen3-4b-base-openhermes.md | 2 - examples/fully_async/README.md | 11 +- .../run-qwen2.5-0.5B-fully_async.sh | 2 +- .../fully_async/run-qwen3.5-9B-fully_async.sh | 2 +- scripts/run-qwen3-235B-A22B-sft.sh | 2 +- scripts/run-qwen3-4B-base-sft.sh | 2 +- scripts/run-qwen3-8B-async-amd.sh | 5 +- scripts/run-qwen3.5-35B-A3B-sft.sh | 2 +- tests/test_accelerator.py | 82 +++- tests/test_cuda_stack_offload.py | 47 ++ tests/test_fully_async_rollout.py | 64 ++- tests/test_megatron_argument_validation.py | 1 - tests/test_qwen2.5_0.5B_fully_async_short.py | 1 - tests/test_qwen3.5_0.8B_gsm8k_async_short.py | 134 ------ tests/test_r3_spill.py | 137 ++++++ tests/test_rollout_buffer_order.py | 50 +++ tests/test_rollout_data_utils.py | 21 + tests/test_sample.py | 8 +- tests/utils/test_megatron_server_arguments.py | 2 - train.py | 2 + train_async.py | 81 ---- vime/backends/megatron_utils/actor.py | 128 +++--- .../megatron_utils/server/arguments.py | 1 - vime/observability/rollout_data_utils.py | 42 +- vime/observability/rollout_metrics.py | 6 + vime/ray/rollout.py | 63 ++- vime/ray/utils.py | 1 + vime/rollout/data_source.py | 16 +- vime/rollout/fully_async_rollout.py | 60 ++- vime/utils/accelerator/__init__.py | 69 +++ vime/utils/accelerator/supa.py | 98 +++++ vime/utils/arguments.py | 63 ++- vime/utils/external_utils/command_utils.py | 5 +- vime/utils/http_utils.py | 31 +- vime/utils/memory_utils.py | 42 ++ vime/utils/misc.py | 20 +- vime/utils/routed_experts.py | 406 ++++++++++++++++++ vime/utils/staleness.py | 29 ++ vime/utils/tensor_backper.py | 5 - vime/utils/tensor_store.py | 91 ++++ vime/utils/types.py | 74 +++- .../rollout_buffer/rollout_buffer_example.sh | 3 +- 45 files changed, 1536 insertions(+), 393 deletions(-) create mode 100644 tests/test_cuda_stack_offload.py delete mode 100644 tests/test_qwen3.5_0.8B_gsm8k_async_short.py create mode 100644 tests/test_r3_spill.py create mode 100644 tests/test_rollout_buffer_order.py delete mode 100644 train_async.py create mode 100644 vime/utils/accelerator/supa.py create mode 100644 vime/utils/routed_experts.py create mode 100644 vime/utils/staleness.py create mode 100644 vime/utils/tensor_store.py diff --git a/docs/en/examples/qwen3-4B.md b/docs/en/examples/qwen3-4B.md index b9c3815de..8d8cab268 100644 --- a/docs/en/examples/qwen3-4B.md +++ b/docs/en/examples/qwen3-4B.md @@ -292,8 +292,8 @@ In this case, 2 GPUs will be allocated for training, and 6 GPUs will be allocate --vllm-cudagraph-capture-sizes 1 2 4 8 $(seq 16 8 256) ``` -### Asynchronous Training +### Fully-Asynchronous Rollout -When you separate training and inference, you may notice that the training and inference GPUs are always waiting for each other. To prevent these resources from being idle, we can enable asynchronous training. This can be done by changing `train.py` to `train_async.py` in the startup script. By doing this, vime will generate data for the next rollout while training on the current one. - -The only difference between `train.py` and `train_async.py` lies in the synchronization logic of the training loop. We achieve this by using Ray's asynchronous features (`.remote`, `ray.get`). +For long-tail rollout workloads, use `train.py` together with +`--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async`. +The background rollout worker keeps generation warm while the normal training loop consumes completed groups. diff --git a/docs/en/examples/qwen3-4b-base-openhermes.md b/docs/en/examples/qwen3-4b-base-openhermes.md index 5dc89a278..d3bef205b 100644 --- a/docs/en/examples/qwen3-4b-base-openhermes.md +++ b/docs/en/examples/qwen3-4b-base-openhermes.md @@ -81,5 +81,3 @@ You can compare [run-qwen3-4B-base-sft.sh](https://github.com/vllm-project/vime/ As for `--calculate-per-token-loss`, this is because `vime` defaults to calculating the per-sample mean for GRPO. In general SFT training, the average is taken over all unmasked tokens in a batch, so it is recommended to configure this. Finally, `--disable-compute-advantages-and-returns` indicates that there is no need to pre-calculate log probabilities during the SFT process, and `--debug-train-only` means that `vllm` does not need to be initialized. - -3. Used `train_async.py` instead of `train.py`. This is to leverage the asynchronous training process to implement data prefetching. diff --git a/docs/zh/examples/qwen3-4B.md b/docs/zh/examples/qwen3-4B.md index 1bf93bc4c..6ec697c45 100644 --- a/docs/zh/examples/qwen3-4B.md +++ b/docs/zh/examples/qwen3-4B.md @@ -292,10 +292,10 @@ ray job submit ... \ --vllm-cudagraph-capture-sizes 1 2 4 8 $(seq 16 8 256) ``` -### 异步训练 +### 全异步 Rollout -当进行训推分离时,你会发现训练和推理的 GPU 总是相互等待着,为了避免这种资源空闲,我们可以开启异步训练。开启的方式即为将启动脚本中的 `train.py` 改变为 `train_async.py`。这样 vime 就会在进行当前 rollout 的训练时进行下一个 rollout 的数据生成了。 +对于长尾 rollout 工作负载,使用 `train.py`,并设置 +`--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async`。 +后台 rollout worker 会持续生成数据,普通训练循环则消费已经完成的 group。 ⚠️ 异步训练时,vLLM 的性能统计日志与训练日志可能混在一起。可通过 `--vllm-disable-log-stats` 关闭性能统计,并用 `--vllm-uvicorn-log-level warning` 降低服务日志量。 - -`train.py` 和 `train_async.py` 的差别只在于 train loop 的同步逻辑,我们通过 ray 的异步(`.remote`, `ray.get`)实现了这点。 diff --git a/docs/zh/examples/qwen3-4b-base-openhermes.md b/docs/zh/examples/qwen3-4b-base-openhermes.md index b0bf37d18..1021e8e2a 100644 --- a/docs/zh/examples/qwen3-4b-base-openhermes.md +++ b/docs/zh/examples/qwen3-4b-base-openhermes.md @@ -81,5 +81,3 @@ bash scripts/run-qwen3-4B-base-sft.sh 至于 `--calculate-per-token-loss`,这是因为 vime 默认是以 GRPO 的 per sample mean 进行计算的,而一般 sft 训练都是按一个 batch 的所有不被 mask 的 token 取平均,所以建议配置上。 最后 `--disable-compute-advantages-and-returns` 表示 sft 的过程中不需要预先计算 log prob,`--debug-train-only` 表示不需要初始化 vllm。 - -3. 使用了 `train_async.py` 而不是 `train.py`。这是为了利用异步训练的流程,来实现数据 prefetch。 diff --git a/examples/fully_async/README.md b/examples/fully_async/README.md index 107fdb775..4e3371169 100644 --- a/examples/fully_async/README.md +++ b/examples/fully_async/README.md @@ -41,13 +41,12 @@ fully-async rollout 0: done in ...s, queue_left=... ## How To Plug Your Own Generate Into This -Two pieces flip the standard pipeline into fully-async: +Fully-async uses the standard `python3 train.py` entrypoint. Select the +rollout implementation with: -1. Use the async training driver: `python3 train_async.py` (not `train.py`). -2. Set the rollout function path: - ``` - --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async - ``` +``` +--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async +``` For custom per-sample logic, use vime's standard plug-in points — they work unchanged under fully-async: diff --git a/examples/fully_async/run-qwen2.5-0.5B-fully_async.sh b/examples/fully_async/run-qwen2.5-0.5B-fully_async.sh index 4c62e8055..ce24626e2 100755 --- a/examples/fully_async/run-qwen2.5-0.5B-fully_async.sh +++ b/examples/fully_async/run-qwen2.5-0.5B-fully_async.sh @@ -121,7 +121,7 @@ ROLLOUT_GPUS=${ROLLOUT_GPUS:-$((NUM_GPUS - ACTOR_GPUS))} ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node "${ACTOR_GPUS}" \ --rollout-num-gpus "${ROLLOUT_GPUS}" \ diff --git a/examples/fully_async/run-qwen3.5-9B-fully_async.sh b/examples/fully_async/run-qwen3.5-9B-fully_async.sh index a17259798..f86b03283 100644 --- a/examples/fully_async/run-qwen3.5-9B-fully_async.sh +++ b/examples/fully_async/run-qwen3.5-9B-fully_async.sh @@ -125,7 +125,7 @@ ROLLOUT_GPUS=${ROLLOUT_GPUS:-$((NUM_GPUS - ACTOR_GPUS))} ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node "${ACTOR_GPUS}" \ --rollout-num-gpus "${ROLLOUT_GPUS}" \ diff --git a/scripts/run-qwen3-235B-A22B-sft.sh b/scripts/run-qwen3-235B-A22B-sft.sh index 62b7eae73..ac3fc2c25 100755 --- a/scripts/run-qwen3-235B-A22B-sft.sh +++ b/scripts/run-qwen3-235B-A22B-sft.sh @@ -138,7 +138,7 @@ RUNTIME_ENV_JSON="{ ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 4 \ --actor-num-gpus-per-node 8 \ ${MODEL_ARGS[@]} \ diff --git a/scripts/run-qwen3-4B-base-sft.sh b/scripts/run-qwen3-4B-base-sft.sh index 849120090..6ec130f8c 100755 --- a/scripts/run-qwen3-4B-base-sft.sh +++ b/scripts/run-qwen3-4B-base-sft.sh @@ -114,7 +114,7 @@ RUNTIME_ENV_JSON="{ ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node 8 \ ${MODEL_ARGS[@]} \ diff --git a/scripts/run-qwen3-8B-async-amd.sh b/scripts/run-qwen3-8B-async-amd.sh index edd37ef3c..ad6ac1ff6 100644 --- a/scripts/run-qwen3-8B-async-amd.sh +++ b/scripts/run-qwen3-8B-async-amd.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Async (non-colocated) GRPO for Qwen3-8B on ROCm (gfx950 / MI350): train_async.py, +# Fully-async non-colocated GRPO for Qwen3-8B on ROCm (gfx950 / MI350), # actor and rollout on disjoint GPUs, RCCL-broadcast weight sync (no colocate IPC). # for rerun the task @@ -48,6 +48,7 @@ CKPT_ARGS=( ) ROLLOUT_ARGS=( + --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async --prompt-data /root/dapo-math-17k/dapo-math-17k.jsonl --input-key prompt --label-key label @@ -131,7 +132,7 @@ RUNTIME_ENV_JSON="{ ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node ${ACTOR_GPUS} \ --rollout-num-gpus ${ROLLOUT_GPUS} \ diff --git a/scripts/run-qwen3.5-35B-A3B-sft.sh b/scripts/run-qwen3.5-35B-A3B-sft.sh index 14f7a010a..d172056d0 100755 --- a/scripts/run-qwen3.5-35B-A3B-sft.sh +++ b/scripts/run-qwen3.5-35B-A3B-sft.sh @@ -150,7 +150,7 @@ RUNTIME_ENV_JSON="{ ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node 8 \ ${MODEL_ARGS[@]} \ diff --git a/tests/test_accelerator.py b/tests/test_accelerator.py index 7826695fb..2072baf22 100644 --- a/tests/test_accelerator.py +++ b/tests/test_accelerator.py @@ -55,18 +55,31 @@ def reset_accelerator_selection(monkeypatch): selected = accelerator._ACCELERATOR patch_imported = accelerator._MUSA_PATCH_IMPORTED bootstrap_checked = accelerator._MUSA_BOOTSTRAP_CHECKED - for name in ("VIME_ACCELERATOR", "MUSA_VISIBLE_DEVICES", "MUSA_PATCH_PATH", "CUDA_VISIBLE_DEVICES"): + supa_imported = accelerator._SUPA_RUNTIME_IMPORTED + supa_bootstrap_checked = accelerator._SUPA_BOOTSTRAP_CHECKED + for name in ( + "VIME_ACCELERATOR", + "MUSA_VISIBLE_DEVICES", + "MUSA_PATCH_PATH", + "CUDA_VISIBLE_DEVICES", + "SUPA_VISIBLE_DEVICES", + "BIREN_HOME", + ): monkeypatch.delenv(name, raising=False) accelerator._REGISTRY.clear() accelerator.reset_accelerator() accelerator._MUSA_PATCH_IMPORTED = False accelerator._MUSA_BOOTSTRAP_CHECKED = False + accelerator._SUPA_RUNTIME_IMPORTED = False + accelerator._SUPA_BOOTSTRAP_CHECKED = False yield accelerator._REGISTRY.clear() accelerator._REGISTRY.update(registry) accelerator._ACCELERATOR = selected accelerator._MUSA_PATCH_IMPORTED = patch_imported accelerator._MUSA_BOOTSTRAP_CHECKED = bootstrap_checked + accelerator._SUPA_RUNTIME_IMPORTED = supa_imported + accelerator._SUPA_BOOTSTRAP_CHECKED = supa_bootstrap_checked @pytest.mark.unit @@ -108,6 +121,7 @@ def import_musa_patch(): @pytest.mark.unit def test_cpu_only_initialization_does_not_require_an_accelerator(monkeypatch): monkeypatch.setattr(accelerator, "is_musa_available", lambda: False) + monkeypatch.setattr(accelerator, "is_supa_available", lambda: False) monkeypatch.setattr(accelerator, "_cuda_available", lambda: False) assert accelerator.initialize_accelerator() is None @@ -146,6 +160,7 @@ class RegisteredAccelerator(FakeAccelerator): name = "registered" monkeypatch.setattr(accelerator, "is_musa_available", lambda: False) + monkeypatch.setattr(accelerator, "is_supa_available", lambda: False) monkeypatch.setattr(accelerator, "_cuda_available", lambda: False) accelerator.register_accelerator("registered", RegisteredAccelerator, lambda: True, priority=300) @@ -199,5 +214,70 @@ def test_musa_availability_handles_missing_torch_namespace(monkeypatch): assert accelerator.is_musa_available() is False +@pytest.mark.unit +def test_cuda_selection_does_not_bootstrap_supa(monkeypatch): + monkeypatch.setenv("VIME_ACCELERATOR", "cuda") + monkeypatch.setenv("SUPA_VISIBLE_DEVICES", "0") + monkeypatch.setattr(accelerator, "_cuda_available", lambda: True) + monkeypatch.setattr(accelerator.CUDAAccelerator, "is_available", lambda self: True) + monkeypatch.setattr( + accelerator, + "_import_torch_supa", + lambda: pytest.fail("CUDA selection must not import torch_supa"), + ) + + assert accelerator.get_accelerator().name == "cuda" + assert accelerator.process_group_backend() == "nccl" + assert accelerator.visible_devices_env_key() == "CUDA_VISIBLE_DEVICES" + + +@pytest.mark.unit +def test_selected_supa_bootstraps_runtime_once(monkeypatch): + imports = [] + fake_supa = SimpleNamespace(is_available=lambda: True) + + def import_torch_supa(): + imports.append("torch_supa") + monkeypatch.setattr(accelerator.torch, "supa", fake_supa, raising=False) + return True + + monkeypatch.setenv("SUPA_VISIBLE_DEVICES", "0") + monkeypatch.setattr(accelerator, "_import_torch_supa", import_torch_supa) + + assert imports == [] + assert accelerator.initialize_accelerator().name == "supa" + assert accelerator.initialize_accelerator().name == "supa" + assert imports == ["torch_supa"] + + +@pytest.mark.unit +def test_supa_backend_maps_devices_and_process_groups(monkeypatch): + monkeypatch.setattr(accelerator.SUPAAccelerator, "is_available", lambda self: True) + monkeypatch.setenv("SUPA_VISIBLE_DEVICES", "2,5") + accelerator.set_accelerator(accelerator.SUPAAccelerator()) + + assert accelerator.device_type() == "supa" + assert accelerator.visible_devices_env_key() == "SUPA_VISIBLE_DEVICES" + assert accelerator.resolve_visible_device_id("5") == 1 + assert accelerator.process_group_backend() == "bccl" + assert accelerator.weight_update_backend() == "cpu:gloo,supa:bccl" + assert accelerator.process_group_backend("gloo") == "gloo" + assert accelerator.distributed_device_id() is None + + +@pytest.mark.unit +def test_supa_backend_is_recognized_as_accelerator_backend(): + assert accelerator.is_accelerator_backend("bccl") is True + assert accelerator.is_accelerator_backend("cpu:gloo,supa:bccl") is True + assert accelerator.is_accelerator_backend("gloo") is False + + +@pytest.mark.unit +def test_supa_availability_handles_missing_torch_namespace(monkeypatch): + monkeypatch.delattr(accelerator.torch, "supa", raising=False) + + assert accelerator.is_supa_available() is False + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_cuda_stack_offload.py b/tests/test_cuda_stack_offload.py new file mode 100644 index 000000000..077941527 --- /dev/null +++ b/tests/test_cuda_stack_offload.py @@ -0,0 +1,47 @@ +import ctypes +from types import SimpleNamespace + +import pytest + +from vime.utils import memory_utils + +NUM_GPUS = 0 + + +@pytest.mark.unit +@pytest.mark.parametrize("cuda,hip,initialized", [("12.0", "6.0", True), ("12.0", None, False), (None, None, True)]) +def test_stack_reset_does_not_initialize_unsupported_cuda(monkeypatch, cuda, hip, initialized): + monkeypatch.setattr(memory_utils.torch.version, "cuda", cuda) + monkeypatch.setattr(memory_utils.torch.version, "hip", hip) + monkeypatch.setattr(memory_utils.torch.cuda, "is_initialized", lambda: initialized) + monkeypatch.setattr(memory_utils, "_cuda_stack_api", lambda: pytest.fail("must not load CUDA")) + + memory_utils.reset_cuda_stack_size() + + +@pytest.mark.unit +def test_stack_reset_restores_cuda_default(monkeypatch): + calls = [] + + class Driver: + def cuCtxGetLimit(self, output, limit): + ctypes.cast(output, ctypes.POINTER(ctypes.c_size_t)).contents.value = 32768 + calls.append(("get", limit)) + return 0 + + def cuCtxSetLimit(self, limit, value): + calls.append(("set", limit, value)) + return 0 + + monkeypatch.setattr(memory_utils.torch, "version", SimpleNamespace(cuda="12.0", hip=None)) + monkeypatch.setattr(memory_utils.torch.cuda, "is_initialized", lambda: True) + monkeypatch.setattr(memory_utils.torch.cuda, "synchronize", lambda: calls.append(("sync",))) + monkeypatch.setattr(memory_utils, "_cuda_stack_api", lambda: Driver()) + + memory_utils.reset_cuda_stack_size() + + assert calls == [("sync",), ("get", 0), ("set", 0, 1024)] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_fully_async_rollout.py b/tests/test_fully_async_rollout.py index bdf320f02..7eca94cf4 100644 --- a/tests/test_fully_async_rollout.py +++ b/tests/test_fully_async_rollout.py @@ -43,6 +43,8 @@ import pytest import vime.rollout.fully_async_rollout as fa +from vime.rollout.filter_hub.base_types import DynamicFilterOutput +from vime.utils.staleness import compute_staleness_metrics, sample_staleness from vime.utils.types import Sample @@ -79,7 +81,7 @@ def _make_group(index: int) -> list[Sample]: def _make_worker(monkeypatch, data_buffer=None, concurrency=4) -> fa.AsyncRolloutWorker: monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) - args = SimpleNamespace(rollout_global_dataset=True, rollout_batch_size=4) + args = SimpleNamespace(rollout_global_dataset=True, rollout_batch_size=4, n_samples_per_prompt=1) return fa.AsyncRolloutWorker(args, data_buffer or _FakeDataBuffer([]), concurrency=concurrency) @@ -112,6 +114,66 @@ def test_get_completed_groups_limit(monkeypatch): assert worker.get_completed_groups(limit=3) == [] +@pytest.mark.unit +def test_concurrency_is_scaled_by_samples_per_prompt(monkeypatch): + monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) + args = SimpleNamespace(n_samples_per_prompt=4) + assert fa.AsyncRolloutWorker(args, _FakeDataBuffer([]), concurrency=10).concurrency == 2 + assert fa.AsyncRolloutWorker(args, _FakeDataBuffer([]), concurrency=2).concurrency == 1 + + +@pytest.mark.unit +def test_dynamic_filter_drops_groups_and_refills(monkeypatch): + worker = _make_worker(monkeypatch) + for gid in range(8): + group = _make_group(gid) + group[0].reward = float(gid % 2) + worker.output_queue.put((gid, group)) + monkeypatch.setattr(fa, "_get_global_worker", lambda args, data_buffer: worker) + + def keep_odd(args, group): + return DynamicFilterOutput(keep=bool(group[0].reward), reason="even") + + monkeypatch.setattr(fa, "load_function", lambda path: keep_odd) + args = SimpleNamespace( + rollout_global_dataset=True, + rollout_batch_size=3, + dynamic_sampling_filter_path="test.keep_odd", + ) + result = asyncio.run(fa._generate_rollout_async(args, rollout_id=7, data_buffer=None)) + + assert [group[0].index for group in result.samples] == [1, 3, 5] + dropped = result.metrics["_dropped_samples"] + assert [group[0].index for group in dropped] == [0, 2, 4] + assert all(sample.remove_sample for group in dropped for sample in group) + assert result.metrics["rollout/dynamic_filter/drop_even"] == 3 + assert [gid for gid, _ in worker.get_completed_groups()] == [6, 7] + + +@pytest.mark.unit +@pytest.mark.parametrize( + "versions,expected", + [(["10"], 1), (["8"], 3), (["3", "10"], 8), ([], None), (["bad"], None), (["11"], None), (["²"], None)], +) +def test_sample_staleness_uses_oldest_valid_version(versions, expected): + assert sample_staleness(SimpleNamespace(weight_versions=versions), 10) == expected + + +@pytest.mark.unit +def test_staleness_metrics_use_serving_snapshot(): + samples = [ + SimpleNamespace(weight_versions=["2"]), + SimpleNamespace(weight_versions=["3"]), + SimpleNamespace(weight_versions=[]), + ] + assert compute_staleness_metrics(samples, 10) == { + "staleness/unknown_count": 1, + "staleness/mean": 8.5, + "staleness/max": 9, + } + assert compute_staleness_metrics(samples, None) == {} + + @pytest.mark.unit def test_done_callback_never_blocks_event_loop_thread(monkeypatch): """The callback runs on the loop thread; blocking there freezes every diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index fd1247191..c07ba4b9a 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -251,7 +251,6 @@ def make_vime_validate_args(**overrides): rollout_max_prompt_len=None, train_backend="megatron", release_train=False, - keep_old_actor=False, only_train_params_name_list=None, freeze_params_name_list=None, update_weight_transport="nccl", diff --git a/tests/test_qwen2.5_0.5B_fully_async_short.py b/tests/test_qwen2.5_0.5B_fully_async_short.py index 92b161c5a..fd60e83ae 100644 --- a/tests/test_qwen2.5_0.5B_fully_async_short.py +++ b/tests/test_qwen2.5_0.5B_fully_async_short.py @@ -133,7 +133,6 @@ def execute(): train_args=train_args, num_gpus_per_node=NUM_GPUS, megatron_model_type=MODEL_TYPE, - train_script="train_async.py", ) diff --git a/tests/test_qwen3.5_0.8B_gsm8k_async_short.py b/tests/test_qwen3.5_0.8B_gsm8k_async_short.py deleted file mode 100644 index 85867d724..000000000 --- a/tests/test_qwen3.5_0.8B_gsm8k_async_short.py +++ /dev/null @@ -1,134 +0,0 @@ -import os - -import vime.utils.external_utils.command_utils as U - - -MODEL_NAME = "Qwen3.5-0.8B" -MODEL_TYPE = "qwen3.5-0.8B" -NUM_GPUS = 4 -TORCH_DIST_CKPT = f"/dev/shm/{MODEL_NAME}_torch_dist" - - -def prepare(): - U.exec_command("mkdir -p /root/models /root/datasets") - U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") - U.hf_download_dataset("zhuzilin/gsm8k") - U.convert_checkpoint( - model_name=MODEL_NAME, - megatron_model_type=MODEL_TYPE, - num_gpus_per_node=NUM_GPUS, - dir_dst="/dev/shm", - ) - - -def execute(): - ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load {TORCH_DIST_CKPT} " - - rollout_args = ( - "--prompt-data /root/datasets/gsm8k/train.parquet " - "--input-key messages " - "--label-key label " - "--apply-chat-template " - "--rollout-shuffle " - "--rm-type math " - "--num-rollout 3 " - "--rollout-batch-size 4 " - "--n-samples-per-prompt 4 " - "--rollout-max-response-len 1024 " - "--rollout-temperature 0.8 " - "--rollout-top-p 0.95 " - "--over-sampling-batch-size 8 " - "--dynamic-sampling-filter-path vime.rollout.filter_hub.dynamic_sampling_filters.check_reward_nonzero_std " - "--global-batch-size 16 " - ) - - eval_args = ( - "--eval-interval 8 " - "--eval-prompt-data gsm8k /root/datasets/gsm8k/test.parquet " - "--n-samples-per-eval-prompt 1 " - "--eval-max-response-len 1024 " - "--eval-top-k 1 " - ) - - perf_args = ( - "--tensor-model-parallel-size 1 " - "--sequence-parallel " - "--pipeline-model-parallel-size 1 " - "--context-parallel-size 1 " - "--expert-model-parallel-size 1 " - "--expert-tensor-parallel-size 1 " - "--use-dynamic-batch-size " - "--max-tokens-per-gpu 2048 " - ) - - grpo_args = ( - "--advantage-estimator grpo " - "--use-kl-loss " - "--kl-loss-coef 0.00 " - "--kl-loss-type low_var_kl " - "--entropy-coef 0.00 " - "--eps-clip 0.2 " - "--eps-clip-high 0.28 " - ) - - optimizer_args = ( - "--optimizer adam " - "--lr 1e-6 " - "--lr-decay-style constant " - "--weight-decay 0.1 " - "--adam-beta1 0.9 " - "--adam-beta2 0.98 " - ) - - vllm_args = ( - "--rollout-num-gpus-per-engine 1 " "--vllm-gpu-memory-utilization 0.65 " "--vllm-max-cudagraph-capture-size 32" - ) - - ci_args = "--ci-test " - - fault_tolerance_args = ( - "--use-fault-tolerance " - "--rollout-health-check-interval 5 " - "--rollout-health-check-timeout 10 " - "--rollout-health-check-first-wait 0 " - ) - - misc_args = ( - "--attention-dropout 0.0 " - "--hidden-dropout 0.0 " - "--accumulate-allreduce-grads-in-fp32 " - "--attention-softmax-in-fp32 " - "--attention-backend flash " - "--loss-mask-type qwen3_5 " - "--actor-num-nodes 1 " - "--actor-num-gpus-per-node 1 " - "--rollout-num-gpus 3 " - ) - - train_args = ( - f"{ckpt_args} " - f"{rollout_args} " - f"{optimizer_args} " - f"{grpo_args} " - f"{U.get_default_wandb_args(__file__)} " - f"{perf_args} " - f"{eval_args} " - f"{vllm_args} " - f"{ci_args} " - f"{fault_tolerance_args} " - f"{misc_args} " - ) - - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - train_script="train_async.py", - ) - - -if __name__ == "__main__": - prepare() - for proxy_var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): - os.environ.pop(proxy_var, None) - execute() diff --git a/tests/test_r3_spill.py b/tests/test_r3_spill.py new file mode 100644 index 000000000..e29819958 --- /dev/null +++ b/tests/test_r3_spill.py @@ -0,0 +1,137 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from vime.observability.rollout_data_utils import tensorize_rollout_data_for_training +from vime.utils.routed_experts import ( + RoutedExpertsMicrobatch, + RoutedExpertsMicrobatchPrefetcher, + cleanup_routed_experts_rollout, + spill_routed_experts, +) +from vime.utils.tensor_store import DiskTensorRef +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +def _args(tmp_path, *, keep=False): + return SimpleNamespace( + num_layers=6, + moe_router_topk=2, + num_experts=16, + moe_layer_freq=[0, 0, 0, 1, 1, 1], + rollout_routed_experts_store_dir=str(tmp_path), + keep_rollout_routed_experts_files=keep, + ) + + +def _routes(rows=4): + routes = torch.zeros((rows, 6, 2), dtype=torch.uint8) + routes[:, 3:, 1] = 7 + return routes + + +@pytest.mark.unit +def test_spill_round_trip_preserves_disk_reference_through_tensorize(tmp_path): + routes = _routes() + sample = Sample( + index=9, + tokens=[0] * 5, + response_length=1, + loss_mask=[1], + rollout_routed_experts=routes, + status=Sample.Status.COMPLETED, + ) + + result = asyncio.run(spill_routed_experts(_args(tmp_path), sample, rollout_id=3)) + + assert isinstance(result.rollout_routed_experts, DiskTensorRef) + assert result.rollout_routed_experts.validated + assert torch.equal(result.rollout_routed_experts.load(), routes) + rollout_data = {"rollout_routed_experts": [result.rollout_routed_experts]} + tensorize_rollout_data_for_training(rollout_data) + assert rollout_data["rollout_routed_experts"][0] is result.rollout_routed_experts + + +@pytest.mark.unit +def test_aborted_sample_stays_resident_for_retry(tmp_path): + routes = _routes() + sample = Sample( + tokens=[0] * 5, + response_length=1, + loss_mask=[1], + rollout_routed_experts=[routes[:2], routes[2:]], + status=Sample.Status.ABORTED, + ) + + result = asyncio.run(spill_routed_experts(_args(tmp_path), sample, rollout_id=3)) + + assert isinstance(result.rollout_routed_experts, list) + assert torch.equal(result.materialize_rollout_routed_experts(replace=False), routes) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.unit +def test_spill_relinks_cross_rollout_reference_and_cleanup_respects_keep(tmp_path): + args = _args(tmp_path) + ref = DiskTensorRef.write(_routes(), tmp_path / "rollout_00000001" / "original.safetensors", validated=True) + sample = Sample(index=1, rollout_routed_experts=ref, status=Sample.Status.COMPLETED) + + asyncio.run(spill_routed_experts(args, sample, rollout_id=2)) + + assert isinstance(sample.rollout_routed_experts, DiskTensorRef) + assert Path(sample.rollout_routed_experts.path).parent.name == "rollout_00000002" + assert torch.equal(sample.rollout_routed_experts.load(), _routes()) + cleanup_routed_experts_rollout(args, 2) + assert not (tmp_path / "rollout_00000002").exists() + + keep_args = _args(tmp_path, keep=True) + asyncio.run( + spill_routed_experts( + keep_args, Sample(index=2, rollout_routed_experts=_routes(), status=Sample.Status.COMPLETED), rollout_id=4 + ) + ) + cleanup_routed_experts_rollout(keep_args, 4) + assert (tmp_path / "rollout_00000004").exists() + + +@pytest.mark.unit +def test_lazy_prefetch_reloads_between_forward_and_backward(monkeypatch, tmp_path): + from vime.utils import routed_experts as routed_experts_module + + ref = DiskTensorRef.write(_routes(rows=3), tmp_path / "routes.safetensors", validated=True) + prefetcher = RoutedExpertsMicrobatchPrefetcher(prefetch_microbatches=1) + source = RoutedExpertsMicrobatch([ref], [torch.arange(4)], consumer_count=2, prepare_kwargs={}) + prefetcher.add(source) + loads = [] + + def load_and_prepare(): + tensor = ref.load() + nbytes = tensor.numel() * tensor.element_size() + source._loaded_nbytes = nbytes + prefetcher.on_loaded(nbytes) + loads.append(1) + return tensor + + monkeypatch.setattr(source, "_load_and_prepare", load_and_prepare) + monkeypatch.setattr(routed_experts_module.accelerator, "current_device", lambda: torch.device("cpu")) + + prefetcher.begin_pass("forward") + source.layer_to_cuda(3, "forward") + source.layer_to_cuda(4, "forward") + assert prefetcher._resident_bytes == 0 + + prefetcher.begin_pass("backward") + source.layer_to_cuda(3, "backward") + source.layer_to_cuda(4, "backward") + assert prefetcher._resident_bytes == 0 + prefetcher.close() + assert len(loads) == 2 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_rollout_buffer_order.py b/tests/test_rollout_buffer_order.py new file mode 100644 index 000000000..a94558055 --- /dev/null +++ b/tests/test_rollout_buffer_order.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace + +import pytest + +from vime.rollout.data_source import RolloutDataSource, RolloutDataSourceWithBuffer, pop_first, pop_oldest +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +@pytest.mark.unit +def test_buffer_resumes_oldest_group_first_and_preserves_ties(): + groups = [ + [Sample(index=0), Sample(index=1)], + [Sample(index=2, weight_versions=["10"]), Sample(index=3, weight_versions=["12"])], + [Sample(index=4, weight_versions=["12"]), Sample(index=5, weight_versions=["2", "11"])], + [Sample(index=6, weight_versions=["2"]), Sample(index=7)], + [Sample(index=8, weight_versions=["default"]), Sample(index=9)], + ] + buffer = groups.copy() + + assert pop_oldest(None, None, buffer, 2) == [groups[2], groups[3]] + assert buffer == [groups[1], groups[0], groups[4]] + assert pop_oldest(None, None, buffer, 10) == [groups[1], groups[0], groups[4]] + assert buffer == [] + + +@pytest.mark.unit +@pytest.mark.parametrize("sort_by_staleness", [None, False, True]) +def test_buffer_sort_is_opt_in_and_explicit_filter_takes_precedence(monkeypatch, sort_by_staleness): + monkeypatch.setattr(RolloutDataSource, "__init__", lambda self, args: setattr(self, "args", args)) + fresh = [Sample(index=0)] + old = [Sample(index=1, weight_versions=["2"])] + args = SimpleNamespace(buffer_filter_path=None, n_samples_per_prompt=1) + if sort_by_staleness is not None: + args.buffer_sort_by_staleness = sort_by_staleness + + source = RolloutDataSourceWithBuffer(args) + source.add_samples([fresh, old]) + expected = [old, fresh] if sort_by_staleness else [fresh, old] + assert source.get_samples(1) == [expected[0]] + assert source.get_samples(1) == [expected[1]] + + args.buffer_filter_path = "vime.rollout.data_source.pop_first" + source = RolloutDataSourceWithBuffer(args) + assert source.buffer_filter is pop_first + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_rollout_data_utils.py b/tests/test_rollout_data_utils.py index 9a22fd5cb..007e5afcf 100644 --- a/tests/test_rollout_data_utils.py +++ b/tests/test_rollout_data_utils.py @@ -19,6 +19,7 @@ def _args(): return SimpleNamespace( num_layers=6, moe_router_topk=2, + num_experts=16, moe_layer_freq=[0, 0, 0, 1, 1, 1], ) @@ -44,6 +45,26 @@ def test_r3_validation_rejects_wrong_shape(): validate_rollout_routed_experts_for_replay([routes], _args()) +def test_r3_validation_rejects_partial_token_routes(): + routes = torch.zeros((4, 6, 2), dtype=torch.uint8) + routes[:, 3:, 1] = 7 + routes[2, 4] = 0 + + with pytest.raises(ValueError, match=r"token/layer pairs.*\(2, 4\)"): + validate_rollout_routed_experts_for_replay([routes], _args()) + + +def test_r3_validation_rejects_row_mismatch_and_expert_range(): + routes = torch.zeros((4, 6, 2), dtype=torch.int32) + routes[:, 3:, 1] = 7 + with pytest.raises(ValueError, match=r"rows=4, expected=5"): + validate_rollout_routed_experts_for_replay([routes], _args(), expected_rows=[5]) + + routes[1, 5, 0] = 16 + with pytest.raises(ValueError, match=r"outside \[0, 15\].*16"): + validate_rollout_routed_experts_for_replay([routes], _args()) + + def test_tensorize_rollout_data_for_training_normalizes_cpu_tensors(): readonly_tokens = np.array([1, 2, 3]) readonly_tokens.flags.writeable = False diff --git a/tests/test_sample.py b/tests/test_sample.py index 32ae15969..3ecb9e51f 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -223,10 +223,8 @@ def test_unknown_finish_reason_leaves_status_unchanged(): @pytest.mark.unit -def test_weight_version_is_appended_when_present(): - """``weight_version`` in meta_info is appended to the sample's list - (types.py:173-174) — partial-rollout uses this to track which model - version produced each chunk.""" +def test_weight_version_is_not_appended_for_zero_token_segment(): + """A terminal metadata-only update must not make an old sample look fresh.""" sample = Sample() sample.weight_versions = ["v1"] sample.append_response_tokens( @@ -238,7 +236,7 @@ def test_weight_version_is_appended_when_present(): "weight_version": "v2", }, ) - assert sample.weight_versions == ["v1", "v2"] + assert sample.weight_versions == ["v1"] @pytest.mark.unit diff --git a/tests/utils/test_megatron_server_arguments.py b/tests/utils/test_megatron_server_arguments.py index d41eb52de..1dc11f282 100644 --- a/tests/utils/test_megatron_server_arguments.py +++ b/tests/utils/test_megatron_server_arguments.py @@ -30,7 +30,6 @@ def _server_args(**overrides): kl_coef=0.1, use_opd=True, use_critic=True, - keep_old_actor=True, no_load_optim=False, no_load_rng=False, only_train_params_name_list=["actor"], @@ -71,7 +70,6 @@ def test_configure_megatron_server_args_forces_teacher_only_mode(): assert args.use_kl_loss is False assert args.use_opd is False assert args.use_critic is False - assert args.keep_old_actor is False assert args.no_load_optim is True assert args.no_load_rng is True assert args.only_train_params_name_list == ["nothing_to_train"] diff --git a/train.py b/train.py index 2130030f5..04027d38b 100644 --- a/train.py +++ b/train.py @@ -79,6 +79,8 @@ def offload_train(actor_trains_this_step): if args.rollout_global_dataset: ray.get(rollout_manager.save.remote(rollout_id)) + ray.get(rollout_manager.cleanup_rollout_data.remote(rollout_id)) + offload_train(actor_trains) if args.offload_rollout and not release_train: ray.get(rollout_manager.onload_weights.remote()) diff --git a/train_async.py b/train_async.py deleted file mode 100644 index 4cbc55bbc..000000000 --- a/train_async.py +++ /dev/null @@ -1,81 +0,0 @@ -import ray - -from vime.observability.logging_utils import configure_logger, finish_tracking, init_tracking -from vime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models -from vime.utils.arguments import parse_args -from vime.utils.misc import should_run_periodic_action - - -# The framework supports other asynchronous approaches such as fully async (which is shown in examples/full_async). -def train(args): - assert not args.colocate, "Colocation is not supported for async training." - configure_logger() - release_train = args.release_train - # allocate the GPUs - pgs = create_placement_groups(args) - init_tracking(args) - - # create the rollout manager, with vLLM engines inside. - # need to initialize rollout manager first to calculate num_rollout - rollout_manager, num_rollout_per_epoch = create_rollout_manager(args, pgs["rollout"]) - - # create the actor and critic models - actor_model, critic_model = create_training_models(args, pgs, rollout_manager) - - # Always push actor weights to rollout once weights are loaded. - actor_model.update_weights() - - if args.check_weight_update_equal: - ray.get(rollout_manager.check_weights.remote(action="compare")) - - # async train loop. - rollout_data_next_future = rollout_manager.generate.remote(args.start_rollout_id) - for rollout_id in range(args.start_rollout_id, args.num_rollout): - # Sync the last generation - if rollout_data_next_future is not None: - rollout_data_curr_ref = ray.get(rollout_data_next_future) - - # Start the next rollout early. - if rollout_id + 1 < args.num_rollout: - rollout_data_next_future = rollout_manager.generate.remote(rollout_id + 1) - - if release_train: - actor_model.create() - - actor_trains = (not args.use_critic) or rollout_id >= args.num_critic_only_steps - if args.use_critic: - value_refs = critic_model.async_train(rollout_id, rollout_data_curr_ref) - if actor_trains: - ray.get(actor_model.async_train(rollout_id, rollout_data_curr_ref, external_data=value_refs)) - else: - ray.get(value_refs) - else: - ray.get(actor_model.async_train(rollout_id, rollout_data_curr_ref)) - - if release_train or should_run_periodic_action( - rollout_id, args.save_interval, num_rollout_per_epoch, args.num_rollout - ): - force_sync = release_train or rollout_id == args.num_rollout - 1 - if actor_trains: - actor_model.save_model(rollout_id, force_sync=force_sync) - if args.use_critic: - critic_model.save_model(rollout_id, force_sync=force_sync) - if args.rollout_global_dataset: - ray.get(rollout_manager.save.remote(rollout_id)) - - if release_train or (rollout_id + 1) % args.update_weights_interval == 0: - # sync generate before update weights to prevent update weight in the middle of generation - rollout_data_curr_ref = ray.get(x) if (x := rollout_data_next_future) is not None else None - rollout_data_next_future = None - actor_model.update_weights() - - if should_run_periodic_action(rollout_id, args.eval_interval, num_rollout_per_epoch): - ray.get(rollout_manager.eval.remote(rollout_id)) - - ray.get(rollout_manager.dispose.remote()) - finish_tracking(args) - - -if __name__ == "__main__": - args = parse_args() - train(args) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 13ded4186..e341b6d73 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -20,7 +20,7 @@ from vime.utils import accelerator from vime.utils.data import process_rollout_data from vime.utils.distributed_utils import get_gloo_group -from vime.utils.memory_utils import clear_memory, print_memory +from vime.utils.memory_utils import clear_memory, get_process_host_memory_gib, print_memory, reset_cuda_stack_size from vime.utils.misc import Box from vime.utils.reloadable_process_group import ( destroy_process_groups, @@ -28,7 +28,9 @@ register_default_process_group, reload_process_groups, ) +from vime.utils.routed_experts import RoutedExpertsLayerRef, RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher from vime.utils.routing_replay import RoutingReplay +from vime.utils.tensor_store import DiskTensorRef from vime.utils.types import RolloutBatch from ...utils.tensor_backper import TensorBackuper @@ -129,13 +131,6 @@ def init( if with_opd_teacher: self.load_other_checkpoint("teacher", args.opd_teacher_load) - if self.args.keep_old_actor: - # Load old_actor checkpoint - self.load_other_checkpoint("old_actor", args.load) - # Create rollout_actor as a copy of current actor - if args.update_weights_interval == 1: - self.weights_backuper.backup("rollout_actor") - if self.args.vocab_size is None: # Prefer HF config vocab_size (which may include model-native padding) # over tokenizer vocab_size, which may be smaller. @@ -186,6 +181,7 @@ def sleep(self) -> None: destroy_process_groups() torch_memory_saver.pause() + reset_cuda_stack_size() print_memory("after offload model") @@ -283,39 +279,82 @@ def fill_routing_replay(self, data_iterator, num_microbatches, rollout_data): from vime.utils.routing_replay import RoutingReplay + layer_ids = [] + for vp_stage, model in enumerate(self.model): + config = model.module.config + num_layers_to_build = get_num_layers_to_build(config, vp_stage=vp_stage) + offset = get_transformer_layer_offset(config, vp_stage=vp_stage) + for layer_id in range(offset, offset + num_layers_to_build): + if isinstance(config.moe_layer_freq, int): + if layer_id % config.moe_layer_freq != 0: + continue + elif isinstance(config.moe_layer_freq, list): + assert len(config.moe_layer_freq) == config.num_layers + if config.moe_layer_freq[layer_id] == 0: + continue + layer_ids.append(layer_id) + assert len(layer_ids) == len(RoutingReplay.all_routing_replays) + for iterator in data_iterator: iterator.reset() + replay_source = rollout_data["rollout_routed_experts"] + disk_prefetcher = None + prepare_kwargs = { + "num_experts": self.args.num_experts, + "data_pad_size_multiplier": self.args.data_pad_size_multiplier, + "sequence_parallel": self.args.sequence_parallel, + "allgather_cp": self.args.allgather_cp, + } for _ in range(sum(num_microbatches)): - batch = data_iterator[0].get_next(["rollout_routed_experts", "tokens"]) - rollout_routed_experts = prepare_routed_experts_for_routing_replay( - batch["rollout_routed_experts"], - batch["tokens"], - num_experts=self.args.num_experts, - data_pad_size_multiplier=self.args.data_pad_size_multiplier, - sequence_parallel=self.args.sequence_parallel, - allgather_cp=self.args.allgather_cp, + iterator = data_iterator[0] + batch_indices = iterator.micro_batch_indices[iterator.offset] + batch = iterator.get_next(["rollout_routed_experts", "tokens"]) + values = batch["rollout_routed_experts"] + + disk_backed = [isinstance(value, DiskTensorRef) for value in values] + if any(disk_backed) and not all(disk_backed): + raise ValueError("A routing replay microbatch cannot mix disk-backed and resident route tensors.") + + if layer_ids and all(disk_backed): + if disk_prefetcher is None: + disk_prefetcher = RoutedExpertsMicrobatchPrefetcher(self.args.routing_replay_prefetch_microbatches) + source = RoutedExpertsMicrobatch( + values, + batch["tokens"], + consumer_count=len(layer_ids), + prepare_kwargs=prepare_kwargs, + ) + disk_prefetcher.add(source) + for replay, layer_id in zip(RoutingReplay.all_routing_replays, layer_ids, strict=True): + replay.record(RoutedExpertsLayerRef(source, layer_id)) + elif layer_ids: + rollout_routed_experts = prepare_routed_experts_for_routing_replay( + values, + batch["tokens"], + **prepare_kwargs, + ) + for replay, layer_id in zip(RoutingReplay.all_routing_replays, layer_ids, strict=True): + replay.record(rollout_routed_experts[:, layer_id]) + + # Drop manager-owned references as soon as this microbatch has + # been registered, bounding actor RSS during setup. + for sample_idx in batch_indices: + replay_source[sample_idx] = None + + if disk_prefetcher is not None: + disk_prefetcher.start() + RoutingReplay.register_lazy_resource(disk_prefetcher) + rss_gib, hwm_gib = get_process_host_memory_gib() + logger.info( + "R3 lazy replay initialized: microbatches=%d layers=%d prefetch=%d rss=%.3f GiB hwm=%.3f GiB", + len(disk_prefetcher.sources), + len(layer_ids), + disk_prefetcher.prefetch_microbatches, + rss_gib, + hwm_gib, ) - routing_replay_offset = 0 - for vp_stage, model in enumerate(self.model): - config = model.module.config - num_layers_to_build = get_num_layers_to_build(config, vp_stage=vp_stage) - offset = get_transformer_layer_offset(config, vp_stage=vp_stage) - for layer_id in range(offset, offset + num_layers_to_build): - # skip dense layer - if isinstance(config.moe_layer_freq, int): - if layer_id % config.moe_layer_freq != 0: - continue - elif isinstance(config.moe_layer_freq, list): - assert len(config.moe_layer_freq) == config.num_layers - if config.moe_layer_freq[layer_id] == 0: - continue - layer_routed_experts = rollout_routed_experts[:, layer_id] - RoutingReplay.all_routing_replays[routing_replay_offset].record(layer_routed_experts) - routing_replay_offset += 1 - assert routing_replay_offset == len(RoutingReplay.all_routing_replays) - del rollout_data["rollout_routed_experts"] for iterator in data_iterator: @@ -424,7 +463,7 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data ) ) - self._switch_model("old_actor" if self.args.keep_old_actor else "actor") + self._switch_model("actor") can_reuse_log_probs_in_loss = ( len(num_microbatches) == 1 and self.args.loss_type == "policy_loss" @@ -432,7 +471,6 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data and not self.args.use_rollout_logprobs and not self.args.get_mismatch_metrics and not self.args.use_critic - and not self.args.keep_old_actor and not self.args.use_opd and (not self.args.use_routing_replay or self.args.use_rollout_routing_replay) and self.args.advantage_estimator != "gspo" @@ -443,6 +481,7 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data if self.args.use_routing_replay: if self.args.use_rollout_routing_replay: os.environ["ROUTING_REPLAY_STAGE"] = "replay_forward" + RoutingReplay.begin_lazy_pass("forward") else: os.environ["ROUTING_REPLAY_STAGE"] = "record" rollout_data.update( @@ -481,6 +520,10 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data # Train if self.args.use_routing_replay: os.environ["ROUTING_REPLAY_STAGE"] = "replay_backward" + if self.args.use_rollout_routing_replay: + # Hold each microbatch across forward and recompute; release + # it after the backward replay has consumed every layer. + RoutingReplay.begin_lazy_pass("backward") # When dumping train debug data but the actor log_probs were not # recomputed separately (can_reuse_log_probs_in_loss / use_rollout_logprobs), # snapshot them from the training forward so the dump still carries @@ -612,17 +655,6 @@ def update_weights(self) -> None: self.weight_updater.update_weights() print_memory("after update_weights") - if getattr(self.args, "keep_old_actor", False): - if self.args.update_weights_interval == 1: - logger.info("updating model queue: rollout_actor -> old_actor, actor -> rollout_actor") - # Queue-style update: rollout_actor params -> old_actor, actor params -> rollout_actor - # First copy rollout_actor to old_actor - self.weights_backuper.copy(src_tag="rollout_actor", dst_tag="old_actor") - # Then copy current actor to rollout_actor - self.weights_backuper.backup("rollout_actor") - else: - self.weights_backuper.backup("old_actor") - if reconnect_rollout_engines: self.sleep() elif self.args.offload_train: diff --git a/vime/backends/megatron_utils/server/arguments.py b/vime/backends/megatron_utils/server/arguments.py index 125ea0642..32c9ba6b6 100644 --- a/vime/backends/megatron_utils/server/arguments.py +++ b/vime/backends/megatron_utils/server/arguments.py @@ -93,7 +93,6 @@ def configure_megatron_server_args(args): args.kl_coef = 0 args.use_opd = False args.use_critic = False - args.keep_old_actor = False args.no_load_optim = True args.no_load_rng = True # Keep this as a list (not str), otherwise freeze logic iterates over characters. diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index ab69784ef..2ad2e9996 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -5,6 +5,8 @@ import numpy as np import torch +from vime.utils.routed_experts import validate_routed_experts_value +from vime.utils.tensor_store import DiskTensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -30,7 +32,10 @@ def _cpu_tensor(value, dtype: torch.dtype | None = None) -> torch.Tensor: def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: for key, dtype in _ROLLOUT_DATA_TENSOR_DTYPES.items(): if key in rollout_data: - rollout_data[key] = [_cpu_tensor(value, dtype=dtype) for value in rollout_data[key]] + rollout_data[key] = [ + value if isinstance(value, DiskTensorRef) else _cpu_tensor(value, dtype=dtype) + for value in rollout_data[key] + ] if "multimodal_train_inputs" in rollout_data: rollout_data["multimodal_train_inputs"] = [ @@ -53,40 +58,21 @@ def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: def validate_rollout_routed_experts_for_replay( - routed_experts: list[torch.Tensor], + routed_experts: list[torch.Tensor | DiskTensorRef], args, + expected_rows: list[int] | None = None, ) -> None: """Reject incomplete PP routing captures before R3 consumes them.""" if not routed_experts: raise ValueError("R3 is enabled but no rollout routed-experts tensors were returned.") - num_layers = int(args.num_layers) - topk = int(args.moe_router_topk) - moe_layer_freq = getattr(args, "moe_layer_freq", None) - if isinstance(moe_layer_freq, (list, tuple)): - moe_layers = [layer_id for layer_id, freq in enumerate(moe_layer_freq[:num_layers]) if int(freq) != 0] - else: - moe_layers = list(range(num_layers)) - for sample_idx, experts in enumerate(routed_experts): - experts = torch.as_tensor(experts) - if experts.ndim != 3 or tuple(experts.shape[1:]) != (num_layers, topk): - raise ValueError( - "Invalid rollout routed-experts shape for R3: " - f"sample={sample_idx}, got={tuple(experts.shape)}, " - f"expected=(*, {num_layers}, {topk})." - ) - if experts.shape[0] == 0: - raise ValueError(f"R3 sample {sample_idx} has no routed-experts rows.") - if topk > 1: - missing_layers = [layer_id for layer_id in moe_layers if not torch.count_nonzero(experts[:, layer_id, :])] - if missing_layers: - raise ValueError( - "R3 routed-experts capture is all zero for MoE layers " - f"{missing_layers} in sample {sample_idx}. This usually means " - "vLLM pipeline stages did not aggregate their disjoint routing " - "captures; refusing to replay expert 0 everywhere." - ) + validate_routed_experts_value( + experts, + args, + sample_index=sample_idx, + expected_rows=None if expected_rows is None else expected_rows[sample_idx], + ) def validate_rollout_id_annotated(node, depth=0): diff --git a/vime/observability/rollout_metrics.py b/vime/observability/rollout_metrics.py index baf91f83c..299df7dbf 100644 --- a/vime/observability/rollout_metrics.py +++ b/vime/observability/rollout_metrics.py @@ -13,6 +13,7 @@ has_repetition, ) from vime.utils.misc import group_by, load_function +from vime.utils.staleness import compute_staleness_metrics, fully_async_metrics_enabled from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -270,6 +271,11 @@ def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_t return log_dict = {**(rollout_extra_metrics or {})} + if fully_async_metrics_enabled(args): + log_dict.pop("_dropped_samples", None) + log_dict |= dict_add_prefix( + compute_staleness_metrics(samples, getattr(args, "_rollout_weight_version", None)), "rollout/" + ) log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), "rollout/") log_dict |= dict_add_prefix(compute_perf_metrics_from_samples(args, samples, rollout_time), "perf/") logger.info(f"perf {rollout_id}: {log_dict}") diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index c4cdae5ea..e0621fd6d 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -24,6 +24,8 @@ from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client from vime.utils.misc import Box, load_function +from vime.utils.staleness import fully_async_metrics_enabled +from vime.utils.tensor_store import DiskTensorRef from vime.utils.types import Sample from .utils import Lock, add_default_ray_env_vars @@ -77,6 +79,7 @@ def __init__(self, args, pg): runtime_env={"env_vars": add_default_ray_env_vars()}, ).remote() self.rollout_id = -1 + self._active_routed_experts_rollouts: set[int] = set() self._health_monitors = [] if not self.args.debug_train_only and self.args.use_fault_tolerance: @@ -135,11 +138,19 @@ def _try_ci_fault_injection(self): def dispose(self): for monitor in self._health_monitors: monitor.stop() + for rollout_id in list(self._active_routed_experts_rollouts): + self.cleanup_rollout_data(rollout_id) engines = [engine for server in self.servers.values() for engine in server.all_engines if engine is not None] if engines: ray.get([engine.shutdown.remote() for engine in engines]) logging_utils.finish_tracking(self.args) + def cleanup_rollout_data(self, rollout_id: int) -> None: + from vime.utils.routed_experts import cleanup_routed_experts_rollout + + cleanup_routed_experts_rollout(self.args, rollout_id) + self._active_routed_experts_rollouts.discard(rollout_id) + @property def server(self) -> Any | None: """Default server (first model). For backward compatibility.""" @@ -282,6 +293,14 @@ def _get_rollout_data(self, rollout_id): ) metrics = None else: + if fully_async_metrics_enabled(self.args): + server = self._get_updatable_server() + engines = [engine for engine in server.engines if engine is not None] if server else [] + versions = ray.get([engine.get_weight_version.remote() for engine in engines]) + valid = bool(versions) and all( + str(version).isascii() and str(version).isdigit() for version in versions + ) + self.args._rollout_weight_version = max(map(int, versions)) if valid else None data = call_rollout_fn(self.generate_rollout, self.args, rollout_id, self.data_source, evaluation=False) metrics = data.metrics data = data.samples @@ -424,10 +443,46 @@ def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sampl train_data["rollout_top_p_token_ids"] = [sample.rollout_top_p_token_ids for sample in samples] train_data["rollout_top_p_token_offsets"] = [sample.rollout_top_p_token_offsets for sample in samples] - if samples[0].rollout_routed_experts is not None: - routed_experts = [torch.as_tensor(sample.rollout_routed_experts) for sample in samples] - if getattr(self.args, "use_rollout_routing_replay", False): - validate_rollout_routed_experts_for_replay(routed_experts, self.args) + routed_experts_present = [sample.rollout_routed_experts is not None for sample in samples] + dead_sample_indices = { + idx + for idx, (present, sample) in enumerate(zip(routed_experts_present, samples, strict=True)) + if not present and sample.loss_mask is not None and not any(sample.loss_mask) + } + live_missing = [ + idx for idx, present in enumerate(routed_experts_present) if not present and idx not in dead_sample_indices + ] + routing_replay_enabled = getattr(self.args, "use_rollout_routing_replay", False) + if live_missing and (routing_replay_enabled or any(routed_experts_present)): + raise ValueError(f"Rollout routed experts are missing for live sample indices {live_missing[:32]}.") + + if any(routed_experts_present) or (routing_replay_enabled and dead_sample_indices): + dtype = torch.uint8 if self.args.num_experts <= 256 else torch.int32 + routed_experts = [ + ( + ( + sample.rollout_routed_experts + if isinstance(sample.rollout_routed_experts, DiskTensorRef) + else sample.materialize_rollout_routed_experts() + ) + if present + else torch.zeros( + (max(0, len(sample.tokens) - 1), self.args.num_layers, self.args.moe_router_topk), dtype=dtype + ) + ) + for sample, present in zip(samples, routed_experts_present, strict=True) + ] + if routing_replay_enabled and any(routed_experts_present): + captured_pairs = [ + (experts, max(0, len(sample.tokens) - 1)) + for sample, experts, present in zip(samples, routed_experts, routed_experts_present, strict=True) + if present + ] + validate_rollout_routed_experts_for_replay( + [experts for experts, _ in captured_pairs], + self.args, + expected_rows=[rows for _, rows in captured_pairs], + ) train_data["rollout_routed_experts"] = routed_experts if samples[0].train_metadata is not None: diff --git a/vime/ray/utils.py b/vime/ray/utils.py index c3bc64537..c6ac5bc52 100644 --- a/vime/ray/utils.py +++ b/vime/ray/utils.py @@ -17,6 +17,7 @@ NOSET_VISIBLE_DEVICES_ENV_VARS_LIST = [ "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_MUSA_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_SUPA_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_HABANA_VISIBLE_MODULES", diff --git a/vime/rollout/data_source.py b/vime/rollout/data_source.py index abfa63ca2..6ba3723a2 100644 --- a/vime/rollout/data_source.py +++ b/vime/rollout/data_source.py @@ -170,7 +170,7 @@ def __init__(self, args): super().__init__(args) self.buffer = [] if self.args.buffer_filter_path is None: - self.buffer_filter = pop_first + self.buffer_filter = pop_oldest if getattr(args, "buffer_sort_by_staleness", False) else pop_first else: self.buffer_filter = load_function(self.args.buffer_filter_path) @@ -222,6 +222,20 @@ def get_buffer_length(self): return len(self.buffer) +def pop_oldest(args, rollout_id, buffer: list[list[Sample]], num_samples: int) -> list[list[Sample]]: + """Resume groups with the oldest generated tokens first; preserve ties.""" + + def oldest_version(group): + versions = (str(v) for sample in group for v in (sample.weight_versions or [])) + return min( + (int(v) for v in versions if v.isascii() and v.removeprefix("-").isdigit()), + default=float("inf"), + ) + + buffer.sort(key=oldest_version) + return pop_first(args, rollout_id, buffer, num_samples) + + def pop_first(args, rollout_id, buffer: list[list[Sample]], num_samples: int) -> list[list[Sample]]: num_to_pop = min(len(buffer), num_samples) samples = buffer[:num_to_pop] diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index 82144b3e6..5608d89e9 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -21,6 +21,9 @@ **redirecting ABORTED groups back to ``data_buffer``** instead of shipping them to training, so the next rollout (with refreshed weights) can pick them up. + +``--dynamic-sampling-filter-path`` is honoured DAPO-style: rejected groups do +not count toward ``rollout_batch_size`` and are replaced from the warm queue. """ from __future__ import annotations @@ -32,9 +35,12 @@ import threading import time +from vime.rollout.base_types import RolloutFnTrainOutput +from vime.rollout.filter_hub.base_types import call_dynamic_filter from vime.rollout.vllm_rollout import GenerateState, generate_and_rm_group from vime.utils.async_utils import run from vime.utils.http_utils import get_rollout_num_engines +from vime.utils.misc import load_function from vime.utils.types import Sample __all__ = [ @@ -80,7 +86,7 @@ class AsyncRolloutWorker: def __init__(self, args, data_buffer, concurrency: int = 10): self.args = args self.data_buffer = data_buffer - self.concurrency = concurrency + self.concurrency = max(1, concurrency // getattr(args, "n_samples_per_prompt", 1)) self.running = True # Unbounded on purpose: put() runs inside the event-loop thread (task # done-callback), so a bounded queue that fills up would block the loop @@ -144,6 +150,10 @@ async def _loop(self) -> None: except Exception as e: # noqa: BLE001 logger.warning("fully-async task crashed: %r", e) active_tasks -= done + if done: + # Done callbacks requeue ABORTED groups. Let them run + # before asking the data source for replacement work. + await asyncio.sleep(0) # Top up. The qsize gate is the queue's backpressure: once a # full pool of completed groups is waiting, stop pulling new @@ -208,8 +218,11 @@ def _cb(done_task: asyncio.Task) -> None: return _cb -async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> list[list[Sample]]: +async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> RolloutFnTrainOutput | list[list[Sample]]: assert args.rollout_global_dataset + filters_enabled = bool( + getattr(args, "dynamic_sampling_filter_path", None) or getattr(args, "rollout_sample_filter_path", None) + ) worker = _get_global_worker(args, data_buffer) target = args.rollout_batch_size @@ -221,6 +234,13 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> list[li ) collected: dict[int, list[Sample]] = {} + dropped: list[list[Sample]] = [] + drop_reasons: dict[str, int] = {} + dynamic_filter = ( + load_function(args.dynamic_sampling_filter_path) + if getattr(args, "dynamic_sampling_filter_path", None) is not None + else None + ) started = time.time() last_log = started LOG_EVERY = 30.0 @@ -230,8 +250,23 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> list[li # the next rollout (that is the "queue stays warm" contract). drained = 0 for gid, group in worker.get_completed_groups(limit=target - len(collected)): - collected[gid] = group drained += 1 + if not filters_enabled: + collected[gid] = group + continue + + verdict = call_dynamic_filter(dynamic_filter, args, group) + if verdict.keep: + collected[gid] = group + continue + + reason = verdict.reason or "dynamic_filter" + for sample in group: + sample.remove_sample = True + if sample.metadata is not None: + sample.metadata["removed_reason"] = reason + dropped.append(group) + drop_reasons[reason] = drop_reasons.get(reason, 0) + 1 if not drained: await asyncio.sleep(0.05) @@ -239,10 +274,11 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> list[li now = time.time() if now - last_log > LOG_EVERY: logger.info( - "fully-async rollout %d: collected %d/%d, queue=%d, elapsed=%.1fs", + "fully-async rollout %d: collected %d/%d (dropped %d), queue=%d, elapsed=%.1fs", rollout_id, len(collected), target, + len(dropped), worker.queue_size(), now - started, ) @@ -257,13 +293,25 @@ def _key(group: list[Sample]) -> int: return 0 out = sorted(collected.values(), key=_key) + dropped = sorted(dropped, key=_key) + if (filter_path := getattr(args, "rollout_sample_filter_path", None)) is not None: + load_function(filter_path)(args, out) logger.info( - "fully-async rollout %d: done in %.1fs, queue_left=%d", + "fully-async rollout %d: done in %.1fs, kept=%d dropped=%d (%s), queue_left=%d", rollout_id, time.time() - started, + len(out), + len(dropped), + drop_reasons, worker.queue_size(), ) - return out + if not filters_enabled: + return out + metrics = {f"rollout/dynamic_filter/drop_{reason}": count for reason, count in drop_reasons.items()} + metrics["rollout/dynamic_filter/dropped_groups"] = len(dropped) + metrics["rollout/dynamic_filter/dropped_ratio"] = len(dropped) / (len(out) + len(dropped)) + metrics["_dropped_samples"] = dropped + return RolloutFnTrainOutput(samples=out, metrics=metrics) def generate_rollout_fully_async(args, rollout_id, data_buffer, evaluation: bool = False): diff --git a/vime/utils/accelerator/__init__.py b/vime/utils/accelerator/__init__.py index 089ac7522..b06110156 100644 --- a/vime/utils/accelerator/__init__.py +++ b/vime/utils/accelerator/__init__.py @@ -22,11 +22,15 @@ from .cuda import CUDAAccelerator from .musa import MUSAAccelerator from .musa import is_musa_available as _is_musa_available +from .supa import SUPAAccelerator +from .supa import is_supa_available as _is_supa_available logger = logging.getLogger(__name__) _MUSA_PATCH_IMPORTED = False _MUSA_BOOTSTRAP_CHECKED = False +_SUPA_RUNTIME_IMPORTED = False +_SUPA_BOOTSTRAP_CHECKED = False _ACCELERATOR: Accelerator | None = None _SELECTION_LOCK = threading.RLock() @@ -122,6 +126,59 @@ def _bootstrap_musa_patch_if_needed() -> bool: return _try_import_musa_patch() +def _import_torch_supa() -> bool: + try: + importlib.import_module("torch_supa") + except ModuleNotFoundError as exc: + if exc.name == "torch_supa": + return False + raise RuntimeError(f"torch_supa failed because dependency {exc.name!r} is missing") from exc + except Exception as exc: + raise RuntimeError(f"torch_supa initialization failed: {exc}") from exc + return True + + +def is_supa_available() -> bool: + return _is_supa_available() + + +def is_supa_environment() -> bool: + return ( + is_supa_available() + or os.environ.get("VIME_ACCELERATOR", "").lower() == "supa" + or "SUPA_VISIBLE_DEVICES" in os.environ + or bool(os.environ.get("BIREN_HOME")) + ) + + +def _try_import_torch_supa() -> bool: + global _SUPA_RUNTIME_IMPORTED + if _SUPA_RUNTIME_IMPORTED: + return True + if not is_supa_environment(): + return False + _SUPA_RUNTIME_IMPORTED = _import_torch_supa() + if not _SUPA_RUNTIME_IMPORTED and is_supa_environment(): + logger.warning("torch_supa is not importable; continuing without it") + return _SUPA_RUNTIME_IMPORTED + + +def _supa_requested() -> bool: + configured = os.environ.get("VIME_ACCELERATOR", "").lower() + if configured and configured != "auto": + return configured == "supa" + return "SUPA_VISIBLE_DEVICES" in os.environ or bool(os.environ.get("BIREN_HOME")) + + +def _bootstrap_torch_supa_if_needed() -> bool: + """Bootstrap the runtime for an already chosen SUPA backend at most once.""" + global _SUPA_BOOTSTRAP_CHECKED + if _SUPA_BOOTSTRAP_CHECKED: + return _SUPA_RUNTIME_IMPORTED + _SUPA_BOOTSTRAP_CHECKED = True + return _try_import_torch_supa() + + def _cuda_available() -> bool: try: return bool(torch.cuda.is_available() and torch.cuda.device_count() > 0) @@ -136,6 +193,10 @@ def _register_builtin_backends() -> None: register_accelerator( "musa", MUSAAccelerator, is_musa_available, priority=200, communication_backends=("mccl",) ) + if "supa" not in _REGISTRY: + register_accelerator( + "supa", SUPAAccelerator, is_supa_available, priority=150, communication_backends=("bccl",) + ) def _requested_name() -> str | None: @@ -144,6 +205,8 @@ def _requested_name() -> str | None: return value.strip().lower() if _musa_requested(): return "musa" + if _supa_requested(): + return "supa" return None @@ -157,11 +220,17 @@ def _make_selected(name: str, explicit: bool) -> Accelerator: # musa_patch may expose torch.musa, so bootstrap after MUSA has been # chosen but before validating and constructing its backend. _bootstrap_musa_patch_if_needed() + if name == "supa": + # torch_supa attaches torch.supa and registers the "supa" device type, + # so bootstrap after SUPA has been chosen but before validating it. + _bootstrap_torch_supa_if_needed() if explicit and not entry.is_available(): if name == "musa": detail = ( "torch.musa is unavailable; install a MUSA-enabled PyTorch runtime and set MUSA_PATCH_PATH if required" ) + elif name == "supa": + detail = "torch.supa is unavailable; install a SUPA-enabled PyTorch runtime that provides torch_supa" elif name == "cuda": detail = "torch.cuda.is_available() is false or no CUDA device is visible" else: diff --git a/vime/utils/accelerator/supa.py b/vime/utils/accelerator/supa.py new file mode 100644 index 000000000..8c05f9ef0 --- /dev/null +++ b/vime/utils/accelerator/supa.py @@ -0,0 +1,98 @@ +"""SUPA accelerator implementation for Biren GPUs. + +Importing this module never imports ``torch_supa``. A SUPA runtime attaches +``torch.supa`` and registers the ``supa`` device type with PyTorch, so this +module only inspects what is already present on ``torch``. + +The Biren stack names its collectives ``bccl`` and accepts both the plain +``"bccl"`` form and the device-qualified ``"supa:bccl"`` form, which is why +:meth:`SUPAAccelerator.weight_update_backend` can hand out a composite string. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +import torch + +from .torch_accelerator import TorchAccelerator + + +def supa_module() -> Any: + return getattr(torch, "supa", None) + + +def is_supa_available() -> bool: + module = supa_module() + checker = getattr(module, "is_available", None) + return bool(module is not None and checker is not None and checker()) + + +class SUPAAccelerator(TorchAccelerator): + name = "supa" + device_type = "supa" + communication_backend_name = "bccl" + + @property + def visible_devices_env(self) -> str: + return "SUPA_VISIBLE_DEVICES" + + def _module(self) -> Any: + module = supa_module() + if module is None: + raise RuntimeError("SUPA backend requires a runtime that exposes torch.supa") + return module + + def is_available(self) -> bool: + return is_supa_available() + + def weight_update_backend(self, default: str = "nccl") -> str: + return "cpu:gloo,supa:bccl" if default == "nccl" else default + + def distributed_device_id(self, index: int | str | torch.device | None = None) -> None: + # Skip the ``device_id`` argument of ``init_process_group`` and let the + # SUPA runtime bind the device itself. + return None + + def post_import_torch(self) -> None: + try: + importlib.import_module("torch_supa") + except ModuleNotFoundError as exc: + if exc.name == "torch_supa": + return + raise RuntimeError(f"torch_supa failed because dependency {exc.name!r} is missing") from exc + + def attach_oom_observer(self, callback) -> bool: + supa_c = getattr(self._module(), "_SUPAC", None) + attach = getattr(supa_c, "_supa_attach_out_of_memory_observer", None) + if attach is None: + return False + attach(callback) + return True + + def autocast(self, *args, **kwargs): + amp = getattr(self._module(), "amp", None) + autocast = getattr(amp, "autocast", None) + if autocast is None: + raise NotImplementedError("SUPA runtime does not expose torch.supa.amp.autocast") + return autocast(*args, **kwargs) + + def supports(self, capability: str) -> bool: + # NVML is NVIDIA-only; the fp8/int4 helpers and the strict fp32 logits + # path all depend on CUDA-specific kernels. + if capability in {"nvml_affinity", "vllm_fp8_utils", "strict_fp32_logits", "cuda_int4_extension"}: + return False + # Inductor codegen is not usable on the SUPA runtime yet. + if capability == "triton_kernels": + return False + # Parameters are built on CPU and then moved onto the device. + if capability == "requires_cpu_initialization": + return True + if capability == "amp": + amp = getattr(self._module(), "amp", None) + return callable(getattr(amp, "autocast", None)) + if capability == "bf16": + checker = getattr(self._module(), "is_bf16_supported", None) + return bool(checker and checker()) + return super().supports(capability) diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index e13e827fb..858c02b18 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -462,7 +462,6 @@ def add_rollout_arguments(parser): "use `vime.rollout.filter_hub.dynamic_sampling_filters.check_reward_nonzero_std_with_fallback`." ), ) - # partial rollout parser.add_argument( "--partial-rollout", @@ -535,6 +534,14 @@ def add_rollout_arguments(parser): "The function should take list[list[Sample]] and return list[list[Sample]]." ), ) + parser.add_argument( + "--buffer-sort-by-staleness", + action="store_true", + help=( + "Resume buffered groups with the oldest generated-token weight version first. " + "Disabled by default; an explicit --buffer-filter-path takes precedence." + ), + ) # update weight parser.add_argument( "--update-weight-buffer-size", @@ -545,18 +552,6 @@ def add_rollout_arguments(parser): "This is used for updating weights by chunk and should be useful for MoE models." ), ) - parser.add_argument( - "--update-weights-interval", - type=int, - default=1, - help="Interval for updating the weights", - ) - parser.add_argument( - "--keep-old-actor", - action="store_true", - help="Whether to keep the rollout model on training process", - ) - parser.add_argument( "--rollout-data-postprocess-path", type=str, @@ -1113,6 +1108,27 @@ def add_algo_arguments(parser): default=False, help="The rollout routing replay technique from https://arxiv.org/abs/2510.11370", ) + parser.add_argument( + "--rollout-routed-experts-store-dir", + type=str, + default=None, + help=( + "Shared filesystem directory used by routed-experts sample spill hooks. " + "All rollout and training nodes must be able to access this path." + ), + ) + parser.add_argument( + "--routing-replay-prefetch-microbatches", + type=int, + default=1, + help="Number of upcoming disk-backed R3 microbatches to prefetch into CPU memory.", + ) + parser.add_argument( + "--keep-rollout-routed-experts-files", + action="store_true", + default=False, + help="Keep disk-backed routed-experts files after all trainers finish the rollout.", + ) parser.add_argument( "--use-opsm", action="store_true", @@ -2147,6 +2163,25 @@ def vime_validate_args(args): if args.use_rollout_routing_replay: args.use_routing_replay = True + if args.routing_replay_prefetch_microbatches < 0: + raise ValueError("--routing-replay-prefetch-microbatches must be non-negative") + + fully_async = "fully_async" in (getattr(args, "rollout_function_path", None) or "") + disk_spill = "vime.utils.routed_experts.spill_routed_experts" in ( + getattr(args, "rollout_sample_hook_path", None) or [] + ) + if disk_spill and not getattr(args, "rollout_routed_experts_store_dir", None): + raise ValueError("vime.utils.routed_experts.spill_routed_experts requires --rollout-routed-experts-store-dir.") + if ( + not getattr(args, "debug_train_only", False) + and fully_async + and disk_spill + and not getattr(args, "keep_rollout_routed_experts_files", False) + ): + raise ValueError( + "fully-async rollout with routed-experts disk spill requires " + "--keep-rollout-routed-experts-files because in-flight samples can cross rollout boundaries." + ) if args.custom_config_path: with open(args.custom_config_path) as f: @@ -2184,8 +2219,6 @@ def vime_validate_args(args): if args.release_train: if args.use_critic: raise ValueError("--release-train does not support critic training yet.") - if args.keep_old_actor: - raise ValueError("--release-train does not support --keep-old-actor.") if args.save is None: raise ValueError("--release-train requires --save so the next Megatron actor can reload.") if args.save_interval is None: diff --git a/vime/utils/external_utils/command_utils.py b/vime/utils/external_utils/command_utils.py index 533d36572..85d2e1833 100644 --- a/vime/utils/external_utils/command_utils.py +++ b/vime/utils/external_utils/command_utils.py @@ -95,7 +95,6 @@ def execute_train( train_args: str, num_gpus_per_node: int, megatron_model_type: str | None, - train_script: str = "train.py", before_ray_job_submit=None, extra_env_vars=None, config: ExecuteTrainConfig | None = None, @@ -189,7 +188,7 @@ def execute_train( exec_command( f"export {amd_exports} && " f"{cmd_megatron_model_source}" - f"python3 {train_script} {model_args} {train_args}" + f"python3 train.py {model_args} {train_args}" ) else: exec_command( @@ -197,7 +196,7 @@ def execute_train( f"{cmd_megatron_model_source}" f'ray job submit --address="http://127.0.0.1:8265" ' f"--runtime-env-json='{runtime_env_json}' " - f"-- python3 {train_script} " + f"-- python3 train.py " f"{model_args} " f"{train_args}" ) diff --git a/vime/utils/http_utils.py b/vime/utils/http_utils.py index 6f59b7e9f..deaa73857 100644 --- a/vime/utils/http_utils.py +++ b/vime/utils/http_utils.py @@ -144,12 +144,15 @@ def _next_actor(): return actor -async def _post(client, url, payload, max_retries=60, headers=None): +async def _post(client, url, payload, max_retries=60, headers=None, timeout=None): retry_count = 0 while retry_count < max_retries: response = None try: - response = await client.post(url, json=payload or {}, headers=headers) + request_kwargs = {"json": payload or {}, "headers": headers} + if timeout is not None: + request_kwargs["timeout"] = timeout + response = await client.post(url, **request_kwargs) response.raise_for_status() content = await response.aread() try: @@ -244,8 +247,8 @@ def __init__(self, concurrency: int): trust_env=False, # internal vLLM comm only — never route through system proxy ) - async def do_post(self, url, payload, max_retries=60, headers=None): - return await _post(self._client, url, payload, max_retries, headers=headers) + async def do_post(self, url, payload, max_retries=60, headers=None, timeout=None): + return await _post(self._client, url, payload, max_retries, headers=headers, timeout=timeout) # Create actors per node created = [] @@ -270,7 +273,7 @@ async def do_post(self, url, payload, max_retries=60, headers=None): _post_actors = created -async def post(url, payload, max_retries=60, headers=None): +async def post(url, payload, max_retries=60, headers=None, timeout=None): # If distributed mode is enabled and actors exist, dispatch via Ray. if _distributed_post_enabled and _post_actors: try: @@ -282,18 +285,20 @@ async def post(url, payload, max_retries=60, headers=None): # `min(32, cpu+4)`), which becomes a hard upper bound on the # number of in-flight POSTs that can be waited on in parallel # and produces large tail latencies under high concurrency. - obj_ref = actor.do_post.remote(url, payload, max_retries, headers=headers) + obj_ref = actor.do_post.remote(url, payload, max_retries, headers=headers, timeout=timeout) return await obj_ref except Exception as e: logger.info(f"[http_utils] Distributed POST failed, falling back to local: {e} (url={url})") # fall through to local - return await _post(_http_client, url, payload, max_retries, headers=headers) + return await _post(_http_client, url, payload, max_retries, headers=headers, timeout=timeout) -async def get(url): - response = await _http_client.get(url) - response.raise_for_status() - content = await response.aread() - output = json.loads(content) - return output +async def get(url, *, timeout=None): + response = await _http_client.get(url, timeout=timeout) + try: + response.raise_for_status() + content = await response.aread() + return json.loads(content) + finally: + await response.aclose() diff --git a/vime/utils/memory_utils.py b/vime/utils/memory_utils.py index 91ce5e577..8aa3e2d8b 100644 --- a/vime/utils/memory_utils.py +++ b/vime/utils/memory_utils.py @@ -1,5 +1,9 @@ +import ctypes import gc import logging +import resource +import sys +from functools import lru_cache import psutil import torch @@ -10,6 +14,44 @@ logger = logging.getLogger(__name__) +@lru_cache(maxsize=1) +def _cuda_stack_api(): + driver = ctypes.CDLL("libcuda.so.1") + driver.cuCtxGetLimit.argtypes = [ctypes.POINTER(ctypes.c_size_t), ctypes.c_int] + driver.cuCtxGetLimit.restype = ctypes.c_int + driver.cuCtxSetLimit.argtypes = [ctypes.c_int, ctypes.c_size_t] + driver.cuCtxSetLimit.restype = ctypes.c_int + return driver + + +def reset_cuda_stack_size() -> None: + """Release an enlarged CUDA per-thread stack after model offload.""" + if torch.version.cuda is None or torch.version.hip is not None or not torch.cuda.is_initialized(): + return + torch.cuda.synchronize() + driver = _cuda_stack_api() + previous = ctypes.c_size_t() + error = driver.cuCtxGetLimit(ctypes.byref(previous), 0) # CU_LIMIT_STACK_SIZE + if error: + raise RuntimeError(f"cuCtxGetLimit(CU_LIMIT_STACK_SIZE) failed: CUDA error {error}") + if previous.value <= 1024: + return + error = driver.cuCtxSetLimit(0, 1024) + if error: + raise RuntimeError(f"cuCtxSetLimit(CU_LIMIT_STACK_SIZE) failed: CUDA error {error}") + logger.info("Reset CUDA stack limit after offload: %d -> 1024 bytes", previous.value) + + +def get_process_host_memory_gib(process: psutil.Process | None = None) -> tuple[float, float]: + """Return current and peak resident host memory in GiB.""" + process = process or psutil.Process() + rss_gib = process.memory_info().rss / 1024**3 + max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + # Linux reports KiB; macOS reports bytes. + hwm_gib = max_rss / (1024**2 if sys.platform != "darwin" else 1024**3) + return rss_gib, hwm_gib + + def clear_memory(clear_host_memory: bool = False): accelerator.synchronize() gc.collect() diff --git a/vime/utils/misc.py b/vime/utils/misc.py index 1f55b6748..f619fb47f 100644 --- a/vime/utils/misc.py +++ b/vime/utils/misc.py @@ -1,5 +1,6 @@ import importlib import subprocess +import warnings from collections import defaultdict from collections.abc import Iterable from functools import cache @@ -10,7 +11,12 @@ from vime.utils.http_utils import is_port_available -def decode_int32_meta_array(meta_info: dict[str, Any], keys: str | Iterable[str]) -> torch.Tensor | None: +def decode_meta_array( + meta_info: dict[str, Any], + keys: str | Iterable[str], + *, + dtype: torch.dtype, +) -> torch.Tensor | None: if isinstance(keys, str): keys = (keys,) for key in keys: @@ -27,12 +33,18 @@ def decode_int32_meta_array(meta_info: dict[str, Any], keys: str | Iterable[str] value = pybase64.b64decode(value.encode("ascii")) if isinstance(value, bytes | bytearray | memoryview): - return torch.frombuffer(bytearray(value), dtype=torch.int32) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="The given buffer is not writable", category=UserWarning) + return torch.frombuffer(value, dtype=dtype) if torch.is_tensor(value): - return value.detach().to(device="cpu", dtype=torch.int32).reshape(-1) + return value.detach().to(device="cpu", dtype=dtype).reshape(-1) if hasattr(value, "flags") and not value.flags.writeable: value = value.copy() - return torch.as_tensor(value, dtype=torch.int32).reshape(-1) + return torch.as_tensor(value, dtype=dtype).reshape(-1) + + +def decode_int32_meta_array(meta_info: dict[str, Any], keys: str | Iterable[str]) -> torch.Tensor | None: + return decode_meta_array(meta_info, keys, dtype=torch.int32) @cache diff --git a/vime/utils/routed_experts.py b/vime/utils/routed_experts.py new file mode 100644 index 000000000..ade2a1dc1 --- /dev/null +++ b/vime/utils/routed_experts.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import logging +import shutil +import threading +import uuid +from concurrent.futures import Future, ThreadPoolExecutor +from pathlib import Path +from typing import Any + +import torch + +from vime.utils import accelerator +from vime.utils.memory_utils import get_process_host_memory_gib +from vime.utils.tensor_store import DiskTensorRef +from vime.utils.types import Sample + +logger = logging.getLogger(__name__) + + +def validate_routed_experts_tensor( + experts: torch.Tensor, + args, + *, + sample_index: int | None = None, + expected_rows: int | None = None, +) -> None: + """Validate one rollout route tensor before R3 consumes or spills it.""" + + num_layers = int(args.num_layers) + topk = int(args.moe_router_topk) + if experts.ndim != 3 or tuple(experts.shape[1:]) != (num_layers, topk): + raise ValueError( + "Invalid rollout routed-experts shape for R3: " + f"sample={sample_index}, got={tuple(experts.shape)}, expected=(*, {num_layers}, {topk})." + ) + if experts.shape[0] == 0: + raise ValueError(f"R3 sample {sample_index} has no routed-experts rows.") + if expected_rows is not None and experts.shape[0] != expected_rows: + raise ValueError( + f"R3 sample {sample_index} routed-experts rows={experts.shape[0]}, " + f"expected={expected_rows} from len(tokens)-1." + ) + + moe_layer_freq = getattr(args, "moe_layer_freq", None) + if isinstance(moe_layer_freq, (list, tuple)): + moe_layers = [layer_id for layer_id, freq in enumerate(moe_layer_freq[:num_layers]) if int(freq) != 0] + elif isinstance(moe_layer_freq, int) and moe_layer_freq > 0: + moe_layers = [layer_id for layer_id in range(num_layers) if layer_id % moe_layer_freq == 0] + else: + moe_layers = list(range(num_layers)) + if topk > 1: + moe_routes = experts[:, moe_layers, :] + token_layer_present = torch.any(moe_routes != 0, dim=-1) + missing_layer_mask = ~torch.any(token_layer_present, dim=0) + missing_layers = [moe_layers[idx] for idx in torch.nonzero(missing_layer_mask).reshape(-1).tolist()] + if missing_layers: + raise ValueError( + "R3 routed-experts capture is all zero for MoE layers " + f"{missing_layers} in sample {sample_index}. This usually means SGLang pipeline stages " + "did not aggregate their disjoint routing captures; refusing to replay expert 0 everywhere." + ) + missing_token_layers = ~token_layer_present + if torch.any(missing_token_layers): + # Partial layers indicate broken PP aggregation. Fully missing rows + # are known request-level capture holes and replay expert 0. + fully_missing_rows = torch.all(missing_token_layers, dim=1) + partial_missing = missing_token_layers & ~fully_missing_rows.unsqueeze(1) + if torch.any(partial_missing): + missing = torch.nonzero(partial_missing, as_tuple=False) + examples = [ + (int(token_idx), int(moe_layers[int(moe_idx)])) for token_idx, moe_idx in missing[:16].tolist() + ] + raise ValueError( + "R3 routed-experts capture is all zero for token/layer pairs " + f"{examples} in sample {sample_index}. This usually means prompt/cache/PD " + "routes were only partially returned." + ) + num_hole_rows = int(fully_missing_rows.sum()) + allowed_hole_rows = max(64, experts.shape[0] // 100) + if num_hole_rows > allowed_hole_rows: + hole_positions = torch.nonzero(fully_missing_rows).reshape(-1)[:16].tolist() + raise ValueError( + f"R3 sample {sample_index} has {num_hole_rows}/{experts.shape[0]} rows with no " + f"routing capture at all (e.g. token rows {hole_positions}), exceeding the " + f"tolerated {allowed_hole_rows}. This looks like a systematic capture failure, " + "not isolated prefix-cache/PD holes." + ) + logger.warning( + "R3 sample %s: tolerating %d/%d fully-missing routing rows; these rows replay expert 0.", + sample_index, + num_hole_rows, + experts.shape[0], + ) + + if moe_layers: + # Cast first so uint8 expert ids compare correctly with num_experts=256. + moe_routes = experts[:, moe_layers, :].to(torch.int64) + num_experts = int(getattr(args, "num_experts", torch.iinfo(torch.int32).max)) + invalid = (moe_routes < 0) | (moe_routes >= num_experts) + if torch.any(invalid): + invalid_ids = torch.unique(moe_routes[invalid])[:16].tolist() + raise ValueError( + f"R3 sample {sample_index} contains routed expert ids outside " + f"[0, {num_experts - 1}]: {invalid_ids}." + ) + + +def validate_routed_experts_value( + value: torch.Tensor | DiskTensorRef, + args, + *, + sample_index: int | None, + expected_rows: int | None = None, +) -> None: + if isinstance(value, DiskTensorRef) and value.validated: + expected_tail = (int(args.num_layers), int(args.moe_router_topk)) + if len(value.shape) != 3 or tuple(value.shape[1:]) != expected_tail or value.shape[0] <= 0: + raise ValueError( + "Invalid rollout routed-experts disk reference for R3: " + f"sample={sample_index}, shape={value.shape}, expected=(*, {expected_tail[0]}, {expected_tail[1]})." + ) + if expected_rows is not None and value.shape[0] != expected_rows: + raise ValueError( + f"R3 sample {sample_index} routed-experts rows={value.shape[0]}, " + f"expected={expected_rows} from len(tokens)-1." + ) + return + + tensor = value.load() if isinstance(value, DiskTensorRef) else value + validate_routed_experts_tensor(tensor, args, sample_index=sample_index, expected_rows=expected_rows) + + +def link_routed_experts_for_rollout(args, sample: Sample, rollout_id: int | None) -> None: + """Retain spilled routes in a rollout directory using only file references.""" + ref = sample.rollout_routed_experts + if not isinstance(ref, DiskTensorRef): + return + store_dir = getattr(args, "rollout_routed_experts_store_dir", None) + if not store_dir: + raise ValueError("R3 file retention requires --rollout-routed-experts-store-dir") + rollout_component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" + output_dir = Path(store_dir) / f"rollout_{rollout_component}" + if Path(ref.path).parent.resolve() == output_dir.resolve(): + if not Path(ref.path).is_file(): + raise FileNotFoundError(f"R3 disk reference no longer exists: {ref.path}") + return + sample_component = "none" if sample.index is None else str(sample.index) + output_path = output_dir / f"sample_{sample_component}_{uuid.uuid4().hex}.safetensors" + sample.rollout_routed_experts = ref.link(output_path) + + +async def spill_routed_experts( + args, + sample: Sample, + *, + rollout_id: int | None = None, + evaluation: bool = False, +) -> Sample: + """Sample hook that replaces rollout routes with a shared-filesystem reference. + + The write intentionally stays on the rollout event-loop thread. This applies + backpressure at response completion instead of allowing a large queue of + completed samples to retain their routed-expert tensors in host memory. + """ + + if evaluation: + sample.rollout_routed_experts = None + return sample + # Aborted partial-rollout samples return to the data buffer and resume in a + # later rollout, after this rollout's spill directory has been deleted, so + # their routes must stay resident and travel with the sample instead. + # Keep missing routes as None too: a waiting-queue abort can have an empty + # loss_mask, and zero placeholders would be counted as an already captured + # prefix by routed_experts_start_len on retry. + if sample.status == Sample.Status.ABORTED: + return sample + + store_dir = getattr(args, "rollout_routed_experts_store_dir", None) + if not store_dir: + raise ValueError("spill_routed_experts requires --rollout-routed-experts-store-dir") + + if isinstance(sample.rollout_routed_experts, DiskTensorRef): + validate_routed_experts_value(sample.rollout_routed_experts, args, sample_index=sample.index) + link_routed_experts_for_rollout(args, sample, rollout_id) + return sample + + if sample.rollout_routed_experts is None: + if sample.loss_mask is None or any(sample.loss_mask): + return sample + dtype = torch.uint8 if args.num_experts <= 256 else torch.int32 + experts = torch.zeros((max(0, len(sample.tokens) - 1), args.num_layers, args.moe_router_topk), dtype=dtype) + else: + experts = sample.materialize_rollout_routed_experts(replace=False) + validate_routed_experts_tensor(experts, args, sample_index=sample.index) + + rollout_component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" + output_dir = Path(store_dir) / f"rollout_{rollout_component}" + sample_component = "none" if sample.index is None else str(sample.index) + output_path = output_dir / f"sample_{sample_component}_{uuid.uuid4().hex}.safetensors" + sample.rollout_routed_experts = DiskTensorRef.write( + experts, + output_path, + kind="rollout_routed_experts", + validated=True, + ) + return sample + + +def cleanup_routed_experts_rollout(args, rollout_id: int) -> None: + store_dir = getattr(args, "rollout_routed_experts_store_dir", None) + if not store_dir or getattr(args, "keep_rollout_routed_experts_files", False): + return + path = Path(store_dir) / f"rollout_{int(rollout_id):08d}" + if path.exists(): + shutil.rmtree(path) + logger.info("Removed routed-experts spill directory %s", path) + + +def materialize_routed_experts(value: Any, *, pin_memory: bool = False) -> torch.Tensor: + if isinstance(value, DiskTensorRef): + return value.load(pin_memory=pin_memory) + if torch.is_tensor(value): + return value + return torch.as_tensor(value) + + +class RoutedExpertsMicrobatchPrefetcher: + """Bounded disk-to-CPU prefetch queue shared by one actor rollout.""" + + def __init__(self, prefetch_microbatches: int) -> None: + self.prefetch_microbatches = max(0, int(prefetch_microbatches)) + self.sources: list[RoutedExpertsMicrobatch] = [] + # Which stage's completed consumption frees a microbatch. "forward" + # suits forward-only passes (log probs); the train pass must hold each + # microbatch until its recompute-backward pops it, or 1F1B re-reads + # every microbatch from disk between forward and backward. + self.release_stage = "forward" + self._executor = ( + ThreadPoolExecutor(max_workers=min(2, self.prefetch_microbatches + 1), thread_name_prefix="r3-prefetch") + if self.prefetch_microbatches > 0 + else None + ) + self._closed = False + self._stats_lock = threading.Lock() + self._resident_bytes = 0 + self._peak_resident_bytes = 0 + self._load_count = 0 + self._disk_bytes = 0 + + def add(self, source: RoutedExpertsMicrobatch) -> None: + source.index = len(self.sources) + source.prefetcher = self + self.sources.append(source) + self._disk_bytes += sum(value.nbytes for value in source.values) + + def start(self) -> None: + for index in range(min(len(self.sources), self.prefetch_microbatches)): + self.sources[index].prefetch() + + def begin_pass(self, release_stage: str) -> None: + """Arm the release policy for the next pass and re-warm its first window. + + Sources released by the previous pass would otherwise load synchronously + on the training thread when the new pass consumes its first microbatch. + """ + self.release_stage = release_stage + self.start() + + def prefetch_after(self, index: int) -> None: + for next_index in range(index + 1, min(len(self.sources), index + 1 + self.prefetch_microbatches)): + self.sources[next_index].prefetch() + + @property + def executor(self) -> ThreadPoolExecutor | None: + return None if self._closed else self._executor + + def on_loaded(self, nbytes: int) -> None: + with self._stats_lock: + self._load_count += 1 + self._resident_bytes += nbytes + self._peak_resident_bytes = max(self._peak_resident_bytes, self._resident_bytes) + + def on_released(self, nbytes: int) -> None: + with self._stats_lock: + self._resident_bytes -= nbytes + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._executor is not None: + self._executor.shutdown(wait=True, cancel_futures=True) + for source in self.sources: + source.release() + rss_gib, hwm_gib = get_process_host_memory_gib() + logger.info( + "R3 disk prefetch profile: microbatches=%d prefetch=%d source_bytes=%.3f GiB " + "loads=%d peak_prepared_cpu=%.3f GiB rss=%.3f GiB hwm=%.3f GiB", + len(self.sources), + self.prefetch_microbatches, + self._disk_bytes / 1024**3, + self._load_count, + self._peak_resident_bytes / 1024**3, + rss_gib, + hwm_gib, + ) + + +class RoutedExpertsMicrobatch: + """Lazily prepares one training-layout routed-experts microbatch.""" + + def __init__( + self, + values: list[DiskTensorRef], + tokens: list[torch.Tensor], + *, + consumer_count: int, + prepare_kwargs: dict[str, Any], + ) -> None: + self.values = values + self.tokens = tokens + self.consumer_count = consumer_count + self.prepare_kwargs = prepare_kwargs + self.index = -1 + self.prefetcher: RoutedExpertsMicrobatchPrefetcher | None = None + self._future: Future | None = None + self._cpu_tensor: torch.Tensor | None = None + self._loaded_nbytes = 0 + self._lock = threading.Lock() + self._consumed = {"forward": 0, "backward": 0} + + def _load_and_prepare(self) -> torch.Tensor: + from vime.backends.megatron_utils.cp_utils import prepare_routed_experts_for_routing_replay + + tensors = [value.load() for value in self.values] + tensor = prepare_routed_experts_for_routing_replay( + tensors, + self.tokens, + **self.prepare_kwargs, + ) + nbytes = tensor.numel() * tensor.element_size() + with self._lock: + self._loaded_nbytes = nbytes + if self.prefetcher is not None: + self.prefetcher.on_loaded(nbytes) + return tensor + + def prefetch(self) -> None: + with self._lock: + if self._cpu_tensor is not None or self._future is not None: + return + executor = self.prefetcher.executor if self.prefetcher is not None else None + if executor is not None: + self._future = executor.submit(self._load_and_prepare) + + def _get_cpu_tensor(self) -> torch.Tensor: + with self._lock: + tensor = self._cpu_tensor + future = self._future + if tensor is None: + tensor = future.result() if future is not None else self._load_and_prepare() + with self._lock: + self._cpu_tensor = tensor + self._future = None + if self.prefetcher is not None: + self.prefetcher.prefetch_after(self.index) + return tensor + + def layer_to_cuda(self, layer_id: int, stage: str) -> torch.Tensor: + cpu_tensor = self._get_cpu_tensor() + result = cpu_tensor[:, layer_id].to( + accelerator.current_device(), + dtype=torch.int32, + non_blocking=cpu_tensor.is_pinned(), + ) + self._consumed[stage] += 1 + if self._consumed[stage] == self.consumer_count: + self._consumed[stage] = 0 + release_stage = self.prefetcher.release_stage if self.prefetcher is not None else stage + if stage == release_stage: + self.release() + return result + + def release(self) -> None: + with self._lock: + future = self._future + self._future = None + self._cpu_tensor = None + loaded_nbytes = self._loaded_nbytes + self._loaded_nbytes = 0 + if future is not None and not future.done(): + future.cancel() + if loaded_nbytes and self.prefetcher is not None: + self.prefetcher.on_released(loaded_nbytes) + + +class RoutedExpertsLayerRef: + """One layer view stored in RoutingReplay instead of a resident CPU tensor.""" + + def __init__(self, source: RoutedExpertsMicrobatch, layer_id: int) -> None: + self.source = source + self.layer_id = layer_id + + def materialize_for_routing_replay(self, stage: str) -> torch.Tensor: + return self.source.layer_to_cuda(self.layer_id, stage) diff --git a/vime/utils/staleness.py b/vime/utils/staleness.py new file mode 100644 index 000000000..33f43d48e --- /dev/null +++ b/vime/utils/staleness.py @@ -0,0 +1,29 @@ +"""Policy age in weight-update units (fresh samples have age one).""" + +from vime.utils.types import Sample + + +def fully_async_metrics_enabled(args) -> bool: + return "fully_async" in (getattr(args, "rollout_function_path", None) or "") + + +def sample_staleness(sample: Sample, current_weight_version: int) -> int | None: + versions = sample.weight_versions or [] + if not versions or any(not str(v).isascii() or not str(v).removeprefix("-").isdigit() for v in versions): + return None + numeric_versions = [int(v) for v in versions] + if max(numeric_versions) > current_weight_version: + return None + return current_weight_version + 1 - min(numeric_versions) + + +def compute_staleness_metrics(samples: list[Sample], current_weight_version: int | None) -> dict[str, float | int]: + if current_weight_version is None: + return {} + ages = [sample_staleness(sample, current_weight_version) for sample in samples] + known = [age for age in ages if age is not None] + metrics: dict[str, float | int] = {"staleness/unknown_count": len(ages) - len(known)} + if known: + metrics["staleness/mean"] = sum(known) / len(known) + metrics["staleness/max"] = max(known) + return metrics diff --git a/vime/utils/tensor_backper.py b/vime/utils/tensor_backper.py index 023971de9..a527a294e 100644 --- a/vime/utils/tensor_backper.py +++ b/vime/utils/tensor_backper.py @@ -29,11 +29,6 @@ def backup(self, tag: str) -> None: backup_dict[name].copy_(param.detach(), non_blocking=True) accelerator.synchronize() - @torch.no_grad() - def copy(self, *, src_tag: str, dst_tag: str): - for name in self._backups[dst_tag]: - self._backups[dst_tag][name].copy_(self._backups[src_tag][name]) - @torch.no_grad() def restore(self, tag: str) -> None: backup_dict = self._backups[tag] diff --git a/vime/utils/tensor_store.py b/vime/utils/tensor_store.py new file mode 100644 index 000000000..b0f0d2d35 --- /dev/null +++ b/vime/utils/tensor_store.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import errno +import os +import shutil +from dataclasses import dataclass, replace +from pathlib import Path + +import torch +from safetensors.torch import load_file, save_file + + +@dataclass(frozen=True) +class DiskTensorRef: + """Small, Ray-serializable reference to a single-tensor safetensors file. + + Files are written and read with the official safetensors implementation. + """ + + path: str + shape: tuple[int, ...] + dtype: str + nbytes: int + kind: str | None = None + validated: bool = False + + @classmethod + def write( + cls, + tensor: torch.Tensor, + path: str | Path, + *, + kind: str | None = None, + validated: bool = False, + ) -> DiskTensorRef: + tensor = tensor.detach().cpu().contiguous() + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + save_file({"tensor": tensor}, path, metadata={"kind": kind} if kind else None) + + return cls( + path=str(path), + shape=tuple(int(dim) for dim in tensor.shape), + dtype=str(tensor.dtype).removeprefix("torch."), + nbytes=tensor.numel() * tensor.element_size(), + kind=kind, + validated=validated, + ) + + @property + def torch_dtype(self) -> torch.dtype: + dtype = getattr(torch, self.dtype, None) + if not isinstance(dtype, torch.dtype): + raise ValueError(f"Unsupported tensor dtype in disk reference: {self.dtype!r}") + return dtype + + def link(self, path: str | Path) -> DiskTensorRef: + """Give immutable tensor data another lifetime without loading it into RAM.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(self.path, path) + except OSError as error: + if error.errno not in (errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP): + raise + # A different filesystem or one without hard links still needs a + # durable copy. Copy bytes directly, without materializing a tensor. + shutil.copyfile(self.path, path) + return replace(self, path=str(path)) + + def load(self, *, pin_memory: bool = False) -> torch.Tensor: + path = Path(self.path) + tensor = load_file(path, device="cpu").get("tensor") + if tensor is None: + raise OSError(f"Disk tensor file {path} does not contain the 'tensor' entry.") + + actual_nbytes = tensor.numel() * tensor.element_size() + if ( + tuple(tensor.shape) != tuple(self.shape) + or tensor.dtype != self.torch_dtype + or actual_nbytes != self.nbytes + ): + raise OSError( + f"Disk tensor metadata mismatch for {path}: file has " + f"shape={tuple(tensor.shape)}, dtype={tensor.dtype}, nbytes={actual_nbytes}; " + f"reference expects shape={self.shape}, dtype={self.dtype}, nbytes={self.nbytes}." + ) + + if pin_memory and not tensor.is_pinned(): + tensor = tensor.pin_memory() + return tensor diff --git a/vime/utils/types.py b/vime/utils/types.py index 54ee716e3..d19bd7a2e 100644 --- a/vime/utils/types.py +++ b/vime/utils/types.py @@ -5,6 +5,7 @@ import torch from vime.utils.misc import decode_int32_meta_array +from vime.utils.tensor_store import DiskTensorRef _TOP_P_TOKEN_ID_META_KEYS = ("top_p_token_ids", "top_p_kept_token_ids") _TOP_P_TOKEN_OFFSET_META_KEYS = ("top_p_token_offsets", "top_p_kept_token_offsets") @@ -123,7 +124,9 @@ class Sample: # token i, kept ids are rollout_top_p_token_ids[offsets[i]:offsets[i + 1]]. rollout_top_p_token_ids: list[int] | torch.Tensor | None = None rollout_top_p_token_offsets: list[int] | torch.Tensor | None = None - rollout_routed_experts: list[list[int]] | torch.Tensor | None = None # Routed experts from rollout engine + rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | DiskTensorRef | None = ( + None # Routed experts from rollout engine + ) remove_sample: bool = False teacher_log_probs: list[float] | None = None # Log probabilities from teacher model for OPD @@ -349,6 +352,7 @@ def _apply_meta_info( new_token_count, ) + # Community SGLang returns routed expert ids as base64-encoded int32. routed_experts = decode_int32_meta_array(meta_info, "routed_experts") if routed_experts is not None: if args is None: @@ -376,23 +380,25 @@ def _apply_meta_info( if routed_experts_start_len == 0: self.rollout_routed_experts = routed_experts else: - existing = self.rollout_routed_experts - if existing is None: + existing_len = self.get_rollout_routed_experts_length() + if existing_len == 0: raise ValueError( "Cannot append partial routed experts without existing routed experts " f"(routed_experts_start_len={routed_experts_start_len})." ) - if not torch.is_tensor(existing): - existing = torch.as_tensor(existing, dtype=routed_experts.dtype) - if existing.shape[0] < routed_experts_start_len: + if existing_len < routed_experts_start_len: raise ValueError( "Existing routed experts shorter than routed_experts_start_len: " - f"existing_rows={existing.shape[0]}, routed_experts_start_len={routed_experts_start_len}." + f"existing_rows={existing_len}, routed_experts_start_len={routed_experts_start_len}." + ) + if existing_len == routed_experts_start_len: + self._append_rollout_routed_experts_chunk(routed_experts) + else: + existing = self.materialize_rollout_routed_experts(replace=False) + self.rollout_routed_experts = torch.cat( + [existing[:routed_experts_start_len], routed_experts], + dim=0, ) - self.rollout_routed_experts = torch.cat( - [existing[:routed_experts_start_len], routed_experts], - dim=0, - ) if not update_terminal_info or "finish_reason" not in meta_info: return @@ -404,7 +410,7 @@ def _apply_meta_info( # Collect prefix cache statistics self.prefix_cache_info.add(meta_info=meta_info) - if "weight_version" in meta_info: + if new_token_count > 0 and "weight_version" in meta_info: self.weight_versions.append(meta_info["weight_version"]) match meta_info["finish_reason"]["type"]: @@ -415,6 +421,50 @@ def _apply_meta_info( case "stop": self.status = Sample.Status.COMPLETED + def _append_rollout_routed_experts_chunk(self, routed_experts: torch.Tensor) -> None: + existing = self.rollout_routed_experts + if existing is None: + self.rollout_routed_experts = routed_experts + elif isinstance(existing, DiskTensorRef): + self.rollout_routed_experts = [existing.load(), routed_experts] + elif isinstance(existing, list) and all(torch.is_tensor(item) for item in existing): + existing.append(routed_experts) + else: + self.rollout_routed_experts = [torch.as_tensor(existing, dtype=routed_experts.dtype), routed_experts] + + def get_rollout_routed_experts_length(self) -> int: + routed_experts = self.rollout_routed_experts + if routed_experts is None: + return 0 + if isinstance(routed_experts, DiskTensorRef): + return int(routed_experts.shape[0]) + if torch.is_tensor(routed_experts): + return int(routed_experts.shape[0]) + if isinstance(routed_experts, list): + if not routed_experts: + return 0 + if all(torch.is_tensor(item) for item in routed_experts): + return sum(int(item.shape[0]) for item in routed_experts) + return int(torch.as_tensor(routed_experts).shape[0]) + return int(torch.as_tensor(routed_experts).shape[0]) + + def materialize_rollout_routed_experts(self, *, replace: bool = True) -> torch.Tensor | None: + routed_experts = self.rollout_routed_experts + if routed_experts is None: + return None + if isinstance(routed_experts, DiskTensorRef): + tensor = routed_experts.load() + elif torch.is_tensor(routed_experts): + tensor = routed_experts.reshape(*routed_experts.shape) + elif isinstance(routed_experts, list) and all(torch.is_tensor(item) for item in routed_experts): + tensor = torch.cat(routed_experts, dim=0) if len(routed_experts) > 1 else routed_experts[0] + else: + tensor = torch.as_tensor(routed_experts, dtype=torch.int32) + tensor = tensor.detach().cpu().contiguous() + if replace: + self.rollout_routed_experts = tensor + return tensor + def _validate_response_metadata_lengths(self): if self.loss_mask is not None and len(self.loss_mask) != self.response_length: raise ValueError(f"loss_mask length {len(self.loss_mask)} != response_length {self.response_length}") diff --git a/vime_plugins/rollout_buffer/rollout_buffer_example.sh b/vime_plugins/rollout_buffer/rollout_buffer_example.sh index 7adff34cb..4ddc955de 100644 --- a/vime_plugins/rollout_buffer/rollout_buffer_example.sh +++ b/vime_plugins/rollout_buffer/rollout_buffer_example.sh @@ -113,7 +113,7 @@ ray job submit --address="http://127.0.0.1:8265" \ "NCCL_CUMEM_ENABLE": "0" } }' \ - -- python3 train_async.py \ + -- python3 train.py \ --actor-num-nodes 1 \ --actor-num-gpus-per-node 4 \ --rollout-num-gpus 4 \ @@ -127,7 +127,6 @@ ray job submit --address="http://127.0.0.1:8265" \ ${WANDB_ARGS[@]} \ ${PERF_ARGS[@]} \ --rollout-buffer-url http://${MASTER_ADDR}:8889 \ - --keep-old-actor \ --disable-rewards-normalization \ --loss-mask-type distill_qwen \ --log-passrate From b7a0762b72b6a3fe5cef1e2d763616e993d9f3ff Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sat, 19 Sep 2026 01:28:18 +0000 Subject: [PATCH 02/58] fix: complete vLLM sync translations Signed-off-by: aoshen02 --- tests/test_vllm_server_control.py | 33 ++++++++++++++++++++++ vime/backends/vllm_utils/server_control.py | 20 ++++++++++++- vime/ray/rollout.py | 21 ++++++++++++++ vime/rollout/vllm_rollout.py | 14 +++++++-- vime/rollout/vllm_streaming_rollout.py | 2 ++ 5 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/test_vllm_server_control.py diff --git a/tests/test_vllm_server_control.py b/tests/test_vllm_server_control.py new file mode 100644 index 000000000..6837a8488 --- /dev/null +++ b/tests/test_vllm_server_control.py @@ -0,0 +1,33 @@ +import asyncio + +import pytest + +from vime.backends.vllm_utils import server_control + +NUM_GPUS = 0 + + +@pytest.mark.unit +def test_get_inflight_diagnostics(monkeypatch): + async def fake_get(url, *, timeout=None): + assert timeout == server_control.DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS + return {"server_load": 1, "inflight": [{"data_parallel_rank": 0}]} + + monkeypatch.setattr(server_control, "get", fake_get) + result = asyncio.run(server_control.get_inflight_diagnostics(["http://worker:8000/"])) + + assert result == {"http://worker:8000/": {"server_load": 1, "inflight": [{"data_parallel_rank": 0}]}} + + +@pytest.mark.unit +def test_get_inflight_diagnostics_keeps_partial_failures(monkeypatch): + async def fake_get(url, *, timeout=None): + if "bad" in url: + raise OSError("unreachable") + return {"server_load": 0} + + monkeypatch.setattr(server_control, "get", fake_get) + result = asyncio.run(server_control.get_inflight_diagnostics(["http://good", "http://bad"])) + + assert result["http://good"] == {"server_load": 0} + assert "unreachable" in result["http://bad"]["error"] diff --git a/vime/backends/vllm_utils/server_control.py b/vime/backends/vllm_utils/server_control.py index 99b45e8cd..2693ade26 100644 --- a/vime/backends/vllm_utils/server_control.py +++ b/vime/backends/vllm_utils/server_control.py @@ -3,10 +3,13 @@ import asyncio import logging -from vime.utils.http_utils import post +from vime.utils.http_utils import get, post logger = logging.getLogger(__name__) +DEFAULT_ABORT_TIMEOUT_SECONDS = 180.0 +DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 10.0 + async def abort_inflight_requests(urls: list[str]) -> None: """Abort all in-flight requests on each worker (one best-effort sweep). @@ -22,3 +25,18 @@ async def _abort_one(url: str) -> None: logger.warning(f"Failed to abort requests on {url}: {e}") await asyncio.gather(*(_abort_one(url) for url in urls)) + + +async def get_inflight_diagnostics(urls: list[str]) -> dict[str, object]: + """Return bounded vLLM queue snapshots for abort timeout errors.""" + + async def _get_one(url: str) -> object: + try: + return await get( + f"{url.rstrip('/')}/load?include_inflight=true&inflight_limit=100", + timeout=DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS, + ) + except Exception as error: + return {"error": repr(error)} + + return dict(zip(urls, await asyncio.gather(*(_get_one(url) for url in urls)), strict=True)) diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index e0621fd6d..18d757ca5 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -3,6 +3,7 @@ import time from typing import Any +import psutil import ray import torch @@ -23,6 +24,7 @@ from vime.utils.dp_schedule import build_dp_schedule from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client +from vime.utils.memory_utils import get_process_host_memory_gib from vime.utils.misc import Box, load_function from vime.utils.staleness import fully_async_metrics_enabled from vime.utils.tensor_store import DiskTensorRef @@ -201,6 +203,25 @@ def generate(self, rollout_id): if self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: self._try_ci_fault_injection() data, metrics = self._get_rollout_data(rollout_id=rollout_id) + disk_routes = [ + sample.rollout_routed_experts + for sample in data + if isinstance(sample.rollout_routed_experts, DiskTensorRef) + ] + if disk_routes: + self._active_routed_experts_rollouts.add(rollout_id) + rss_gib, hwm_gib = get_process_host_memory_gib() + logger.info( + "R3 manager spill profile: rollout_id=%d samples=%d files=%d disk_bytes=%.3f GiB " + "rss=%.3f GiB hwm=%.3f GiB host_available=%.3f GiB", + rollout_id, + len(data), + len(disk_routes), + sum(ref.nbytes for ref in disk_routes) / 1024**3, + rss_gib, + hwm_gib, + psutil.virtual_memory().available / 1024**3, + ) save_debug_rollout_data( self.args.save_debug_rollout_data, data, diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 779d0daae..24b14239f 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -15,7 +15,11 @@ import vllm_router # noqa: F401 — ensures vllm-router is importable on startup from tqdm import tqdm -from vime.backends.vllm_utils.server_control import abort_inflight_requests +from vime.backends.vllm_utils.server_control import ( + DEFAULT_ABORT_TIMEOUT_SECONDS, + abort_inflight_requests, + get_inflight_diagnostics, +) from vime.observability.trace_utils import build_vllm_meta_trace_attrs, trace_function, trace_span from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput from vime.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter, should_drop_dynamic_filter_output @@ -644,13 +648,19 @@ async def abort(args: Namespace, rollout_id: int) -> list[list[Sample]]: # Delete-type abort: drop in-flight requests without pausing the scheduler. await abort_inflight_requests(urls) - last_sweep = loop.time() + abort_started = last_sweep = loop.time() await asyncio.gather(*cancellable_tasks, return_exceptions=True) # make sure all the pending tasks are finished count = 0 while state.pendings: + if server_abort and loop.time() - abort_started >= DEFAULT_ABORT_TIMEOUT_SECONDS: + diagnostics = await get_inflight_diagnostics(urls) + raise TimeoutError( + f"Timed out draining vLLM requests after {DEFAULT_ABORT_TIMEOUT_SECONDS:.0f}s; " + f"in-flight queues={diagnostics!r}" + ) done, state.pendings = await asyncio.wait( state.pendings, timeout=_ABORT_RESWEEP_INTERVAL_S, diff --git a/vime/rollout/vllm_streaming_rollout.py b/vime/rollout/vllm_streaming_rollout.py index 6eac7d7ed..fb9471b06 100644 --- a/vime/rollout/vllm_streaming_rollout.py +++ b/vime/rollout/vllm_streaming_rollout.py @@ -273,6 +273,8 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d raw = base64.b64decode(last_choice["routed_experts"].encode("ascii"), validate=True) meta["routed_experts"] = np.load(io.BytesIO(raw), allow_pickle=False) # tokens already accumulated above; finalize metadata only (no token re-append). + if weight_version is not None and call_tokens: + sample.weight_versions.append(weight_version) sample.append_response_tokens(args, meta_info=meta) elif state.aborted: if weight_version is not None: From 3f5f82dc73bb25a62a0907692eec73a5dccdb290 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 21 Sep 2026 00:46:26 +0000 Subject: [PATCH 03/58] sync: complete Slime update through #2394 Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 4 + tests/test_vllm_rollout.py | 30 ++-- tests/test_vllm_server_control.py | 103 +++++++++++-- vime/backends/megatron_utils/actor.py | 16 +- vime/backends/vllm_utils/server_control.py | 165 ++++++++++++++++++--- vime/ray/rollout.py | 8 + vime/rollout/vllm_rollout.py | 36 +---- 7 files changed, 265 insertions(+), 97 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 9c1fb955e..458edd78d 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -147,6 +147,7 @@ steps: tests/test_advantage_whiten_cp.py \ tests/test_accelerator.py \ tests/test_block_fp8_zero_block.py \ + tests/test_cuda_stack_offload.py \ tests/test_deep_ep_tms_patch.py \ tests/test_discounted_returns.py \ tests/test_eval_config.py \ @@ -165,6 +166,8 @@ steps: tests/test_qwen3_linear_attention_cu_seqlens.py \ tests/test_read_file_slicing.py \ tests/test_reloadable_process_group_world.py \ + tests/test_r3_spill.py \ + tests/test_rollout_buffer_order.py \ tests/test_rollout_data_utils.py \ tests/test_rollout_metrics.py \ tests/test_rollout_sample_hooks.py \ @@ -174,6 +177,7 @@ steps: tests/test_tau_bench_token_delta.py \ tests/test_train_data_utils.py \ tests/test_update_weight_factory.py \ + tests/test_vllm_server_control.py \ tests/observability/test_trace_utils.py; do python -m pytest "$$test_file" done diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index 07fc8ec42..bda5be182 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -1000,27 +1000,22 @@ async def fake_generate_and_rm(args, sample, sampling_params, evaluation=False): @pytest.mark.unit def test_abort_deletes_inflight_without_pause_resume(patch_generate_state, monkeypatch): - from vime.backends.vllm_utils import server_control - state = _PatchedGenerateState(_rollout_args()) state.active_server_generations = 1 monkeypatch.setattr(mod, "GenerateState", lambda args: state) aborted = asyncio.Event() - posted_paths: list[str] = [] + aborted_urls: list[str] = [] async def fake_get(url): return {"workers": [{"url": "http://w0:9000"}]} - async def fake_post(url, payload, max_retries=60, headers=None): - posted_paths.append(url) - if url.endswith("/abort_requests"): - aborted.set() - return {} + async def abort_servers(urls): + aborted_urls.extend(urls) + aborted.set() monkeypatch.setattr(mod, "get", fake_get) - # abort() drives the delete-type sweep through the server_control helper. - monkeypatch.setattr(server_control, "post", fake_post) + monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(index=0, prompt="p") @@ -1036,8 +1031,7 @@ async def run_abort(): aborted_samples = asyncio.run(run_abort()) - # Only /abort_requests is posted -- never /pause or /resume. - assert posted_paths and all(u.endswith("/abort_requests") for u in posted_paths) + assert aborted_urls == ["http://w0:9000"] assert state.pendings == set() # partial_rollout is off by default, so drained groups are discarded, not returned. assert aborted_samples == [] @@ -1045,8 +1039,6 @@ async def run_abort(): @pytest.mark.unit def test_abort_collects_partial_samples_when_partial_rollout(patch_generate_state, monkeypatch): - from vime.backends.vllm_utils import server_control - args = _rollout_args(partial_rollout=True) state = _PatchedGenerateState(args) state.active_server_generations = 1 @@ -1057,13 +1049,11 @@ def test_abort_collects_partial_samples_when_partial_rollout(patch_generate_stat async def fake_get(url): return {"workers": [{"url": "http://w0:9000"}]} - async def fake_post(url, payload, max_retries=60, headers=None): - if url.endswith("/abort_requests"): - aborted.set() - return {} + async def abort_servers(urls): + aborted.set() monkeypatch.setattr(mod, "get", fake_get) - monkeypatch.setattr(server_control, "post", fake_post) + monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(index=0, prompt="p") sample.response = "partial" @@ -1166,7 +1156,7 @@ async def abort_servers(urls): monkeypatch.setattr(mod, "generate", server_generate) get_mock = AsyncMock(return_value={"workers": [{"url": "http://worker:9000"}]}) monkeypatch.setattr(mod, "get", get_mock) - monkeypatch.setattr(mod, "abort_inflight_requests", abort_servers) + monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(prompt="abc", generate_function_path="streaming") async def exercise(): diff --git a/tests/test_vllm_server_control.py b/tests/test_vllm_server_control.py index 6837a8488..5d14d2623 100644 --- a/tests/test_vllm_server_control.py +++ b/tests/test_vllm_server_control.py @@ -1,33 +1,104 @@ import asyncio +import json import pytest from vime.backends.vllm_utils import server_control +from vime.utils import http_utils NUM_GPUS = 0 @pytest.mark.unit -def test_get_inflight_diagnostics(monkeypatch): - async def fake_get(url, *, timeout=None): - assert timeout == server_control.DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS - return {"server_load": 1, "inflight": [{"data_parallel_rank": 0}]} +def test_num_requests_includes_all_dp_queues(): + load = { + "server_load": 3, + "inflight": [ + { + "data_parallel_rank": 0, + "queues": [{"name": "running", "num_requests": 2, "requests": ["a", "b"]}], + }, + { + "data_parallel_rank": 1, + "queues": [{"name": "waiting", "num_requests": 3, "requests": ["c", "d", "e"]}], + }, + ], + } - monkeypatch.setattr(server_control, "get", fake_get) - result = asyncio.run(server_control.get_inflight_diagnostics(["http://worker:8000/"])) + assert server_control.num_requests_from_load(load) == 5 + assert server_control.non_idle_request_details(load) == [ + { + "data_parallel_rank": 0, + "queue": "running", + "num_requests": 2, + "requests": ["a", "b"], + }, + { + "data_parallel_rank": 1, + "queue": "waiting", + "num_requests": 3, + "requests": ["c", "d", "e"], + }, + ] - assert result == {"http://worker:8000/": {"server_load": 1, "inflight": [{"data_parallel_rank": 0}]}} + +@pytest.mark.unit +def test_abort_retries_load_failure_until_idle(monkeypatch): + calls = 0 + + async def abort_once(url, request_timeout): + return None + + async def get_load(url, request_timeout): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("temporary control-plane failure") + return {"server_load": 0, "inflight": []} + + monkeypatch.setattr(server_control, "_abort_server_once", abort_once) + monkeypatch.setattr(server_control, "_get_server_load", get_load) + + asyncio.run(server_control.abort_server_until_idle("http://engine", retry_interval=0, timeout=1)) + assert calls == 2 + + +@pytest.mark.unit +def test_abort_servers_surfaces_partial_failure(monkeypatch): + async def abort(url, **kwargs): + if url.endswith("bad"): + raise TimeoutError("not idle") + + monkeypatch.setattr(server_control, "abort_server_until_idle", abort) + with pytest.raises(RuntimeError, match="bad.*not idle"): + asyncio.run(server_control.abort_servers_until_idle(["http://good", "http://bad"])) @pytest.mark.unit -def test_get_inflight_diagnostics_keeps_partial_failures(monkeypatch): - async def fake_get(url, *, timeout=None): - if "bad" in url: - raise OSError("unreachable") - return {"server_load": 0} +def test_http_post_forwards_timeout_and_closes_response(): + class Response: + text = "" + closed = False + + def raise_for_status(self): + return None + + async def aread(self): + return json.dumps({"ok": True}).encode() + + async def aclose(self): + self.closed = True + + response = Response() + + class Client: + async def post(self, url, **kwargs): + assert kwargs["timeout"] == 2.5 + return response + + assert asyncio.run(http_utils._post(Client(), "http://engine", {}, max_retries=1, timeout=2.5)) == {"ok": True} + assert response.closed - monkeypatch.setattr(server_control, "get", fake_get) - result = asyncio.run(server_control.get_inflight_diagnostics(["http://good", "http://bad"])) - assert result["http://good"] == {"server_load": 0} - assert "unreachable" in result["http://bad"]["error"] +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index e341b6d73..527fb117c 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -68,14 +68,16 @@ def init( self.args = args return 0 - monkey_patch_torch_dist() + if args.offload_train: + monkey_patch_torch_dist() super().init(args, role, with_ref, with_opd_teacher) - # Destroying and recreating WORLD invalidates raw dist.group.WORLD references cached by external code. - # Set VIME_DESTROY_WORLD_PROCESS_GROUP=0 when such references may outlive a train sleep/wake cycle. - if os.getenv("VIME_DESTROY_WORLD_PROCESS_GROUP", "1").lower() not in {"0", "false", "no"}: - register_default_process_group(timeout=timedelta(minutes=args.distributed_timeout_minutes)) - else: - logger.info("Default WORLD process-group destruction is disabled") + if args.offload_train: + # Destroying and recreating WORLD invalidates raw dist.group.WORLD references cached by external code. + # Set VIME_DESTROY_WORLD_PROCESS_GROUP=0 when such references may outlive a train sleep/wake cycle. + if os.getenv("VIME_DESTROY_WORLD_PROCESS_GROUP", "1").lower() not in {"0", "false", "no"}: + register_default_process_group(timeout=timedelta(minutes=args.distributed_timeout_minutes)) + else: + logger.info("Default WORLD process-group destruction is disabled") init(args) diff --git a/vime/backends/vllm_utils/server_control.py b/vime/backends/vllm_utils/server_control.py index 2693ade26..bde788b34 100644 --- a/vime/backends/vllm_utils/server_control.py +++ b/vime/backends/vllm_utils/server_control.py @@ -1,42 +1,163 @@ -"""Control-plane helper for aborting in-flight requests on vLLM workers.""" - import asyncio import logging +from typing import Any from vime.utils.http_utils import get, post logger = logging.getLogger(__name__) +ABORT_RETRY_INTERVAL_SECONDS = 3 DEFAULT_ABORT_TIMEOUT_SECONDS = 180.0 DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 10.0 -async def abort_inflight_requests(urls: list[str]) -> None: - """Abort all in-flight requests on each worker (one best-effort sweep). +def num_requests_from_load(load: Any) -> int: + if isinstance(load, list): + return sum(num_requests_from_load(item) for item in load) + if not isinstance(load, dict): + return 0 + if "loads" in load: + return num_requests_from_load(load["loads"]) - Posts to ``/abort_requests`` with an empty body; failures are logged, not - raised. Idempotent, so the caller may re-issue it to converge. - """ + core_requests = load.get("server_load", 0) + if not isinstance(core_requests, int) or isinstance(core_requests, bool): + core_requests = 0 + detailed_counts = [core_requests] + for key in ("inflight", "queues"): + if key in load: + detailed_counts.append(num_requests_from_load(load[key])) + for key in ("num_requests", "num_reqs"): + value = load.get(key) + if isinstance(value, int) and not isinstance(value, bool): + detailed_counts.append(value) + return max(detailed_counts, default=0) - async def _abort_one(url: str) -> None: - try: - await post(f"{url.rstrip('/')}/abort_requests", {}, max_retries=3) - except Exception as e: - logger.warning(f"Failed to abort requests on {url}: {e}") - await asyncio.gather(*(_abort_one(url) for url in urls)) +def non_idle_request_details(load: Any) -> list[dict[str, Any]]: + if not isinstance(load, dict): + return [] + details: list[dict[str, Any]] = [] + inflight = load.get("inflight", []) + if not isinstance(inflight, list): + return details + for rank_load in inflight: + if not isinstance(rank_load, dict): + continue + dp_rank = rank_load.get("data_parallel_rank") + queues = rank_load.get("queues", []) + if not isinstance(queues, list): + continue + for queue in queues: + if not isinstance(queue, dict): + continue + requests = queue.get("requests") if isinstance(queue.get("requests"), list) else [] + count = queue.get("num_requests", len(requests)) + if isinstance(count, int) and count > 0: + details.append( + { + "data_parallel_rank": dp_rank, + "queue": queue.get("name", "unknown"), + "num_requests": count, + "requests": requests, + } + ) + return details -async def get_inflight_diagnostics(urls: list[str]) -> dict[str, object]: - """Return bounded vLLM queue snapshots for abort timeout errors.""" - async def _get_one(url: str) -> object: - try: - return await get( - f"{url.rstrip('/')}/load?include_inflight=true&inflight_limit=100", - timeout=DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS, +async def _abort_server_once(url: str, request_timeout: float) -> None: + await asyncio.wait_for( + post(f"{url.rstrip('/')}/abort_requests", {}, max_retries=1, timeout=request_timeout), + timeout=request_timeout, + ) + + +async def _get_server_load(url: str, request_timeout: float) -> Any: + return await asyncio.wait_for( + get( + f"{url.rstrip('/')}/load?include_inflight=true&inflight_limit=100", + timeout=request_timeout, + ), + timeout=request_timeout, + ) + + +async def abort_server_until_idle( + url: str, + retry_interval: float = ABORT_RETRY_INTERVAL_SECONDS, + *, + timeout: float = DEFAULT_ABORT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS, +) -> None: + if timeout <= 0 or request_timeout <= 0: + raise ValueError("abort and control-request timeouts must be positive") + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + attempt = 1 + last_error: Exception | None = None + last_num_requests: int | None = None + last_load: Any = None + while True: + remaining = deadline - loop.time() + if remaining <= 0: + detail = ( + f"last observed request count={last_num_requests}" + if last_num_requests is not None + else f"last error={last_error!r}" ) + if last_load is not None: + detail += f", non-idle queues={non_idle_request_details(last_load)!r}" + raise TimeoutError(f"Timed out draining vLLM server {url} after {timeout:.1f}s ({detail})") + + per_request_timeout = min(request_timeout, remaining) + try: + await _abort_server_once(url, per_request_timeout) + except Exception as error: + last_error = error + logger.warning(f"Failed to abort vLLM server at {url}: {error}") + + try: + remaining = deadline - loop.time() + if remaining <= 0: + continue + load = await _get_server_load(url, min(request_timeout, remaining)) + num_requests = num_requests_from_load(load) except Exception as error: - return {"error": repr(error)} + last_error = error + logger.warning(f"Failed to get vLLM server load from {url}: {error}") + else: + last_load = load + last_num_requests = num_requests + if num_requests <= 0: + return + logger.info( + "vLLM server %s still has %d requests after abort attempt %d; " + "non-idle queues=%r; retrying in %s seconds.", + url, + num_requests, + attempt, + non_idle_request_details(load), + retry_interval, + ) + + remaining = deadline - loop.time() + if remaining > 0: + await asyncio.sleep(min(retry_interval, remaining)) + attempt += 1 + - return dict(zip(urls, await asyncio.gather(*(_get_one(url) for url in urls)), strict=True)) +async def abort_servers_until_idle( + urls: list[str], + *, + timeout: float = DEFAULT_ABORT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS, +) -> None: + results = await asyncio.gather( + *(abort_server_until_idle(url, timeout=timeout, request_timeout=request_timeout) for url in urls), + return_exceptions=True, + ) + failures = [(url, result) for url, result in zip(urls, results, strict=True) if isinstance(result, BaseException)] + if failures: + detail = "; ".join(f"{url}: {error}" for url, error in failures) + raise RuntimeError(f"Failed to abort all vLLM servers to a confirmed idle state: {detail}") from failures[0][1] diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 18d757ca5..ecbe67a30 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -315,6 +315,8 @@ def _get_rollout_data(self, rollout_id): metrics = None else: if fully_async_metrics_enabled(self.args): + # The training loop keeps serving weights fixed while collecting + # this batch. Query only the updatable model. server = self._get_updatable_server() engines = [engine for engine in server.engines if engine is not None] if server else [] versions = ray.get([engine.get_weight_version.remote() for engine in engines]) @@ -504,6 +506,12 @@ def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sampl self.args, expected_rows=[rows for _, rows in captured_pairs], ) + if dead_sample_indices: + logger.warning( + "Using all-zero routed experts for %d fully loss-masked samples (indices %s).", + len(dead_sample_indices), + sorted(dead_sample_indices)[:16], + ) train_data["rollout_routed_experts"] = routed_experts if samples[0].train_metadata is not None: diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 24b14239f..c2397e850 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -15,11 +15,7 @@ import vllm_router # noqa: F401 — ensures vllm-router is importable on startup from tqdm import tqdm -from vime.backends.vllm_utils.server_control import ( - DEFAULT_ABORT_TIMEOUT_SECONDS, - abort_inflight_requests, - get_inflight_diagnostics, -) +from vime.backends.vllm_utils.server_control import abort_servers_until_idle from vime.observability.trace_utils import build_vllm_meta_trace_attrs, trace_function, trace_span from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput from vime.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter, should_drop_dynamic_filter_output @@ -45,9 +41,6 @@ _PROCESSOR_PROMPT_KEYS = {"input_ids", "attention_mask"} -# Re-sweep interval while draining; bounds how long a late straggler can run. -_ABORT_RESWEEP_INTERVAL_S = 3.0 - def _coerce_flat_int_token_ids(ids: Any) -> list[int]: """Flatten tokenizer/processor output into ``list[int]`` for vLLM ``/inference/v1/generate``.""" @@ -639,39 +632,18 @@ async def abort(args: Namespace, rollout_id: int) -> list[list[Sample]]: for task in cancellable_tasks: task.cancel() - loop = asyncio.get_running_loop() - server_abort = state.active_server_generations > 0 - if server_abort: + if state.active_server_generations > 0: base = f"http://{args.vllm_router_ip}:{args.vllm_router_port}" response = await get(f"{base}/workers") urls = [worker["url"] for worker in response["workers"]] - - # Delete-type abort: drop in-flight requests without pausing the scheduler. - await abort_inflight_requests(urls) - abort_started = last_sweep = loop.time() + await abort_servers_until_idle(urls) await asyncio.gather(*cancellable_tasks, return_exceptions=True) # make sure all the pending tasks are finished count = 0 while state.pendings: - if server_abort and loop.time() - abort_started >= DEFAULT_ABORT_TIMEOUT_SECONDS: - diagnostics = await get_inflight_diagnostics(urls) - raise TimeoutError( - f"Timed out draining vLLM requests after {DEFAULT_ABORT_TIMEOUT_SECONDS:.0f}s; " - f"in-flight queues={diagnostics!r}" - ) - done, state.pendings = await asyncio.wait( - state.pendings, - timeout=_ABORT_RESWEEP_INTERVAL_S, - return_when=asyncio.FIRST_COMPLETED, - ) - - # Re-sweep on a fixed interval to truncate late stragglers (e.g. a - # multi-turn turn-2 fired after the initial abort), regardless of drain. - if server_abort and loop.time() - last_sweep >= _ABORT_RESWEEP_INTERVAL_S: - await abort_inflight_requests(urls) - last_sweep = loop.time() + done, state.pendings = await asyncio.wait(state.pendings, return_when=asyncio.FIRST_COMPLETED) if not args.partial_rollout: continue From cd3992f4cc40c2607f5f5ad03667e18e31acb026 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 24 Sep 2026 01:42:07 +0000 Subject: [PATCH 04/58] Sync Slime through score centering Signed-off-by: aoshen02 --- .buildkite/README.md | 10 +- .buildkite/gpu_suites.py | 3 +- .buildkite/pipeline.yml | 4 +- docker/Dockerfile | 3 + .../patch/latest/vllm-score-centering.patch | 13 + docker/version.txt | 2 +- docs/en/developer_guide/ci.md | 5 +- docs/en/get_started/quick_start.md | 1 + docs/en/get_started/usage.md | 37 + docs/zh/developer_guide/ci.md | 4 +- docs/zh/get_started/quick_start.md | 1 + docs/zh/get_started/usage.md | 37 + .../test_plugin_path_loading_contracts.py | 2 +- tests/test_fully_async_rollout.py | 5 +- tests/test_megatron_argument_validation.py | 12 +- tests/test_qwen2.5_0.5B_score_centering.py | 79 ++ tests/test_rollout_buffer_order.py | 38 + tests/test_score_centering.py | 928 ++++++++++++++++++ tests/test_score_centering_transport.py | 344 +++++++ tests/utils/test_megatron_role_config.py | 7 +- train.py | 3 +- vime/backends/megatron_utils/actor.py | 15 + vime/backends/megatron_utils/loss.py | 190 +++- vime/backends/megatron_utils/model.py | 3 + vime/backends/vllm_utils/vllm_engine.py | 2 + vime/observability/rollout_data_utils.py | 9 +- vime/observability/train_data_utils.py | 69 +- vime/observability/train_metric_utils.py | 3 + vime/ray/placement_group.py | 3 +- vime/ray/rollout.py | 44 +- vime/rollout/data_source.py | 10 +- vime/rollout/fully_async_rollout.py | 1 - vime/rollout/sft_rollout.py | 1 - vime/rollout/vllm_rollout.py | 78 +- vime/rollout/vllm_streaming_rollout.py | 3 + vime/utils/arguments.py | 54 +- vime/utils/ppo_utils.py | 172 ++++ vime/utils/routed_experts.py | 6 + vime/utils/score_centering.py | 251 +++++ vime/utils/tensor_store.py | 48 + vime/utils/types.py | 71 +- 41 files changed, 2490 insertions(+), 81 deletions(-) create mode 100644 docker/patch/latest/vllm-score-centering.patch create mode 100644 tests/test_qwen2.5_0.5B_score_centering.py create mode 100644 tests/test_score_centering.py create mode 100644 tests/test_score_centering_transport.py create mode 100644 vime/utils/score_centering.py diff --git a/.buildkite/README.md b/.buildkite/README.md index 5449bcbdd..11876bf03 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -17,9 +17,9 @@ The four test steps depend on the pre-commit gate. Each suite runs its files sequentially inside one step because these queues boot a fresh EC2 instance per job — a per-file matrix would be mostly boot + pip-install time. Most always-on CPU steps use the standard `python:3.11` image and install their -lightweight dependencies at runtime. `upstream-sync-cpu` uses -`vllm/vime:latest` because the synchronized GLM and checkpoint tests import the -image-pinned Megatron stack even though they do not allocate a GPU. +lightweight dependencies at runtime. `upstream-sync-cpu` uses `VIME_CI_IMAGE` +(defaulting to `vllm/vime:latest`) because the synchronized GLM and checkpoint +tests import the image-pinned Megatron stack even though they do not allocate a GPU. ## Creating the pipeline (one-time, Buildkite UI) @@ -70,8 +70,8 @@ startup, so a warm HF cache is all they need. `WANDB_API_KEY` is not wired up yet; runs report without wandb until it's added (e.g. as a k8s secret in the pod spec). -GPU jobs use `vllm/vime:latest`. Rebuild and publish that image before validating -Dockerfile or vLLM patch changes. +Set `VIME_CI_IMAGE` to an immutable candidate digest for image-backed jobs; +otherwise they use `vllm/vime:latest`. Do not update `latest` before merge. ## Keeping it in sync diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index 6a564ff27..5b4ccf118 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -23,7 +23,7 @@ import subprocess GPU_QUEUE = "mithril-h100-pool" -CI_IMAGE = "vllm/vime:latest" +CI_IMAGE = os.environ.get("VIME_CI_IMAGE", "vllm/vime:latest") HF_CACHE_HOST_PATH = "/mnt/hf-cache" HF_HOME = "/root/.cache/huggingface" NODE_INSTANCE_TYPE = "gpu-h100-sxm" @@ -66,6 +66,7 @@ ("test_qwen2.5_0.5B_debug_rollout_then_train.py", 8, "", {}), ("test_qwen2.5_0.5B_opd_vllm.py", 8, "", {}), ("test_qwen2.5_0.5B_fanout_short.py", 4, "", {}), + ("test_qwen2.5_0.5B_score_centering.py", 2, "", {}), ("test_qwen2.5_0.5B_debug_train_dump_e2e.py", 8, "", {}), ("test_qwen3_4B_external_pd.py", 6, "", {"VIME_TEST_UPDATE_MODE": "delta"}), ], diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 458edd78d..72f3acae9 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -140,7 +140,7 @@ steps: -e GIT_CONFIG_PARAMETERS="'safe.directory=/workspace'" \ -e GLOO_SOCKET_IFNAME=lo -e TP_SOCKET_IFNAME=lo \ -v "$$PWD:/workspace" -w /workspace \ - vllm/vime:latest bash -lc ' + "$${VIME_CI_IMAGE:-vllm/vime:latest}" bash -lc ' set -euo pipefail pip install -q -e . --no-deps --break-system-packages for test_file in \ @@ -161,6 +161,8 @@ steps: tests/test_model_provider_freeze.py \ tests/test_policy_loss.py \ tests/test_ppo_kl_metric.py \ + tests/test_score_centering.py \ + tests/test_score_centering_transport.py \ tests/test_process_rollout_data.py \ tests/test_qwen3_5_vl_native.py \ tests/test_qwen3_linear_attention_cu_seqlens.py \ diff --git a/docker/Dockerfile b/docker/Dockerfile index 8617855f4..72239c8df 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -153,15 +153,18 @@ RUN cd Megatron-LM && \ # so apply the independently maintained patches in their validated order. COPY docker/patch/${PATCH_VERSION}/vllm-pull_weights.patch /tmp/vllm-pull_weights.patch COPY docker/patch/${PATCH_VERSION}/vllm.patch /tmp/vllm.patch +COPY docker/patch/${PATCH_VERSION}/vllm-score-centering.patch /tmp/vllm-score-centering.patch COPY docker/patch/${PATCH_VERSION}/vllm-pd-request-metrics.patch /tmp/vllm-pd-request-metrics.patch COPY docker/patch/${PATCH_VERSION}/vllm-inflight-queue-diagnostics.patch /tmp/vllm-inflight-queue-diagnostics.patch RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirname(vllm.__file__)))')" && \ cd "$VLLM_SITE" && \ git apply -v /tmp/vllm-pull_weights.patch && \ git apply -v --allow-empty /tmp/vllm.patch && \ + git apply -v /tmp/vllm-score-centering.patch && \ git apply -v /tmp/vllm-pd-request-metrics.patch && \ git apply -v /tmp/vllm-inflight-queue-diagnostics.patch && \ rm /tmp/vllm-pull_weights.patch /tmp/vllm.patch \ + /tmp/vllm-score-centering.patch \ /tmp/vllm-pd-request-metrics.patch \ /tmp/vllm-inflight-queue-diagnostics.patch diff --git a/docker/patch/latest/vllm-score-centering.patch b/docker/patch/latest/vllm-score-centering.patch new file mode 100644 index 000000000..e24e36587 --- /dev/null +++ b/docker/patch/latest/vllm-score-centering.patch @@ -0,0 +1,13 @@ +diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py +--- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py ++++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py +@@ -589,5 +589,8 @@ class ServingTokens(GenerateBaseServing): + ) + if num_output_top_logprobs is not None +- and i < max(num_output_top_logprobs, 1) ++ and ( ++ num_output_top_logprobs == -1 ++ or i < max(num_output_top_logprobs, 1) ++ ) + ], + ) diff --git a/docker/version.txt b/docker/version.txt index 2b2bb2c29..5ea860b9f 100644 --- a/docker/version.txt +++ b/docker/version.txt @@ -1 +1 @@ -nightly-dev-20260828a +nightly-dev-20260923a diff --git a/docs/en/developer_guide/ci.md b/docs/en/developer_guide/ci.md index 738403f5e..0d40b3083 100644 --- a/docs/en/developer_guide/ci.md +++ b/docs/en/developer_guide/ci.md @@ -31,8 +31,9 @@ After the CPU steps pass, the Buildkite build exposes a block step named - `ckpt` `.buildkite/gpu_suites.py` expands each selected suite into one Buildkite job -per test. GPU tests use `vllm/vime:latest`; rebuild and publish that image -before validating a Dockerfile or vLLM patch change. +per test. Set `VIME_CI_IMAGE` to an immutable candidate digest when validating +Dockerfile or vLLM patch changes. Jobs otherwise use `vllm/vime:latest`, which +must not be updated before the change merges. ## Registering tests diff --git a/docs/en/get_started/quick_start.md b/docs/en/get_started/quick_start.md index 134ebcaae..8d6cf6b52 100644 --- a/docs/en/get_started/quick_start.md +++ b/docs/en/get_started/quick_start.md @@ -279,6 +279,7 @@ GRPO_ARGS=( - `--advantage-estimator`: In addition to [GRPO](https://arxiv.org/abs/2402.03300), vime also supports several other training algorithms, such as [GSPO](https://arxiv.org/abs/2507.18071), [Reinforce++](https://arxiv.org/abs/2501.03262) and [Reinforce++ Baseline](https://arxiv.org/abs/2501.03262), and [PPO](https://arxiv.org/abs/1707.06347). - `--calculate-per-token-loss`: By default, vime calculates the loss on a per-sample basis, i.e., `mean(sum(sample_i) / len(sample_i))`. To calculate the loss on a per-token basis, i.e., `sum(sum(sample_i)) / sum(len(sample_i))`, you can enable this flag. - `--use-tis`: Enable this setting to use TIS (Truncated Importance Sampling), which is introduced by this [blog](https://fengyao.notion.site/off-policy-rl). +- `--use-score-centering`: Enable [Score Centering](https://arxiv.org/abs/2609.20807) with the REINFORCE objective, optionally combined with TIS. See [configuration and sampling requirements](usage.md#score-centering). ### OPTIMIZER_ARGS: Optimizer Parameters diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index a42b18fa9..7cbbdf4b8 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -151,6 +151,8 @@ For details on some of vLLM's customizations and the principles behind how vime ### Data Format +Raw data is always managed by the DataSource. The built-in DataSource loads `--prompt-data` when provided; use `--data-source-path` for custom data management. The former `--disable-rollout-global-dataset` flag has been removed; remove it from older launch scripts. + vime supports `.jsonl` and `.parquet` files; reading Parquet requires `pyarrow`. Each record in either format should contain the fields selected by `--input-key` and `--label-key`. An expanded JSONL record looks like this: ```json @@ -203,6 +205,7 @@ The recommended contract is to put the source identifier in `metadata["source_na Note: On-policy distillation (OPD) is now orthogonal to the advantage estimator. Use `--use-opd` and `--opd-kl-coef` to enable OPD on top of any estimator. - `--calculate-per-token-loss`: By default, vime calculates loss on a per-sample basis, i.e., `mean(sum(sample_i) / len(sample_i))`. Enable this flag to calculate loss on a per-token basis, i.e., `sum(sum(sample_i)) / sum(len(sample_i))`. - `--use-tis`: Enable this setting to use TIS (Truncated Importance Sampling) (https://fengyao.notion.site/off-policy-rl). +- `--use-score-centering`: Enable [Score Centering](https://arxiv.org/abs/2609.20807), optionally combined with TIS. See [Score Centering](#score-centering) below. #### GRPO Algorithm @@ -252,6 +255,40 @@ PPO-related parameters: - `--value-clip`: Clip range for value loss. - `--kl-coef`: KL penalty coefficient for reward shaping. +#### Score Centering + +[Score Centering Stabilizes Off-policy Reinforcement Learning](https://arxiv.org/abs/2609.20807) introduces an additive correction to reduce drift caused by training-inference mismatch. It can also be combined with importance sampling. In vime, score centering (SC) is supported by the Megatron backend with non-streaming vLLM rollouts. + +Add the following options to an existing RL launch: + +```bash +--use-score-centering \ +--score-centering-top-k 128 \ +--pg-loss-type reinforce \ +--advantage-estimator grpo \ +--disable-grpo-std-normalization \ +--calculate-per-token-loss \ +--rollout-temperature 1.0 \ +--rollout-top-p 1.0 \ +--rollout-top-k -1 \ +--entropy-coef 0 \ +--kl-coef 0 +``` + +- `--use-score-centering`: Enable the REINFORCE score-centering objective. If `--pg-loss-type` is omitted, SC selects `reinforce` automatically. Without SC, the existing PPO/CISPO default is preserved. SC cannot be combined with `--pg-loss-type ppo` or the GSPO/CISPO advantage estimators; PPO clipping parameters do not affect the REINFORCE objective. +- `--score-centering-top-k`: Applies only when `--rollout-top-p 1`. Number of sampler top-k token IDs and logprobs retained for each response token; defaults to 128 and must fit the model vocabulary. The head retains its full-vocabulary probability mass. The remaining sampler mass is modeled as proportional to the current trainer's tail mass. +- `--use-tis`: Optional and independent of SC. Combine the two to center the weighted scores using `--tis-clip-low` and `--tis-clip`. The built-in `vime.backends.megatron_utils.loss.icepop_function` is also supported through `--custom-tis-function-path`; arbitrary custom TIS callbacks are not supported with SC. REINFORCE uses detached current-trainer/sampler weights, while PPO preserves its old-trainer/sampler weights. + +**Sampling requirements:** Use a positive temperature, `0 < top_p <= 1`, `top_k=-1`, `min_p=0`, and no repetition/frequency/presence penalties or constrained decoding. Keep `VLLM_RETURN_ORIGINAL_LOGPROB` unset or false on all sampler workers when temperature differs from one or top_p is below one. Per-request temperature or top_p changes and streaming SC are unsupported. Evaluation does not request SC data and may use its own sampling settings. + +**Combining with top-p replay:** Change `--rollout-top-p 1.0` above to, for example, `--rollout-top-p 0.9`. SC automatically sums over the complete replay support and ignores `--score-centering-top-k`. Rollout returns all support IDs and their post-truncation, normalized sampler logprobs. The trainer normalizes on the same support and computes the correction `sum(stop_gradient(q * weight) * log p)`. This uses no tail approximation and excludes tokens outside the support. Full trainer logits cannot reconstruct the sampler probabilities, so the original probabilities must still be stored. Payload size varies with the support and can greatly exceed fixed top-k heads when top-p approaches one. Exactness is relative to the recorded replay support, including replay's existing rule for retaining sampled boundary tokens. + +**vLLM support:** For `top_p=1`, Vime requests `k+1` native vLLM top logprobs and retains the highest-probability `k`. For `top_p<1`, it requests all logprobs, intersects them with vLLM's sampling mask, and normalizes the recorded support. Custom generators should call `score_centering_request` from `vime.utils.score_centering` and pass equivalent metadata to `Sample.append_response_tokens`. + +Top-p probabilities stay on CPU with replay IDs/offsets and support partial rollouts, masked tool tokens, DP, TP, and both CP layouts. Exact top-p SC has a larger response payload because vLLM currently returns the full logprob vector before Vime selects the replay support. + +Sampler heads survive partial-rollout continuation, masked tool tokens, DP partitioning, microbatch selection, TP and both CP layouts. With the R3 spill hook, they share its file lifetime. Logged metrics include `sc_correction`, `sc_sampler_head_mass`, `sc_train_head_mass` and `sc_importance_weight`. + ### Advanced Megatron Configuration (--megatron-config-path) For PPO workflows, you can use `--megatron-config-path` with a YAML file to override Megatron arguments separately for actor and critic. Common use cases include setting a different critic `lr`, or giving actor and critic different `load` / `save` paths. diff --git a/docs/zh/developer_guide/ci.md b/docs/zh/developer_guide/ci.md index 0fa353287..5fbd123fb 100644 --- a/docs/zh/developer_guide/ci.md +++ b/docs/zh/developer_guide/ci.md @@ -30,8 +30,8 @@ block step。可以选择一个或多个套件: - `ckpt` `.buildkite/gpu_suites.py` 会把所选套件展开为每个测试一个 Buildkite -job。GPU 测试使用 `vllm/vime:latest`;验证 Dockerfile 或 vLLM patch 修改前, -需要先重建并发布该镜像。 +job。验证 Dockerfile 或 vLLM patch 修改时,通过 `VIME_CI_IMAGE` 指定不可变的 +候选镜像 digest;未设置时使用 `vllm/vime:latest`,且 PR 合入前不得更新该标签。 ## 注册测试 diff --git a/docs/zh/get_started/quick_start.md b/docs/zh/get_started/quick_start.md index e999ccb83..42342ee54 100644 --- a/docs/zh/get_started/quick_start.md +++ b/docs/zh/get_started/quick_start.md @@ -278,6 +278,7 @@ GRPO_ARGS=( - `--advantage-estimator`: 除去 [GRPO](https://arxiv.org/abs/2402.03300),vime 还支持丰富的其他训练算法,例如 [GSPO](https://arxiv.org/abs/2507.18071)、[Reinforce++](https://arxiv.org/abs/2501.03262) 与 [Reinforce++ Baseline](https://arxiv.org/abs/2501.03262)、以及 [PPO](https://arxiv.org/abs/1707.06347); - `--calculate-per-token-loss`:vime 中默认的方案是 per sample loss,即 `mean(sum(sample_i) / len(sample_i))`,如果需要计算 per token loss,即 `sum(sum(sample_i)) / sum(len(sample_i))`,可以开启 `--calculate-per-token-loss`; - `--use-tis`:如果需要开启 TIS (Truncated Importance Sampling),可以开启这一设置。TIS 由此[博客](https://fengyao.notion.site/off-policy-rl)介绍。 +- `--use-score-centering`:启用 [Score Centering](https://arxiv.org/abs/2609.20807),使用 REINFORCE 目标,也可与 TIS 组合使用,详见[配置与采样要求](usage.md#score-centering)。 ### OPTIMIZER_ARGS: 优化器参数 diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index fdd1d7058..6d0a22aac 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -154,6 +154,8 @@ vLLM 的加载非常简单,只需要: ### 数据格式 +原始数据统一由 DataSource 管理。有 `--prompt-data` 时内置 DataSource 会加载数据;需要自行管理数据时可通过 `--data-source-path` 提供自定义实现。原来的 `--disable-rollout-global-dataset` 已移除,旧启动脚本需要删除该参数。 + vime 支持加载 `.jsonl` 和 `.parquet` 格式文件;读取 Parquet 需要安装 `pyarrow`。两种格式中的每条记录都应包含 `--input-key` 和 `--label-key` 指定的字段。下面是一条 JSONL 数据展开后的示例: ```json @@ -206,6 +208,7 @@ vime 支持加载 `.jsonl` 和 `.parquet` 格式文件;读取 Parquet 需要 注意:在策略蒸馏 (OPD) 现在与 advantage estimator 正交,使用 `--use-opd` 和 `--opd-kl-coef` 可以在任意 estimator 之上启用 OPD。 - `--calculate-per-token-loss`:vime 中默认的方案是 per sample loss,即 `mean(sum(sample_i) / len(sample_i))`,如果需要计算 per token loss,即 `sum(sum(sample_i)) / sum(len(sample_i))`,可以开启 `--calculate-per-token-loss`; - `--use-tis`:如果需要开启 tis(https://fengyao.notion.site/off-policy-rl),可以开启这一设置; +- `--use-score-centering`:启用 [Score Centering](https://arxiv.org/abs/2609.20807),可与 TIS 组合使用,详见下方的 [Score Centering](#score-centering)。 #### GRPO 算法 @@ -255,6 +258,40 @@ PPO 相关参数: - `--value-clip`:value loss 的 clip 范围; - `--kl-coef`:KL penalty 系数,用于 reward shaping。 +#### Score Centering + +[Score Centering Stabilizes Off-policy Reinforcement Learning](https://arxiv.org/abs/2609.20807) 提出了一种加性修正,用于减轻训练与推理不一致引起的梯度漂移,也可以与重要性采样组合使用。vime 的 score centering(SC)目前支持 Megatron backend 和非流式 vLLM rollout。 + +在已有的 RL 启动命令中加入以下配置: + +```bash +--use-score-centering \ +--score-centering-top-k 128 \ +--pg-loss-type reinforce \ +--advantage-estimator grpo \ +--disable-grpo-std-normalization \ +--calculate-per-token-loss \ +--rollout-temperature 1.0 \ +--rollout-top-p 1.0 \ +--rollout-top-k -1 \ +--entropy-coef 0 \ +--kl-coef 0 +``` + +- `--use-score-centering`:启用 REINFORCE score-centering 目标。省略 `--pg-loss-type` 时,SC 会自动选择 `reinforce`;未开启 SC 时保留原有的 PPO/CISPO 默认行为。SC 不能与 `--pg-loss-type ppo` 或 GSPO/CISPO advantage estimator 组合,PPO clipping 参数不影响 REINFORCE 目标。 +- `--score-centering-top-k`:仅在 `--rollout-top-p 1` 时生效。每个 response token 保存的 sampler top-k token ID 和 logprob 数量,默认为 128,不能超过模型词表大小。top-k 概率保留其在完整词表上的概率质量;剩余的 sampler 概率质量按当前 trainer 的尾部概率分布估计。 +- `--use-tis`:可选,与 SC 独立开关。组合开启时,使用 `--tis-clip-low` 和 `--tis-clip` 对加权后的 score 做 centering。也支持通过 `--custom-tis-function-path` 选择内置的 `vime.backends.megatron_utils.loss.icepop_function`,但不支持与任意自定义 TIS 回调组合。REINFORCE 使用 detached 的当前 trainer/sampler 权重,PPO 保留原有的旧 trainer/sampler 权重。 + +**采样要求:** temperature 必须为正,使用 `0 < top_p <= 1`、`top_k=-1`、`min_p=0`,不启用 repetition/frequency/presence penalty 或约束解码。temperature 不为 1 或 top_p 小于 1 时,所有 sampler worker 上的 `VLLM_RETURN_ORIGINAL_LOGPROB` 必须未设置或为 false。目前不支持逐请求修改 temperature 或 top_p,也不支持流式 SC。评估不会请求 SC 数据,可以使用独立的采样配置。 + +**与 top-p replay 组合:** 将上面的 `--rollout-top-p 1.0` 改为例如 `--rollout-top-p 0.9`,SC 会自动改用精确支持集求和,`--score-centering-top-k` 不再生效。rollout 返回每个 token 完整的 replay 支持集及其截断、归一化后的 sampler logprobs;trainer 在同一份支持集上归一化,并计算 `sum(stop_gradient(q * weight) * log p)` 的校正项。不使用论文的长尾近似,也不把支持集外的 token 纳入求和。训练端的完整 logits 本身无法恢复 sampler 概率,因此仍需要保存原始采样概率。传输量随每步支持集大小变化,top-p 接近 1 时可能明显大于固定的 top-k。精确性针对保存的 replay 支持集;沿用 replay 对边界 sampled token 的保留规则。 + +**vLLM 支持:** `top_p=1` 时,Vime 请求 vLLM 原生的 `k+1` 个 top logprobs,再保留概率最高的 `k` 个;`top_p<1` 时请求全部 logprobs,与 vLLM sampling mask 取交集,并对记录的支持集归一化。自定义 generator 应调用 `vime.utils.score_centering` 中的 `score_centering_request`,并将等价 metadata 传给 `Sample.append_response_tokens`。 + +top-p 概率与 replay ID/offset 一起驻留在 CPU,支持 partial rollout、masked tool token、DP、TP 和两种 CP 布局。由于 vLLM 当前会先返回完整 logprob 向量,再由 Vime 选择 replay support,因此精确 top-p SC 的响应载荷更大。 + +sampler top-k 数据支持 partial rollout 续接、masked tool token、DP 划分、microbatch 选择、TP 及两种 CP 布局;使用 R3 spill hook 时共享其文件生命周期。相关日志指标包括 `sc_correction`、`sc_sampler_head_mass`、`sc_train_head_mass` 和 `sc_importance_weight`。 + ### 高级 Megatron 配置(--megatron-config-path) 对于 PPO 场景,可以使用 `--megatron-config-path` 指定一个 YAML 文件,对 actor / critic 分别覆盖 Megatron 参数。常见用途包括给 critic 设置不同的 `lr`,或者分别指定 `load` / `save` 等路径。 diff --git a/tests/plugin_contracts/test_plugin_path_loading_contracts.py b/tests/plugin_contracts/test_plugin_path_loading_contracts.py index a55100f90..ea9096777 100644 --- a/tests/plugin_contracts/test_plugin_path_loading_contracts.py +++ b/tests/plugin_contracts/test_plugin_path_loading_contracts.py @@ -82,7 +82,7 @@ def make_sample(index: int, reward: float = 1.0) -> Sample: def make_args(**overrides): class Args: - rollout_global_dataset = False + prompt_data = None buffer_filter_path = None n_samples_per_prompt = 2 custom_rm_path = None diff --git a/tests/test_fully_async_rollout.py b/tests/test_fully_async_rollout.py index 7eca94cf4..ebc79ff35 100644 --- a/tests/test_fully_async_rollout.py +++ b/tests/test_fully_async_rollout.py @@ -81,7 +81,7 @@ def _make_group(index: int) -> list[Sample]: def _make_worker(monkeypatch, data_buffer=None, concurrency=4) -> fa.AsyncRolloutWorker: monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) - args = SimpleNamespace(rollout_global_dataset=True, rollout_batch_size=4, n_samples_per_prompt=1) + args = SimpleNamespace(rollout_batch_size=4, n_samples_per_prompt=1) return fa.AsyncRolloutWorker(args, data_buffer or _FakeDataBuffer([]), concurrency=concurrency) @@ -92,7 +92,7 @@ def test_rollout_takes_target_groups_and_leaves_surplus_queued(monkeypatch): worker.output_queue.put((gid, _make_group(gid))) monkeypatch.setattr(fa, "_get_global_worker", lambda args, data_buffer: worker) - args = SimpleNamespace(rollout_global_dataset=True, rollout_batch_size=4) + args = SimpleNamespace(rollout_batch_size=4) out = asyncio.run(fa._generate_rollout_async(args, rollout_id=0, data_buffer=None)) assert len(out) == 4 @@ -136,7 +136,6 @@ def keep_odd(args, group): monkeypatch.setattr(fa, "load_function", lambda path: keep_odd) args = SimpleNamespace( - rollout_global_dataset=True, rollout_batch_size=3, dynamic_sampling_filter_path="test.keep_odd", ) diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index c07ba4b9a..3a274c7b7 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -240,7 +240,6 @@ def make_vime_validate_args(**overrides): over_sampling_batch_size=None, num_epoch=None, num_rollout=1, - rollout_global_dataset=False, enable_mtp_training=False, mtp_num_layers=None, use_rollout_routing_replay=False, @@ -263,6 +262,17 @@ def make_vime_validate_args(**overrides): return types.SimpleNamespace(**values) +def test_global_dataset_flag_is_removed(monkeypatch): + module = load_vime_arguments_module(monkeypatch) + parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) + args = parser.parse_args(["--rollout-batch-size", "1"]) + assert not hasattr(args, "rollout_global_dataset") + with pytest.raises(SystemExit): + parser.parse_args(["--rollout-batch-size", "1", "--disable-rollout-global-dataset"]) + # Epoch-based scheduling no longer depends on this attribute. + module.vime_validate_args(make_vime_validate_args(num_epoch=2, num_rollout=None)) + + @pytest.mark.unit def test_vime_validate_args_preserves_explicit_start_rollout_id(monkeypatch): """``--start-rollout-id`` is only a fallback when the user did not set it. diff --git a/tests/test_qwen2.5_0.5B_score_centering.py b/tests/test_qwen2.5_0.5B_score_centering.py new file mode 100644 index 000000000..a507ae81b --- /dev/null +++ b/tests/test_qwen2.5_0.5B_score_centering.py @@ -0,0 +1,79 @@ +"""Megatron + vLLM smoke test with score centering enabled.""" + +import os +import tempfile +from pathlib import Path + +import torch + +import vime.utils.external_utils.command_utils as U +from vime.utils.score_centering import validate_sampler_top_p, validate_sampler_topk +from vime.utils.types import Sample + +MODEL_NAME = "Qwen2.5-0.5B-Instruct" +MODEL_TYPE = "qwen2.5-0.5B" +NUM_GPUS = 2 + + +def prepare(): + U.exec_command("mkdir -p /root/models /root/datasets") + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def execute(top_p=1.0): + with tempfile.TemporaryDirectory(prefix="vime-score-centering-") as directory: + train_args = ( + f"--hf-checkpoint /root/models/{MODEL_NAME} --ref-load /root/models/{MODEL_NAME} " + "--prompt-data /root/datasets/gsm8k/train.parquet " + "--input-key messages --label-key label --apply-chat-template --rm-type math " + "--num-rollout 2 --rollout-batch-size 2 --n-samples-per-prompt 4 " + f"--rollout-max-response-len 256 --rollout-temperature 0.8 --rollout-top-p {top_p} --rollout-top-k -1 " + "--global-batch-size 8 --use-score-centering --score-centering-top-k 128 " + "--pg-loss-type reinforce --advantage-estimator grpo --disable-grpo-std-normalization " + "--calculate-per-token-loss --entropy-coef 0 --kl-coef 0 " + "--optimizer adam --lr 1e-6 --lr-decay-style constant --weight-decay 0 " + "--tensor-model-parallel-size 2 --sequence-parallel --pipeline-model-parallel-size 1 " + "--context-parallel-size 1 --expert-model-parallel-size 1 --expert-tensor-parallel-size 1 " + "--use-dynamic-batch-size --max-tokens-per-gpu 4096 --log-probs-chunk-size 128 " + "--rollout-num-gpus-per-engine 1 --vllm-mem-fraction-static 0.6 --vllm-cuda-graph-max-bs 8 " + "--attention-dropout 0 --hidden-dropout 0 --attention-backend flash " + "--accumulate-allreduce-grads-in-fp32 --attention-softmax-in-fp32 " + "--actor-num-nodes 1 --actor-num-gpus-per-node 2 --colocate --ci-test " + f"--save-debug-rollout-data {directory}/rollout_{{rollout_id}}.pt " + f"--ci-save-grad-norm {directory}/grad_{{rollout_id}}_{{step_id}}.pt " + ) + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + extra_env_vars={"VLLM_RETURN_ORIGINAL_LOGPROB": "0"}, + ) + for rollout_id in range(2): + data = torch.load(Path(directory) / f"rollout_{rollout_id}.pt", weights_only=False) + assert data["samples"] + for item in data["samples"]: + sample = Sample.from_dict(item) + if top_p < 1: + validate_sampler_top_p( + sample.rollout_top_p_token_ids, + sample.rollout_top_p_token_offsets, + sample.rollout_top_p_log_probs, + sample.response_length, + sample.loss_mask, + sample.tokens[-sample.response_length :], + sample.rollout_log_probs, + ) + else: + validate_sampler_topk(sample, 128) + assert len(sample.rollout_log_probs) == sample.response_length + grad = torch.load(Path(directory) / f"grad_{rollout_id}_0.pt", weights_only=False) + assert torch.isfinite(torch.as_tensor(grad)).all() + + +if __name__ == "__main__": + prepare() + for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): + os.environ.pop(key, None) + for top_p in (1.0, 0.95): + execute(top_p) diff --git a/tests/test_rollout_buffer_order.py b/tests/test_rollout_buffer_order.py index a94558055..ff2ca72fb 100644 --- a/tests/test_rollout_buffer_order.py +++ b/tests/test_rollout_buffer_order.py @@ -46,5 +46,43 @@ def test_buffer_sort_is_opt_in_and_explicit_filter_takes_precedence(monkeypatch, assert source.buffer_filter is pop_first +def test_data_source_reads_and_checkpoints_without_global_dataset_flag(monkeypatch, tmp_path): + import vime.rollout.data_source as module + + prompts = [Sample(prompt="first"), Sample(prompt="second")] + monkeypatch.setattr(module, "load_tokenizer", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "load_processor", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "Dataset", lambda *args, **kwargs: SimpleNamespace(samples=prompts)) + args = SimpleNamespace( + prompt_data="local.jsonl", + hf_checkpoint="local-tokenizer", + dump_details=None, + rollout_max_prompt_len=None, + input_key="text", + multimodal_keys=None, + label_key=None, + metadata_key="metadata", + tool_key=None, + apply_chat_template=False, + apply_chat_template_kwargs=None, + rollout_seed=42, + rollout_shuffle=False, + n_samples_per_prompt=1, + save=str(tmp_path), + load=None, + ) + source = RolloutDataSource(args) + assert source.dataset.samples == prompts + # Empty-prompt custom rollouts still own a checkpointable global cursor. + args.prompt_data = None + source = RolloutDataSource(args) + assert source.get_samples(2)[1][0].index == 1 + source.save(4) + args.load = str(tmp_path) + restored = RolloutDataSource(args) + restored.load(4) + assert restored.get_samples(1)[0][0].index == 2 + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_score_centering.py b/tests/test_score_centering.py new file mode 100644 index 000000000..a237b052f --- /dev/null +++ b/tests/test_score_centering.py @@ -0,0 +1,928 @@ +"""Equation-level and distributed integration checks for arXiv:2609.20807.""" + +from argparse import Namespace + +import _cp_dist_helpers +import numpy as np +import pytest +import torch + +from vime.utils.ppo_utils import calculate_ragged_log_probs +from vime.utils.score_centering import ( + get_score_centering_is_config, + importance_weights, + score_centering_correction, + score_centering_request, + validate_sampler_topk, + validate_score_centering_args, +) +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +def args(mode="none", **overrides): + values = dict( + pg_loss_type=None, + use_score_centering=True, + score_centering_top_k=3, + use_tis=mode != "none", + tis_clip_low=0.5 if mode == "mis" else 0.0, + tis_clip=5.0 if mode == "mis" else 2.0, + custom_tis_function_path="vime.backends.megatron_utils.loss.icepop_function" if mode == "mis" else None, + rollout_temperature=0.8, + rollout_top_p=1.0, + rollout_top_k=-1, + allgather_cp=False, + log_probs_chunk_size=2, + entropy_coef=0.0, + use_kl_loss=False, + use_rollout_logprobs=False, + loss_type="policy_loss", + advantage_estimator="grpo", + opd_full_vocab=False, + opd_teacher_top_k=0, + use_opsm=False, + eps_clip=0.2, + eps_clip_high=0.2, + eps_clip_c=None, + get_mismatch_metrics=False, + custom_pg_loss_reducer_function_path=None, + calculate_per_token_loss=False, + use_unbiased_kl=False, + ) + return Namespace(**(values | overrides)) + + +def dense_reference(logits, ids, q_head, targets, sampled_q, advantage, mode, temperature=1.0, low=None, high=None): + """Explicitly reconstruct every tail token, then sum weighted scores over V.""" + logp = (logits.float() / temperature).log_softmax(-1) + with torch.no_grad(): + p = logp.exp() + rho = (1 - q_head.exp().sum(-1)).clamp_min(1e-6) / (1 - p.gather(-1, ids).sum(-1)).clamp_min(1e-6) + q = p * rho[:, None] + q.scatter_(-1, ids, q_head.exp()) + + # Independent weight implementation for the oracle. + def weight(r): + if mode == "none": + return torch.ones_like(r) + if mode == "tis": + return torch.maximum( + torch.minimum(r, torch.tensor(2.0 if high is None else high)), + torch.tensor(0.0 if low is None else low), + ) + return torch.where((r >= (0.5 if low is None else low)) & (r <= (5 if high is None else high)), r, 0) + + coeff = q * weight(p / q) + w = weight((logp.gather(-1, targets[:, None]).squeeze(-1) - sampled_q).exp()) + return -advantage * (w * logp.gather(-1, targets[:, None]).squeeze(-1) - (coeff * logp).sum(-1)) + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +def test_topk_gradient_matches_full_reconstructed_distribution(mode): + torch.manual_seed(71) + logits = (torch.randn(5, 11) * 2).requires_grad_() + q = (torch.randn(5, 11) * 2).log_softmax(-1).requires_grad_() + q_head, ids = q.topk(3) + targets = torch.tensor([0, 7, 2, 10, 4]) # includes actions outside the head + adv = torch.tensor([1.0, -2.0, 0.5, 0.0, -1.0]) + logp = logits.log_softmax(-1) + correction, _, _ = score_centering_correction(logp.gather(-1, ids), q_head, mode=mode) + sampled = logp.gather(-1, targets[:, None]).squeeze(-1) + sampled_q = q.gather(-1, targets[:, None]).squeeze(-1) + w = importance_weights((sampled - sampled_q).exp(), mode).detach() + loss = (-adv * (w * sampled - correction)).sum() + ref = dense_reference(logits, ids, q_head, targets, sampled_q, adv, mode).sum() + torch.testing.assert_close( + torch.autograd.grad(loss, logits)[0], torch.autograd.grad(ref, logits)[0], atol=2e-6, rtol=2e-6 + ) + assert torch.autograd.grad(loss, q, allow_unused=True)[0] is None + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +def test_constant_reward_has_zero_expected_gradient(mode): + # Full vocabulary head: expectation over all sampled actions cancels drift. + logits = torch.tensor([1.0, -1.0, 0.2, 2.0], requires_grad=True) + q = torch.tensor([0.15, 0.55, 0.25, 0.05]) + logp = logits.log_softmax(-1) + correction, _, _ = score_centering_correction(logp[None], q.log()[None], mode=mode) + w = importance_weights((logp - q.log()).exp(), mode).detach() + expected_loss = (q * (-w * logp + correction)).sum() + torch.testing.assert_close( + torch.autograd.grad(expected_loss, logits)[0], torch.zeros_like(logits), atol=2e-7, rtol=0 + ) + + +def test_on_policy_correction_zero_and_tiny_tails_finite(): + p = torch.tensor([[0.0, -90.0, -95.0]], requires_grad=True) + correction, _, _ = score_centering_correction(p, p.detach()) + assert correction.item() == 0 + correction, _, _ = score_centering_correction(p, torch.tensor([[-0.01, -8.0, -9.0]]), mode="tis") + correction.sum().backward() + assert torch.isfinite(correction).all() and torch.isfinite(p.grad).all() + + +def make_batch(): + torch.manual_seed(8) + total, response = [8, 8], [3, 2] + q = [torch.randn(r, 12).log_softmax(-1) for r in response] + heads = [x.topk(3) for x in q] + tokens = [torch.randint(0, 12, (t,)) for t in total] + return dict( + total_lengths=total, + response_lengths=response, + unconcat_tokens=tokens, + loss_masks=[torch.tensor([1, 0, 1]), torch.tensor([1, 1])], + rollout_mask_sums=[torch.tensor(2), torch.tensor(2)], + rollout_topk_token_ids=[x.indices for x in heads], + rollout_topk_log_probs=[x.values for x in heads], + rollout_log_probs=[ + x.gather(-1, t[-r:, None]).squeeze(-1) for x, t, r in zip(q, tokens, response, strict=True) + ], + advantages=[torch.tensor([1.0, -3.0, -1.0]), torch.tensor([2.0, -0.5])], + ) + + +def reference_batch(logits, batch, a): + loss = logits.new_zeros(()) + offset = 0 + for i, (t, r) in enumerate(zip(batch["total_lengths"], batch["response_lengths"], strict=True)): + rows = logits.squeeze(0)[offset + t - r - 1 : offset + t - 1] + term = dense_reference( + rows, + batch["rollout_topk_token_ids"][i], + batch["rollout_topk_log_probs"][i], + batch["unconcat_tokens"][i][-r:], + batch["rollout_log_probs"][i], + batch["advantages"][i], + ("mis" if a.custom_tis_function_path else "tis") if a.use_tis else "none", + a.rollout_temperature, + a.tis_clip_low, + a.tis_clip, + ) + mask = batch["loss_masks"][i] + loss = loss + (term * mask).sum() / mask.sum() + offset += t + return loss + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_policy_loss_gradient_matches_dense_reference(mode, dtype, monkeypatch): + from megatron.core import mpu + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from vime.backends.megatron_utils.loss import policy_loss_function + + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + batch = make_batch() + logits = torch.randn(1, 16, 12, dtype=dtype).requires_grad_() + a = args(mode=mode) + reduce = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + loss, metrics = policy_loss_function(a, batch, logits.float(), reduce) + ref = reference_batch(logits, batch, a) + # Head-only scalar differs from dense CE by a zero-gradient multiple of sum p log p. + actual_grad = torch.autograd.grad(loss, logits)[0] + expected_grad = torch.autograd.grad(ref, logits)[0] + tol = 0.008 if dtype == torch.bfloat16 else 2e-6 + torch.testing.assert_close(actual_grad, expected_grad, atol=tol, rtol=tol) + assert set(metrics) >= {"loss", "pg_loss", "sc_correction", "sc_sampler_head_mass"} + assert actual_grad[0, 5].count_nonzero() == 0 # masked response position + + +def distributed_worker(rank, world_size, port, layout, mode, disk=False): + import torch.distributed as dist + from megatron.core import mpu + + torch.set_num_threads(1) + group = _cp_dist_helpers.init_worker_process_group(rank, world_size, port) + is_tp = layout == "tp" + _cp_dist_helpers.stub_megatron_in_worker(1 if is_tp else world_size, 0 if is_tp else rank) + # Every rank must create the singleton groups in the same order. + singles = [dist.new_group([i]) for i in range(world_size)] + mpu.get_tensor_model_parallel_group = lambda: group if is_tp else singles[rank] + mpu.get_context_parallel_group = lambda: group + try: + batch = make_batch() + full_logits = torch.randn(16, 12) + a = args(mode=mode, allgather_cp=layout == "allgather") + if is_tp: + local = full_logits[:, rank * 6 : (rank + 1) * 6] + elif layout == "allgather": + local = full_logits[rank * 8 : (rank + 1) * 8] + else: + row_ids = torch.cat( + [ + torch.tensor([i + rank * 2, i + rank * 2 + 1, i + (3 - rank) * 2, i + (3 - rank) * 2 + 1]) + for i in [0, 8] + ] + ) + local = full_logits[row_ids] + local = local.clone().requires_grad_() + original = dict(batch) + import tempfile + from pathlib import Path + from vime.utils.tensor_store import DiskTensorRef + + with tempfile.TemporaryDirectory() as directory: + if disk: + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + batch[key] = [ + DiskTensorRef.write( + value.int() if key.endswith("ids") else value, Path(directory) / f"{key}_{i}.safetensors" + ) + for i, value in enumerate(batch[key]) + ] + _check_distributed_batch( + rank, + layout, + is_tp, + batch, + original, + full_logits, + local, + a, + None if is_tp or layout == "allgather" else row_ids, + disk, + ) + finally: + dist.destroy_process_group() + + +def _check_distributed_batch(rank, layout, is_tp, batch, original, full_logits, local, a, row_ids, disk): + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean, slice_log_prob_with_cp + from vime.backends.megatron_utils.loss import policy_loss_function + + if not is_tp: + fields = ["advantages", "rollout_log_probs"] + if layout != "allgather" and not disk: + fields += ["rollout_topk_token_ids", "rollout_topk_log_probs"] + for key in fields: + batch[key] = [ + slice_log_prob_with_cp(x, t, r) + for x, t, r in zip(batch[key], batch["total_lengths"], batch["response_lengths"], strict=True) + ] + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + loss, _ = policy_loss_function(a, batch, local.unsqueeze(0), reducer) + ref_logits = full_logits.clone().requires_grad_() + ref = reference_batch(ref_logits.unsqueeze(0), original, a) + loss.backward() + ref.backward() + if is_tp: + expected = ref_logits.grad[:, rank * 6 : (rank + 1) * 6] + elif layout == "allgather": + expected = ref_logits.grad[rank * 8 : (rank + 1) * 8] + else: + expected = ref_logits.grad[row_ids] + torch.testing.assert_close(local.grad, expected, atol=2e-6, rtol=2e-6) + # Training dumps must restore integer heads without losing CP rows or precision. + from vime.observability.train_data_utils import restore_context_parallel_fields_to_cpu + + restored = restore_context_parallel_fields_to_cpu( + { + key: value + for key, value in batch.items() + if key in ("total_lengths", "response_lengths", "rollout_topk_token_ids", "rollout_topk_log_probs") + }, + lambda *args: pytest.fail("Sampler heads must use their own integer-preserving gather"), + keep_restored=True, + allgather_cp=layout == "allgather", + ) + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + for actual, reference in zip(restored[key], original[key], strict=True): + if disk: + actual = actual.load() + torch.testing.assert_close(actual, reference.to(actual.dtype)) + + +@pytest.mark.parametrize("layout", ["tp", "zigzag", "allgather"]) +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +def test_distributed_gradients(layout, mode): + torch.multiprocessing.spawn(distributed_worker, args=(2, _cp_dist_helpers.free_port(), layout, mode), nprocs=2) + + +def meta(n=1): + return { + "score_centering_topk": ( + np.asarray([[3, 1, 4]] * n, dtype=np.int32), + np.asarray([[-0.5, -2.0, -3.0]] * n, dtype=np.float32), + ) + } + + +def test_sample_append_resume_tool_tokens_and_serialization(): + a = args() + s = Sample(tokens=[9]) + s.append_response_tokens(a, tokens=[3], log_probs=[-0.5], meta_info=meta()) + s.append_response_tokens(a, tokens=[8, 9], trainable=False) + s = Sample.from_dict(s.to_dict()) + s.append_response_tokens(a, tokens=[1], log_probs=[-2.0], meta_info=meta()) + validate_sampler_topk(s, 3) + assert s.loss_mask == [1, 0, 0, 1] + assert s.rollout_topk_token_ids[0].tolist() == s.rollout_topk_token_ids[-1].tolist() == [3, 1, 4] + assert len(s.rollout_topk_log_probs) == s.response_length == 4 + + +def test_missing_topk_fails_before_token_append(): + s = Sample(tokens=[9]) + with pytest.raises(ValueError, match="requires sampler top-k"): + s.append_response_tokens(args(), tokens=[3], log_probs=[-0.5]) + assert s.tokens == [9] + assert s.response_length == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + dict(pg_loss_type="ppo"), + dict(rollout_top_p=0.0), + dict(rollout_top_k=8), + dict(rollout_temperature=0), + dict(score_centering_top_k=0), + dict(train_backend="fsdp"), + dict(custom_generate_function_path="vime.rollout.vllm_streaming_rollout.generate_streaming"), + dict(use_opd=True), + dict(loss_type="sft_loss"), + dict(use_tis=True, tis_clip_low=6), + ], +) +def test_invalid_configuration(overrides): + with pytest.raises(ValueError): + validate_score_centering_args(args(**overrides)) + + +def test_request_and_configuration(): + a = args() + validate_score_centering_args(a) + assert score_centering_request(a, {}) == {} + assert score_centering_request(args(use_score_centering=False), {}) == {} + + +@pytest.mark.parametrize( + "params", [{"temperature": 0.6}, {"min_p": 0.1}, {"repetition_penalty": 1.1}, {"regex": "[0-9]+"}] +) +def test_request_rejects_distribution_overrides(params): + with pytest.raises(ValueError): + score_centering_request(args(), params) + + +def test_empty_sample_topk_shape(): + s = Sample(tokens=[0]) + validate_sampler_topk(s, 3) + assert s.rollout_topk_token_ids.shape == (0, 3) + + +@pytest.mark.parametrize( + "ids,logps", + [ + ([[1, 1, 2]], [[-1.0, -2.0, -3.0]]), + ([[1, 2, 3]], [[0.0, 0.0, 0.0]]), + ([[1, 2, 3]], [[float("nan"), -2.0, -3.0]]), + ], +) +def test_loaded_sampler_data_is_validated(ids, logps): + s = Sample(tokens=[0, 1], response_length=1, rollout_topk_token_ids=ids, rollout_topk_log_probs=logps) + with pytest.raises(ValueError): + validate_sampler_topk(s, 3) + + +def test_cli_defaults_and_overrides(monkeypatch): + import argparse + from test_megatron_argument_validation import load_vime_arguments_module + + module = load_vime_arguments_module(monkeypatch) + parser = argparse.ArgumentParser() + module.get_vime_extra_args_provider()(parser) + default = parser.parse_args(["--rollout-batch-size", "1"]) + assert default.pg_loss_type is None + assert default.use_score_centering is False + assert default.score_centering_top_k == 128 + configured = parser.parse_args( + [ + "--rollout-batch-size", + "1", + "--use-score-centering", + "--pg-loss-type", + "reinforce", + "--score-centering-top-k", + "32", + "--use-tis", + "--custom-tis-function-path", + "vime.backends.megatron_utils.loss.icepop_function", + "--tis-clip-low", + "0.4", + "--tis-clip", + "4", + ] + ) + validate_score_centering_args(configured) + assert configured.pg_loss_type == "reinforce" + assert configured.tis_clip_low == 0.4 + assert configured.tis_clip == 4 + assert configured.score_centering_top_k == 32 + + +@pytest.mark.parametrize("sc", [False, True]) +@pytest.mark.parametrize("tis", [False, True]) +@pytest.mark.parametrize("pg_loss_type", [None, "reinforce"]) +def test_independent_switches_preserve_losses_and_gradients(sc, tis, pg_loss_type, monkeypatch): + from megatron.core import mpu + from vime.backends.megatron_utils import loss as loss_module + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + a = args( + pg_loss_type=pg_loss_type, + use_score_centering=sc, + use_tis=tis, + tis_clip_low=0.3, + tis_clip=1.5, + opd_full_vocab=False, + opd_teacher_top_k=0, + use_opsm=False, + eps_clip=0.2, + eps_clip_high=0.2, + eps_clip_c=None, + get_mismatch_metrics=False, + custom_pg_loss_reducer_function_path=None, + calculate_per_token_loss=False, + ) + validate_score_centering_args(a) + batch = make_batch() + # Deliberately distinguish stored old policy from current policy. SC must + # use current/sampler weights; the old PPO+TIS path must keep old/sampler. + batch["log_probs"] = [x + 0.7 for x in batch["rollout_log_probs"]] + batch["rollout_mask_sums"] = [x.sum() for x in batch["loss_masks"]] + logits = torch.randn(1, 16, 12).requires_grad_() + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + calls = [] + original_tis = loss_module.vanilla_tis_function + + def tracked_tis(**kwargs): + calls.append(1) + return original_tis(**kwargs) + + monkeypatch.setattr(loss_module, "vanilla_tis_function", tracked_tis) + loss, _ = loss_module.policy_loss_function(a, batch, logits, reducer) + assert len(calls) == int(tis) + ref_logits = logits.detach().clone().requires_grad_() + if sc: + ref_loss = reference_batch(ref_logits, batch, a) + else: + ref_loss = ref_logits.new_zeros(()) + offset = 0 + for i, (t, r) in enumerate(zip(batch["total_lengths"], batch["response_lengths"], strict=True)): + logp = (ref_logits[0, offset + t - r - 1 : offset + t - 1] / a.rollout_temperature).log_softmax(-1) + logp = logp.gather(-1, batch["unconcat_tokens"][i][-r:, None]).squeeze(-1) + ratio = (logp - batch["log_probs"][i]).exp() + adv, mask = batch["advantages"][i], batch["loss_masks"][i] + term = ( + -adv * logp + if pg_loss_type == "reinforce" + else torch.maximum(-adv * ratio, -adv * ratio.clamp(0.8, 1.2)) + ) + if tis: + train_logp = logp.detach() if pg_loss_type == "reinforce" else batch["log_probs"][i] + term = term * (train_logp - batch["rollout_log_probs"][i]).exp().clamp(0.3, 1.5) + ref_loss = ref_loss + (term * mask).sum() / mask.sum() + offset += t + torch.testing.assert_close(loss, ref_loss, atol=2e-6, rtol=2e-6) + torch.testing.assert_close( + torch.autograd.grad(loss, logits)[0], torch.autograd.grad(ref_loss, ref_logits)[0], atol=2e-6, rtol=2e-6 + ) + + +@pytest.mark.parametrize("sc", [False, True]) +def test_reinforce_shares_entropy_and_reference_kl(sc, monkeypatch): + from megatron.core import mpu + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from vime.backends.megatron_utils.loss import policy_loss_function + + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + a = args(pg_loss_type="reinforce", use_score_centering=sc, mode="tis") + batch = make_batch() + batch["ref_log_probs"] = [x - 0.2 for x in batch["rollout_log_probs"]] + logits = torch.randn(1, 16, 12).requires_grad_() + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + base, _ = policy_loss_function(a, batch, logits, reducer) + a.entropy_coef, a.use_kl_loss, a.kl_loss_coef, a.kl_loss_type = 0.13, True, 0.27, "k2" + actual, metrics = policy_loss_function(a, batch, logits, reducer) + entropy, kl = logits.new_zeros(()), logits.new_zeros(()) + offset = 0 + for i, (t, r) in enumerate(zip(batch["total_lengths"], batch["response_lengths"], strict=True)): + logp = (logits[0, offset + t - r - 1 : offset + t - 1] / a.rollout_temperature).log_softmax(-1) + sampled = logp.gather(-1, batch["unconcat_tokens"][i][-r:, None]).squeeze(-1) + mask = batch["loss_masks"][i] + entropy = entropy + (-(logp.exp() * logp).sum(-1) * mask).sum() / mask.sum() + kl = kl + (0.5 * (sampled - batch["ref_log_probs"][i]).square() * mask).sum() / mask.sum() + offset += t + expected = base - a.entropy_coef * entropy + a.kl_loss_coef * kl + torch.testing.assert_close(actual, expected) + torch.testing.assert_close(metrics["entropy_loss"], entropy.detach()) + torch.testing.assert_close(metrics["kl_loss"], kl.detach()) + torch.testing.assert_close(torch.autograd.grad(actual, logits)[0], torch.autograd.grad(expected, logits)[0]) + + +@pytest.mark.parametrize( + "overrides,expected", + [ + ({}, "ppo"), + ({"advantage_estimator": "cispo"}, "cispo"), + ({"advantage_estimator": "gspo"}, "ppo"), + ({"pg_loss_type": "reinforce"}, "reinforce"), + ({"use_score_centering": True}, "reinforce"), + ], +) +def test_pg_objective_defaults(overrides, expected): + from vime.utils.ppo_utils import get_pg_loss_type + + a = args(**({"use_score_centering": False} | overrides)) + assert get_pg_loss_type(a) == expected + + +@pytest.mark.parametrize("estimator", ["gspo", "cispo"]) +def test_reinforce_rejects_conflicting_objectives(estimator): + from vime.utils.ppo_utils import get_pg_loss_type + + with pytest.raises(ValueError, match="cannot combine"): + get_pg_loss_type(args(use_score_centering=False, pg_loss_type="reinforce", advantage_estimator=estimator)) + + +@pytest.mark.parametrize( + "path,mode", + [ + (None, "tis"), + ("vime.backends.megatron_utils.loss.vanilla_tis_function", "tis"), + ("vime.backends.megatron_utils.loss.icepop_function", "mis"), + ], +) +def test_score_centering_uses_shared_is_config(path, mode): + a = args(use_tis=True, custom_tis_function_path=path, tis_clip_low=0.4, tis_clip=3) + validate_score_centering_args(a) + assert get_score_centering_is_config(a) == dict(mode=mode, low=0.4, high=3) + a.use_tis = False + assert get_score_centering_is_config(a) == dict(mode="none") + + +def test_unknown_tis_callback_rejected_only_when_composing(): + a = args(use_tis=True, custom_tis_function_path="my_module.sequence_mask") + with pytest.raises(ValueError, match="custom loss/mask callbacks"): + validate_score_centering_args(a) + a.use_tis = False + validate_score_centering_args(a) + a.use_score_centering, a.use_tis = False, True + validate_score_centering_args(a) + + +def test_full_argument_validation_accepts_sc_tis_with_rollout_logprobs(monkeypatch): + from test_megatron_argument_validation import load_vime_arguments_module, make_vime_validate_args + + module = load_vime_arguments_module(monkeypatch) + a = make_vime_validate_args( + use_score_centering=True, + score_centering_top_k=128, + use_tis=True, + tis_clip_low=0.0, + tis_clip=2.0, + rollout_top_p=1.0, + rollout_top_k=-1, + rollout_temperature=1.0, + use_rollout_logprobs=True, + loss_type="policy_loss", + eval_resume_step=None, + ) + module.vime_validate_args(a) + assert a.use_tis and a.use_score_centering and a.use_rollout_logprobs + + +@pytest.mark.parametrize("mode", ["tis", "mis"]) +def test_shared_weight_rule_preserves_legacy_callbacks(mode): + from vime.backends.megatron_utils.loss import icepop_function, vanilla_tis_function + + a = args(tis_clip_low=0.5, tis_clip=2.0) + ratios = torch.tensor([0.1, 0.5, 1.0, 2.0, 5.0]) + old, sampler = ratios.log() - 3, torch.full_like(ratios, -3) + actual_ratios = (old - sampler).exp() + pg = torch.tensor([1.0, -2.0, 3.0, -4.0, 5.0], requires_grad=True) + masks = [torch.ones(5)] + fn = vanilla_tis_function if mode == "tis" else icepop_function + loss, returned_masks, metrics = fn( + a, pg_loss=pg, train_log_probs=[old], rollout_log_probs=[sampler], loss_masks=masks + ) + expected_weights = ( + actual_ratios.clamp(0.5, 2.0) + if mode == "tis" + else torch.where((actual_ratios >= 0.5) & (actual_ratios <= 2.0), actual_ratios, 0) + ) + torch.testing.assert_close(loss, pg * expected_weights, atol=0, rtol=0) + torch.testing.assert_close(torch.autograd.grad(loss.sum(), pg)[0], expected_weights, atol=0, rtol=0) + torch.testing.assert_close(metrics["tis_clipfrac"], (expected_weights != actual_ratios).float(), atol=0, rtol=0) + assert returned_masks is masks + + +@pytest.mark.parametrize("layout", ["tp", "zigzag", "allgather"]) +def test_distributed_disk_gradients(layout): + torch.multiprocessing.spawn( + distributed_worker, args=(2, _cp_dist_helpers.free_port(), layout, "none", True), nprocs=2 + ) + + +def top_p_batch(top_p=None): + batch = make_batch() + batch.pop("rollout_topk_token_ids") + batch.pop("rollout_topk_log_probs") + batch.update(rollout_top_p_token_ids=[], rollout_top_p_token_offsets=[], rollout_top_p_log_probs=[]) + for i, r in enumerate(batch["response_lengths"]): + ids, offsets, logps, sampled = [], [0], [], [] + for row, target in enumerate(batch["unconcat_tokens"][i][-r:]): + if top_p is None: + # Ragged sets, including a singleton and a masked environment row. + support = target[None] if row == 0 else torch.unique(torch.cat((torch.arange(7), target[None]))) + q = torch.randn(len(support)).log_softmax(0) + else: + # Known nucleus boundaries: .9 retains one token, .95 retains + # three, including the token that crosses the threshold. + probs = torch.tensor([0.91, 0.03, 0.02, 0.01, 0.01, 0.01, 0.002, 0.002, 0.002, 0.001, 0.001, 0.002]) + ordered_ids = (torch.arange(12) + target) % 12 + sorted_probs, order = probs.sort(descending=True) + keep = sorted_probs.cumsum(0) - sorted_probs <= top_p + support = ordered_ids[order[keep]] + q = (sorted_probs[keep] / sorted_probs[keep].sum()).log() + sampled.append(q[support == target][0]) + if batch["loss_masks"][i][row]: + ids.extend(support.tolist()) + logps.extend(q.tolist()) + offsets.append(len(ids)) + batch["rollout_top_p_token_ids"].append(torch.tensor(ids, dtype=torch.int32)) + batch["rollout_top_p_token_offsets"].append(torch.tensor(offsets, dtype=torch.int32)) + batch["rollout_top_p_log_probs"].append(torch.tensor(logps)) + batch["rollout_log_probs"][i] = torch.stack(sampled) + return batch + + +def top_p_weight(ratio, a): + if not a.use_tis: + return torch.ones_like(ratio) + if a.custom_tis_function_path: + return torch.where((ratio >= a.tis_clip_low) & (ratio <= a.tis_clip), ratio, 0) + return ratio.clamp(a.tis_clip_low, a.tis_clip) + + +def top_p_reference(logits, batch, a): + result = logits.sum() * 0 + position = 0 + for i, (total, response) in enumerate(zip(batch["total_lengths"], batch["response_lengths"], strict=True)): + offsets = batch["rollout_top_p_token_offsets"][i] + for row in range(response): + if not batch["loss_masks"][i][row]: + continue + start, end = offsets[row : row + 2] + ids = batch["rollout_top_p_token_ids"][i][start:end].long() + q = batch["rollout_top_p_log_probs"][i][start:end].exp() + values = logits[0, position + total - response - 1 + row].float() / a.rollout_temperature + keep = torch.zeros_like(values, dtype=torch.bool).scatter_(0, ids, True) + logp = values.masked_fill(~keep, -torch.inf).log_softmax(0) + coeff = (q * top_p_weight(logp[ids].exp() / q, a)).detach() + target = batch["unconcat_tokens"][i][-response + row] + w = top_p_weight((logp[target] - batch["rollout_log_probs"][i][row]).exp(), a).detach() + loss = -batch["advantages"][i][row] * (w * logp[target] - (coeff * logp[ids]).sum()) + result = result + loss / batch["loss_masks"][i].sum() + position += total + return result + + +def test_request_selects_complete_support(): + top_p = 0.95 + a = args(rollout_top_p=top_p) + validate_score_centering_args(a) + params = {"custom_params": {"other": 1}} + assert score_centering_request(a, params) == {} + assert params["top_p"] == top_p + assert params["custom_params"] == {"other": 1} + with pytest.raises(ValueError, match="configured"): + score_centering_request(a, {"top_p": 0.8}) + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_exact_loss_gradient(mode, dtype, monkeypatch): + top_p = 0.95 + from megatron.core import mpu + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from vime.backends.megatron_utils.loss import policy_loss_function + + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) + batch = top_p_batch(top_p=top_p) + for offsets, mask in zip(batch["rollout_top_p_token_offsets"], batch["loss_masks"], strict=True): + assert offsets.diff().tolist() == (3 * mask).tolist() + a = args(mode=mode, rollout_top_p=top_p) + logits = torch.randn(1, 16, 12, dtype=dtype).requires_grad_() + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + loss, metrics = policy_loss_function(a, batch, logits.float(), reducer) + expected = top_p_reference(logits, batch, a) + torch.testing.assert_close(loss, expected, atol=2e-6, rtol=2e-6) + tol = 0.004 if dtype == torch.bfloat16 else 2e-6 + torch.testing.assert_close( + torch.autograd.grad(loss, logits)[0], torch.autograd.grad(expected, logits)[0], atol=tol, rtol=tol + ) + assert metrics["sc_sampler_head_mass"] == pytest.approx(2.0, abs=1e-5) + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_singleton_support_has_zero_correction_and_policy_gradient(mode, dtype, monkeypatch): + from megatron.core import mpu + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from vime.backends.megatron_utils.loss import ( + get_log_probs_and_entropy, + get_rollout_top_p_logprob_kwargs, + get_score_centering_terms, + policy_loss_function, + ) + + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) + batch = top_p_batch() + for i, response in enumerate(batch["response_lengths"]): + batch["rollout_top_p_token_ids"][i] = batch["unconcat_tokens"][i][-response:].int() + batch["rollout_top_p_token_offsets"][i] = torch.arange(response + 1, dtype=torch.int32) + batch["rollout_top_p_log_probs"][i] = torch.zeros(response) + batch["rollout_log_probs"][i] = torch.zeros(response) + a = args(mode=mode, rollout_top_p=0.95) + # Large differences in the unmasked logits must not affect singleton rows. + logits = (100 * torch.randn(1, 16, 12, dtype=dtype)).requires_grad_() + _, sampled = get_log_probs_and_entropy( + logits.float(), + args=a, + unconcat_tokens=batch["unconcat_tokens"], + total_lengths=batch["total_lengths"], + response_lengths=batch["response_lengths"], + **get_rollout_top_p_logprob_kwargs(a, batch), + ) + for logp in sampled["log_probs"]: + torch.testing.assert_close(logp, torch.zeros_like(logp), atol=0, rtol=0) + terms = get_score_centering_terms(a, batch, logits.float()) + torch.testing.assert_close(terms["sc_correction"], torch.zeros(5), atol=0, rtol=0) + for key in ("sc_sampler_head_mass", "sc_train_head_mass"): + torch.testing.assert_close(terms[key], torch.ones(5), atol=0, rtol=0) + correction_grad = torch.autograd.grad(terms["sc_correction"].sum(), logits)[0] + torch.testing.assert_close(correction_grad, torch.zeros_like(logits), atol=0, rtol=0) + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + loss, _ = policy_loss_function(a, batch, logits.float(), reducer) + assert loss.item() == 0 + grad = torch.autograd.grad(loss, logits)[0] + assert torch.isfinite(grad).all() + torch.testing.assert_close(grad, torch.zeros_like(logits), atol=0, rtol=0) + + +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +def test_exact_correction_cancels_expected_drift(mode): + a = args(mode=mode) + logits = torch.tensor([[1.0, -2, 0.3, 0.8]], requires_grad=True) + ids, offsets = torch.tensor([0, 2, 3]), torch.tensor([0, 3]) + q = torch.tensor([0.1, 0.3, 0.6]) + logp = calculate_ragged_log_probs(logits, ids, offsets, None, 0.8) + w = top_p_weight(logp.exp() / q, a).detach() + correction = (q * w * logp).sum() + expected_loss = (q * (-w * logp + correction)).sum() + torch.testing.assert_close( + torch.autograd.grad(expected_loss, logits)[0], torch.zeros_like(logits), atol=2e-7, rtol=0 + ) + + +def top_p_distributed_worker(rank, world_size, port, layout, mode): + import torch.distributed as dist + from megatron.core import mpu + + torch.set_num_threads(1) + group = _cp_dist_helpers.init_worker_process_group(rank, world_size, port) + is_tp = layout == "tp" + _cp_dist_helpers.stub_megatron_in_worker(1 if is_tp else world_size, 0 if is_tp else rank) + singles = [dist.new_group([i]) for i in range(world_size)] + mpu.get_tensor_model_parallel_group = lambda: group if is_tp else singles[rank] + mpu.get_context_parallel_group = lambda: group + mpu.get_tensor_model_parallel_rank = lambda: rank if is_tp else 0 + try: + from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean, slice_log_prob_with_cp + from vime.backends.megatron_utils.loss import policy_loss_function + + batch = top_p_batch() + full_logits = torch.randn(16, 12) + a = args(mode=mode, rollout_top_p=0.95, allgather_cp=layout == "allgather") + if is_tp: + local = full_logits[:, rank * 6 : (rank + 1) * 6] + elif layout == "allgather": + local = full_logits[rank * 8 : (rank + 1) * 8] + else: + row_ids = torch.cat( + [ + torch.tensor([i + rank * 2, i + rank * 2 + 1, i + (3 - rank) * 2, i + (3 - rank) * 2 + 1]) + for i in [0, 8] + ] + ) + local = full_logits[row_ids] + local = local.clone().requires_grad_() + original = dict(batch) + if not is_tp: + for key in ["advantages", "rollout_log_probs"]: + batch[key] = [ + slice_log_prob_with_cp(x, t, r) + for x, t, r in zip(batch[key], batch["total_lengths"], batch["response_lengths"], strict=True) + ] + reducer = get_sum_of_sample_mean( + batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] + ) + loss, _ = policy_loss_function(a, batch, local.unsqueeze(0), reducer) + ref_logits = full_logits.clone().requires_grad_() + expected_loss = top_p_reference(ref_logits.unsqueeze(0), original, a) + loss.backward() + expected_loss.backward() + expected = ( + ref_logits.grad[:, rank * 6 : (rank + 1) * 6] + if is_tp + else ref_logits.grad[rank * 8 : (rank + 1) * 8] if layout == "allgather" else ref_logits.grad[row_ids] + ) + torch.testing.assert_close(local.grad, expected, atol=2e-6, rtol=2e-6) + finally: + dist.destroy_process_group() + + +@pytest.mark.parametrize("layout", ["tp", "zigzag", "allgather"]) +@pytest.mark.parametrize("mode", ["none", "tis", "mis"]) +def test_distributed_exact_gradients(layout, mode): + torch.multiprocessing.spawn( + top_p_distributed_worker, args=(2, _cp_dist_helpers.free_port(), layout, mode), nprocs=2 + ) + + +def top_p_meta(): + return dict( + score_centering_top_p=( + np.asarray([1, 4, 2], dtype=np.int32), + np.asarray([0, 2, 3], dtype=np.int32), + np.asarray(np.log([0.3, 0.7, 1.0]), dtype=np.float32), + ) + ) + + +def test_top_p_resume_and_masked_environment(): + from vime.utils.score_centering import validate_sampler_top_p + from vime.utils.types import Sample + + a = args(rollout_top_p=0.95) + sample = Sample(tokens=[8]) + sample.append_response_tokens(a, tokens=[9], trainable=False) + for _ in range(2): + sample.append_response_tokens(a, tokens=[4, 2], log_probs=[float(np.log(0.7)), 0], meta_info=top_p_meta()) + sample.append_response_tokens(a, tokens=[9], trainable=False) + sample = Sample.from_dict(sample.to_dict()) + assert sample.loss_mask == [0, 1, 1, 0, 1, 1, 0] + assert sample.rollout_top_p_token_offsets.tolist() == [0, 0, 2, 3, 3, 5, 6, 6] + validate_sampler_top_p( + sample.rollout_top_p_token_ids, + sample.rollout_top_p_token_offsets, + sample.rollout_top_p_log_probs, + sample.response_length, + sample.loss_mask, + sample.tokens[-sample.response_length :], + sample.rollout_log_probs, + ) + assert sample.rollout_topk_token_ids is None + + +@pytest.mark.parametrize("corruption", ["missing", "partial", "duplicate", "offsets", "nan", "sampled"]) +def test_invalid_support_rejected(corruption): + from vime.utils.score_centering import validate_sampler_top_p + + ids, offsets, q = [1, 2], [0, 2], np.log([0.3, 0.7]) + if corruption == "missing": + q = None + elif corruption == "partial": + q = np.log([0.2, 0.4]) + elif corruption == "duplicate": + ids = [1, 1] + elif corruption == "offsets": + offsets = [0, 1] + elif corruption == "nan": + q[0] = np.nan + with pytest.raises(ValueError): + validate_sampler_top_p( + ids, offsets, q, 1, tokens=[3] if corruption == "sampled" else [2], sampled_logps=[float(np.log(0.7))] + ) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py new file mode 100644 index 000000000..f04a9ae8f --- /dev/null +++ b/tests/test_score_centering_transport.py @@ -0,0 +1,344 @@ +"""Sampler heads survive the real rollout-manager, DP and microbatch boundaries.""" + +import asyncio +import sys +import types +from types import SimpleNamespace + +import _cp_dist_helpers # noqa: F401 +import numpy as np +import pytest +import torch +from test_score_centering import args, meta + +from vime.observability.rollout_data_utils import tensorize_rollout_data_for_training +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +@pytest.fixture(autouse=True) +def no_gpu_server_imports(monkeypatch): + deployment = types.ModuleType("vime.backends.vllm_utils.deployment") + deployment.start_rollout_servers = lambda *args: None + monkeypatch.setitem(sys.modules, deployment.__name__, deployment) + if "vllm_router" not in sys.modules: + monkeypatch.setitem(sys.modules, "vllm_router", SimpleNamespace(__version__="0.3.0")) + + +def manager(**overrides): + from vime.ray.rollout import RolloutManager + + cls = RolloutManager.__ray_metadata__.modified_class + result = cls.__new__(cls) + result.args = args(**overrides) + result.custom_convert_samples_to_train_data_func = None + result._post_process_rewards = lambda samples: ([1.0] * len(samples), [1.0] * len(samples)) + return result + + +def samples(): + result = [] + for i in range(2): + sample = Sample(index=i, tokens=[9]) + sample.append_response_tokens(args(), tokens=[3], log_probs=[-0.5], meta_info=meta()) + result.append(sample) + return result + + +def test_topk_training_transport_and_microbatch_order(monkeypatch): + packed = types.ModuleType("megatron.core.packed_seq_params") + packed.PackedSeqParams = object + training = types.ModuleType("megatron.training") + training.get_args = lambda: None + monkeypatch.setitem(sys.modules, "megatron.core.packed_seq_params", packed) + monkeypatch.setitem(sys.modules, "megatron.training", training) + from vime.backends.megatron_utils.data import DataIterator + + data = samples() + data[1].rollout_topk_token_ids[0] = [5, 6, 7] + batch = manager()._convert_samples_to_train_data(data) + tensorize_rollout_data_for_training(batch) + assert batch["rollout_topk_token_ids"][0].dtype == torch.int32 + assert batch["rollout_topk_log_probs"][0].dtype == torch.float32 + iterator = DataIterator(batch, micro_batch_indices=[[1], [0]]) + keys = ["rollout_topk_token_ids", "rollout_topk_log_probs", "rollout_log_probs"] + assert iterator.get_next(keys)["rollout_topk_token_ids"][0].tolist() == [[5, 6, 7]] + assert iterator.get_next(keys)["rollout_topk_token_ids"][0].tolist() == [[3, 1, 4]] + + +@pytest.mark.parametrize("field", ["rollout_topk_token_ids", "rollout_topk_log_probs", "rollout_log_probs"]) +def test_missing_sampler_metadata_rejected_by_manager(field): + data = samples() + setattr(data[1], field, None) + with pytest.raises(ValueError, match="Score centering"): + manager()._convert_samples_to_train_data(data) + + +def test_generate_requests_sampler_topk(monkeypatch): + from vime.rollout import vllm_rollout as rollout + + a = args( + hf_checkpoint="model", + vllm_router_ip="localhost", + vllm_router_port=1234, + use_rollout_routing_replay=False, + ci_test=False, + ) + monkeypatch.setattr( + rollout, + "GenerateState", + lambda _: SimpleNamespace(tokenizer=SimpleNamespace(decode=lambda *_args, **_kwargs: "x"), processor=None), + ) + monkeypatch.setattr(rollout, "_prepare_prompt_ids", lambda *_: [9]) + captured = [] + + async def post(url, payload, **kwargs): + captured.append((payload, kwargs)) + return { + "choices": [ + { + "token_ids": [3], + "finish_reason": "stop", + "logprobs": { + "content": [ + { + "logprob": -0.5, + "top_logprobs": [ + {"token": "token_id:9", "logprob": -4.0}, + {"token": "token_id:4", "logprob": -3.0}, + {"token": "token_id:1", "logprob": -2.0}, + {"token": "token_id:3", "logprob": -0.5}, + ], + } + ] + }, + } + ], + "usage": {}, + } + + monkeypatch.setattr(rollout, "post", post) + sample = asyncio.run(rollout.generate(a, Sample(prompt="test"), {"max_new_tokens": 8})) + payload, kwargs = captured[0] + assert payload["sampling_params"]["logprobs"] == 4 + assert kwargs == {"headers": None} + assert sample.rollout_topk_token_ids.tolist() == [[3, 1, 4]] + + +def test_streaming_score_centering_rejected(): + from vime.rollout.vllm_streaming_rollout import generate_streaming + + with pytest.raises(ValueError, match="streaming"): + asyncio.run(generate_streaming(args(), Sample(), {})) + + +@pytest.mark.parametrize("transport", ["object-store", "nixl"]) +def test_dp_transport_keeps_heads_aligned(monkeypatch, transport): + from vime.ray import rollout + + mgr = manager(rollout_data_transport=transport, global_batch_size=2) + mgr.train_parallel_config = {"dp_size": 2} + monkeypatch.setattr(rollout, "build_dp_schedule", lambda *a, **kw: ([[1], [0]], [[[0]], [[0]]], [1], [2])) + captured = [] + + def put(data, **kwargs): + captured.append(kwargs) + return data + + monkeypatch.setattr(rollout.ray, "put", put) + data = samples() + data[1].rollout_topk_token_ids[0] = [5, 6, 7] + refs = mgr._split_train_data_by_dp(mgr._convert_samples_to_train_data(data)) + assert refs[0].inner["rollout_topk_token_ids"][0].tolist() == [[5, 6, 7]] + assert refs[1].inner["rollout_topk_token_ids"][0].tolist() == [[3, 1, 4]] + assert captured == ([{"_tensor_transport": "nixl"}] * 2 if transport == "nixl" else [{}, {}]) + + +def test_evaluation_preserves_training_score_centering(monkeypatch): + from contextlib import nullcontext + from vime.rollout import vllm_rollout as rollout + + a = args(partial_rollout=False, group_rm=True, custom_generate_function_path=None) + state = SimpleNamespace( + semaphore=asyncio.Semaphore(1), + aborted=False, + active_server_generations=0, + dp_rank_context=lambda: nullcontext(), + ) + flags = [] + state_args = [] + + def get_state(received): + state_args.append(received) + return state + + async def generate(received, sample, params): + flags.append(received.use_score_centering) + return sample + + async def hooks(received, sample, **kwargs): + return sample + + monkeypatch.setattr(rollout, "GenerateState", get_state) + monkeypatch.setattr(rollout, "generate", generate) + monkeypatch.setattr(rollout, "apply_rollout_sample_hooks", hooks) + + async def run(): + await rollout.generate_and_rm(a, Sample(), {"temperature": 0}, evaluation=True) + await rollout.generate_and_rm(a, Sample(), {"temperature": 0.8}) + + asyncio.run(run()) + assert flags == [False, True] + assert all(value is a for value in state_args) + assert a.use_score_centering + + +def test_spilled_heads_survive_buffer_and_debug_dump_lifetimes(tmp_path): + from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data + from vime.utils.routed_experts import cleanup_routed_experts_rollout, link_routed_experts_for_rollout + from vime.utils.score_centering import spill_sampler_topk, validate_sampler_topk + from vime.utils.tensor_store import DiskTensorRef + + a = args(rollout_routed_experts_store_dir=str(tmp_path)) + sample = samples()[0] + spill_sampler_topk(a, sample, 1) + assert isinstance(sample.rollout_topk_token_ids, DiskTensorRef) + link_routed_experts_for_rollout(a, sample, 2) + path = str(tmp_path / "debug.pt") + save_debug_rollout_data(path, [sample], rollout_id=2, evaluation=False) + cleanup_routed_experts_rollout(a, 1) + validate_sampler_topk(sample, 3) + cleanup_routed_experts_rollout(a, 2) + restored = load_debug_rollout_data(path, rollout_id=2)[0] + validate_sampler_topk(restored, 3) + restored.append_response_tokens(a, tokens=[3], log_probs=[-0.5], meta_info=meta()) + assert restored.rollout_topk_token_ids.tolist() == [[3, 1, 4]] * 2 + + +@pytest.mark.parametrize("disk", [False, True]) +def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, disk): + from megatron.core import mpu + from vime.observability import train_metric_utils as metrics + from vime.utils.tensor_store import DiskTensorRef + + for name, value in { + "get_tensor_model_parallel_rank": 0, + "is_pipeline_last_stage": True, + "get_context_parallel_world_size": 1, + "get_data_parallel_world_size": 1, + }.items(): + monkeypatch.setattr(mpu, name, lambda *a, _value=value, **kw: _value, raising=False) + reported = [] + monkeypatch.setattr(metrics, "gather_log_data", lambda name, args, rollout_id, data: reported.append(data)) + batch = manager()._convert_samples_to_train_data(samples()) + tensorize_rollout_data_for_training(batch) + batch.update(total_lengths=[2, 2], global_batch_sizes=[2]) + if disk: + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + batch[key] = [DiskTensorRef.write(x, tmp_path / f"{key}_{i}") for i, x in enumerate(batch[key])] + metrics.log_rollout_data( + 0, args(ci_test=False, log_multi_turn=False, log_passrate=False, log_correct_samples=False), batch + ) + assert "rollout_topk_token_ids" not in reported[0] + assert "rollout_topk_log_probs" not in reported[0] + assert "rollout_log_probs" in reported[0] + + +@pytest.mark.parametrize("transport", ["object-store", "nixl"]) +def test_exact_top_p_transport_and_microbatch(monkeypatch, transport): + import numpy as np + from test_score_centering import top_p_meta + from vime.ray import rollout + + packed = types.ModuleType("megatron.core.packed_seq_params") + packed.PackedSeqParams = object + training = types.ModuleType("megatron.training") + training.get_args = lambda: None + monkeypatch.setitem(sys.modules, "megatron.core.packed_seq_params", packed) + monkeypatch.setitem(sys.modules, "megatron.training", training) + from vime.backends.megatron_utils.data import DataIterator + + mgr = manager(rollout_top_p=0.9, rollout_data_transport=transport, global_batch_size=2) + mgr.train_parallel_config = {"dp_size": 2} + monkeypatch.setattr(rollout, "build_dp_schedule", lambda *a, **kw: ([[1], [0]], [[[0]], [[0]]], [1], [2])) + monkeypatch.setattr(rollout.ray, "put", lambda data, **kwargs: data) + samples = [] + for i in range(2): + sample = Sample(index=i, tokens=[9]) + sample.append_response_tokens( + mgr.args, tokens=[4, 2], log_probs=[float(np.log(0.7)), 0.0], meta_info=top_p_meta() + ) + if i == 1: + sample.append_response_tokens(mgr.args, tokens=[8], trainable=False) + samples.append(sample) + batch = mgr._convert_samples_to_train_data(samples) + refs = mgr._split_train_data_by_dp(batch) + assert refs[0].inner["rollout_top_p_token_offsets"][0].tolist() == [0, 2, 3, 3] + for ref in refs: + tensorize_rollout_data_for_training(ref.inner) + iterator = DataIterator(ref.inner, micro_batch_indices=[[0]]) + data = iterator.get_next(["rollout_top_p_log_probs", "rollout_top_p_token_ids", "rollout_top_p_token_offsets"]) + assert data["rollout_top_p_log_probs"][0].dtype == torch.float32 + torch.testing.assert_close(data["rollout_top_p_log_probs"][0].exp(), torch.tensor([0.3, 0.7, 1.0])) + assert data["rollout_top_p_token_ids"][0].tolist() == [1, 4, 2] + + +def test_generate_requests_complete_top_p_probabilities(monkeypatch): + from vime.rollout import vllm_rollout as rollout + + a = args( + hf_checkpoint="model", + rollout_top_p=0.9, + vllm_router_ip="localhost", + vllm_router_port=1234, + use_rollout_routing_replay=False, + ci_test=False, + ) + monkeypatch.setattr( + rollout, + "GenerateState", + lambda _: SimpleNamespace(tokenizer=SimpleNamespace(decode=lambda *_args, **_kwargs: "x"), processor=None), + ) + monkeypatch.setattr(rollout, "_prepare_prompt_ids", lambda *_: [9]) + + async def post(url, payload, **kwargs): + assert payload["sampling_params"]["logprobs"] == -1 + assert kwargs == {"headers": None} + return { + "choices": [ + { + "token_ids": [4, 2], + "finish_reason": "stop", + "sampling_mask": [[1, 4], [2]], + "logprobs": { + "content": [ + { + "logprob": float(np.log(0.7)), + "top_logprobs": [ + {"token": "token_id:1", "logprob": float(np.log(0.3))}, + {"token": "token_id:4", "logprob": float(np.log(0.7))}, + ], + }, + { + "logprob": -2.0, + "top_logprobs": [ + {"token": "token_id:2", "logprob": -2.0}, + {"token": "token_id:7", "logprob": -3.0}, + ], + }, + ] + }, + } + ], + "usage": {}, + } + + monkeypatch.setattr(rollout, "post", post) + sample = asyncio.run(rollout.generate(a, Sample(prompt="test"), {"max_new_tokens": 8})) + np.testing.assert_allclose(np.exp(sample.rollout_top_p_log_probs), [0.3, 0.7, 1.0], rtol=1e-6) + assert sample.rollout_top_p_token_ids.tolist() == [1, 4, 2] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/utils/test_megatron_role_config.py b/tests/utils/test_megatron_role_config.py index 21f2891df..c91da0ae0 100644 --- a/tests/utils/test_megatron_role_config.py +++ b/tests/utils/test_megatron_role_config.py @@ -2,6 +2,7 @@ import sys import tempfile +import types from argparse import Namespace from pathlib import Path @@ -43,7 +44,6 @@ def _base_args(**overrides): use_critic=False, megatron_config_path=None, start_rollout_id=None, - rollout_global_dataset=False, ) args.update(overrides) return Namespace(**args) @@ -164,16 +164,19 @@ def fake_allocate_train_group( monkeypatch.setattr(placement_group_module, "allocate_train_group", fake_allocate_train_group) monkeypatch.setattr(placement_group_module.ray, "get", lambda value: value) + loaded_rollout_ids = [] + rollout_manager = types.SimpleNamespace(load=types.SimpleNamespace(remote=loaded_rollout_ids.append)) actor_model, critic_model = placement_group_module.create_training_models( args, {"actor": None, "critic": None}, - object(), + rollout_manager, ) assert critic_model is None assert actor_model.args.lr == 1e-6 assert actor_model.create_calls[0]["args"].lr == 1e-6 assert args.start_rollout_id == 7 + assert loaded_rollout_ids == [6] if __name__ == "__main__": diff --git a/train.py b/train.py index 04027d38b..52c9998b0 100644 --- a/train.py +++ b/train.py @@ -76,8 +76,7 @@ def offload_train(actor_trains_this_step): actor_model.save_model(rollout_id, force_sync=force_sync) if args.use_critic: critic_model.save_model(rollout_id, force_sync=force_sync) - if args.rollout_global_dataset: - ray.get(rollout_manager.save.remote(rollout_id)) + ray.get(rollout_manager.save.remote(rollout_id)) ray.get(rollout_manager.cleanup_rollout_data.remote(rollout_id)) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 527fb117c..023d52eb3 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -262,6 +262,21 @@ def _get_rollout_data(self, rollout_data_ref: Box) -> RolloutBatch: strict=False, ) ] + for key, dtype in (("rollout_topk_token_ids", torch.int32), ("rollout_topk_log_probs", torch.float32)): + if key not in rollout_data: + continue + rollout_data[key] = [ + ( + value + if isinstance(value, DiskTensorRef) + else (value if self.args.allgather_cp else slice_log_prob_with_cp(value, total, response)).to( + device="cpu", dtype=dtype + ) + ) + for value, total, response in zip( + rollout_data[key], rollout_data["total_lengths"], rollout_data["response_lengths"], strict=True + ) + ] return rollout_data def _switch_model(self, target_tag: str) -> None: diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index f4ad09898..8f2260848 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -12,6 +12,8 @@ from vime.utils.misc import load_function from vime.utils.ppo_utils import ( calculate_log_probs_and_entropy, + calculate_ragged_log_probs, + calculate_topk_log_probs, compute_approx_kl, compute_cispo_loss, compute_gspo_kl, @@ -19,9 +21,13 @@ compute_policy_loss, get_advantages_and_returns_batch, get_grpo_returns, + get_pg_loss_type, get_reinforce_plus_plus_baseline_advantages, get_reinforce_plus_plus_returns, + importance_weights, ) +from vime.utils.score_centering import get_score_centering_is_config, score_centering_correction +from vime.utils.tensor_store import DiskTensorRef from vime.utils.types import RolloutBatch from .cp_utils import ( @@ -893,7 +899,7 @@ def vanilla_tis_function( old_log_probs = torch.cat(train_log_probs, dim=0) tis = torch.exp(old_log_probs - rollout_log_probs) tis_abs = (torch.exp(old_log_probs - rollout_log_probs) - 1).abs() - tis_weights = torch.clamp(tis, min=args.tis_clip_low, max=args.tis_clip) + tis_weights = importance_weights(tis, mode="tis", low=args.tis_clip_low, high=args.tis_clip) tis_clipfrac = (tis_weights != tis).float() metrics = { "tis": tis.clone().detach(), @@ -917,9 +923,7 @@ def icepop_function( old_log_probs = torch.cat(train_log_probs, dim=0) ice_ratio = torch.exp(old_log_probs - rollout_log_probs) ice_abs = (torch.exp(old_log_probs - rollout_log_probs) - 1).abs() - ice_weight = torch.where( - (ice_ratio >= args.tis_clip_low) & (ice_ratio <= args.tis_clip), ice_ratio, torch.zeros_like(ice_ratio) - ) + ice_weight = importance_weights(ice_ratio, mode="mis", low=args.tis_clip_low, high=args.tis_clip) ice_clipfrac = (ice_weight != ice_ratio).float() metrics = { "tis": ice_ratio.clone().detach(), @@ -930,19 +934,167 @@ def icepop_function( return pg_loss, loss_masks, metrics +def _slice_allgather_response_rows( + values: list[torch.Tensor], + total_lengths: list[int], + response_lengths: list[int], + logits_local_len: int, +) -> list[torch.Tensor]: + """Slice full per-sample `[R, ...]` response tensors to this rank's rows under + allgather-CP, mirroring the allgather branch of `get_responses`.""" + cp_rank = mpu.get_context_parallel_rank() + chunk_start = cp_rank * logits_local_len + chunk_end = chunk_start + logits_local_len + + sliced = [] + seq_start = 0 + for value, total_length, response_length in zip(values, total_lengths, response_lengths, strict=True): + prompt_length = total_length - response_length + logit_global_start = seq_start + prompt_length - 1 + logit_global_end = seq_start + total_length - 1 + + s = max(logit_global_start, chunk_start) + e = min(logit_global_end, chunk_end) + sliced.append(value[0:0] if e <= s else value[s - logit_global_start : e - logit_global_start]) + seq_start += total_length + return sliced + + +def get_score_centering_terms(args, batch, logits): + """Compute per-token centering corrections and head-mass diagnostics. + + With top-p replay, sum over the complete recorded support. Otherwise use + the paper's top-k approximation. Redistribute the scalar correction before + multiplying by zigzag-CP advantages. + """ + total_lengths, response_lengths = batch["total_lengths"], batch["response_lengths"] + if args.rollout_top_p < 1: + if batch.get("rollout_top_p_log_probs") is None or batch.get("rollout_log_probs") is None: + raise ValueError("Top-p score centering requires complete sampler top-p and sampled-token logprobs.") + get_rollout_top_p_logprob_kwargs(args, batch) + # Ragged replay data remains complete on CPU across CP ranks. Slice row + # indices first, then gather only the supports consumed by this rank. + row_indices = [torch.arange(length) for length in response_lengths] + allgather_cp = args.allgather_cp and mpu.get_context_parallel_world_size() > 1 + if allgather_cp: + row_indices = _slice_allgather_response_rows(row_indices, total_lengths, response_lengths, logits.size(1)) + else: + row_indices = [ + slice_log_prob_with_cp(indices, total, response) + for indices, total, response in zip(row_indices, total_lengths, response_lengths, strict=True) + ] + weighting = get_score_centering_is_config(args) + res = {"sc_correction": [], "sc_sampler_head_mass": [], "sc_train_head_mass": []} + for (rows, _), indices, ids, offsets, q in zip( + get_responses( + logits, + args=args, + unconcat_tokens=batch["unconcat_tokens"], + total_lengths=total_lengths, + response_lengths=response_lengths, + apply_temperature=False, + ), + row_indices, + batch["rollout_top_p_token_ids"], + batch["rollout_top_p_token_offsets"], + batch["rollout_top_p_log_probs"], + strict=True, + ): + offsets = torch.as_tensor(offsets, device="cpu", dtype=torch.long) + spans = [torch.arange(offsets[i], offsets[i + 1]) for i in indices.tolist()] + selected = torch.cat(spans) if spans else torch.empty(0, dtype=torch.long) + lengths = offsets[indices + 1] - offsets[indices] + local_offsets = torch.cat((lengths.new_zeros(1), lengths.cumsum(0))).to(logits.device) + head_ids = torch.as_tensor(ids)[selected].to(device=logits.device, dtype=torch.long) + q = torch.as_tensor(q)[selected].to(device=logits.device, dtype=torch.float32) + p = calculate_ragged_log_probs( + rows, head_ids, local_offsets, mpu.get_tensor_model_parallel_group(), args.rollout_temperature + ) + row_ids = torch.repeat_interleave(torch.arange(len(rows), device=logits.device), lengths.to(logits.device)) + with torch.no_grad(): + coefficients = q.exp() * importance_weights((p - q).exp(), **weighting) + q_mass = q.new_zeros(len(rows)).scatter_add_(0, row_ids, q.exp()) + p_mass = p.new_zeros(len(rows)).scatter_add_(0, row_ids, p.exp()) + correction = p.new_zeros(len(rows)).scatter_add_(0, row_ids, coefficients * p) + for key, value in zip(res, (correction, q_mass, p_mass), strict=True): + res[key].append(value) + if allgather_cp: + _allgather_cp_redistribute( + res, logits_local_len=logits.size(1), total_lengths=total_lengths, response_lengths=response_lengths + ) + return {key: torch.cat(values) for key, values in res.items()} + ids, sampler_head = batch.get("rollout_topk_token_ids"), batch.get("rollout_topk_log_probs") + if ids is None or sampler_head is None or batch.get("rollout_log_probs") is None: + raise ValueError("Score centering requires sampler top-k ids, top-k logprobs, and sampled-token logprobs.") + allgather_cp = args.allgather_cp and mpu.get_context_parallel_world_size() > 1 + if allgather_cp: + ids = _slice_allgather_response_rows(ids, total_lengths, response_lengths, logits.size(1)) + sampler_head = _slice_allgather_response_rows(sampler_head, total_lengths, response_lengths, logits.size(1)) + else: + # Resident values were CP-sliced by the actor. Disk references stay full + # until this microbatch, then read only this rank's zigzag response rows. + def local_heads(values): + return [ + ( + ( + value[:] + if mpu.get_context_parallel_world_size() == 1 + else slice_log_prob_with_cp(value, total, response) + ) + if isinstance(value, DiskTensorRef) + else value + ) + for value, total, response in zip(values, total_lengths, response_lengths, strict=True) + ] + + ids, sampler_head = local_heads(ids), local_heads(sampler_head) + weighting = get_score_centering_is_config(args) + res = {"sc_correction": [], "sc_sampler_head_mass": [], "sc_train_head_mass": []} + for (rows, _), head_ids, q in zip( + get_responses( + logits, + args=args, + unconcat_tokens=batch["unconcat_tokens"], + total_lengths=total_lengths, + response_lengths=response_lengths, + apply_temperature=False, + ), + ids, + sampler_head, + strict=True, + ): + head_ids = head_ids.to(device=logits.device, dtype=torch.long, non_blocking=True) + q = q.to(device=logits.device, non_blocking=True) + p = calculate_topk_log_probs( + rows, + head_ids, + mpu.get_tensor_model_parallel_group(), + chunk_size=args.log_probs_chunk_size, + temperature=args.rollout_temperature, + ) + correction, q_mass, p_mass = score_centering_correction(p, q, **weighting) + for key, value in zip(res, (correction, q_mass, p_mass), strict=True): + res[key].append(value) + if allgather_cp: + _allgather_cp_redistribute( + res, logits_local_len=logits.size(1), total_lengths=total_lengths, response_lengths=response_lengths + ) + return {key: torch.cat(values) for key, values in res.items()} + + def policy_loss_function( args: Namespace, batch: RolloutBatch, logits: torch.Tensor, sum_of_sample_mean: Callable[[torch.Tensor], torch.Tensor], ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: - """Compute policy loss (PPO/GSPO) and metrics. + """Compute policy loss (PPO/GSPO, CISPO or REINFORCE) and metrics. Computes current log-probabilities and entropy from model logits, then - calculates PPO-style clipped policy gradient loss. For GSPO, gathers + calculates the configured policy gradient loss. For GSPO, gathers full sequences via context-parallel all-gather before computing per-sample KL. Optionally applies TIS (Truncated Importance Sampling) correction and - adds KL loss term if configured. + adds score centering and a KL loss term if configured. Args: args: Configuration controlling advantage estimator, clipping thresholds, @@ -960,6 +1112,8 @@ def policy_loss_function( "tis", "ois", "tis_clipfrac" are included when the respective features are enabled. """ + pg_loss_type = get_pg_loss_type(args) + use_score_centering = getattr(args, "use_score_centering", False) advantages = torch.cat(batch["advantages"], dim=0) old_log_probs = batch["rollout_log_probs"] if args.use_rollout_logprobs else batch.get("log_probs") @@ -984,7 +1138,7 @@ def policy_loss_function( if not args.use_rollout_logprobs and not old_log_probs: old_log_probs = [log_prob.detach() for log_prob in log_probs] train_log_probs_for_tis = batch.get("log_probs") - if not train_log_probs_for_tis: + if pg_loss_type == "reinforce" or not train_log_probs_for_tis: train_log_probs_for_tis = [log_prob.detach() for log_prob in log_probs] # Pre-gather log probs if needed by OPSM or GSPO to avoid duplicate gathering @@ -1031,7 +1185,10 @@ def policy_loss_function( log_probs = torch.cat(log_probs, dim=0) ppo_kl = old_log_probs - log_probs - if args.advantage_estimator == "cispo": + if pg_loss_type == "reinforce": + pg_loss = -advantages.detach() * log_probs + pg_clipfrac = torch.zeros_like(log_probs) + elif pg_loss_type == "cispo": pg_loss, pg_clipfrac = compute_cispo_loss(ppo_kl, log_probs, advantages, args.eps_clip, args.eps_clip_high) else: pg_loss, pg_clipfrac = compute_policy_loss( @@ -1090,6 +1247,16 @@ def policy_loss_function( args.calculate_per_token_loss, ) + sc_terms = {} + if use_score_centering: + # Add the correction AFTER weighting the sampled score. The head/tail + # coefficients already contain the same IS rule and must not be weighted twice. + sc_terms = get_score_centering_terms(args, batch, logits) + pg_loss = pg_loss + advantages.detach() * sc_terms["sc_correction"] + with torch.no_grad(): + sampled_ratio = (log_probs - torch.cat(batch["rollout_log_probs"])).exp() + sc_terms["sc_importance_weight"] = importance_weights(sampled_ratio, **get_score_centering_is_config(args)) + # Determine pg_loss reducer: use custom if specified, otherwise default if getattr(args, "custom_pg_loss_reducer_function_path", None) is not None: custom_pg_loss_reducer_func = load_function(args.custom_pg_loss_reducer_function_path) @@ -1135,7 +1302,7 @@ def policy_loss_function( train_rollout_logprob_abs_diff = None if "rollout_log_probs" in batch and batch["rollout_log_probs"]: rollout_log_probs = torch.cat(batch["rollout_log_probs"], dim=0) - log_probs_to_compare = log_probs if args.use_rollout_logprobs else old_log_probs + log_probs_to_compare = log_probs if args.use_rollout_logprobs or use_score_centering else old_log_probs train_rollout_logprob_abs_diff = sum_of_sample_mean((log_probs_to_compare - rollout_log_probs).abs()) reported_loss = { @@ -1146,6 +1313,9 @@ def policy_loss_function( "ppo_kl": ppo_kl.clone().detach(), } + for key, value in sc_terms.items(): + reported_loss[key] = sum_of_sample_mean(value.detach()) + if train_rollout_logprob_abs_diff is not None: reported_loss["train_rollout_logprob_abs_diff"] = train_rollout_logprob_abs_diff.clone().detach() diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index 5f625e19f..b80d5f96b 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -594,6 +594,9 @@ def forward_step(data_iterator: DataIterator, model: GPTModel, return_schedule_p "advantages", "returns", "rollout_log_probs", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + "rollout_top_p_log_probs", "teacher_log_probs", "rollout_mask_sums", # Only present when dumping train debug data; lets the loss diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index 21a674034..0230cac5e 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -686,6 +686,8 @@ def _compute_server_args( kwargs["per_request_spec_decode_metrics"] = "summary" if getattr(args, "rollout_top_p", 1.0) != 1.0: kwargs["return_sampling_mask"] = True + if getattr(args, "use_score_centering", False) and getattr(args, "rollout_top_p", 1.0) == 1.0: + kwargs["max_logprobs"] = args.score_centering_top_k + 1 if args.fp16: kwargs["dtype"] = "float16" diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index 2ad2e9996..f2338540b 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -6,7 +6,7 @@ import torch from vime.utils.routed_experts import validate_routed_experts_value -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, retain_debug_tensor_refs from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -15,8 +15,11 @@ "tokens": torch.long, "loss_masks": torch.int, "rollout_log_probs": torch.float32, + "rollout_topk_token_ids": torch.int32, + "rollout_topk_log_probs": torch.float32, "rollout_top_p_token_ids": torch.int32, "rollout_top_p_token_offsets": torch.int32, + "rollout_top_p_log_probs": torch.float32, "teacher_log_probs": torch.float32, "rollout_routed_experts": None, } @@ -136,4 +139,6 @@ def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: else: samples = [sample.to_dict() for sample in data] - torch.save({"rollout_id": rollout_id, "samples": samples}, path) + dump_data = {"rollout_id": rollout_id, "samples": samples} + retain_debug_tensor_refs(dump_data, path) + torch.save(dump_data, path) diff --git a/vime/observability/train_data_utils.py b/vime/observability/train_data_utils.py index 54b9b7937..2036ac7a3 100644 --- a/vime/observability/train_data_utils.py +++ b/vime/observability/train_data_utils.py @@ -5,6 +5,8 @@ import torch +from vime.utils.tensor_store import DiskTensorRef, retain_debug_tensor_refs + logger = logging.getLogger(__name__) @@ -22,6 +24,41 @@ ) +_SAMPLER_HEAD_FIELDS = ("rollout_topk_token_ids", "rollout_topk_log_probs") + + +def _gather_sampler_heads_with_cp(value, total_length, response_length): + """Restore CPU heads without casting integer ids or using autograd collectives.""" + from megatron.core import mpu + + from vime.backends.megatron_utils.cp_utils import get_logits_and_tokens_offset_with_cp + + if mpu.get_context_parallel_world_size() == 1: + return value.detach().cpu() + group = mpu.get_context_parallel_group() + device = ( + torch.device("cuda", torch.cuda.current_device()) + if torch.distributed.get_backend(group) == "nccl" + else value.device + ) + # Bound extra device storage to one sample; disk-backed heads never enter + # this path. NCCL requires CUDA tensors even though heads live on CPU. + full = torch.zeros((response_length, *value.shape[1:]), dtype=value.dtype, device=device) + _, _, offsets, _ = get_logits_and_tokens_offset_with_cp(total_length, response_length) + prompt_length = total_length - response_length + offset = 0 + for start, end in offsets: + count = end - start + if count: + full[start - prompt_length + 1 : end - prompt_length + 1] = value[offset : offset + count].to(device) + offset += count + if offset != value.size(0): + raise ValueError(f"Sampler head CP shard has {value.size(0)} rows, expected {offset}.") + if full.numel(): + torch.distributed.all_reduce(full, group=group) + return full + + def _to_cpu(value): if torch.is_tensor(value): return value.detach().cpu() @@ -40,6 +77,7 @@ def restore_context_parallel_fields_to_cpu( gather_tensor: Callable[[torch.Tensor, int, int], torch.Tensor], *, keep_restored: bool, + allgather_cp: bool = False, ) -> dict[str, Any] | None: """Restore CP fields one tensor at a time, retaining CPU values only on the writer.""" total_lengths = rollout_data["total_lengths"] @@ -52,10 +90,34 @@ def restore_context_parallel_fields_to_cpu( ) restored = ( - {key: _to_cpu(value) for key, value in rollout_data.items() if key not in _CONTEXT_PARALLEL_FIELDS} + { + key: _to_cpu(value) + for key, value in rollout_data.items() + if key not in _CONTEXT_PARALLEL_FIELDS + _SAMPLER_HEAD_FIELDS + } if keep_restored else None ) + for key in _SAMPLER_HEAD_FIELDS: + values = rollout_data.get(key) + if values is None: + continue + if len(values) != num_samples: + raise ValueError(f"Sampler head field {key!r} must contain one value per sample.") + full_values = [] if keep_restored else None + for value, total_length, response_length in zip(values, total_lengths, response_lengths, strict=True): + # Disk references and allgather-CP heads retain full response rows. + if isinstance(value, DiskTensorRef) or allgather_cp: + full_value = value + if len(full_value) != response_length: + raise ValueError(f"Full sampler head field {key!r} must have {response_length} rows.") + else: + full_value = _gather_sampler_heads_with_cp(value, int(total_length), int(response_length)) + if keep_restored: + full_values.append(_to_cpu(full_value)) + del full_value + if keep_restored: + restored[key] = full_values for key in _CONTEXT_PARALLEL_FIELDS: values = rollout_data.get(key) if values is None: @@ -209,6 +271,7 @@ def save_debug_train_data(args, *, rollout_id, rollout_data): rollout_data, all_gather_with_cp, keep_restored=cp_rank == 0, + allgather_cp=getattr(args, "allgather_cp", False), ) if cp_rank != 0: return @@ -239,4 +302,6 @@ def save_debug_train_data(args, *, rollout_id, rollout_data): path = Path(path_template.format(rollout_id=rollout_id, rank=rank)) logger.info(f"Save debug train data from {dp_size} DP shard(s) to {path}") path.parent.mkdir(parents=True, exist_ok=True) - torch.save(_build_dump_payload(dp_shards, rollout_id=rollout_id, writer_rank=rank), path) + dump_data = _build_dump_payload(dp_shards, rollout_id=rollout_id, writer_rank=rank) + retain_debug_tensor_refs(dump_data, path) + torch.save(dump_data, path) diff --git a/vime/observability/train_metric_utils.py b/vime/observability/train_metric_utils.py index 0d42c1bcb..a9d43844f 100644 --- a/vime/observability/train_metric_utils.py +++ b/vime/observability/train_metric_utils.py @@ -166,8 +166,11 @@ def log_rollout_data( "sample_indices", "rollout_ids", "rollout_mask_sums", + "rollout_topk_token_ids", + "rollout_topk_log_probs", "rollout_top_p_token_ids", "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", "rollout_routed_experts", "global_batch_sizes", "num_microbatches", diff --git a/vime/ray/placement_group.py b/vime/ray/placement_group.py index 145677b62..ef88e2b11 100644 --- a/vime/ray/placement_group.py +++ b/vime/ray/placement_group.py @@ -218,8 +218,7 @@ def create_training_models(args, pgs, rollout_manager, actor_cls=None): if args.start_rollout_id is None: args.start_rollout_id = start_rollout_ids[0] - if args.rollout_global_dataset: - ray.get(rollout_manager.load.remote(args.start_rollout_id - 1)) + ray.get(rollout_manager.load.remote(args.start_rollout_id - 1)) return actor_model, critic_model diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index ecbe67a30..07304ee7b 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -192,7 +192,6 @@ def get_updatable_engines_and_lock(self): return engines, self.rollout_engine_lock, num_new, gpu_counts, gpu_offsets, parallel_configs def get_num_rollout_per_epoch(self): - assert self.args.rollout_global_dataset return len(self.data_source) // self.args.rollout_batch_size def generate(self, rollout_id): @@ -204,9 +203,10 @@ def generate(self, rollout_id): self._try_ci_fault_injection() data, metrics = self._get_rollout_data(rollout_id=rollout_id) disk_routes = [ - sample.rollout_routed_experts + value for sample in data - if isinstance(sample.rollout_routed_experts, DiskTensorRef) + for value in (sample.rollout_routed_experts, sample.rollout_topk_token_ids, sample.rollout_topk_log_probs) + if isinstance(value, DiskTensorRef) ] if disk_routes: self._active_routed_experts_rollouts.add(rollout_id) @@ -450,6 +450,34 @@ def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sampl if samples[0].rollout_log_probs is not None: train_data["rollout_log_probs"] = [sample.rollout_log_probs for sample in samples] + if getattr(self.args, "use_score_centering", False): + from vime.utils.score_centering import validate_sampler_top_p, validate_sampler_topk + + for sample in samples: + if sample.rollout_log_probs is None or len(sample.rollout_log_probs) != sample.response_length: + raise ValueError("Score centering requires sampler logprobs for every response token.") + if self.args.rollout_top_p < 1: + if sample.response_length == 0 and sample.rollout_top_p_token_ids is None: + sample.rollout_top_p_token_ids = torch.empty(0, dtype=torch.int32) + sample.rollout_top_p_token_offsets = torch.zeros(1, dtype=torch.int32) + sample.rollout_top_p_log_probs = torch.empty(0, dtype=torch.float32) + validate_sampler_top_p( + sample.rollout_top_p_token_ids, + sample.rollout_top_p_token_offsets, + sample.rollout_top_p_log_probs, + sample.response_length, + sample.loss_mask, + sample.tokens[-sample.response_length :] if sample.response_length else [], + sample.rollout_log_probs, + ) + else: + validate_sampler_topk(sample, self.args.score_centering_top_k) + if self.args.rollout_top_p < 1: + train_data["rollout_top_p_log_probs"] = [sample.rollout_top_p_log_probs for sample in samples] + else: + train_data["rollout_topk_token_ids"] = [sample.rollout_topk_token_ids for sample in samples] + train_data["rollout_topk_log_probs"] = [sample.rollout_topk_log_probs for sample in samples] + if getattr(self.args, "rollout_top_p", 1.0) != 1.0: for sample in samples: assert sample.rollout_top_p_token_ids is not None @@ -459,10 +487,9 @@ def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sampl f"!= response length + 1 {sample.response_length + 1}" ) offset_end = int(sample.rollout_top_p_token_offsets[-1]) - assert offset_end == len(sample.rollout_top_p_token_ids), ( - f"top-p token offsets[-1] {offset_end} " - f"!= token ids length {len(sample.rollout_top_p_token_ids)}" - ) + assert offset_end == len( + sample.rollout_top_p_token_ids + ), f"top-p token offsets[-1] {offset_end} != token ids length {len(sample.rollout_top_p_token_ids)}" train_data["rollout_top_p_token_ids"] = [sample.rollout_top_p_token_ids for sample in samples] train_data["rollout_top_p_token_offsets"] = [sample.rollout_top_p_token_offsets for sample in samples] @@ -572,8 +599,11 @@ def _split_train_data_by_dp(self, data): "rollout_ids", "rollout_mask_sums", "rollout_log_probs", + "rollout_topk_token_ids", + "rollout_topk_log_probs", "rollout_top_p_token_ids", "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", "rollout_routed_experts", "source_names", "prompt", diff --git a/vime/rollout/data_source.py b/vime/rollout/data_source.py index 6ba3723a2..5dfa2c351 100644 --- a/vime/rollout/data_source.py +++ b/vime/rollout/data_source.py @@ -58,7 +58,7 @@ def __init__(self, args): # TODO remove this self.metadata = {} - if args.rollout_global_dataset and args.prompt_data is not None: + if args.prompt_data is not None: tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True) processor = load_processor(args.hf_checkpoint, trust_remote_code=True) @@ -121,9 +121,6 @@ def add_samples(self, samples: list[list[Sample]]): raise RuntimeError(f"Cannot add samples to {self.__class__.__name__}. This is a read-only data source.") def save(self, rollout_id): - if not self.args.rollout_global_dataset: - return - state_dict = { "sample_offset": self.sample_offset, "epoch_id": self.epoch_id, @@ -136,9 +133,6 @@ def save(self, rollout_id): torch.save(state_dict, path) def load(self, rollout_id=None): - if not self.args.rollout_global_dataset: - return - if self.args.load is None: return @@ -156,7 +150,7 @@ def load(self, rollout_id=None): self.sample_index = state_dict.get("sample_index", 0) self.metadata = state_dict.get("metadata", {}) - if self.args.rollout_global_dataset and self.args.rollout_shuffle and self.dataset is not None: + if self.args.rollout_shuffle and self.dataset is not None: self.dataset.shuffle(self.epoch_id) def __len__(self) -> int: diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index 5608d89e9..333b2e234 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -219,7 +219,6 @@ def _cb(done_task: asyncio.Task) -> None: async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> RolloutFnTrainOutput | list[list[Sample]]: - assert args.rollout_global_dataset filters_enabled = bool( getattr(args, "dynamic_sampling_filter_path", None) or getattr(args, "rollout_sample_filter_path", None) ) diff --git a/vime/rollout/sft_rollout.py b/vime/rollout/sft_rollout.py index 67e30ea63..393bc0894 100644 --- a/vime/rollout/sft_rollout.py +++ b/vime/rollout/sft_rollout.py @@ -27,7 +27,6 @@ def generate_rollout(args, rollout_id, data_buffer, evaluation=False): list[Sample]: a list of samples generated by the rollout """ assert not evaluation - assert args.rollout_global_dataset global TOKENIZER, PROCESSOR, MASK_GENERATOR, SAMPLE_PRINTED if TOKENIZER is None: diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index c2397e850..5cc8d6694 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -207,13 +207,13 @@ def submit_generate_tasks(self, samples: list[list[Sample]]) -> None: self.remaining_batch_size += len(samples) -def _build_inference_sampling_params(sampling_params: dict[str, Any]) -> dict[str, Any]: +def _build_inference_sampling_params(sampling_params: dict[str, Any], logprobs: int = 1) -> dict[str, Any]: """Map rollout ``sampling_params`` to vLLM ``/inference/v1/generate`` body.""" sp: dict[str, Any] = { "max_tokens": sampling_params["max_new_tokens"], "temperature": sampling_params["temperature"], "top_p": sampling_params["top_p"], - "logprobs": 1, + "logprobs": logprobs, } tk = sampling_params.get("top_k") if tk is not None and (tk > 0 or tk == -1): @@ -253,6 +253,53 @@ def _inference_generate_tokens_and_logprobs(choice: dict[str, Any]) -> tuple[lis return token_ids, log_probs +def _score_centering_metadata( + choice: dict[str, Any], token_ids: list[int], *, top_p: float, top_k: int +) -> tuple[dict[str, Any], list[float] | None]: + content = (choice.get("logprobs") or {}).get("content") or [] + if len(content) != len(token_ids): + raise ValueError("Score centering requires top logprobs for every generated token.") + + rows = [] + for item in content: + row = {} + for entry in item.get("top_logprobs") or []: + token = entry.get("token", "") + if token.startswith("token_id:"): + row[int(token.removeprefix("token_id:"))] = float(entry["logprob"]) + rows.append(row) + + if top_p == 1.0: + heads = [sorted(row.items(), key=lambda item: item[1], reverse=True)[:top_k] for row in rows] + if any(len(head) != top_k for head in heads): + raise ValueError(f"Score centering requires {top_k} sampler top logprobs per token.") + return { + "score_centering_topk": ( + np.asarray([[token_id for token_id, _ in head] for head in heads], dtype=np.int32), + np.asarray([[logprob for _, logprob in head] for head in heads], dtype=np.float32), + ) + }, None + + support = choice.get("sampling_mask") + if not isinstance(support, list) or len(support) != len(token_ids): + raise ValueError("Top-p score centering requires a sampling mask for every generated token.") + ids = np.asarray([token_id for row in support for token_id in row], dtype=np.int32) + offsets = np.cumsum([0, *(len(row) for row in support)], dtype=np.int32) + normalized_logprobs = [] + sampled_logprobs = [] + for sampled_token, support_ids, row in zip(token_ids, support, rows, strict=True): + try: + values = np.asarray([row[token_id] for token_id in support_ids], dtype=np.float64) + except KeyError as error: + raise ValueError("Top-p score centering requires logprobs for the complete sampling mask.") from error + values -= np.logaddexp.reduce(values) + normalized_logprobs.extend(values) + sampled_logprobs.append(float(values[support_ids.index(sampled_token)])) + return { + "score_centering_top_p": (ids, offsets, np.asarray(normalized_logprobs, dtype=np.float32)) + }, sampled_logprobs + + def _mm_render_response_to_generate_body(render_data: Any, model: str) -> dict[str, Any]: """Turn ``/v1/chat/completions/render`` JSON into a ``/inference/v1/generate`` request body.""" if isinstance(render_data, dict) and isinstance(render_data.get("token_ids"), list): @@ -364,7 +411,13 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A sample.status = Sample.Status.TRUNCATED return sample - inference_sampling_params = _build_inference_sampling_params(sampling_params) + score_centering = getattr(args, "use_score_centering", False) + if score_centering: + from vime.utils.score_centering import score_centering_request + + score_centering_request(args, sampling_params) + logprobs = (-1 if args.rollout_top_p < 1 else args.score_centering_top_k + 1) if score_centering else 1 + inference_sampling_params = _build_inference_sampling_params(sampling_params, logprobs) messages = build_multimodal_messages(sample.prompt, sample.multimodal_inputs) @@ -412,6 +465,17 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A # Parse token_ids and logprobs from vLLM response new_response_tokens, new_response_log_probs = _inference_generate_tokens_and_logprobs(choice) + meta_info = _inference_generate_meta_info(output) + if score_centering: + score_metadata, normalized_logprobs = _score_centering_metadata( + choice, + new_response_tokens, + top_p=args.rollout_top_p, + top_k=args.score_centering_top_k, + ) + meta_info.update(score_metadata) + if normalized_logprobs is not None: + new_response_log_probs = normalized_logprobs # Decode text from token_ids skip_sp = sampling_params.get("skip_special_tokens") @@ -423,7 +487,7 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A tokens=new_response_tokens, log_probs=new_response_log_probs, trainable=True, - meta_info=_inference_generate_meta_info(output), + meta_info=meta_info, text=text, ) @@ -525,6 +589,10 @@ async def generate_and_rm( state = GenerateState(args) + if evaluation and getattr(args, "use_score_centering", False): + args = copy.copy(args) + args.use_score_centering = False + # generate async with state.semaphore: if state.aborted: @@ -680,7 +748,6 @@ async def generate_rollout_async( - data: a list of groups of samples generated by the rollout, length equals `rollout_batch_size` - aborted_samples: any partial groups collected during abort when partial_rollout is enabled """ - assert args.rollout_global_dataset state = GenerateState(args) @@ -931,7 +998,6 @@ def generate_rollout( Returns: RolloutFnTrainOutput | RolloutFnEvalOutput: the output of the rollout """ - assert args.rollout_global_dataset if evaluation: output, _ = run(eval_rollout(args, rollout_id)) return output diff --git a/vime/rollout/vllm_streaming_rollout.py b/vime/rollout/vllm_streaming_rollout.py index fb9471b06..1000bc8f1 100644 --- a/vime/rollout/vllm_streaming_rollout.py +++ b/vime/rollout/vllm_streaming_rollout.py @@ -66,6 +66,9 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d Writes the cumulative state from each SSE chunk onto ``sample`` so an abort that cuts the stream still leaves a coherent partial sample behind. """ + if getattr(args, "use_score_centering", False): + raise ValueError("Score centering does not support streaming rollout.") + if args.ci_test: assert isinstance(sample.prompt, str) diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index 858c02b18..a2f82f561 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -630,18 +630,6 @@ def add_data_arguments(parser): ), ) - parser.add_argument( - "--disable-rollout-global-dataset", - action="store_false", - dest="rollout_global_dataset", - help=( - "Whether to use a global dataset for rollout. " - "If set, the rollout will use the `--prompt-data` as the prompt dataset, " - "and the prompts for rollout will be sampled from the dataset. " - "If not set, you need to manage the data by your self." - ), - ) - parser.add_argument( "--data-source-path", type=str, @@ -846,6 +834,31 @@ def add_eval_arguments(parser): return parser def add_algo_arguments(parser): + parser.add_argument( + "--pg-loss-type", + choices=["ppo", "reinforce"], + default=None, + help=( + "Policy gradient objective. Defaults to REINFORCE with score centering, " + "otherwise preserves the existing PPO/CISPO objective." + ), + ) + parser.add_argument( + "--use-score-centering", + action="store_true", + help=( + "Use the REINFORCE score-centering objective from " + "Score Centering Stabilizes Off-policy Reinforcement Learning " + "(https://arxiv.org/abs/2609.20807). Uses exact centering on the complete replay support " + "when rollout-top-p < 1, otherwise uses the paper's top-k tail approximation." + ), + ) + parser.add_argument( + "--score-centering-top-k", + type=int, + default=128, + help="Number of sampler top logprobs retained when rollout-top-p=1; ignored for exact top-p centering.", + ) parser.add_argument( "--ref-load", type=str, @@ -1069,7 +1082,10 @@ def add_algo_arguments(parser): "--use-tis", action="store_true", default=False, - help="Enable TIS from https://fengyao.notion.site/off-policy-rl for off-policy importance sampling.", + help=( + "Enable TIS for off-policy importance sampling. With --use-score-centering, " + "center the weighted scores using the same --tis-clip/--tis-clip-low bounds." + ), ) parser.add_argument( "--tis-clip", @@ -1905,6 +1921,11 @@ def _resolve_eval_datasets(args) -> list[EvalDatasetConfig]: def vime_validate_args(args): + from vime.utils.ppo_utils import get_pg_loss_type + from vime.utils.score_centering import validate_score_centering_args + + get_pg_loss_type(args) + validate_score_centering_args(args) args.eval_datasets = _resolve_eval_datasets(args) args.dspark_enabled = (getattr(args, "vllm_speculative_config", None) or {}).get("method") == "dspark" @@ -1992,7 +2013,7 @@ def vime_validate_args(args): "require advantage normalization. Please add `--normalize-advantages` to your command." ) - if args.use_rollout_logprobs: + if args.use_rollout_logprobs and get_pg_loss_type(args) != "reinforce": assert not args.use_tis, "use_rollout_logprobs and use_tis cannot be set at the same time." if args.get_mismatch_metrics: @@ -2147,11 +2168,6 @@ def vime_validate_args(args): if args.num_epoch is not None: if args.num_rollout is not None: logger.info("Both num_epoch and num_rollout are set, num_epoch will be ignored.") - else: - assert args.rollout_global_dataset, ( - "num_epoch is set, but rollout_global_dataset is not set, " - "please remove --disable-rollout-global-dataset to use num_epoch" - ) else: # if num_epoch is not set, we should set num_rollout assert args.num_rollout is not None, ( diff --git a/vime/utils/ppo_utils.py b/vime/utils/ppo_utils.py index 5f07c6c21..434e88864 100644 --- a/vime/utils/ppo_utils.py +++ b/vime/utils/ppo_utils.py @@ -8,6 +8,178 @@ import torch.nn.functional as F +def get_pg_loss_type(args): + """Resolve the PG objective while preserving legacy launch configurations.""" + configured = getattr(args, "pg_loss_type", None) + sc = getattr(args, "use_score_centering", False) + estimator = getattr(args, "advantage_estimator", None) + objective = configured or ("reinforce" if sc else "cispo" if estimator == "cispo" else "ppo") + if sc and objective != "reinforce": + raise ValueError("--use-score-centering requires --pg-loss-type reinforce.") + if configured is not None and estimator in ("gspo", "cispo"): + if objective != ("ppo" if estimator == "gspo" else "cispo"): + raise ValueError(f"--pg-loss-type {objective} cannot combine with --advantage-estimator {estimator}.") + return objective + + +def importance_weights(ratio, mode="none", low=None, high=None): + """Token-wise IS weights shared by TIS/IcePop and score centering.""" + if mode == "none": + return torch.ones_like(ratio) + if mode == "tis": + return ratio.clamp(min=0.0 if low is None else low, max=2.0 if high is None else high) + if mode == "mis": + low = 0.5 if low is None else low + high = 5.0 if high is None else high + return torch.where((ratio >= low) & (ratio <= high), ratio, 0.0) + raise ValueError(f"Unknown importance sampling mode: {mode}") + + +class _VocabParallelTopKLogProbs(torch.autograd.Function): + """Top-k logprobs with bounded FP32 workspace in forward AND backward. + + Saving autograd's exp/gather intermediates for every row chunk retains + O(N * V_p) FP32 activations despite chunking. Save the original logits + (no copy), ids, and two scalars per row instead, and recompute softmax + chunks in backward. The single Function also avoids a full [N, V_p] + slice-backward allocation for each chunk. + """ + + @staticmethod + def forward(ctx, logits, token_ids, process_group, chunk_size, temperature): + n, vocab_size = logits.shape + rank, _ = _get_vocab_parallel_rank_size(process_group) + vocab_start = rank * vocab_size + chunk_size = chunk_size if chunk_size > 0 else max(n, 1) + output = torch.empty(token_ids.shape, device=logits.device, dtype=torch.float32) + maxima = torch.empty((n, 1), device=logits.device, dtype=torch.float32) + denominators = torch.empty_like(maxima) + for start in range(0, n, chunk_size): + end = min(start + chunk_size, n) + # Always own the workspace: in-place operations must not modify + # FP32 inputs or their views. Cast before temperature scaling. + work = logits[start:end].to(dtype=torch.float32, copy=True) + if temperature != 1.0: + work.div_(temperature) + maximum = work.max(dim=-1, keepdim=True).values + _maybe_all_reduce(maximum, dist.ReduceOp.MAX, process_group) + ids = token_ids[start:end] + local_mask = (ids >= vocab_start) & (ids < vocab_start + vocab_size) + local_ids = (ids - vocab_start).clamp(0, vocab_size - 1) + targets = work.gather(-1, local_ids).masked_fill_(~local_mask, 0.0) + _maybe_all_reduce(targets, dist.ReduceOp.SUM, process_group) + work.sub_(maximum).exp_() + denominator = work.sum(dim=-1, keepdim=True) + _maybe_all_reduce(denominator, dist.ReduceOp.SUM, process_group) + # Normalize over the full distributed vocabulary. + output[start:end] = (targets - maximum) - denominator.log() + maxima[start:end] = maximum + denominators[start:end] = denominator + del work + ctx.save_for_backward(logits, token_ids, maxima, denominators) + ctx.chunk_size = chunk_size + ctx.temperature = temperature + ctx.vocab_start = vocab_start + return output + + @staticmethod + @torch.autograd.function.once_differentiable + def backward(ctx, grad_output): + logits, token_ids, maxima, denominators = ctx.saved_tensors + n, vocab_size = logits.shape + # Allocate the returned gradient once, in the input dtype. All softmax + # and scatter accumulation stays FP32 within a bounded row chunk. + grad_input = torch.empty_like(logits) + for start in range(0, n, ctx.chunk_size): + end = min(start + ctx.chunk_size, n) + work = logits[start:end].to(dtype=torch.float32, copy=True) + if ctx.temperature != 1.0: + work.div_(ctx.temperature) + work.sub_(maxima[start:end]).exp_().div_(denominators[start:end]) + grad = grad_output[start:end].float() + work.mul_(-grad.sum(dim=-1, keepdim=True)) + ids = token_ids[start:end] + local_mask = (ids >= ctx.vocab_start) & (ids < ctx.vocab_start + vocab_size) + local_ids = (ids - ctx.vocab_start).clamp(0, vocab_size - 1) + work.scatter_add_(-1, local_ids, grad.masked_fill(~local_mask, 0.0)) + if ctx.temperature != 1.0: + work.div_(ctx.temperature) + grad_input[start:end] = work + del work + # Every TP rank differentiates the replicated loss. No backward + # all-reduce: that would multiply the gradient by the TP world size. + return grad_input, None, None, None, None + + +def calculate_topk_log_probs( + logits: torch.Tensor, + token_ids: torch.Tensor, + process_group, + chunk_size: int = -1, + temperature: float = 1.0, +) -> torch.Tensor: + """Full-vocab-normalized log-probs at arbitrary token ids under vocab parallelism. + + Args: + logits: `[N, V_p]` local vocab shard, cast to FP32 per row chunk + before the softmax math. + token_ids: `[N, k]` global token ids. + temperature: Divisor applied to the logits after the per-chunk FP32 cast, + matching the RL log-prob path's cast-then-scale order. + + Returns `[N, k]` FP32 log-probs; first-order gradients flow to the local + logits shard only. Saved state shares the original logits storage plus + O(N * k + N) ids/normalizers; FP32 workspace is O(chunk_size * V_p). + """ + return _VocabParallelTopKLogProbs.apply(logits, token_ids, process_group, chunk_size, temperature) + + +class _VocabParallelRaggedLogProbs(torch.autograd.Function): + """Normalize only over the recorded support, without gathering the vocabulary.""" + + @staticmethod + def forward(ctx, logits, token_ids, offsets, process_group, temperature): + n, vocab_size = logits.shape + rank, world_size = _get_vocab_parallel_rank_size(process_group) + if ((token_ids < 0) | (token_ids >= vocab_size * world_size)).any(): + raise ValueError("Top-p replay token ids are outside the model vocabulary.") + rows = torch.repeat_interleave(torch.arange(n, device=logits.device), offsets.diff()) + local_ids = token_ids - rank * vocab_size + local = (local_ids >= 0) & (local_ids < vocab_size) + local_ids = local_ids.clamp(0, vocab_size - 1) + values = logits[rows, local_ids].float().div_(temperature).masked_fill_(~local, 0) + _maybe_all_reduce(values, dist.ReduceOp.SUM, process_group) + maxima = values.new_full((n,), -torch.inf) + maxima.scatter_reduce_(0, rows, values, reduce="amax", include_self=True) + values = values - maxima[rows] + probs = values.exp() + denominators = values.new_zeros(n).scatter_add_(0, rows, probs) + log_probs = values - denominators[rows].log() + probs.div_(denominators[rows]) + ctx.save_for_backward(rows, local_ids, local, probs) + ctx.shape, ctx.dtype, ctx.temperature = logits.shape, logits.dtype, temperature + return log_probs + + @staticmethod + @torch.autograd.function.once_differentiable + def backward(ctx, grad_output): + rows, local_ids, local, probs = ctx.saved_tensors + row_sum = grad_output.new_zeros(ctx.shape[0]).scatter_add_(0, rows, grad_output) + grad = (grad_output - probs * row_sum[rows]) / ctx.temperature + grad_input = torch.zeros(ctx.shape, device=grad.device, dtype=ctx.dtype) + grad_input.index_put_((rows[local], local_ids[local]), grad[local].to(ctx.dtype), accumulate=True) + return grad_input, None, None, None, None + + +def calculate_ragged_log_probs(logits, token_ids, offsets, process_group, temperature=1.0): + """Return flat logprobs normalized over each row's complete replay support. + + Empty spans are allowed for masked environment tokens. TP ranks hold local + vocabulary shards; only the selected logits are reduced across ranks. + """ + return _VocabParallelRaggedLogProbs.apply(logits, token_ids, offsets, process_group, temperature) + + @torch.compile(dynamic=True) def compute_approx_kl( log_probs: torch.Tensor, diff --git a/vime/utils/routed_experts.py b/vime/utils/routed_experts.py index ade2a1dc1..7ba7f4b4d 100644 --- a/vime/utils/routed_experts.py +++ b/vime/utils/routed_experts.py @@ -133,6 +133,9 @@ def validate_routed_experts_value( def link_routed_experts_for_rollout(args, sample: Sample, rollout_id: int | None) -> None: """Retain spilled routes in a rollout directory using only file references.""" + from vime.utils.score_centering import spill_sampler_topk + + spill_sampler_topk(args, sample, rollout_id) ref = sample.rollout_routed_experts if not isinstance(ref, DiskTensorRef): return @@ -180,6 +183,9 @@ async def spill_routed_experts( if not store_dir: raise ValueError("spill_routed_experts requires --rollout-routed-experts-store-dir") + from vime.utils.score_centering import spill_sampler_topk + + spill_sampler_topk(args, sample, rollout_id) if isinstance(sample.rollout_routed_experts, DiskTensorRef): validate_routed_experts_value(sample.rollout_routed_experts, args, sample_index=sample.index) link_routed_experts_for_rollout(args, sample, rollout_id) diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py new file mode 100644 index 000000000..d550edbd3 --- /dev/null +++ b/vime/utils/score_centering.py @@ -0,0 +1,251 @@ +"""Score Centering, arXiv:2609.20807, equations (9), (12), and (14).""" + +import math +import os +import uuid +from pathlib import Path + +import numpy as np +import torch + +from vime.utils.ppo_utils import get_pg_loss_type, importance_weights +from vime.utils.tensor_store import DiskTensorRef + +SAMPLER_TOPK_FIELDS = ("rollout_topk_token_ids", "rollout_topk_log_probs") + + +def get_score_centering_is_config(args): + """Use the existing TIS switch and bounds for equation (14). + + Arbitrary loss/mask callbacks cannot supply weights for every candidate + token and the modeled tail. Only the built-in token-wise rules compose. + """ + if not getattr(args, "use_tis", False): + return dict(mode="none") + path = getattr(args, "custom_tis_function_path", None) + if path in (None, "vime.backends.megatron_utils.loss.vanilla_tis_function"): + mode = "tis" + elif path == "vime.backends.megatron_utils.loss.icepop_function": + mode = "mis" + else: + raise ValueError( + "Score centering with --use-tis supports the built-in TIS and icepop_function only; " + "custom loss/mask callbacks cannot be applied to the head and tail scores." + ) + low, high = args.tis_clip_low, args.tis_clip + if not math.isfinite(low) or not math.isfinite(high) or low < 0 or high <= 0 or low > high: + raise ValueError("Score centering IS bounds require finite 0 <= tis_clip_low <= tis_clip and tis_clip > 0.") + return dict(mode=mode, low=low, high=high) + + +def score_centering_correction(train_head_logp, sampler_head_logp, *, mode="none", low=None, high=None, eps=1e-6): + """Return the differentiable head-only correction and detached head masses. + + Inputs are full-vocabulary-normalized log probabilities [tokens, k], not + probabilities renormalized over the head. Tail masses are floored as in + appendix A.3. Only the final multiplication by train_head_logp has a gradient. + """ + with torch.no_grad(): + p, q = train_head_logp.float().exp(), sampler_head_logp.float().exp() + p_mass, q_mass = p.sum(-1), q.sum(-1) + rho = (1 - q_mass).clamp_min(eps) / (1 - p_mass).clamp_min(eps) + alpha = rho * importance_weights(rho.reciprocal(), mode, low, high) + weights = importance_weights((train_head_logp.float() - sampler_head_logp.float()).exp(), mode, low, high) + residual = q * weights - alpha.unsqueeze(-1) * p + return (residual * train_head_logp).sum(-1), q_mass, p_mass + + +def score_centering_request(args, sampling_params): + """Extra vLLM /generate fields; validate the actual request's distribution.""" + if not getattr(args, "use_score_centering", False): + return {} + # vLLM's omitted-temperature default can differ from the trainer's. + temperature = sampling_params.setdefault("temperature", args.rollout_temperature) + if not math.isfinite(temperature) or temperature <= 0: + raise ValueError("Score centering requires stochastic sampling (temperature > 0).") + if temperature != args.rollout_temperature: + raise ValueError("Score centering requires the configured rollout temperature on every training request.") + top_p = sampling_params.setdefault("top_p", getattr(args, "rollout_top_p", 1.0)) + if not 0 < top_p <= 1 or top_p != getattr(args, "rollout_top_p", 1.0): + raise ValueError("Score centering requires the configured rollout top_p in (0, 1] on every request.") + if (temperature != 1 or top_p < 1) and os.environ.get("VLLM_RETURN_ORIGINAL_LOGPROB", "").lower() in ( + "1", + "true", + ): + raise ValueError( + "Score centering requires temperature-scaled, post-truncation sampler logprobs; " + "unset VLLM_RETURN_ORIGINAL_LOGPROB." + ) + for key, default in (("top_k", -1), ("min_p", 0.0)): + if sampling_params.get(key, default) != default: + raise ValueError(f"Score centering requires {key}={default}; only top-p truncation is supported.") + for key, default in (("repetition_penalty", 1.0), ("presence_penalty", 0.0), ("frequency_penalty", 0.0)): + if sampling_params.get(key, default) != default: + raise ValueError(f"Score centering requires {key}={default}.") + for key in ("json_schema", "regex", "ebnf", "structural_tag", "logit_bias"): + if sampling_params.get(key): + raise ValueError(f"Score centering does not support constrained sampling ({key}).") + return {} + + +def validate_score_centering_args(args): + if not getattr(args, "use_score_centering", False): + return + get_pg_loss_type(args) + if getattr(args, "train_backend", "megatron") != "megatron": + raise ValueError("Score centering is supported by the Megatron backend only.") + if ( + getattr(args, "custom_generate_function_path", None) + == "vime.rollout.vllm_streaming_rollout.generate_streaming" + ): + raise ValueError("Score centering does not support streaming rollout.") + if args.rollout_top_p == 1 and args.score_centering_top_k < 1: + raise ValueError("--score-centering-top-k must be positive.") + get_score_centering_is_config(args) + if args.loss_type != "policy_loss": + raise ValueError("--use-score-centering requires --loss-type policy_loss.") + for key in ("use_opd", "use_opsm", "get_mismatch_metrics", "use_unbiased_kl"): + if getattr(args, key, False): + raise ValueError(f"--use-score-centering cannot combine with --{key.replace('_', '-')}.") + if getattr(args, "custom_pg_loss_reducer_function_path", None): + raise ValueError("Score centering does not support a custom PG reducer.") + if getattr(args, "rollout_logprob_server_url", None): + raise ValueError( + "Score centering requires original sampler probabilities, not a rollout logprob server's recomputation." + ) + if args.advantage_estimator in ("gspo", "cispo"): + raise ValueError("Score centering uses a REINFORCE objective; select grpo or a REINFORCE advantage estimator.") + score_centering_request( + args, {"temperature": args.rollout_temperature, "top_p": args.rollout_top_p, "top_k": args.rollout_top_k} + ) + + +def _validate_head_arrays(chunk_ids, chunk_logps): + if ( + (chunk_ids < 0).any() + or (chunk_ids > np.iinfo(np.int32).max).any() + or (np.diff(np.sort(chunk_ids, axis=-1), axis=-1) == 0).any() + ): + raise ValueError("Sampler top-k token ids must be distinct and nonnegative int32 integers.") + if not np.isfinite(chunk_logps).all() or (chunk_logps > 0).any() or (np.exp(chunk_logps).sum(-1) > 1.0001).any(): + raise ValueError("Sampler top-k logprobs must describe a finite, normalized probability subdistribution.") + + +def extract_sampler_topk(meta_info, count, k): + """Read sampler heads parsed from the vLLM token response.""" + if "score_centering_topk" not in meta_info: + raise ValueError("Score centering requires sampler top-k metadata for every generated token.") + ids, logps = meta_info["score_centering_topk"] + if ids.shape != (count, k) or logps.shape != (count, k): + raise ValueError(f"Sampler top-k tensors must have shape {(count, k)}.") + for start in range(0, count, 1024): + _validate_head_arrays(ids[start : start + 1024], logps[start : start + 1024].astype(np.float64)) + return ids, logps + + +def extract_sampler_top_p(meta_info, count): + """Read the complete normalized sampler distribution on the replay mask.""" + if "score_centering_top_p" not in meta_info: + raise ValueError("Top-p score centering requires complete sampler top-p ids, offsets, and logprobs.") + ids, offsets, logps = meta_info["score_centering_top_p"] + validate_sampler_top_p(ids, offsets, logps, count) + return ids, offsets, logps + + +def validate_sampler_top_p(ids, offsets, logps, count, loss_mask=None, tokens=None, sampled_logps=None): + if ids is None or offsets is None or logps is None: + raise ValueError("Top-p score centering requires complete sampler top-p ids, offsets, and logprobs.") + ids, offsets, logps = np.asarray(ids), np.asarray(offsets), np.asarray(logps) + if ( + ids.ndim != 1 + or logps.shape != ids.shape + or offsets.shape != (count + 1,) + or not np.issubdtype(ids.dtype, np.integer) + or not np.issubdtype(offsets.dtype, np.integer) + or offsets[0] != 0 + or offsets[-1] != len(ids) + or (np.diff(offsets) < 0).any() + ): + raise ValueError("Invalid top-p score-centering ids/logprobs/offsets.") + for row, (start, end) in enumerate(zip(offsets[:-1], offsets[1:], strict=True)): + if loss_mask is not None and not loss_mask[row] and start == end: + continue + row_ids, row_logps = ids[start:end], logps[start:end].astype(np.float64) + _validate_head_arrays(row_ids, row_logps) + if not np.isclose(np.exp(row_logps).sum(), 1.0, rtol=1e-4, atol=1e-6): + raise ValueError("Top-p score centering requires the complete normalized support, not a truncated head.") + if tokens is not None: + selected = row_logps[row_ids == tokens[row]] + if len(selected) != 1 or not np.isclose(selected[0], sampled_logps[row], rtol=1e-4, atol=1e-5): + raise ValueError("Top-p sampler distribution must include the sampled token with its rollout logprob.") + + +def validate_sampler_topk(sample, k): + """Validate arrays once before spilling; subsequent transport checks only references.""" + ids, logps = sample.rollout_topk_token_ids, sample.rollout_topk_log_probs + if sample.response_length == 0 and ids is None and logps is None: + sample.rollout_topk_token_ids = torch.empty((0, k), dtype=torch.int32) + sample.rollout_topk_log_probs = torch.empty((0, k), dtype=torch.float32) + return + if ids is None or logps is None: + raise ValueError("Score centering requires rollout_topk_token_ids and rollout_topk_log_probs on every sample.") + shape = (sample.response_length, k) + disk = [isinstance(value, DiskTensorRef) for value in (ids, logps)] + if any(disk): + if not all(disk): + raise ValueError("Sampler top-k ids and logprobs must both be disk references.") + for value, dtype, key in zip((ids, logps), ("int32", "float32"), SAMPLER_TOPK_FIELDS, strict=True): + if tuple(value.shape) != shape or value.dtype != dtype or value.nbytes != math.prod(shape) * 4: + raise ValueError(f"Sampler top-k disk reference must have shape {shape} and dtype {dtype}.") + if value.kind != key: + raise ValueError(f"Unexpected sampler top-k disk reference kind: {value.kind}") + if not Path(value.path).is_file(): + raise FileNotFoundError(value.path) + if ids.validated and logps.validated: + return + else: + ids, logps = np.asarray(ids), np.asarray(logps) + if tuple(ids.shape) != shape or tuple(logps.shape) != shape: + raise ValueError(f"Sampler top-k tensors must have shape {shape}.") + if not np.issubdtype(ids.dtype, np.integer): + raise ValueError("Sampler top-k ids must be distinct nonnegative integers.") + for start in range(0, sample.response_length, 1024): + _validate_head_arrays( + np.asarray(ids[start : start + 1024]), np.asarray(logps[start : start + 1024], dtype=np.float64) + ) + + +def spill_sampler_topk(args, sample, rollout_id): + """Keep sampler heads beside routes under the existing R3 spill directory.""" + if not getattr(args, "use_score_centering", False): + return + if getattr(args, "rollout_top_p", 1.0) < 1: + return + store_dir = getattr(args, "rollout_routed_experts_store_dir", None) + if not store_dir: + if any(isinstance(getattr(sample, key), DiskTensorRef) for key in SAMPLER_TOPK_FIELDS): + raise ValueError("Sampler top-k file retention requires --rollout-routed-experts-store-dir") + return + validate_sampler_topk(sample, args.score_centering_top_k) + component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" + directory = Path(store_dir) / f"rollout_{component}" + for key, dtype in zip(SAMPLER_TOPK_FIELDS, (torch.int32, torch.float32), strict=True): + value = getattr(sample, key) + if isinstance(value, DiskTensorRef): + if Path(value.path).parent.resolve() != directory.resolve(): + setattr( + sample, key, value.link(directory / f"sample_{sample.index}_{key}_{uuid.uuid4().hex}.safetensors") + ) + continue + # Ray may return read-only arrays; writing safetensors only reads them. + array = np.asarray(value) + if not array.flags.writeable: + array = array.copy() + tensor = torch.as_tensor(array, dtype=dtype) + ref = DiskTensorRef.write( + tensor, + directory / f"sample_{sample.index}_{key}_{uuid.uuid4().hex}.safetensors", + kind=key, + validated=True, + ) + setattr(sample, key, ref) diff --git a/vime/utils/tensor_store.py b/vime/utils/tensor_store.py index b0f0d2d35..7744db9b3 100644 --- a/vime/utils/tensor_store.py +++ b/vime/utils/tensor_store.py @@ -1,15 +1,31 @@ from __future__ import annotations import errno +import math import os import shutil +import uuid from dataclasses import dataclass, replace from pathlib import Path import torch +from safetensors import safe_open from safetensors.torch import load_file, save_file +def retain_debug_tensor_refs(dump_data: dict, dump_path: Path) -> None: + """Give a debug dump its own file lifetime, independent of rollout cleanup.""" + directory = dump_path.parent / (dump_path.stem + "_r3_spill") / uuid.uuid4().hex + retained = {} + for sample in dump_data.get("samples", []): + for key in ("rollout_routed_experts", "rollout_topk_token_ids", "rollout_topk_log_probs"): + ref = sample.get(key) + if isinstance(ref, DiskTensorRef): + if ref.path not in retained: + retained[ref.path] = ref.link(directory / f"{len(retained)}_{Path(ref.path).name}") + sample[key] = retained[ref.path] + + @dataclass(frozen=True) class DiskTensorRef: """Small, Ray-serializable reference to a single-tensor safetensors file. @@ -24,6 +40,38 @@ class DiskTensorRef: kind: str | None = None validated: bool = False + def __len__(self) -> int: + return self.shape[0] + + def __getitem__(self, rows: slice) -> torch.Tensor: + """Read only selected rows, so CP ranks never materialize a full sample.""" + if not isinstance(rows, slice) or rows.step not in (None, 1): + raise TypeError("Disk tensor reads require a contiguous row slice.") + start, stop, _ = rows.indices(len(self)) + if stop <= start: + return torch.empty((0, *self.shape[1:]), dtype=self.torch_dtype) + dtype_codes = { + "uint8": "U8", + "int8": "I8", + "int16": "I16", + "int32": "I32", + "int64": "I64", + "float16": "F16", + "bfloat16": "BF16", + "float32": "F32", + "float64": "F64", + "bool": "BOOL", + } + with safe_open(self.path, framework="pt", device="cpu") as file: + value = file.get_slice("tensor") + if ( + tuple(value.get_shape()) != tuple(self.shape) + or value.get_dtype() != dtype_codes.get(self.dtype) + or math.prod(self.shape) * torch.empty((), dtype=self.torch_dtype).element_size() != self.nbytes + ): + raise OSError(f"Disk tensor metadata mismatch for {self.path}") + return value[start : max(start, stop)] + @classmethod def write( cls, diff --git a/vime/utils/types.py b/vime/utils/types.py index d19bd7a2e..d09f2b44f 100644 --- a/vime/utils/types.py +++ b/vime/utils/types.py @@ -1,7 +1,9 @@ +import math from dataclasses import dataclass, field from enum import Enum from typing import Any +import numpy as np import torch from vime.utils.misc import decode_int32_meta_array @@ -120,10 +122,13 @@ class Sample: loss_mask: list[int] | None = None weight_versions: list[str] = field(default_factory=list) rollout_log_probs: list[float] | None = None # Log probabilities from rollout engine + rollout_topk_token_ids: np.ndarray | torch.Tensor | list[list[int]] | DiskTensorRef | None = None + rollout_topk_log_probs: np.ndarray | torch.Tensor | list[list[float]] | DiskTensorRef | None = None # Ragged top-p nucleus token ids replayed from rollout sampling. For response # token i, kept ids are rollout_top_p_token_ids[offsets[i]:offsets[i + 1]]. rollout_top_p_token_ids: list[int] | torch.Tensor | None = None rollout_top_p_token_offsets: list[int] | torch.Tensor | None = None + rollout_top_p_log_probs: np.ndarray | torch.Tensor | list[float] | None = None rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | DiskTensorRef | None = ( None # Routed experts from rollout engine ) @@ -283,10 +288,66 @@ def append_response_tokens( raise ValueError("non-trainable response tokens should not pass rollout log probabilities.") log_probs = [0.0] * len(tokens) + previous_response_length = self.response_length + if tokens and getattr(args, "use_score_centering", False) and getattr(args, "rollout_top_p", 1.0) == 1.0: + from .score_centering import extract_sampler_topk + + k = args.score_centering_top_k + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + value = getattr(self, key) + if isinstance(value, DiskTensorRef): + setattr(self, key, value.load().numpy()) + if trainable: + ids, logps = extract_sampler_topk(meta_info or {}, len(tokens), k) + else: + # Dummy distributions for masked tool/environment tokens. Distinct + # ids and finite logprobs keep the loss finite before masking. + ids = np.broadcast_to(np.arange(k, dtype=np.int32), (len(tokens), k)).copy() + logps = np.full((len(tokens), k), -math.log(k), dtype=np.float32) + if self.rollout_topk_token_ids is None: + if previous_response_length and any(self.loss_mask or [1] * previous_response_length): + raise ValueError("Existing trainable response tokens have no sampler top-k data.") + self.rollout_topk_token_ids = np.broadcast_to( + np.arange(k, dtype=np.int32), (previous_response_length, k) + ).copy() + self.rollout_topk_log_probs = np.full((previous_response_length, k), -math.log(k), dtype=np.float32) + self.rollout_topk_token_ids = ( + np.concatenate((np.asarray(self.rollout_topk_token_ids, dtype=np.int32), ids)) + if previous_response_length + else ids + ) + self.rollout_topk_log_probs = ( + np.concatenate((np.asarray(self.rollout_topk_log_probs, dtype=np.float32), logps)) + if previous_response_length + else logps + ) + if tokens and getattr(args, "use_score_centering", False) and getattr(args, "rollout_top_p", 1.0) < 1: + from .score_centering import extract_sampler_top_p, validate_sampler_top_p + + if self.rollout_top_p_token_ids is None: + if previous_response_length and any(self.loss_mask or [1] * previous_response_length): + raise ValueError("Existing trainable response tokens have no sampler top-p data.") + self.rollout_top_p_token_ids = torch.empty(0, dtype=torch.int32) + self.rollout_top_p_token_offsets = torch.zeros(previous_response_length + 1, dtype=torch.int32) + self.rollout_top_p_log_probs = np.empty(0, dtype=np.float32) + if trainable: + ids, offsets, logps = extract_sampler_top_p(meta_info or {}, len(tokens)) + validate_sampler_top_p(ids, offsets, logps, len(tokens), tokens=tokens, sampled_logps=log_probs) + self.rollout_top_p_token_ids, self.rollout_top_p_token_offsets = _merge_rollout_top_p_token_data( + self.rollout_top_p_token_ids, + self.rollout_top_p_token_offsets, + torch.as_tensor(ids), + torch.as_tensor(offsets), + ) + self.rollout_top_p_log_probs = np.concatenate((np.asarray(self.rollout_top_p_log_probs), logps)) + # Already appended the validated replay data above. + meta_info = { + key: value + for key, value in (meta_info or {}).items() + if key not in (*_TOP_P_TOKEN_ID_META_KEYS, *_TOP_P_TOKEN_OFFSET_META_KEYS) + } if text is not None: self.response += text - - previous_response_length = self.response_length if tokens: self.tokens += tokens self.response_length += len(tokens) @@ -466,6 +527,10 @@ def materialize_rollout_routed_experts(self, *, replace: bool = True) -> torch.T return tensor def _validate_response_metadata_lengths(self): + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + value = getattr(self, key) + if value is not None and len(value) != self.response_length: + raise ValueError(f"{key} length {len(value)} != response_length {self.response_length}") if self.loss_mask is not None and len(self.loss_mask) != self.response_length: raise ValueError(f"loss_mask length {len(self.loss_mask)} != response_length {self.response_length}") @@ -486,6 +551,8 @@ def _validate_response_metadata_lengths(self): f"len(offsets)={offsets.numel()}, response_length={self.response_length}." ) token_id_count = _numel(self.rollout_top_p_token_ids) + if self.rollout_top_p_log_probs is not None and len(self.rollout_top_p_log_probs) != token_id_count: + raise ValueError("Top-p logprobs must align with the replay token ids.") if int(offsets[-1]) != token_id_count: raise ValueError( "rollout top-p token ids/offsets mismatch: " From cfcf9c83946bcf7cff4a04334183025593f523c0 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 24 Sep 2026 03:24:21 +0000 Subject: [PATCH 05/58] fix: rebase vLLM patches onto current nightly Signed-off-by: aoshen02 --- .../vllm-inflight-queue-diagnostics.patch | 125 +-- .../latest/vllm-pd-request-metrics.patch | 919 +++--------------- docker/patch/latest/vllm-pull_weights.patch | 240 +---- docker/patch/latest/vllm.patch | 196 +--- tests/test_vllm_rollout.py | 19 +- vime/observability/trace_utils.py | 2 +- vime/rollout/vllm_rollout.py | 8 +- vime/rollout/vllm_streaming_rollout.py | 24 +- 8 files changed, 315 insertions(+), 1218 deletions(-) diff --git a/docker/patch/latest/vllm-inflight-queue-diagnostics.patch b/docker/patch/latest/vllm-inflight-queue-diagnostics.patch index 7b09c1360..12717f850 100644 --- a/docker/patch/latest/vllm-inflight-queue-diagnostics.patch +++ b/docker/patch/latest/vllm-inflight-queue-diagnostics.patch @@ -1,85 +1,8 @@ -diff --git a/tests/entrypoints/serve/instrumentator/test_basic.py b/tests/entrypoints/serve/instrumentator/test_basic.py -index 73a97c4fa26..d238bfb3de9 100644 ---- a/tests/entrypoints/serve/instrumentator/test_basic.py -+++ b/tests/entrypoints/serve/instrumentator/test_basic.py -@@ -205,6 +205,29 @@ async def test_server_load(server: RemoteOpenAIServer): - assert response.json().get("server_load") == 0 - - -+@pytest.mark.asyncio -+async def test_server_load_with_inflight_diagnostics(): -+ from vllm.entrypoints.serve.instrumentator.basic import get_server_load_metrics -+ -+ mock_request = Mock(spec=Request) -+ mock_request.app.state.server_load_metrics = 2 -+ mock_request.app.state.engine_client = AsyncMock() -+ mock_request.app.state.engine_client.get_inflight_queue_diagnostics.return_value = [ -+ {"data_parallel_rank": 0, "queues": []} -+ ] -+ -+ response = await get_server_load_metrics( -+ mock_request, include_inflight=True, inflight_limit=10 -+ ) -+ -+ assert response.body == ( -+ b'{"server_load":2,"inflight":[{"data_parallel_rank":0,"queues":[]}]}' -+ ) -+ mock_request.app.state.engine_client.get_inflight_queue_diagnostics.assert_awaited_once_with( -+ 10 -+ ) -+ -+ - @pytest.mark.asyncio - async def test_health_check_engine_dead_error(): - # Import the health function directly to test it in isolation -diff --git a/tests/v1/core/test_scheduler.py b/tests/v1/core/test_scheduler.py -index 1ef63cbb042..506b75039fb 100644 ---- a/tests/v1/core/test_scheduler.py -+++ b/tests/v1/core/test_scheduler.py -@@ -1,6 +1,7 @@ - # SPDX-License-Identifier: Apache-2.0 - # SPDX-FileCopyrightText: Copyright contributors to the vLLM project - import dataclasses -+import time - from concurrent.futures import Future - from unittest.mock import Mock - -@@ -147,6 +148,30 @@ def test_get_num_unfinished_requests(): - assert scheduler.get_num_unfinished_requests() == len(requests) - i - 1 - - -+def test_get_inflight_queue_diagnostics(): -+ scheduler = create_scheduler() -+ scheduler.parallel_config.data_parallel_index = 1 -+ requests = create_requests(num_requests=3) -+ for request in requests: -+ request.arrival_time = time.time() - 1 -+ scheduler.add_request(request) -+ -+ scheduler.running.append(scheduler.waiting.pop_request()) -+ scheduler.running[0].status = RequestStatus.RUNNING -+ -+ diagnostics = scheduler.get_inflight_queue_diagnostics(limit=2) -+ -+ assert diagnostics["data_parallel_rank"] == 1 -+ assert [queue["name"] for queue in diagnostics["queues"]] == [ -+ "running", -+ "waiting", -+ "skipped_waiting", -+ ] -+ assert [len(queue["requests"]) for queue in diagnostics["queues"]] == [1, 1, 0] -+ assert diagnostics["queues"][0]["requests"][0]["status"] == "RUNNING" -+ assert diagnostics["queues"][0]["requests"][0]["age_seconds"] >= 1 -+ -+ - @pytest.mark.parametrize( - "enable_prefix_caching, prompt_logprobs", - [ diff --git a/vllm/engine/protocol.py b/vllm/engine/protocol.py -index 63f6970e056..13a97bfe64b 100644 +index 74849a85ce..470f81b774 100644 --- a/vllm/engine/protocol.py +++ b/vllm/engine/protocol.py -@@ -296,3 +296,7 @@ class EngineClient(ABC): +@@ -311,3 +311,7 @@ class EngineClient(ABC): async def get_weight_version(self) -> str: """Return the latest committed weight version.""" raise NotImplementedError @@ -88,7 +11,7 @@ index 63f6970e056..13a97bfe64b 100644 + """Return bounded snapshots of in-flight request queues.""" + raise NotImplementedError diff --git a/vllm/entrypoints/serve/instrumentator/basic.py b/vllm/entrypoints/serve/instrumentator/basic.py -index be091a1f433..73f5bbcf9b2 100644 +index 64201db3f3..16f1d7bdac 100644 --- a/vllm/entrypoints/serve/instrumentator/basic.py +++ b/vllm/entrypoints/serve/instrumentator/basic.py @@ -28,7 +28,9 @@ def engine_client(request: Request) -> EngineClient: @@ -117,7 +40,7 @@ index be091a1f433..73f5bbcf9b2 100644 @router.get("/version") diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py -index c15296dd051..273fda18929 100644 +index c868d249c8..4acaf60415 100644 --- a/vllm/v1/core/sched/interface.py +++ b/vllm/v1/core/sched/interface.py @@ -3,7 +3,7 @@ @@ -129,7 +52,7 @@ index c15296dd051..273fda18929 100644 from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry -@@ -235,6 +235,11 @@ class SchedulerInterface(ABC): +@@ -243,6 +243,11 @@ class SchedulerInterface(ABC): """Returns (num_running_reqs, num_waiting_reqs).""" raise NotImplementedError @@ -142,10 +65,10 @@ index c15296dd051..273fda18929 100644 """Returns the fraction of the KV cache currently in use (0.0-1.0).""" return 0.0 diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index d9d86668a62..8fda1fd42f5 100644 +index 3996a257e7..82bf206c02 100644 --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py -@@ -2391,6 +2391,40 @@ class Scheduler(SchedulerInterface): +@@ -2491,6 +2491,40 @@ class Scheduler(SchedulerInterface): """Returns (num_running_reqs, num_waiting_reqs).""" return len(self.running), len(self.waiting) + len(self.skipped_waiting) @@ -187,21 +110,21 @@ index d9d86668a62..8fda1fd42f5 100644 """Returns the fraction of the KV cache currently in use (0.0-1.0).""" return self.kv_cache_manager.usage diff --git a/vllm/v1/engine/async_llm.py b/vllm/v1/engine/async_llm.py -index fdeebdcceb5..f4815325186 100644 +index 7d71761990..6c485201c7 100644 --- a/vllm/v1/engine/async_llm.py +++ b/vllm/v1/engine/async_llm.py -@@ -1240,3 +1240,6 @@ class AsyncLLM(EngineClient): - async def get_weight_version(self) -> str: - """Return the latest committed weight version.""" - return await self.engine_core.get_weight_version_async() +@@ -1297,3 +1297,6 @@ class AsyncLLM(EngineClient): + + def get_kv_event_sources(self) -> dict[int, KVEventsConfig]: + return self.engine_core.get_kv_event_sources() + + async def get_inflight_queue_diagnostics(self, limit: int) -> list[dict[str, Any]]: + return await self.engine_core.get_inflight_queue_diagnostics_async(limit) diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py -index 06ebfb73051..4572dd9ec1e 100644 +index 9b27f50edc..f546277ea6 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py -@@ -996,6 +996,9 @@ class EngineCore: +@@ -1046,6 +1046,9 @@ class EngineCore: """Return the latest committed weight version.""" return self._weight_version @@ -212,10 +135,10 @@ index 06ebfb73051..4572dd9ec1e 100644 """Preprocess the request. diff --git a/vllm/v1/engine/core_client.py b/vllm/v1/engine/core_client.py -index fa856461326..9627f1d79b2 100644 +index 6ad05e1f8c..97814cc28e 100644 --- a/vllm/v1/engine/core_client.py +++ b/vllm/v1/engine/core_client.py -@@ -209,6 +209,9 @@ class EngineCoreClient(ABC): +@@ -216,6 +216,9 @@ class EngineCoreClient(ABC): def get_weight_version(self) -> str: raise NotImplementedError @@ -225,7 +148,7 @@ index fa856461326..9627f1d79b2 100644 async def execute_dummy_batch_async(self) -> None: raise NotImplementedError -@@ -218,6 +221,11 @@ class EngineCoreClient(ABC): +@@ -225,6 +228,11 @@ class EngineCoreClient(ABC): async def get_weight_version_async(self) -> str: raise NotImplementedError @@ -237,7 +160,7 @@ index fa856461326..9627f1d79b2 100644 def abort_requests(self, request_ids: list[str]) -> None: raise NotImplementedError -@@ -414,6 +422,9 @@ class InprocClient(EngineCoreClient): +@@ -426,6 +434,9 @@ class InprocClient(EngineCoreClient): def get_weight_version(self) -> str: return self.engine_core.get_weight_version() @@ -247,7 +170,7 @@ index fa856461326..9627f1d79b2 100644 def add_lora(self, lora_request: LoRARequest) -> bool: return self.engine_core.add_lora(lora_request) -@@ -1028,6 +1039,9 @@ class SyncMPClient(MPClient): +@@ -1062,6 +1073,9 @@ class SyncMPClient(MPClient): def get_weight_version(self) -> str: return self.call_utility("get_weight_version") @@ -257,7 +180,7 @@ index fa856461326..9627f1d79b2 100644 def collective_rpc( self, method: str | Callable[..., _R], -@@ -1272,6 +1286,12 @@ class AsyncMPClient(MPClient): +@@ -1309,6 +1323,12 @@ class AsyncMPClient(MPClient): async def get_weight_version_async(self) -> str: return await self.call_utility_async("get_weight_version") @@ -270,7 +193,7 @@ index fa856461326..9627f1d79b2 100644 async def add_lora_async(self, lora_request: LoRARequest) -> bool: return await self.call_utility_async("add_lora", lora_request) -@@ -1607,6 +1627,18 @@ class DPLBAsyncMPClient(DPAsyncMPClient): +@@ -1644,6 +1664,18 @@ class DPLBAsyncMPClient(DPAsyncMPClient): ) )[0] @@ -290,15 +213,15 @@ index fa856461326..9627f1d79b2 100644 async def process_engine_outputs( self: "DPLBAsyncMPClient", outputs: EngineCoreOutputs diff --git a/vllm/v1/engine/llm_engine.py b/vllm/v1/engine/llm_engine.py -index 32096079e06..994cda54a53 100644 +index c925ba90e8..6a3c5ffdfb 100644 --- a/vllm/v1/engine/llm_engine.py +++ b/vllm/v1/engine/llm_engine.py -@@ -443,6 +443,9 @@ class LLMEngine: +@@ -451,6 +451,9 @@ class LLMEngine: """Return the latest committed weight version.""" return self.engine_core.get_weight_version() + def get_inflight_queue_diagnostics(self, limit: int) -> list[dict[str, Any]]: -+ return [self.engine_core.get_inflight_queue_diagnostics(limit)] ++ return self.engine_core.get_inflight_queue_diagnostics(limit) + def apply_model(self, func: Callable[[nn.Module], _R]) -> list[_R]: return self.collective_rpc("apply_model", args=(func,)) diff --git a/docker/patch/latest/vllm-pd-request-metrics.patch b/docker/patch/latest/vllm-pd-request-metrics.patch index 4191aa2bc..ef7bd7add 100644 --- a/docker/patch/latest/vllm-pd-request-metrics.patch +++ b/docker/patch/latest/vllm-pd-request-metrics.patch @@ -1,579 +1,8 @@ -diff --git a/rust/src/engine-core-client/src/protocol/output.rs b/rust/src/engine-core-client/src/protocol/output.rs -index cc7541eae1b..50eb218932e 100644 ---- a/rust/src/engine-core-client/src/protocol/output.rs -+++ b/rust/src/engine-core-client/src/protocol/output.rs -@@ -130,6 +130,10 @@ pub struct EngineCoreOutput { - /// the Rust frontend does not yet surface it in responses. - #[serde(default)] - pub spec_decode_metrics: Option, -+ #[serde(default)] -+ pub remote_kv_wait_time: Option, -+ #[serde(default)] -+ pub kv_transfer_metrics: Option, - } - - impl EngineCoreOutput { -@@ -449,6 +453,8 @@ mod tests { - mm_cache_miss_hashes: None, - new_sampling_mask: None, - spec_decode_metrics: None, -+ remote_kv_wait_time: None, -+ kv_transfer_metrics: None, - }, - ], - scheduler_stats: None, -diff --git a/rust/src/engine-core-client/src/tests/client.rs b/rust/src/engine-core-client/src/tests/client.rs -index 6f4e53ff1b4..f0951ff1d97 100644 ---- a/rust/src/engine-core-client/src/tests/client.rs -+++ b/rust/src/engine-core-client/src/tests/client.rs -@@ -2744,6 +2744,8 @@ fn python_msgpack_fixtures_match_rust_encoding() { - mm_cache_miss_hashes: None, - new_sampling_mask: None, - spec_decode_metrics: None, -+ remote_kv_wait_time: None, -+ kv_transfer_metrics: None, - }, - ], - scheduler_stats: None, -diff --git a/rust/src/engine-core-client/src/tests/python_compat.py b/rust/src/engine-core-client/src/tests/python_compat.py -index d3705cc3918..2a438ca88bd 100755 ---- a/rust/src/engine-core-client/src/tests/python_compat.py -+++ b/rust/src/engine-core-client/src/tests/python_compat.py -@@ -100,6 +100,9 @@ class EngineCoreOutput( - num_nans_in_logits: int = 0 - mm_cache_miss_hashes: list[str] | None = None - new_sampling_mask: object | None = None -+ spec_decode_metrics: object | None = None -+ remote_kv_wait_time: float | None = None -+ kv_transfer_metrics: dict[str, float] | None = None - - - class EngineCoreOutputs( -diff --git a/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py b/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py -index 99cf457935f..59453b0b0ac 100644 ---- a/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py -+++ b/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py -@@ -23,6 +23,7 @@ from vllm.renderers import renderer_from_config - from vllm.renderers.online_renderer import OnlineRenderer - from vllm.sampling_params import SamplingParams - from vllm.v1.engine.async_llm import AsyncLLM -+from vllm.v1.metrics.stats import RequestStateStats - - MODEL_NAME = "openai-community/gpt2" - BASE_MODEL_PATHS = [ -@@ -125,6 +126,7 @@ def _make_request_output( - logprobs: list[dict[int, Any] | None] | None = None, - num_cached_tokens: int | None = None, - index: int = 0, -+ metrics: RequestStateStats | None = None, - ) -> RequestOutput: - return RequestOutput( - request_id=request_id, -@@ -142,7 +144,7 @@ def _make_request_output( - ) - ], - finished=finished, -- metrics=None, -+ metrics=metrics, - lora_request=None, - encoder_prompt=None, - encoder_prompt_token_ids=None, -@@ -152,6 +154,7 @@ def _make_request_output( - - def _mock_engine() -> MagicMock: - engine = MagicMock(spec=AsyncLLM) -+ engine.get_weight_version = AsyncMock(return_value=None) - engine.errored = False - engine.model_config = MockModelConfig() - engine.vllm_config = MockVllmConfig( -@@ -197,12 +200,85 @@ async def test_serve_tokens_skips_mm_cache_for_remote_engine_execution(): - response = await serving.serve_tokens(request) - - assert isinstance(response, GenerateResponse) -+ assert response.request_metrics is None - assert ( - serving.online_renderer.preprocess_completion.call_args.kwargs["skip_mm_cache"] - is True - ) - - -+@pytest.mark.asyncio -+@pytest.mark.parametrize("stream", [False, True]) -+async def test_serve_tokens_returns_enabled_request_metrics(stream): -+ engine = _mock_engine() -+ engine.get_weight_version = AsyncMock(return_value="v1") -+ metrics = RequestStateStats( -+ queued_ts=1.0, -+ scheduled_ts=2.0, -+ first_token_ts=5.0, -+ last_token_ts=9.0, -+ first_token_latency=6.0, -+ remote_kv_wait_time=0.75, -+ kv_transfer_metrics={ -+ "kv_initial_queue_wait_time_ms": 7.0, -+ "kv_post_receive_queue_wait_time_ms": 2.0, -+ "kv_transfer_worker_time_ms": 10.0, -+ "kv_transfer_bytes": 1024, -+ }, -+ ) -+ -+ async def mock_generate(*args, **kwargs): -+ yield _make_request_output( -+ "req-1", -+ token_ids=[10], -+ finish_reason="stop", -+ finished=True, -+ metrics=metrics, -+ ) -+ -+ engine.generate = MagicMock(side_effect=mock_generate) -+ serving = _build_serving_tokens(engine, enable_per_request_metrics=True) -+ request = GenerateRequest( -+ token_ids=[1, 2, 3], -+ sampling_params=SamplingParams(max_tokens=1), -+ model=MODEL_NAME, -+ stream=stream, -+ kv_transfer_params={ -+ "prefill_metrics": { -+ "queue_time_ms": 12.0, -+ "time_to_first_token_ms": 25.0, -+ "kv_allocation_wait_time_ms": 3.0, -+ "kv_initial_queue_wait_time_ms": 4.0, -+ } -+ }, -+ ) -+ -+ response = await serving.serve_tokens(request) -+ -+ if stream: -+ chunks = _parse_sse_chunks([chunk async for chunk in response]) -+ result = next( -+ chunk["request_metrics"] -+ for chunk in chunks -+ if chunk != "[DONE]" and chunk.get("request_metrics") -+ ) -+ else: -+ assert isinstance(response, GenerateResponse) -+ result = response.request_metrics.model_dump() -+ assert result["queue_time_ms"] == 1000.0 -+ assert result["time_to_first_token_ms"] == 3000.0 -+ assert result["generation_time_ms"] == 4000.0 -+ assert result["remote_kv_wait_time_ms"] == 750.0 -+ assert result["kv_transfer_worker_time_ms"] == 10.0 -+ assert result["kv_transfer_bytes"] == 1024 -+ assert result["prefill_queue_time_ms"] == 12.0 -+ assert result["prefill_time_to_first_token_ms"] == 25.0 -+ assert result["prefill_kv_allocation_wait_time_ms"] == 3.0 -+ assert result["prefill_kv_initial_queue_wait_time_ms"] == 4.0 -+ assert result["kv_initial_queue_wait_time_ms"] == 7.0 -+ assert result["kv_post_receive_queue_wait_time_ms"] == 2.0 -+ -+ - @pytest.mark.asyncio - async def test_serve_tokens_threads_session_id_header_to_engine(): - engine = _mock_engine() -@@ -359,15 +435,19 @@ async def test_stream_error_with_empty_delta(): - - - @pytest.mark.asyncio --async def test_stream_skips_empty_token_output(): -- """Outputs with empty token_ids are skipped (no chunk emitted).""" -+@pytest.mark.parametrize("terminal_empty", [False, True]) -+async def test_stream_skips_empty_token_output(terminal_empty): -+ """Skip empty nonterminal outputs, but retain the terminal finish reason.""" - engine = _mock_engine() - - async def mock_generate(*args, **kwargs): - yield _make_request_output("req-1", token_ids=[10]) - yield _make_request_output("req-1", token_ids=[]) - yield _make_request_output( -- "req-1", token_ids=[20], finish_reason="stop", finished=True -+ "req-1", -+ token_ids=[] if terminal_empty else [20], -+ finish_reason="stop", -+ finished=True, - ) - - engine.generate = MagicMock(side_effect=mock_generate) -@@ -392,7 +472,8 @@ async def test_stream_skips_empty_token_output(): - # Only 2 data chunks — the empty one is skipped - assert len(data_chunks) == 2 - assert data_chunks[0]["choices"][0]["token_ids"] == [10] -- assert data_chunks[1]["choices"][0]["token_ids"] == [20] -+ assert data_chunks[1]["choices"][0]["token_ids"] == ([] if terminal_empty else [20]) -+ assert data_chunks[1]["choices"][0]["finish_reason"] == "stop" - - - @pytest.mark.asyncio -@@ -602,3 +683,47 @@ async def test_stream_prompt_tokens_details_zero_cached(): - # Zero cached tokens must be present, not omitted - assert usage_chunk["usage"]["prompt_tokens_details"] is not None - assert usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"] == 0 -+ -+ -+@pytest.mark.asyncio -+@pytest.mark.parametrize("with_mask", [False, True]) -+async def test_stream_sampling_mask_matches_each_token_chunk(with_mask): -+ from vllm.outputs import SamplingMask -+ -+ engine = _mock_engine() -+ engine.get_weight_version = AsyncMock(return_value=None) -+ -+ async def generate(*args, **kwargs): -+ for position, tokens in enumerate(([10], [20, 30])): -+ result = _make_request_output( -+ "req-mask", -+ token_ids=list(tokens), -+ finish_reason="length" if position else None, -+ finished=bool(position), -+ ) -+ if with_mask: -+ result.outputs[0].sampling_mask = SamplingMask( -+ [[token, token + 1] for token in tokens] -+ ) -+ yield result -+ -+ engine.generate = MagicMock(side_effect=generate) -+ serving = _build_serving_tokens(engine) -+ response = await serving.serve_tokens( -+ GenerateRequest( -+ model=MODEL_NAME, token_ids=[1, 2, 3], sampling_params={}, stream=True -+ ) -+ ) -+ chunks = _parse_sse_chunks([chunk async for chunk in response]) -+ choices = [ -+ choice -+ for chunk in chunks -+ if isinstance(chunk, dict) -+ for choice in chunk.get("choices", []) -+ ] -+ assert [choice["token_ids"] for choice in choices] == [[10], [20, 30]] -+ for choice in choices: -+ expected = ( -+ [[token, token + 1] for token in choice["token_ids"]] if with_mask else None -+ ) -+ assert choice["sampling_mask"] == expected -diff --git a/tests/v1/core/test_scheduler.py b/tests/v1/core/test_scheduler.py -index 2192401163d..241acb1fc62 100644 ---- a/tests/v1/core/test_scheduler.py -+++ b/tests/v1/core/test_scheduler.py -@@ -2000,6 +2000,50 @@ def _step_until_kv_transfer_finished(scheduler: Scheduler, req_ids: list[str]): - return initial_ecos - - -+@pytest.mark.parametrize("remote_load", [False, True]) -+def test_kv_allocation_wait_includes_capacity_retry(monkeypatch, tmp_path, remote_load): -+ (tmp_path / "config.json").write_text( -+ '{"architectures": ["OPTForCausalLM"], "model_type": "opt"}' -+ ) -+ scheduler = create_scheduler( -+ model=str(tmp_path), -+ skip_tokenizer_init=True, -+ use_kv_connector=mock_kv( -+ matched_tokens=16 if remote_load else 0, is_async=remote_load -+ ), -+ block_size=16, -+ ) -+ request = create_requests(num_requests=1, num_tokens=32, block_size=16)[0] -+ scheduler.add_request(request) -+ request.kv_queue_started_at["initial"] -= 1 -+ with monkeypatch.context() as context: -+ context.setattr( -+ scheduler.kv_cache_manager, "allocate_slots", Mock(return_value=None) -+ ) -+ scheduler.schedule() -+ assert request.kv_allocation_started_at is not None -+ assert request.kv_transfer_metrics["kv_initial_queue_wait_time_ms"] >= 1000 -+ assert not request.kv_queue_started_at -+ assert request.remote_kv_wait_started_at is None -+ assert "kv_allocation_wait_time_ms" not in request.kv_transfer_metrics -+ request.kv_allocation_started_at -= 1 -+ scheduler.schedule() -+ assert request.kv_allocation_started_at is None -+ assert request.kv_transfer_metrics["kv_allocation_wait_time_ms"] >= 1000 -+ if remote_load: -+ assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS -+ assert request.remote_kv_wait_started_at is not None -+ scheduler.finished_recving_kv_req_ids.add(request.request_id) -+ assert scheduler._try_promote_blocked_waiting_request(request) -+ request.kv_queue_started_at["post_receive"] -= 1 -+ scheduler.schedule() -+ assert request.kv_transfer_metrics["kv_post_receive_queue_wait_time_ms"] >= 1000 -+ assert not request.kv_queue_started_at -+ else: -+ assert request.status == RequestStatus.RUNNING -+ assert request.remote_kv_wait_started_at is None -+ -+ - @pytest.mark.parametrize("is_async", [False, True]) - def test_kv_connector_basic(is_async: bool): - """ -@@ -2051,6 +2095,12 @@ def test_kv_connector_basic(is_async: bool): - - # Ensure ScheduleOutput is correct. - output = scheduler.schedule() -+ for request in requests: -+ if is_async: -+ assert request.remote_kv_wait_time > 0 -+ assert request.remote_kv_wait_started_at is None -+ else: -+ assert request.remote_kv_wait_time == 0 - _assert_right_scheduler_output( - output=output, - num_requests=NUM_REQUESTS, -diff --git a/tests/v1/engine/test_output_processor.py b/tests/v1/engine/test_output_processor.py -index f578aae7f01..218e1ae81ce 100644 ---- a/tests/v1/engine/test_output_processor.py -+++ b/tests/v1/engine/test_output_processor.py -@@ -23,6 +23,7 @@ from vllm.tokenizers import TokenizerLike - from vllm.v1.engine import ( - EngineCoreEvent, - EngineCoreEventType, -+ EngineCoreOutput, - EngineCoreOutputs, - EngineCoreRequest, - FinishReason, -@@ -32,7 +33,33 @@ from vllm.v1.engine.output_processor import ( - RequestOutputCollector, - RequestState, - ) --from vllm.v1.metrics.stats import IterationStats, SchedulerStats -+from vllm.v1.metrics.stats import IterationStats, RequestStateStats, SchedulerStats -+ -+ -+def test_remote_kv_wait_time_is_added_to_request_stats(): -+ output_processor = object.__new__(OutputProcessor) -+ request_stats = RequestStateStats() -+ request_state = MagicMock( -+ stats=request_stats, -+ routed_experts_chunks=[], -+ is_prefilling=False, -+ ) -+ request_state.make_request_output.return_value = None -+ output_processor.request_states = {"request": request_state} -+ output_processor._update_stats_from_output = MagicMock() -+ -+ output_processor.process_outputs( -+ [ -+ EngineCoreOutput( -+ request_id="request", -+ new_token_ids=[], -+ pooling_output=MagicMock(), -+ remote_kv_wait_time=0.75, -+ ) -+ ] -+ ) -+ -+ assert request_stats.remote_kv_wait_time == 0.75 - - - @pytest.mark.parametrize("flat_logprobs", [False, True]) -@@ -1448,3 +1475,99 @@ def test_abort_requests(runner: str, abort_by: str, dummy_test_vectors): - output_processor.abort_requests([request.request_id], internal=True) - else: - output_processor.abort_requests([request.external_req_id], internal=False) -+ -+ -+@pytest.mark.parametrize("output_kind", list(RequestOutputKind)) -+@pytest.mark.parametrize("coalesce", [False, True]) -+@pytest.mark.parametrize("stream_interval", [1, 2]) -+def test_sampling_masks_follow_output_token_boundaries( -+ output_kind, coalesce, stream_interval -+): -+ import numpy as np -+ -+ from vllm.v1.outputs import SamplingMaskLists -+ -+ state = RequestState.__new__(RequestState) -+ state.detokenizer = MagicMock() -+ state.detokenizer.get_next_output_text.return_value = "" -+ state.detokenizer.output_token_ids = [] -+ state.detokenizer.num_output_tokens.side_effect = lambda: len( -+ state.detokenizer.output_token_ids -+ ) -+ state.stream_interval = stream_interval -+ state.sent_tokens_offset = 0 -+ state.external_req_id = "request" -+ state.parent_req = None -+ state.prompt = None -+ state.prompt_token_ids = [1] -+ state.lora_request = None -+ state.num_cached_tokens = 0 -+ state.num_cache_creation_tokens = 0 -+ state.stats = None -+ state.logprobs_processor = MagicMock() -+ state.logprobs_processor.logprobs = None -+ state.logprobs_processor.cumulative_logprob = None -+ state.output_kind = output_kind -+ state.request_index = 0 -+ state.sampling_mask_chunks = [] -+ state.routed_experts_chunks = [] -+ state.spec_decode_metrics = None -+ collector = RequestOutputCollector(output_kind, "request") -+ -+ expected = [[10, 11], [20], [30, 31]] -+ received = [] -+ for position, support in enumerate(expected): -+ state.detokenizer.output_token_ids.append(support[0]) -+ state.sampling_mask_chunks.append( -+ SamplingMaskLists(np.asarray(support), np.asarray([0, len(support)])) -+ ) -+ finished = position == len(expected) - 1 -+ result = state.make_request_output( -+ [support[0]], None, FinishReason.LENGTH if finished else None, None -+ ) -+ if result is None: -+ continue -+ collector.put(result) -+ if not coalesce: -+ received.append(collector.get_nowait()) -+ if coalesce: -+ received.append(collector.get_nowait()) -+ -+ if output_kind == RequestOutputKind.DELTA: -+ masks = [ -+ support -+ for result in received -+ for support in result.outputs[0].sampling_mask.token_ids -+ ] -+ assert masks == expected -+ for result in received: -+ completion = result.outputs[0] -+ assert len(completion.sampling_mask.token_ids) == len(completion.token_ids) -+ assert state.sampling_mask_chunks == [] -+ else: -+ assert received[-1].outputs[0].sampling_mask.token_ids == expected -+ -+ -+def test_sampling_mask_multi_token_delta_and_empty_finish(): -+ import numpy as np -+ -+ from vllm.v1.outputs import SamplingMaskLists -+ -+ state = RequestState.__new__(RequestState) -+ state.detokenizer = MagicMock() -+ state.detokenizer.get_next_output_text.return_value = "" -+ state.logprobs_processor = MagicMock() -+ state.logprobs_processor.logprobs = None -+ state.logprobs_processor.cumulative_logprob = None -+ state.output_kind = RequestOutputKind.DELTA -+ state.request_index = 0 -+ state.routed_experts_chunks = [] -+ state.spec_decode_metrics = None -+ state.sampling_mask_chunks = [ -+ SamplingMaskLists(np.asarray([10, 11, 20]), np.asarray([0, 2, 3])) -+ ] -+ -+ completion = state._new_completion_output([10, 20], None, None) -+ assert completion.sampling_mask.token_ids == [[10, 11], [20]] -+ terminal = state._new_completion_output([], FinishReason.LENGTH, None) -+ assert terminal.sampling_mask is None -diff --git a/tests/v1/kv_connector/unit/test_nixl_connector.py b/tests/v1/kv_connector/unit/test_nixl_connector.py -index d4064d6f6fe..a9bfd08ba85 100644 ---- a/tests/v1/kv_connector/unit/test_nixl_connector.py -+++ b/tests/v1/kv_connector/unit/test_nixl_connector.py -@@ -4,11 +4,13 @@ - import contextlib - import inspect - import os -+import queue - import tempfile - import textwrap - import time - import uuid - from collections import defaultdict -+from concurrent.futures import Future - from types import SimpleNamespace - from typing import Any, cast - from unittest.mock import MagicMock, patch -@@ -44,6 +46,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl import ( - NixlKVConnectorStats, - ) - from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( -+ NixlRequestMetrics, - compute_nixl_compatibility_hash, - ) - from vllm.distributed.kv_transfer.kv_transfer_state import ( -@@ -77,6 +80,41 @@ from .utils import ( - ) - - -+def test_request_transfer_metrics_aggregate(): -+ first = NixlRequestMetrics( -+ {"req": {"kv_transfer_bytes": 128, "kv_transfer_worker_time_ms": 3}} -+ ) -+ second = NixlRequestMetrics( -+ {"req": {"kv_transfer_bytes": 256, "kv_transfer_worker_time_ms": 2}} -+ ) -+ merged = first.aggregate(second).get_request_metrics() -+ assert merged == { -+ "req": {"kv_transfer_bytes": 384, "kv_transfer_worker_time_ms": 5} -+ } -+ -+ -+def test_request_handshake_timing_precedes_ready_publication(): -+ worker = object.__new__(NixlConnectorWorker) -+ future: Future[None] = Future() -+ worker._ensure_handshake = MagicMock(return_value=future) -+ worker._ready_requests = queue.Queue() -+ meta = SimpleNamespace( -+ remote=SimpleNamespace(host="localhost", port=1234), -+ tp_size=1, -+ handshake_wait_time=0.0, -+ ) -+ with patch( -+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.time.perf_counter", -+ side_effect=[10.0, 12.5], -+ ): -+ worker._background_nixl_handshake("req", "engine", meta) -+ assert worker._ready_requests.empty() -+ future.set_result(None) -+ request_id, ready_meta = worker._ready_requests.get_nowait() -+ assert request_id == "req" -+ assert ready_meta.handshake_wait_time == 2.5 -+ -+ - @pytest.fixture(scope="module", autouse=True) - def clear_kv_transfer(): - """ -diff --git a/tests/v1/test_request.py b/tests/v1/test_request.py -index be417b9b2ff..3b2d33e09db 100644 ---- a/tests/v1/test_request.py -+++ b/tests/v1/test_request.py -@@ -40,3 +40,28 @@ def test_request_copies_session_id_from_engine_core_request(): - request = Request.from_engine_core_request(engine_request, block_hasher=None) - - assert request.session_id == "session-1" -+ -+ -+def test_request_accumulates_remote_kv_waits(monkeypatch): -+ engine_request = EngineCoreRequest( -+ request_id="request-1", -+ prompt_token_ids=[1, 2, 3], -+ mm_features=None, -+ sampling_params=SamplingParams(max_tokens=1), -+ pooling_params=None, -+ arrival_time=0.0, -+ lora_request=None, -+ cache_salt=None, -+ data_parallel_rank=None, -+ ) -+ request = Request.from_engine_core_request(engine_request, block_hasher=None) -+ timestamps = iter([1.0, 3.0, 5.0, 9.0]) -+ monkeypatch.setattr("vllm.v1.request.time.monotonic", lambda: next(timestamps)) -+ -+ request.start_remote_kv_wait() -+ request.stop_remote_kv_wait() -+ request.start_remote_kv_wait() -+ request.stop_remote_kv_wait() -+ -+ assert request.remote_kv_wait_time == 6.0 -+ assert request.remote_kv_wait_started_at is None diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/base.py b/vllm/distributed/kv_transfer/kv_connector/v1/base.py -index b0d7cf23883..d03cba83bee 100644 +index 072018c039..f2feef64cb 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/base.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/base.py -@@ -158,6 +158,9 @@ class KVConnectorWorkerMetadata(ABC): +@@ -167,6 +167,9 @@ class KVConnectorWorkerMetadata(ABC): being passed to the Scheduler KVConnector. """ @@ -584,10 +13,10 @@ index b0d7cf23883..d03cba83bee 100644 def aggregate( self, other: "KVConnectorWorkerMetadata" diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py -index c8166b2e6ae..bb0f534fe79 100644 +index 0f13a265df..9b34d898ea 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py -@@ -37,6 +37,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( +@@ -43,6 +43,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( NixlAgentMetadata, NixlConnectorMetadata, NixlHandshakePayload, @@ -595,16 +24,16 @@ index c8166b2e6ae..bb0f534fe79 100644 ReqId, ReqMeta, TransferHandle, -@@ -547,6 +548,8 @@ class NixlBaseConnectorWorker: - # finish reading before safely freeing the blocks. +@@ -868,6 +869,8 @@ class NixlBaseConnectorWorker: self.consumer_notification_counts_by_req = defaultdict[ReqId, int](int) + self.expected_consumer_notifications_by_req: dict[ReqId, int] = {} self.xfer_stats = NixlKVConnectorStats() + self.request_metrics = NixlRequestMetrics() + self.pending_request_metrics: dict[str, dict[str, float]] = {} self._physical_blocks_per_logical_kv_block = 1 self._sync_block_size_with_kernel() -@@ -955,6 +958,7 @@ class NixlBaseConnectorWorker: +@@ -1318,6 +1321,7 @@ class NixlBaseConnectorWorker: ): # Do NIXL handshake in background and add to _ready_requests when done. assert meta.remote is not None @@ -612,7 +41,7 @@ index c8166b2e6ae..bb0f534fe79 100644 fut = self._ensure_handshake( remote_engine_id, meta.remote.host, -@@ -970,6 +974,7 @@ class NixlBaseConnectorWorker: +@@ -1335,6 +1339,7 @@ class NixlBaseConnectorWorker: def request_ready(f: Future[Any], entry=(req_id, meta)): try: f.result() @@ -620,8 +49,8 @@ index c8166b2e6ae..bb0f534fe79 100644 self._ready_requests.put(entry) except Exception as e: self._log_failure( -@@ -2207,6 +2212,13 @@ class NixlBaseConnectorWorker: - # Get telemetry from NIXL +@@ -3033,6 +3038,13 @@ class NixlBaseConnectorWorker: + if xfer_state == "DONE": res = self.nixl_wrapper.get_xfer_telemetry(handle) self.xfer_stats.record_transfer(res) + metrics = self.pending_request_metrics.setdefault(req_id, {}) @@ -634,30 +63,29 @@ index c8166b2e6ae..bb0f534fe79 100644 self.nixl_wrapper.release_xfer_handle(handle) elif xfer_state == "PROC": in_progress.append(handle) -@@ -2229,6 +2241,9 @@ class NixlBaseConnectorWorker: - self._handle_failed_transfer(req_id, handle) +@@ -3056,6 +3068,10 @@ class NixlBaseConnectorWorker: + in_progress.append(handle) if not in_progress: + completed_metrics = self.pending_request_metrics.pop(req_id, None) + if completed_metrics is not None: + self.request_metrics.requests[req_id] = completed_metrics - # Only report request as completed when all transfers are done. ++ # Only report request as completed when all transfers are done. done_req_ids.add(req_id) del transfers[req_id] -@@ -2573,6 +2588,8 @@ class NixlBaseConnectorWorker: + else: +@@ -3612,4 +3628,6 @@ class NixlBaseConnectorWorker: for handle in handles: self.nixl_wrapper.release_xfer_handle(handle) self._recving_transfers.clear() + self.pending_request_metrics.clear() + self.request_metrics.requests.clear() - for handle in self.src_xfer_handles_by_block_size.values(): - self.nixl_wrapper.release_dlist_handle(handle) - self.src_xfer_handles_by_block_size.clear() + self._finish_shutdown() diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py -index fa5537c3259..3e2a067e7f2 100644 +index ac929f7fcd..dc5e847fdf 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py -@@ -36,6 +36,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( +@@ -37,6 +37,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( ) from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( NixlConnectorMetadata, @@ -665,7 +93,7 @@ index fa5537c3259..3e2a067e7f2 100644 ) from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import ( NixlPullConnectorScheduler, -@@ -254,6 +255,12 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA): +@@ -258,6 +259,12 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA): assert self.connector_worker is not None return self.connector_worker.get_block_ids_with_load_errors() @@ -679,18 +107,9 @@ index fa5537c3259..3e2a067e7f2 100644 if self.connector_worker is None: return None diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py -index 0fca03c93b0..bf983a85f02 100644 +index ca6f30d0cf..914bd79489 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py -@@ -2,7 +2,7 @@ - # SPDX-FileCopyrightText: Copyright contributors to the vLLM project - """Metadata dataclasses and helpers for the NIXL connector.""" - --from dataclasses import dataclass -+from dataclasses import dataclass, field - from typing import Any - - from vllm.config import VllmConfig @@ -10,6 +10,7 @@ from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds, EngineId from vllm.distributed.kv_transfer.kv_connector.v1.base import ( KVConnectorHandshakeMetadata, @@ -722,19 +141,19 @@ index 0fca03c93b0..bf983a85f02 100644 GET_META_MSG = b"get_meta_msg" # Push-mode (WRITE-based) registration notification. -@@ -233,6 +250,7 @@ class ReqMeta: - remote_block_size: int | None = None - # Remote producer pipeline-parallel size (push mode, D side). - pp_size: int = 1 +@@ -255,6 +272,7 @@ class ReqMeta: + # Worker-only, per-region physical pages to zero after a successful pull. + # None selects group-based completion; empty lists mean no zeroing. + region_blocks_to_zero: BlockIds | None = None + handshake_wait_time: float = 0.0 class NixlConnectorMetadata(KVConnectorMetadata): diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py -index a09fd684e3b..717e0a135f0 100644 +index 6539b7e57e..887912f94b 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py -@@ -128,6 +128,9 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker): +@@ -143,6 +143,9 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker): def _read_blocks_for_req(self, req_id: str, meta: ReqMeta): assert meta.remote is not None and self.transfer_topo is not None @@ -744,53 +163,11 @@ index a09fd684e3b..717e0a135f0 100644 engine_id = meta.remote.engine_id # Update last activity from this remote. Mind that cleanup is done on main # thread (this one), so we don't race on this structure. -diff --git a/vllm/entrypoints/generate/base/serving.py b/vllm/entrypoints/generate/base/serving.py -index d6ae0a20906..807117196ea 100644 ---- a/vllm/entrypoints/generate/base/serving.py -+++ b/vllm/entrypoints/generate/base/serving.py -@@ -52,6 +52,7 @@ PRIORITY_HEADER = "X-Vllm-Priority" - def build_per_request_timing_metrics( - metrics: RequestStateStats | None, - num_generation_tokens: int, -+ kv_transfer_params: dict | None = None, - ) -> PerRequestMetrics: - """Build per-request timing metrics from ``RequestStateStats``. - -@@ -96,11 +97,29 @@ def build_per_request_timing_metrics( - tokens_per_second = num_generation_tokens / inference_time_ms * 1000 - - return PerRequestMetrics( -+ prefill_queue_time_ms=(kv_transfer_params or {}) -+ .get("prefill_metrics", {}) -+ .get("queue_time_ms"), -+ prefill_time_to_first_token_ms=(kv_transfer_params or {}) -+ .get("prefill_metrics", {}) -+ .get("time_to_first_token_ms"), -+ prefill_kv_allocation_wait_time_ms=(kv_transfer_params or {}) -+ .get("prefill_metrics", {}) -+ .get("kv_allocation_wait_time_ms"), -+ prefill_kv_initial_queue_wait_time_ms=(kv_transfer_params or {}) -+ .get("prefill_metrics", {}) -+ .get("kv_initial_queue_wait_time_ms"), - time_to_first_token_ms=time_to_first_token_ms, - generation_time_ms=generation_time_ms, - queue_time_ms=queue_time_ms, - mean_itl_ms=mean_itl_ms, - tokens_per_second=tokens_per_second, -+ **(metrics.kv_transfer_metrics or {}), -+ remote_kv_wait_time_ms=( -+ metrics.remote_kv_wait_time * 1000 -+ if metrics.remote_kv_wait_time is not None -+ else None -+ ), - ) - - -diff --git a/vllm/entrypoints/openai/engine/protocol.py b/vllm/entrypoints/openai/engine/protocol.py -index 9635ece46b0..fd606a314d6 100644 ---- a/vllm/entrypoints/openai/engine/protocol.py -+++ b/vllm/entrypoints/openai/engine/protocol.py -@@ -159,6 +159,52 @@ class PerRequestMetrics(OpenAIBaseModel): +diff --git a/vllm/entrypoints/generate/base/protocol.py b/vllm/entrypoints/generate/base/protocol.py +index 91d2e30a47..cbd34e8298 100644 +--- a/vllm/entrypoints/generate/base/protocol.py ++++ b/vllm/entrypoints/generate/base/protocol.py +@@ -79,6 +79,52 @@ class PerRequestMetrics(OpenAIBaseModel): tokens_per_second: float | None = None # Experimental, subject to change. speculative_decoding: SpeculativeDecodingMetrics | None = None @@ -843,11 +220,53 @@ index 9635ece46b0..fd606a314d6 100644 class RequestResponseMetadata(BaseModel): +diff --git a/vllm/entrypoints/generate/base/serving.py b/vllm/entrypoints/generate/base/serving.py +index 5142346df6..213962bd55 100644 +--- a/vllm/entrypoints/generate/base/serving.py ++++ b/vllm/entrypoints/generate/base/serving.py +@@ -50,6 +50,7 @@ PRIORITY_HEADER = "X-Vllm-Priority" + def build_per_request_timing_metrics( + metrics: RequestStateStats | None, + num_generation_tokens: int, ++ kv_transfer_params: dict | None = None, + ) -> PerRequestMetrics: + """Build per-request timing metrics from ``RequestStateStats``. + +@@ -94,11 +95,29 @@ def build_per_request_timing_metrics( + tokens_per_second = num_generation_tokens / inference_time_ms * 1000 + + return PerRequestMetrics( ++ prefill_queue_time_ms=(kv_transfer_params or {}) ++ .get("prefill_metrics", {}) ++ .get("queue_time_ms"), ++ prefill_time_to_first_token_ms=(kv_transfer_params or {}) ++ .get("prefill_metrics", {}) ++ .get("time_to_first_token_ms"), ++ prefill_kv_allocation_wait_time_ms=(kv_transfer_params or {}) ++ .get("prefill_metrics", {}) ++ .get("kv_allocation_wait_time_ms"), ++ prefill_kv_initial_queue_wait_time_ms=(kv_transfer_params or {}) ++ .get("prefill_metrics", {}) ++ .get("kv_initial_queue_wait_time_ms"), + time_to_first_token_ms=time_to_first_token_ms, + generation_time_ms=generation_time_ms, + queue_time_ms=queue_time_ms, + mean_itl_ms=mean_itl_ms, + tokens_per_second=tokens_per_second, ++ **(metrics.kv_transfer_metrics or {}), ++ remote_kv_wait_time_ms=( ++ metrics.remote_kv_wait_time * 1000 ++ if metrics.remote_kv_wait_time is not None ++ else None ++ ), + ) + + diff --git a/vllm/entrypoints/scale_out/factories.py b/vllm/entrypoints/scale_out/factories.py -index 341dad13a86..dc31e1536fc 100644 +index bd48c709dd..dfe4d09911 100644 --- a/vllm/entrypoints/scale_out/factories.py +++ b/vllm/entrypoints/scale_out/factories.py -@@ -53,6 +53,7 @@ def init_scale_out_state( +@@ -57,6 +57,7 @@ def init_scale_out_state( request_logger=request_logger, return_tokens_as_token_ids=args.return_tokens_as_token_ids, enable_prompt_tokens_details=args.enable_prompt_tokens_details, @@ -856,54 +275,31 @@ index 341dad13a86..dc31e1536fc 100644 force_no_detokenize=args.tokens_only, ) diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index 71a17e9363d..88740e37f31 100644 +index eb4000c7e2..50d376f747 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -20,7 +20,11 @@ from vllm.entrypoints.openai.completion.protocol import ( - CompletionRequest, - CompletionStreamResponse, - ) --from vllm.entrypoints.openai.engine.protocol import StreamOptions, UsageInfo -+from vllm.entrypoints.openai.engine.protocol import ( -+ PerRequestMetrics, -+ StreamOptions, -+ UsageInfo, -+) - from vllm.logprobs import Logprob - from vllm.renderers import TokenizeParams - from vllm.sampling_params import SamplingParams -@@ -230,6 +234,7 @@ class GenerateResponseStreamChoice(BaseModel): - - - class GenerateStreamResponse(BaseModel): -+ request_metrics: PerRequestMetrics | None = None - request_id: str = Field( - default_factory=lambda: f"{random_uuid()}", - description=( -@@ -271,6 +276,10 @@ class GenerateResponse(BaseModel): +@@ -397,8 +397,6 @@ class GenerateResponse(BaseModel): "ECTransfer parameters used for encoder-cache disaggregated serving." ), ) -+ request_metrics: PerRequestMetrics | None = Field( -+ default=None, -+ description="Per-request generation and remote KV wait timings.", -+ ) - - +- +- class DerenderChatRequest(BaseModel): + """Request for the /v1/chat/completions/derender endpoint (non-streaming). + diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index f24c306f9ec..dc02efe1d94 100644 +index 6e86ac8341..5c38c0f672 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -14,6 +14,7 @@ from vllm.engine.protocol import EngineClient - from vllm.entrypoints.chat_utils import AsyncMultiModalItemTracker +@@ -19,6 +19,7 @@ from vllm.entrypoints.generate.base.protocol import ( from vllm.entrypoints.generate.base.serving import ( GenerateBaseServing, -+ build_per_request_timing_metrics, build_spec_decoding_metrics, ++ build_per_request_timing_metrics, clamp_prompt_logprobs, ) -@@ -71,6 +72,7 @@ class ServingTokens(GenerateBaseServing): + from vllm.entrypoints.openai.chat_completion.protocol import ( +@@ -76,6 +77,7 @@ class ServingTokens(GenerateBaseServing): force_no_detokenize: bool = False, return_tokens_as_token_ids: bool = False, enable_prompt_tokens_details: bool = False, @@ -911,7 +307,7 @@ index f24c306f9ec..dc02efe1d94 100644 enable_log_outputs: bool = False, ): super().__init__( -@@ -81,6 +83,7 @@ class ServingTokens(GenerateBaseServing): +@@ -86,6 +88,7 @@ class ServingTokens(GenerateBaseServing): ) self.online_renderer = online_renderer self.enable_prompt_tokens_details = enable_prompt_tokens_details @@ -919,11 +315,12 @@ index f24c306f9ec..dc02efe1d94 100644 self.enable_log_outputs = enable_log_outputs self.force_no_detokenize = force_no_detokenize if force_no_detokenize: -@@ -371,6 +374,16 @@ class ServingTokens(GenerateBaseServing): +@@ -385,11 +388,20 @@ class ServingTokens(GenerateBaseServing): request_metadata.final_usage_info = usage -+ request_metrics = ( +- per_request_metrics = None ++ per_request_metrics = ( + build_per_request_timing_metrics( + final_res.metrics, + num_generated_tokens, @@ -932,56 +329,67 @@ index f24c306f9ec..dc02efe1d94 100644 + if self.enable_per_request_metrics + else None + ) -+ + if request.sampling_params.n == 1: + spec_stats = build_spec_decoding_metrics(final_res) + if spec_stats is not None: +- per_request_metrics = PerRequestMetrics(speculative_decoding=spec_stats) ++ per_request_metrics = per_request_metrics or PerRequestMetrics() ++ per_request_metrics.speculative_decoding = spec_stats response = GenerateResponse( request_id=request_id, created=created_time, -@@ -382,8 +395,21 @@ class ServingTokens(GenerateBaseServing): - prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs), - kv_transfer_params=final_res.kv_transfer_params, +@@ -407,6 +419,18 @@ class ServingTokens(GenerateBaseServing): ec_transfer_params=final_res.ec_transfer_params, -+ request_metrics=request_metrics, ) -+ if request_metrics is not None and response.kv_transfer_params is not None: ++ if per_request_metrics is not None and response.kv_transfer_params is not None: + response.kv_transfer_params["prefill_metrics"] = { -+ "queue_time_ms": request_metrics.queue_time_ms, -+ "time_to_first_token_ms": request_metrics.time_to_first_token_ms, ++ "queue_time_ms": per_request_metrics.queue_time_ms, ++ "time_to_first_token_ms": per_request_metrics.time_to_first_token_ms, + "kv_allocation_wait_time_ms": ( -+ request_metrics.kv_allocation_wait_time_ms ++ per_request_metrics.kv_allocation_wait_time_ms + ), + "kv_initial_queue_wait_time_ms": ( -+ request_metrics.kv_initial_queue_wait_time_ms ++ per_request_metrics.kv_initial_queue_wait_time_ms + ), + } + # Log complete response if output logging is enabled if self.enable_log_outputs and self.request_logger: for choice in choices: -@@ -468,6 +494,15 @@ class ServingTokens(GenerateBaseServing): - ) +@@ -545,13 +569,20 @@ class ServingTokens(GenerateBaseServing): + ) - chunk = GenerateStreamResponse( -+ request_metrics=( -+ build_per_request_timing_metrics( -+ res.metrics, -+ sum(num_generated_tokens), -+ request.kv_transfer_params, -+ ) -+ if self.enable_per_request_metrics and res.finished -+ else None -+ ), - request_id=request_id, - weight_version=weight_version, - request_spec_decode_stats=request_spec_decode_stats, + if include_usage: +- per_request_metrics = None ++ per_request_metrics = ( ++ build_per_request_timing_metrics( ++ last_res.metrics if last_res is not None else None, ++ total_completion_tokens, ++ request.kv_transfer_params, ++ ) ++ if self.enable_per_request_metrics ++ else None ++ ) + if sampling_params.n == 1: + spec_stats = build_spec_decoding_metrics(last_res) + if spec_stats is not None: +- per_request_metrics = PerRequestMetrics( +- speculative_decoding=spec_stats +- ) ++ per_request_metrics = per_request_metrics or PerRequestMetrics() ++ per_request_metrics.speculative_decoding = spec_stats + final_chunk = GenerateStreamResponse( + request_id=request_id, + weight_version=weight_version, diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index 88c2ce289c4..f4ee2f16ce2 100644 +index 18bbc10283..3996a257e7 100644 --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py -@@ -1059,6 +1059,18 @@ class Scheduler(SchedulerInterface): - # avoid deadlock and predictable preemptions. - reserved_blocks = self._inflight_prefill_reserved_blocks() - +@@ -1198,6 +1198,18 @@ class Scheduler(SchedulerInterface): + # Replayed tokens are already counted in the adopted hit; a + # chunk that ends inside the replayed range needs no new slots. + num_tokens_past_hit = max(num_new_tokens - num_replay_tokens, 0) + for phase, started_at in request.kv_queue_started_at.items(): + key = f"kv_{phase}_queue_wait_time_ms" + request.kv_transfer_metrics[key] = ( @@ -996,8 +404,8 @@ index 88c2ce289c4..f4ee2f16ce2 100644 + request.kv_allocation_started_at = time.monotonic() new_blocks = self.kv_cache_manager.allocate_slots( request, - num_new_tokens, -@@ -1082,6 +1094,13 @@ class Scheduler(SchedulerInterface): + num_tokens_past_hit, +@@ -1221,6 +1233,13 @@ class Scheduler(SchedulerInterface): self.encoder_cache_manager.free(request) break @@ -1011,7 +419,7 @@ index 88c2ce289c4..f4ee2f16ce2 100644 # KVTransfer: the connector uses this info to determine # if a load is needed. Note that # This information is used to determine if a load is -@@ -1112,6 +1131,7 @@ class Scheduler(SchedulerInterface): +@@ -1251,6 +1270,7 @@ class Scheduler(SchedulerInterface): if load_kv_async: # If loading async, allocate memory and put request # into the WAITING_FOR_REMOTE_KV state. @@ -1019,24 +427,24 @@ index 88c2ce289c4..f4ee2f16ce2 100644 request.status = RequestStatus.WAITING_FOR_REMOTE_KVS step_skipped_waiting.prepend_request(request) # Set num_computed_tokens even though KVs are not yet loaded. -@@ -1913,6 +1933,7 @@ class Scheduler(SchedulerInterface): +@@ -2036,6 +2056,7 @@ class Scheduler(SchedulerInterface): pooler_output = pooler_outputs[req_index] if pooler_outputs else None kv_transfer_params = None ec_transfer_params = None + remote_kv_wait_time = None prefill_stats = None status_before_stop = request.status - num_output_tokens_before = len(request._output_token_ids) -@@ -2024,6 +2045,8 @@ class Scheduler(SchedulerInterface): - finished = self._handle_stopped_request(request) - if finished: - kv_transfer_params, ec_transfer_params = self._free_request(request) + +@@ -2107,6 +2128,8 @@ class Scheduler(SchedulerInterface): + kv_transfer_params["remote_prefill_cached_tokens"] = ( + prefill_stats.num_cached_tokens + ) + if request.remote_kv_wait_time: + remote_kv_wait_time = request.remote_kv_wait_time if status_before_stop == RequestStatus.RUNNING: stopped_running_reqs.add(request) -@@ -2071,6 +2094,8 @@ class Scheduler(SchedulerInterface): +@@ -2154,6 +2177,8 @@ class Scheduler(SchedulerInterface): ), kv_transfer_params=kv_transfer_params, ec_transfer_params=ec_transfer_params, @@ -1045,7 +453,7 @@ index 88c2ce289c4..f4ee2f16ce2 100644 trace_headers=request.trace_headers, routed_experts=routed_experts, num_nans_in_logits=request.num_nans_in_logits, -@@ -2409,6 +2434,7 @@ class Scheduler(SchedulerInterface): +@@ -2494,6 +2519,7 @@ class Scheduler(SchedulerInterface): self.num_spec_tokens ) if self.connector is not None: @@ -1053,16 +461,16 @@ index 88c2ce289c4..f4ee2f16ce2 100644 self.connector.on_new_request(request) if self.log_stats: request.record_event(EngineCoreEventType.QUEUED) -@@ -2481,6 +2507,8 @@ class Scheduler(SchedulerInterface): +@@ -2567,6 +2593,8 @@ class Scheduler(SchedulerInterface): ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: assert request.is_finished() + request.stop_remote_kv_wait() + + if self.aux_output_connector is not None: + self.aux_output_connector.request_finished(request) self._inflight_prefills.discard(request) - connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request) - -@@ -2866,6 +2894,8 @@ class Scheduler(SchedulerInterface): +@@ -3022,6 +3050,8 @@ class Scheduler(SchedulerInterface): if request.request_id not in self.finished_recving_kv_req_ids: return False self._update_waiting_for_remote_kv(request) @@ -1071,7 +479,7 @@ index 88c2ce289c4..f4ee2f16ce2 100644 if request.num_preemptions: request.status = RequestStatus.PREEMPTED else: -@@ -2905,6 +2935,16 @@ class Scheduler(SchedulerInterface): +@@ -3059,6 +3089,16 @@ class Scheduler(SchedulerInterface): if self.connector is not None: self.connector.update_connector_output(kv_connector_output) @@ -1089,28 +497,23 @@ index 88c2ce289c4..f4ee2f16ce2 100644 for req_id in kv_connector_output.finished_recving or (): logger.debug("Finished recving KV transfer for request %s", req_id) diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py -index 5ae9ee0cac8..a31ad48de46 100644 +index e5bbb96a9c..516918aa9b 100644 --- a/vllm/v1/engine/__init__.py +++ b/vllm/v1/engine/__init__.py -@@ -230,9 +230,12 @@ class EngineCoreOutput( - new_sampling_mask: SamplingMaskLists | None = None - - # Per-request spec-decode acceptance; attached only on the final output. -- # Appended last so `array_like` positional serialization stays compatible. - spec_decode_metrics: RequestSpecDecodeMetrics | None = None +@@ -231,6 +231,8 @@ class EngineCoreOutput( + mm_cache_miss_hashes: list[str] | None = None -+ # Appended last so `array_like` positional serialization stays compatible. + new_sampling_mask: SamplingMaskLists | None = None + remote_kv_wait_time: float | None = None + kv_transfer_metrics: dict[str, float] | None = None -+ - @property - def finished(self) -> bool: - return self.finish_reason is not None + + # Per-request spec-decode acceptance; attached only on the final output. + # Appended last so `array_like` positional serialization stays compatible. diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index 8260c3d326e..136cb0bdc93 100644 +index e739e83b1c..465c7f3e8f 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -654,6 +654,17 @@ class OutputProcessor: +@@ -690,6 +690,17 @@ class OutputProcessor: stop_reason = engine_core_output.stop_reason kv_transfer_params = engine_core_output.kv_transfer_params ec_transfer_params = engine_core_output.ec_transfer_params @@ -1129,10 +532,10 @@ index 8260c3d326e..136cb0bdc93 100644 req_state.routed_experts_chunks.append( engine_core_output.routed_experts diff --git a/vllm/v1/metrics/stats.py b/vllm/v1/metrics/stats.py -index 3dbc5206ca9..3aafee6935f 100644 +index 66088c7954..45afed4ff1 100644 --- a/vllm/v1/metrics/stats.py +++ b/vllm/v1/metrics/stats.py -@@ -232,6 +232,9 @@ class RequestStateStats: +@@ -234,6 +234,9 @@ class RequestStateStats: # first token latency first_token_latency: float = 0.0 @@ -1143,10 +546,10 @@ index 3dbc5206ca9..3aafee6935f 100644 is_corrupted: bool = False diff --git a/vllm/v1/request.py b/vllm/v1/request.py -index 8b453a09069..c50850887b2 100644 +index 4b1b9ecc2e..4dcf2c5ae9 100644 --- a/vllm/v1/request.py +++ b/vllm/v1/request.py -@@ -101,6 +101,11 @@ class Request: +@@ -103,6 +103,11 @@ class Request: # P/D: Connector-specific KV transfer parameters. self.kv_transfer_params: dict[str, Any] | None = None @@ -1158,7 +561,7 @@ index 8b453a09069..c50850887b2 100644 # E/P/D: Connector-specific encoder-cache transfer parameters. self.ec_transfer_params: dict[str, Any] | None = None -@@ -334,6 +339,16 @@ class Request: +@@ -341,6 +346,16 @@ class Request: ) -> None: self.events.append(EngineCoreEvent.new_event(event_type, timestamp)) diff --git a/docker/patch/latest/vllm-pull_weights.patch b/docker/patch/latest/vllm-pull_weights.patch index 53a6ca06b..4c44b9f2e 100644 --- a/docker/patch/latest/vllm-pull_weights.patch +++ b/docker/patch/latest/vllm-pull_weights.patch @@ -1,153 +1,50 @@ -diff --git a/tests/model_executor/model_loader/test_local_checkpoint.py b/tests/model_executor/model_loader/test_local_checkpoint.py -new file mode 100644 -index 0000000000..192b7e979a ---- /dev/null -+++ b/tests/model_executor/model_loader/test_local_checkpoint.py -@@ -0,0 +1,144 @@ -+# SPDX-License-Identifier: Apache-2.0 -+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -+ -+import json -+import zlib -+ -+import numpy as np -+import pytest -+import safetensors.numpy -+import zstandard -+ -+from vllm.utils.local_checkpoint import pull_checkpoint -+ -+ -+def _checksum(data: np.ndarray) -> str: -+ return f"{zlib.adler32(data):08x}" -+ +diff --git a/vllm/config/model.py b/vllm/config/model.py +index 7b938bc8e5..8f7fb67152 100644 +--- a/vllm/config/model.py ++++ b/vllm/config/model.py +@@ -341,6 +341,9 @@ class ModelConfig: + used with `--generation-config auto`, the override parameters will be + merged with the default config from the model. If used with + `--generation-config vllm`, only the override parameters are used.""" ++ custom_pull_weights_pre_read_hook: str | None = None ++ """Import path of a hook(source_dir, target_version) called before reading published weights.""" + -+def _write_delta( -+ source_dir, -+ version: int, -+ old: np.ndarray, -+ new: np.ndarray, -+ encoding: str, -+) -> None: -+ version_dir = source_dir / f"weight_v{version:06d}" -+ version_dir.mkdir() -+ old_bytes = old.view(np.uint8).reshape(-1) -+ new_bytes = new.view(np.uint8).reshape(-1) -+ if encoding == "xor": -+ payload = old_bytes ^ new_bytes -+ else: -+ positions = np.flatnonzero(old_bytes != new_bytes).astype(" None: -+ version_dir = source_dir / f"weight_v{version:06d}" -+ version_dir.mkdir() -+ safetensors.numpy.save_file({"weight": weight}, version_dir / "model.safetensors") -+ (version_dir / "config.json").write_text("{}") -+ -+ -+@pytest.mark.parametrize("encoding", ["xor", "overwrite"]) -+def test_pull_checkpoint_applies_vime_delta(tmp_path, encoding): -+ base_dir = tmp_path / "base" -+ source_dir = tmp_path / "published" -+ local_dir = tmp_path / "local" -+ base_dir.mkdir() -+ source_dir.mkdir() -+ -+ baseline = np.arange(12, dtype=np.float32).reshape(3, 4) -+ updated = baseline.copy() -+ updated[0, 1] = 100.0 -+ updated[2, 3] = -5.0 -+ safetensors.numpy.save_file({"weight": baseline}, base_dir / "model.safetensors") -+ (base_dir / "config.json").write_text("{}") -+ _write_delta(source_dir, 1, baseline, updated, encoding) -+ -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 0) -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 1) -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 1) -+ -+ actual = safetensors.numpy.load_file(local_dir / "model.safetensors") -+ np.testing.assert_array_equal(actual["weight"], updated) -+ state = json.loads((local_dir / ".weight_sync" / "state.json").read_text()) -+ assert state == {"version": "000001"} -+ -+ -+def test_pull_checkpoint_resets_to_latest_full_version(tmp_path): -+ base_dir = tmp_path / "base" -+ source_dir = tmp_path / "published" -+ local_dir = tmp_path / "local" -+ base_dir.mkdir() -+ source_dir.mkdir() -+ -+ baseline = np.arange(12, dtype=np.float32).reshape(3, 4) -+ first = baseline + 1 -+ reset = baseline + 10 -+ latest = reset.copy() -+ latest[1, 2] = -7.0 -+ safetensors.numpy.save_file({"weight": baseline}, base_dir / "model.safetensors") -+ (base_dir / "config.json").write_text("{}") -+ _write_delta(source_dir, 1, baseline, first, "xor") -+ _write_full(source_dir, 2, reset) -+ _write_delta(source_dir, 3, reset, latest, "xor") -+ -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 1) -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 3) -+ -+ actual = safetensors.numpy.load_file(local_dir / "model.safetensors") -+ np.testing.assert_array_equal(actual["weight"], latest) -+ state = json.loads((local_dir / ".weight_sync" / "state.json").read_text()) -+ assert state == {"version": "000003"} -+ -+ -+def test_pull_checkpoint_does_not_advance_on_checksum_failure(tmp_path): -+ base_dir = tmp_path / "base" -+ source_dir = tmp_path / "published" -+ local_dir = tmp_path / "local" -+ base_dir.mkdir() -+ source_dir.mkdir() -+ -+ baseline = np.arange(12, dtype=np.float32).reshape(3, 4) -+ updated = baseline + 1 -+ safetensors.numpy.save_file({"weight": baseline}, base_dir / "model.safetensors") -+ (base_dir / "config.json").write_text("{}") -+ _write_delta(source_dir, 1, baseline, updated, "xor") -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 0) -+ safetensors.numpy.save_file( -+ {"weight": baseline + 2}, local_dir / "model.safetensors" + enable_sleep_mode: bool = False + """Enable sleep mode for the engine (only cuda and + hip platforms are supported).""" +diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py +index 0a5472bce7..7369989bb7 100644 +--- a/vllm/engine/arg_utils.py ++++ b/vllm/engine/arg_utils.py +@@ -740,6 +740,9 @@ class EngineArgs: + reasoning_config: ReasoningConfig = get_field(VllmConfig, "reasoning_config") + + generation_config: str = ModelConfig.generation_config ++ custom_pull_weights_pre_read_hook: str | None = ( ++ ModelConfig.custom_pull_weights_pre_read_hook + ) -+ -+ with pytest.raises(RuntimeError, match="Checksum mismatch"): -+ pull_checkpoint(str(local_dir), str(base_dir), str(source_dir), 1) -+ -+ state = json.loads((local_dir / ".weight_sync" / "state.json").read_text()) -+ assert state == {"version": "000000"} + enable_sleep_mode: bool = ModelConfig.enable_sleep_mode + sleep_preserve_parameter_names: list[str] = get_field( + ModelConfig, "sleep_preserve_parameter_names" +@@ -975,6 +978,10 @@ class EngineArgs: + model_group.add_argument( + "--override-generation-config", **model_kwargs["override_generation_config"] + ) ++ model_group.add_argument( ++ "--custom-pull-weights-pre-read-hook", ++ **model_kwargs["custom_pull_weights_pre_read_hook"], ++ ) + model_group.add_argument( + "--enable-sleep-mode", **model_kwargs["enable_sleep_mode"] + ) +@@ -1910,6 +1917,7 @@ class EngineArgs: + pooler_config=self.pooler_config, + generation_config=self.generation_config, + override_generation_config=self.override_generation_config, ++ custom_pull_weights_pre_read_hook=self.custom_pull_weights_pre_read_hook, + enable_sleep_mode=self.enable_sleep_mode, + sleep_preserve_parameter_names=self.sleep_preserve_parameter_names, + enable_cumem_allocator=self.enable_cumem_allocator, diff --git a/vllm/utils/local_checkpoint.py b/vllm/utils/local_checkpoint.py new file mode 100644 index 0000000000..42be58d249 @@ -474,10 +371,10 @@ index 0000000000..42be58d249 + ) + _write_applied_version(local_checkpoint_dir, version) diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py -index a3b00aaad2..2b05c5e2f5 100644 +index 3d4c5f3d5c..483ef331bf 100644 --- a/vllm/v1/worker/gpu_worker.py +++ b/vllm/v1/worker/gpu_worker.py -@@ -471,6 +471,24 @@ class Worker(WorkerBase): +@@ -562,6 +562,24 @@ class Worker(WorkerBase): with set_current_vllm_config(self.vllm_config): self.model_runner.reload_weights(*args, **kwargs) @@ -502,46 +399,3 @@ index a3b00aaad2..2b05c5e2f5 100644 @torch.inference_mode() def determine_available_memory(self) -> int: """Profiles the peak memory usage of the model to determine how much -diff --git a/vllm/config/model.py b/vllm/config/model.py ---- a/vllm/config/model.py -+++ b/vllm/config/model.py -@@ -335,6 +335,9 @@ - used with `--generation-config auto`, the override parameters will be - merged with the default config from the model. If used with - `--generation-config vllm`, only the override parameters are used.""" -+ custom_pull_weights_pre_read_hook: str | None = None -+ """Import path of a hook(source_dir, target_version) called before reading published weights.""" -+ - enable_sleep_mode: bool = False - """Enable sleep mode for the engine (only cuda and - hip platforms are supported).""" -diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py ---- a/vllm/engine/arg_utils.py -+++ b/vllm/engine/arg_utils.py -@@ -705,6 +705,7 @@ - reasoning_config: ReasoningConfig = get_field(VllmConfig, "reasoning_config") - - generation_config: str = ModelConfig.generation_config -+ custom_pull_weights_pre_read_hook: str | None = ModelConfig.custom_pull_weights_pre_read_hook - enable_sleep_mode: bool = ModelConfig.enable_sleep_mode - enable_cumem_allocator: bool = ModelConfig.enable_cumem_allocator - override_generation_config: dict[str, Any] = get_field( -@@ -922,6 +923,10 @@ - ) - model_group.add_argument( - "--override-generation-config", **model_kwargs["override_generation_config"] -+ ) -+ model_group.add_argument( -+ "--custom-pull-weights-pre-read-hook", -+ **model_kwargs["custom_pull_weights_pre_read_hook"], - ) - model_group.add_argument( - "--enable-sleep-mode", **model_kwargs["enable_sleep_mode"] -@@ -1811,6 +1816,7 @@ - pooler_config=self.pooler_config, - generation_config=self.generation_config, - override_generation_config=self.override_generation_config, -+ custom_pull_weights_pre_read_hook=self.custom_pull_weights_pre_read_hook, - enable_sleep_mode=self.enable_sleep_mode, - enable_cumem_allocator=self.enable_cumem_allocator, - model_impl=self.model_impl, diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index e56516310..4bc8ccb6d 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -1,38 +1,28 @@ diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index f304bf677ba..71a17e9363d 100644 +index fb2b938723..eb4000c7e2 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -240,6 +240,8 @@ class GenerateStreamResponse(BaseModel): +@@ -362,6 +362,7 @@ class GenerateStreamResponse(BaseModel): ) choices: list[GenerateResponseStreamChoice] usage: UsageInfo | None = Field(default=None) + weight_version: str | None = None -+ request_spec_decode_stats: dict[str, Any] | None = Field(default=None) - - - class GenerateResponse(BaseModel): -@@ -255,6 +257,8 @@ class GenerateResponse(BaseModel): + prompt_token_ids: list[int] | None = None + mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None + metrics: PerRequestMetrics | None = None +@@ -380,6 +381,7 @@ class GenerateResponse(BaseModel): created: int | None = None choices: list[GenerateResponseChoice] usage: UsageInfo | None = Field(default=None) + weight_version: str | None = None -+ request_spec_decode_stats: dict[str, Any] | None = Field(default=None) prompt_logprobs: list[dict[int, Logprob] | None] | None = None - - kv_transfer_params: dict[str, Any] | None = Field( + prompt_token_ids: list[int] | None = None + mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index bbbd85137ce..2edec47e1b1 100644 +index 59fda2dbf8..6e86ac8341 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -14,6 +14,7 @@ from vllm.engine.protocol import EngineClient - from vllm.entrypoints.chat_utils import AsyncMultiModalItemTracker - from vllm.entrypoints.generate.base.serving import ( - GenerateBaseServing, -+ build_spec_decoding_metrics, - clamp_prompt_logprobs, - ) - from vllm.entrypoints.openai.chat_completion.protocol import ( -@@ -261,6 +262,7 @@ class ServingTokens(GenerateBaseServing): +@@ -281,6 +281,7 @@ class ServingTokens(GenerateBaseServing): ) assert result_generator is not None @@ -40,7 +30,7 @@ index bbbd85137ce..2edec47e1b1 100644 if request.stream: return self.serve_tokens_stream_generator( -@@ -269,10 +271,16 @@ class ServingTokens(GenerateBaseServing): +@@ -289,10 +290,16 @@ class ServingTokens(GenerateBaseServing): request_id, model_name, request_metadata, @@ -58,7 +48,7 @@ index bbbd85137ce..2edec47e1b1 100644 ) async def serve_tokens_full_generator( -@@ -282,6 +290,7 @@ class ServingTokens(GenerateBaseServing): +@@ -302,6 +309,7 @@ class ServingTokens(GenerateBaseServing): request_id: str, model_name: str, request_metadata: RequestResponseMetadata, @@ -66,28 +56,15 @@ index bbbd85137ce..2edec47e1b1 100644 ) -> ErrorResponse | GenerateResponse: created_time = int(time.time()) final_res: RequestOutput | None = None -@@ -355,6 +364,11 @@ class ServingTokens(GenerateBaseServing): - cached_tokens=final_res.num_cached_tokens - ) - -+ spec_decode_metrics = build_spec_decoding_metrics(final_res) -+ request_spec_decode_stats = ( -+ spec_decode_metrics.model_dump() if spec_decode_metrics else None -+ ) -+ - request_metadata.final_usage_info = usage - - response = GenerateResponse( -@@ -363,6 +377,8 @@ class ServingTokens(GenerateBaseServing): +@@ -388,6 +396,7 @@ class ServingTokens(GenerateBaseServing): model=model_name, choices=choices, usage=usage, + weight_version=weight_version, -+ request_spec_decode_stats=request_spec_decode_stats, prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs), - kv_transfer_params=final_res.kv_transfer_params, - ec_transfer_params=final_res.ec_transfer_params, -@@ -396,11 +412,13 @@ class ServingTokens(GenerateBaseServing): + prompt_token_ids=( + final_res.prompt_token_ids if request.return_token_ids else None +@@ -426,6 +435,7 @@ class ServingTokens(GenerateBaseServing): request_id: str, model_name: str, request_metadata: RequestResponseMetadata, @@ -95,44 +72,15 @@ index bbbd85137ce..2edec47e1b1 100644 ) -> AsyncGenerator[str, None]: num_prompt_tokens = 0 num_generated_tokens: list[int] = [] - first_iteration = True - num_cached_tokens = None -+ request_spec_decode_stats: dict[str, object] | None = None - sampling_params: SamplingParams = request.sampling_params - - include_usage, include_continuous_usage = should_include_usage( -@@ -409,6 +427,9 @@ class ServingTokens(GenerateBaseServing): - - try: - async for res in result_generator: -+ spec_decode_metrics = build_spec_decoding_metrics(res) -+ if spec_decode_metrics is not None: -+ request_spec_decode_stats = spec_decode_metrics.model_dump() - if first_iteration: - if res.prompt_token_ids is not None: - num_prompt_tokens = len(res.prompt_token_ids) -@@ -427,9 +448,9 @@ class ServingTokens(GenerateBaseServing): - self._raise_if_error(finish_reason, request_id) - -- if not delta_token_ids: -+ if not delta_token_ids and finish_reason is None: - continue - -- if sampling_params.logprobs is not None: -+ if sampling_params.logprobs is not None and delta_token_ids: - out_logprobs = output.logprobs - assert out_logprobs is not None, "Did not output logprobs" - logprobs = self._create_tokens_logprobs( -@@ -448,6 +469,8 @@ class ServingTokens(GenerateBaseServing): +@@ -487,6 +497,7 @@ class ServingTokens(GenerateBaseServing): chunk = GenerateStreamResponse( request_id=request_id, + weight_version=weight_version, -+ request_spec_decode_stats=request_spec_decode_stats, choices=[ GenerateResponseStreamChoice( index=i, -@@ -455,6 +478,11 @@ class ServingTokens(GenerateBaseServing): +@@ -494,6 +505,11 @@ class ServingTokens(GenerateBaseServing): finish_reason=finish_reason, token_ids=as_list(delta_token_ids), routed_experts=routed_experts_b64, @@ -144,57 +92,19 @@ index bbbd85137ce..2edec47e1b1 100644 ) ], ) -@@ -482,6 +510,8 @@ class ServingTokens(GenerateBaseServing): - if include_usage: +@@ -538,6 +554,7 @@ class ServingTokens(GenerateBaseServing): + ) final_chunk = GenerateStreamResponse( request_id=request_id, + weight_version=weight_version, -+ request_spec_decode_stats=request_spec_decode_stats, choices=[], usage=final_usage_info, - ) -diff --git a/vllm/model_executor/models/qwen3_omni_moe_thinker.py b/vllm/model_executor/models/qwen3_omni_moe_thinker.py ---- a/vllm/model_executor/models/qwen3_omni_moe_thinker.py -+++ b/vllm/model_executor/models/qwen3_omni_moe_thinker.py -@@ -45,6 +45,6 @@ from vllm.compilation.decorators import support_torch_compile - from vllm.config import ModelConfig, SpeechToTextConfig, VllmConfig - from vllm.config.speech_to_text import SpeechToTextParams --from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size -+from vllm.distributed import get_pp_group - from vllm.inputs import PromptType - from vllm.logger import init_logger - from vllm.model_executor.layers.activation import _ACTIVATION_REGISTRY -@@ -188,8 +188,7 @@ class Qwen3OmniMoeAudioAttention(nn.Module): - self.embed_dim = config.d_model - self.num_heads = config.encoder_attention_heads - self.head_dim = self.embed_dim // self.num_heads -- tp_size = get_tensor_model_parallel_world_size() -- self.num_local_heads = self.num_heads // tp_size -+ self.num_local_heads = self.num_heads - - if (self.head_dim * self.num_heads) != self.embed_dim: - raise ValueError( -@@ -213,6 +212,7 @@ class Qwen3OmniMoeAudioAttention(nn.Module): - total_num_kv_heads=self.num_heads, - bias=True, - prefix=f"{prefix}.qkv_proj", -+ disable_tp=True, - ) - - self.out_proj = RowParallelLinear( -@@ -213,6 +213,7 @@ class Qwen3OmniMoeAudioAttention(nn.Module): - output_size=self.embed_dim, - bias=True, - prefix=f"{prefix}.out_proj", -+ disable_tp=True, - ) - - self.attn = MMEncoderAttention( + metrics=per_request_metrics, diff --git a/vllm/outputs.py b/vllm/outputs.py -index 84b9fccdc88..5f1685bef83 100644 +index e9a08f7c46..0507f2555d 100644 --- a/vllm/outputs.py +++ b/vllm/outputs.py -@@ -189,6 +189,13 @@ class RequestOutput: +@@ -191,6 +191,13 @@ class RequestOutput: if next_completion.logprobs: assert completion.logprobs is not None completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type] @@ -208,47 +118,43 @@ index 84b9fccdc88..5f1685bef83 100644 completion.cumulative_logprob = ( next_completion.cumulative_logprob ) -diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index 6238fbc8175..8260c3d326e 100644 ---- a/vllm/v1/engine/output_processor.py -+++ b/vllm/v1/engine/output_processor.py -@@ -418,9 +418,11 @@ class RequestState: - logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] - - sampling_mask = None -- if finished and self.sampling_mask_chunks: -+ if self.sampling_mask_chunks and (delta or finished): - merged = SamplingMaskLists.merge(self.sampling_mask_chunks) -- sampling_mask = SamplingMask(merged.to_nested_list()) -+ sampling_mask = SamplingMask(merged.to_nested_list()[: len(token_ids)]) -+ if delta: -+ self.sampling_mask_chunks.clear() - - # Concatenate routed experts on finish - routed_experts = None diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py -index 4cc433cbfe3..4281a707af8 100644 +index cb7d3d4718..09618d7eb6 100644 --- a/vllm/v1/engine/input_processor.py +++ b/vllm/v1/engine/input_processor.py -@@ -101,17 +101,10 @@ class InputProcessor: - self.tokenizer, - ) - -- if self.model_config.return_sampling_mask: -- if params.temperature <= 0: -- raise ValueError( -- "sampling distribution replay requires temperature > 0" -- ) +@@ -150,12 +150,6 @@ class InputProcessor: + raise ValueError( + "sampling distribution replay requires temperature > 0" + ) - if params.top_k <= 0: - raise ValueError( - "sampling distribution replay requires top_k > 0 to " - "bound sampling mask size, reduce transfer overhead, " - "and avoid potential OOMs" - ) -+ if self.model_config.return_sampling_mask and params.temperature <= 0: -+ raise ValueError( -+ "sampling distribution replay requires temperature > 0" -+ ) if params.thinking_token_budget is not None and ( self.vllm_config.reasoning_config is None or not self.vllm_config.reasoning_config.enabled +diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py +index b4f7338048..e739e83b1c 100644 +--- a/vllm/v1/engine/output_processor.py ++++ b/vllm/v1/engine/output_processor.py +@@ -434,10 +434,16 @@ class RequestState: + logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] + + sampling_mask = None +- if finished and self.sampling_mask_chunks: ++ if self.sampling_mask_chunks and (delta or finished): + sampling_mask = SamplingMask( +- [chunk.token_ids.tolist() for chunk in self.sampling_mask_chunks] ++ [ ++ mask ++ for chunk in self.sampling_mask_chunks ++ for mask in chunk.to_nested_list() ++ ][: len(token_ids)] + ) ++ if delta: ++ self.sampling_mask_chunks.clear() + + # Concatenate routed experts on finish + routed_experts = None diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index bda5be182..752e619fe 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -152,12 +152,13 @@ def _generate_response( } if weight_version is not None: response["weight_version"] = weight_version + metrics = dict(request_metrics or {}) if request_spec_decode_stats is not None: - response["request_spec_decode_stats"] = request_spec_decode_stats + metrics["speculative_decoding"] = request_spec_decode_stats if sampling_mask is not None: response["choices"][0]["sampling_mask"] = sampling_mask - if request_metrics is not None: - response["request_metrics"] = request_metrics + if metrics: + response["metrics"] = metrics return response @@ -474,10 +475,12 @@ async def aiter_lines(self): { "request_id": "stream-7", "weight_version": "step-7", - "request_spec_decode_stats": { - "num_accepted_tokens": 6, - "num_draft_tokens": 8, - "num_verify_steps": 2, + "metrics": { + "speculative_decoding": { + "num_accepted_tokens": 6, + "num_draft_tokens": 8, + "num_verify_steps": 2, + } }, "choices": [ { @@ -504,7 +507,7 @@ async def aiter_lines(self): "request_id": "stream-7", "choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2}, - "request_metrics": {"queue_time_ms": 100}, + "metrics": {"queue_time_ms": 100}, }, ] if terminal_only: diff --git a/vime/observability/trace_utils.py b/vime/observability/trace_utils.py index c90a8c942..28bccaaef 100644 --- a/vime/observability/trace_utils.py +++ b/vime/observability/trace_utils.py @@ -169,7 +169,7 @@ def build_vllm_meta_trace_attrs(meta: dict[str, Any]) -> dict[str, Any]: meta["prompt_tokens"] = usage.get("prompt_tokens", 0) meta["completion_tokens"] = usage.get("completion_tokens", 0) meta["cached_tokens"] = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) - request_metrics = meta.get("request_metrics") + request_metrics = meta.get("metrics", meta.get("request_metrics")) if isinstance(request_metrics, dict): meta = dict(meta) for target, source, scale in ( diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 5cc8d6694..5b6ff0e37 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -494,6 +494,11 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A return sample +def _inference_generate_metrics(output: dict[str, Any]) -> dict[str, Any]: + metrics = output.get("metrics", output.get("request_metrics")) + return metrics if isinstance(metrics, dict) else {} + + def _inference_generate_meta_info(output: dict[str, Any]) -> dict[str, Any]: choice = output["choices"][0] # Build meta_info from the vLLM `choices` response format. @@ -514,7 +519,8 @@ def _inference_generate_meta_info(output: dict[str, Any]) -> dict[str, Any]: meta["prompt_tokens"] = usage.get("prompt_tokens", 0) meta["completion_tokens"] = usage.get("completion_tokens", 0) meta["cached_tokens"] = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) - spec_stats = output.get("request_spec_decode_stats") + metrics = _inference_generate_metrics(output) + spec_stats = metrics.get("speculative_decoding", output.get("request_spec_decode_stats")) if spec_stats: meta["spec_accept_token_num"] = spec_stats.get( "num_accepted_draft_tokens", spec_stats.get("num_accepted_tokens", 0) diff --git a/vime/rollout/vllm_streaming_rollout.py b/vime/rollout/vllm_streaming_rollout.py index 1000bc8f1..90cb1170e 100644 --- a/vime/rollout/vllm_streaming_rollout.py +++ b/vime/rollout/vllm_streaming_rollout.py @@ -47,6 +47,7 @@ _build_inference_sampling_params, _coerce_flat_int_token_ids, _inference_generate_meta_info, + _inference_generate_metrics, _mm_render_response_to_generate_body, _prepare_prompt_ids, prime_encoder, @@ -146,7 +147,7 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d last_choice: dict[str, Any] | None = None last_usage: dict[str, Any] | None = None weight_version: str | None = None - request_spec_decode_stats: dict[str, int] | None = None + response_metrics: dict[str, Any] = {} trace_metadata: dict[str, Any] = {} finish_reason: Any = None @@ -172,9 +173,11 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d if chunk.get("weight_version") is not None: weight_version = str(chunk["weight_version"]) - if chunk.get("request_spec_decode_stats") is not None: - request_spec_decode_stats = chunk["request_spec_decode_stats"] - for key in ("request_id", "request_metrics"): + chunk_metrics = _inference_generate_metrics(chunk) + if chunk_metrics: + response_metrics.update(chunk_metrics) + trace_metadata["metrics"] = response_metrics + for key in ("request_id",): if chunk.get(key) is not None: trace_metadata[key] = chunk[key] @@ -257,14 +260,13 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d meta["prompt_tokens"] = last_usage.get("prompt_tokens", 0) meta["completion_tokens"] = last_usage.get("completion_tokens", 0) meta["cached_tokens"] = (last_usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) - if request_spec_decode_stats: - meta["spec_accept_token_num"] = request_spec_decode_stats.get( - "num_accepted_draft_tokens", request_spec_decode_stats.get("num_accepted_tokens", 0) - ) - meta["spec_draft_token_num"] = request_spec_decode_stats.get("num_draft_tokens", 0) - meta["spec_verify_ct"] = request_spec_decode_stats.get( - "num_spec_steps", request_spec_decode_stats.get("num_verify_steps", 0) + spec_stats = response_metrics.get("speculative_decoding") + if spec_stats: + meta["spec_accept_token_num"] = spec_stats.get( + "num_accepted_draft_tokens", spec_stats.get("num_accepted_tokens", 0) ) + meta["spec_draft_token_num"] = spec_stats.get("num_draft_tokens", 0) + meta["spec_verify_ct"] = spec_stats.get("num_spec_steps", spec_stats.get("num_verify_steps", 0)) if new_response_tokens: meta["output_token_logprobs"] = [ [float(lp), int(tid)] for lp, tid in zip(new_response_log_probs, new_response_tokens, strict=True) From f58399c744cdda5d4ff2161af2abd298963c5d60 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 28 Sep 2026 03:08:03 +0000 Subject: [PATCH 06/58] fix: remove stale async CI entry and obsolete metrics fallbacks Signed-off-by: aoshen02 --- .buildkite/gpu_suites.py | 1 - vime/rollout/vllm_rollout.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index 5b4ccf118..ba2e38789 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -31,7 +31,6 @@ # (test_file, num_gpus, extra_args, env overrides) SUITES = { "short": [ - ("test_qwen3.5_0.8B_gsm8k_async_short.py", 4, "", {}), ("test_qwen3.5_0.8B_gsm8k_short.py", 4, "", {}), ("test_qwen2.5_0.5B_fully_async_short.py", 4, "", {}), ], diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 5b6ff0e37..ab95eae02 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -495,7 +495,7 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A def _inference_generate_metrics(output: dict[str, Any]) -> dict[str, Any]: - metrics = output.get("metrics", output.get("request_metrics")) + metrics = output.get("metrics") return metrics if isinstance(metrics, dict) else {} @@ -520,7 +520,7 @@ def _inference_generate_meta_info(output: dict[str, Any]) -> dict[str, Any]: meta["completion_tokens"] = usage.get("completion_tokens", 0) meta["cached_tokens"] = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) metrics = _inference_generate_metrics(output) - spec_stats = metrics.get("speculative_decoding", output.get("request_spec_decode_stats")) + spec_stats = metrics.get("speculative_decoding") if spec_stats: meta["spec_accept_token_num"] = spec_stats.get( "num_accepted_draft_tokens", spec_stats.get("num_accepted_tokens", 0) From 72bdd1472f81aba1c494b23716a5d5ce9f0cc077 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 28 Sep 2026 04:20:47 +0000 Subject: [PATCH 07/58] feat: transport sampling-mask logprobs for score centering Signed-off-by: aoshen02 --- .../patch/latest/vllm-score-centering.patch | 320 +++++++++++++++++- docker/patch/latest/vllm.patch | 10 +- tests/test_score_centering_transport.py | 13 +- tests/utils/test_vllm_engine.py | 9 + vime/backends/vllm_utils/vllm_engine.py | 2 + vime/rollout/vllm_rollout.py | 36 +- 6 files changed, 358 insertions(+), 32 deletions(-) diff --git a/docker/patch/latest/vllm-score-centering.patch b/docker/patch/latest/vllm-score-centering.patch index e24e36587..317ebfa21 100644 --- a/docker/patch/latest/vllm-score-centering.patch +++ b/docker/patch/latest/vllm-score-centering.patch @@ -1,7 +1,123 @@ +diff --git a/vllm/config/model.py b/vllm/config/model.py +index 8f7fb671525..3cdf929306b 100644 +--- a/vllm/config/model.py ++++ b/vllm/config/model.py +@@ -253,6 +253,8 @@ class ModelConfig: + equivalent to setting `-cc.mode=none -cc.cudagraph_mode=none`.""" + return_sampling_mask: bool = False + """Whether to return the post-processing token support for each sample.""" ++ return_sampling_mask_logprobs: bool = False ++ """Whether to return normalized logprobs aligned with sampling-mask IDs.""" + max_logprobs: int = Field(default=20, ge=-1) + """Maximum number of log probabilities to return when `logprobs` is + specified in `SamplingParams`. The default value comes the default for the +@@ -448,6 +450,7 @@ class ModelConfig: + "spec_target_max_model_len", + "enforce_eager", + "return_sampling_mask", ++ "return_sampling_mask_logprobs", + "logprobs_mode", + "use_fp64_gumbel", + "enable_trace_replay", +@@ -1014,6 +1017,10 @@ class ModelConfig: + @model_validator(mode="after") + def validate_model_config_after(self: "ModelConfig") -> "ModelConfig": + """Called after __post_init__.""" ++ if self.return_sampling_mask_logprobs and not self.return_sampling_mask: ++ raise ValueError( ++ "return_sampling_mask_logprobs requires return_sampling_mask" ++ ) + if not isinstance(self.tokenizer, str): + raise ValueError( + f"tokenizer must be a string, got " +diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py +index 7369989bb78..a1f801cc66c 100644 +--- a/vllm/engine/arg_utils.py ++++ b/vllm/engine/arg_utils.py +@@ -452,6 +452,7 @@ class EngineArgs: + # AuxOutputConfig. + enable_return_routed_experts: bool = False + return_sampling_mask: bool = ModelConfig.return_sampling_mask ++ return_sampling_mask_logprobs: bool = ModelConfig.return_sampling_mask_logprobs + model_weights: str = ModelConfig.model_weights + served_model_name: str | list[str] | None = ModelConfig.served_model_name + tokenizer: str | None = ModelConfig.tokenizer +@@ -944,6 +945,10 @@ class EngineArgs: + "--return-sampling-mask", + **model_kwargs["return_sampling_mask"], + ) ++ model_group.add_argument( ++ "--return-sampling-mask-logprobs", ++ **model_kwargs["return_sampling_mask_logprobs"], ++ ) + model_group.add_argument("--max-logprobs", **model_kwargs["max_logprobs"]) + model_group.add_argument("--logprobs-mode", **model_kwargs["logprobs_mode"]) + model_group.add_argument("--use-fp64-gumbel", **model_kwargs["use_fp64_gumbel"]) +@@ -1886,6 +1891,7 @@ class EngineArgs: + allow_deprecated_quantization=self.allow_deprecated_quantization, + enforce_eager=self.enforce_eager, + return_sampling_mask=self.return_sampling_mask, ++ return_sampling_mask_logprobs=self.return_sampling_mask_logprobs, + max_logprobs=self.max_logprobs, + logprobs_mode=self.logprobs_mode, + use_fp64_gumbel=self.use_fp64_gumbel, +diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +index eb4000c7e29..b2be4c2c663 100644 +--- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py ++++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +@@ -333,6 +333,7 @@ class GenerateResponseChoice(BaseModel): + # or (b) ``enable_return_routed_experts`` is off server-side. + routed_experts: str | None = None + sampling_mask: list[list[int]] | None = None ++ sampling_mask_logprobs: list[list[float]] | None = None + + @field_validator("token_ids") + @classmethod +@@ -349,6 +350,7 @@ class GenerateResponseStreamChoice(BaseModel): + token_ids: list[int] | None = None + routed_experts: str | None = None + sampling_mask: list[list[int]] | None = None ++ sampling_mask_logprobs: list[list[float]] | None = None + + + class GenerateStreamResponse(BaseModel): diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py +index 6e86ac83417..397902ddca1 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -589,5 +589,8 @@ class ServingTokens(GenerateBaseServing): +@@ -349,8 +349,10 @@ class ServingTokens(GenerateBaseServing): + ) + + sampling_mask = None ++ sampling_mask_logprobs = None + if output.sampling_mask is not None: + sampling_mask = output.sampling_mask.token_ids ++ sampling_mask_logprobs = output.sampling_mask.logprobs + + choice_data = GenerateResponseChoice( + index=output.index, +@@ -359,6 +361,7 @@ class ServingTokens(GenerateBaseServing): + token_ids=as_list(output.token_ids), + routed_experts=routed_experts_b64, + sampling_mask=sampling_mask, ++ sampling_mask_logprobs=sampling_mask_logprobs, + ) + + choices.append(choice_data) +@@ -510,6 +513,11 @@ class ServingTokens(GenerateBaseServing): + if output.sampling_mask is not None + else None + ), ++ sampling_mask_logprobs=( ++ output.sampling_mask.logprobs ++ if output.sampling_mask is not None ++ else None ++ ), + ) + ], + ) +@@ -607,7 +615,10 @@ class ServingTokens(GenerateBaseServing): + step_top_logprobs.items() ) if num_output_top_logprobs is not None - and i < max(num_output_top_logprobs, 1) @@ -11,3 +127,205 @@ diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/ent + ) ], ) + ) +diff --git a/vllm/outputs.py b/vllm/outputs.py +index 0507f2555df..814fa16d86b 100644 +--- a/vllm/outputs.py ++++ b/vllm/outputs.py +@@ -27,6 +27,7 @@ class SamplingMask: + """ + + token_ids: list[list[int]] ++ logprobs: list[list[float]] | None = None + + + @dataclass +diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py +index 09618d7eb66..92ad049f5dc 100644 +--- a/vllm/v1/engine/input_processor.py ++++ b/vllm/v1/engine/input_processor.py +@@ -146,6 +146,13 @@ class InputProcessor: + ) + + if self.model_config.return_sampling_mask: ++ if ( ++ self.model_config.return_sampling_mask_logprobs ++ and self.vllm_config.speculative_config is not None ++ ): ++ raise ValueError( ++ "sampling-mask logprobs do not support speculative decoding" ++ ) + if params.temperature <= 0: + raise ValueError( + "sampling distribution replay requires temperature > 0" +diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py +index e739e83b1c8..41ad126c67d 100644 +--- a/vllm/v1/engine/output_processor.py ++++ b/vllm/v1/engine/output_processor.py +@@ -435,12 +435,18 @@ class RequestState: + + sampling_mask = None + if self.sampling_mask_chunks and (delta or finished): ++ chunks = self.sampling_mask_chunks[: len(token_ids)] + sampling_mask = SamplingMask( + [ + mask +- for chunk in self.sampling_mask_chunks ++ for chunk in chunks + for mask in chunk.to_nested_list() +- ][: len(token_ids)] ++ ], ++ ( ++ [chunk.logprobs.tolist() for chunk in chunks] ++ if chunks and chunks[0].logprobs is not None ++ else None ++ ), + ) + if delta: + self.sampling_mask_chunks.clear() +diff --git a/vllm/v1/outputs.py b/vllm/v1/outputs.py +index c38eafa4a48..933a0e1e116 100644 +--- a/vllm/v1/outputs.py ++++ b/vllm/v1/outputs.py +@@ -65,11 +65,17 @@ class SamplingMaskLists(NamedTuple): + offsets: np.ndarray | None = None + # Unused with one position per request; kept for the wire layout. + cu_num_generated_tokens: list[int] | None = None ++ logprobs: np.ndarray | None = None + + def slice_request(self, req_idx: int, num_positions: int) -> "SamplingMaskLists": + assert num_positions == 1 and self.offsets is not None + return SamplingMaskLists( +- self.token_ids[self.offsets[req_idx] : self.offsets[req_idx + 1]] ++ self.token_ids[self.offsets[req_idx] : self.offsets[req_idx + 1]], ++ logprobs=( ++ self.logprobs[self.offsets[req_idx] : self.offsets[req_idx + 1]] ++ if self.logprobs is not None ++ else None ++ ), + ) + + def to_nested_list(self) -> list[list[int]]: +diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py +index 57f0180edc9..3a4d262210d 100644 +--- a/vllm/v1/worker/gpu/model_runner.py ++++ b/vllm/v1/worker/gpu/model_runner.py +@@ -453,6 +453,9 @@ class GPUModelRunner(LoRAModelRunnerMixin): + "use_fp64_gumbel": self.model_config.use_fp64_gumbel, + "enable_trace_replay": self.model_config.enable_trace_replay, + "return_sampling_mask": self.model_config.return_sampling_mask, ++ "return_sampling_mask_logprobs": ( ++ self.model_config.return_sampling_mask_logprobs ++ ), + "custom_logits_processors": custom_logits_processors, + } + if self.vllm_config.watermark_config is None: +diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/output.py +index aedff5ce187..2bedf360f56 100644 +--- a/vllm/v1/worker/gpu/sample/output.py ++++ b/vllm/v1/worker/gpu/sample/output.py +@@ -85,6 +85,7 @@ class SamplingMaskTensors(NamedTuple): + # [num_requests] + counts: torch.Tensor + vocab_size: int ++ logprobs: torch.Tensor | None = None + + @classmethod + def from_logits( +@@ -92,10 +93,15 @@ class SamplingMaskTensors(NamedTuple): + logits: torch.Tensor, + num_sampled_tokens: torch.Tensor, + max_num_kept: int, ++ return_logprobs: bool = False, + ) -> SamplingMaskTensors: + """Capture the finite-logit support of every row with a sampled token.""" + num_reqs, vocab_size = logits.shape +- max_num_kept = min(max_num_kept, vocab_size, MAX_COMPACT_SUPPORT) ++ max_num_kept = ( ++ vocab_size ++ if return_logprobs ++ else min(max_num_kept, vocab_size, MAX_COMPACT_SUPPORT) ++ ) + device = logits.device + + token_ids = torch.empty( +@@ -119,16 +125,34 @@ class SamplingMaskTensors(NamedTuple): + max_num_kept, + BLOCK_SIZE=8192, + ) +- return cls(token_ids, packed_mask, counts, vocab_size) ++ logprobs = None ++ if return_logprobs: ++ logprobs = torch.log_softmax(logits.float(), dim=-1) ++ return cls(token_ids, packed_mask, counts, vocab_size, logprobs) + + def to_cpu_nonblocking(self) -> SamplingMaskTensors: + if self.token_ids.device.type == "cpu": + return self ++ if self.logprobs is not None: ++ counts = self.counts.cpu() ++ width = int(counts.max()) ++ ids = self.token_ids[:, :width] ++ positions = torch.arange(width, device=ids.device)[None, :] ++ safe_ids = torch.where(positions < self.counts[:, None], ids, 0) ++ scores = self.logprobs.gather(1, safe_ids.long()) ++ return SamplingMaskTensors( ++ ids.to("cpu", non_blocking=True), ++ self.packed_mask.to("cpu", non_blocking=True), ++ counts, ++ self.vocab_size, ++ scores.to("cpu", non_blocking=True), ++ ) + return SamplingMaskTensors( + self.token_ids.to("cpu", non_blocking=True), + self.packed_mask.to("cpu", non_blocking=True), + self.counts.to("cpu", non_blocking=True), + self.vocab_size, ++ None, + ) + + def tolists(self) -> SamplingMaskLists: +@@ -150,4 +174,10 @@ class SamplingMaskTensors(NamedTuple): + supports = [support(row) for row in range(len(counts))] + offsets = np.zeros(len(supports) + 1, dtype=np.int64) + np.cumsum([len(s) for s in supports], out=offsets[1:]) +- return SamplingMaskLists(np.concatenate(supports), offsets) ++ logprobs = None ++ if self.logprobs is not None: ++ scores = self.logprobs.cpu().numpy() ++ logprobs = np.concatenate( ++ [scores[row, :count] for row, count in enumerate(counts)] ++ ) ++ return SamplingMaskLists(np.concatenate(supports), offsets, logprobs=logprobs) +diff --git a/vllm/v1/worker/gpu/sample/sampler.py b/vllm/v1/worker/gpu/sample/sampler.py +index 802adb6f642..0deb66ec5a2 100644 +--- a/vllm/v1/worker/gpu/sample/sampler.py ++++ b/vllm/v1/worker/gpu/sample/sampler.py +@@ -52,6 +52,7 @@ class Sampler: + use_fp64_gumbel: bool = False, + enable_trace_replay: bool = False, + return_sampling_mask: bool = False, ++ return_sampling_mask_logprobs: bool = False, + custom_logits_processors: Sequence[LogitsProcessor] = (), + ): + self.logprobs_mode = logprobs_mode +@@ -85,6 +86,7 @@ class Sampler: + self.needs_logits_processing = np.zeros(max_num_reqs, dtype=bool) + self.num_speculative_tokens = num_speculative_tokens + self.return_sampling_mask = return_sampling_mask ++ self.return_sampling_mask_logprobs = return_sampling_mask_logprobs + self.use_flashinfer = ( + not return_sampling_mask and flashinfer_sampler_supported() + ) +@@ -203,7 +205,10 @@ class Sampler: + # Size by the validated top_k batch max; wider supports use the bitmask. + max_num_kept = int(np.max(self.sampling_states.top_k.np[idx_mapping_np])) + sampling_mask_tensors = SamplingMaskTensors.from_logits( +- processed_logits, num_sampled, max_num_kept ++ processed_logits, ++ num_sampled, ++ max_num_kept, ++ return_logprobs=self.return_sampling_mask_logprobs, + ) + + # These are GPU tensors. diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index 4bc8ccb6d..749b7b72b 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -1,5 +1,5 @@ diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index fb2b938723..eb4000c7e2 100644 +index fb2b9387238..eb4000c7e29 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py @@ -362,6 +362,7 @@ class GenerateStreamResponse(BaseModel): @@ -19,7 +19,7 @@ index fb2b938723..eb4000c7e2 100644 prompt_token_ids: list[int] | None = None mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index 59fda2dbf8..6e86ac8341 100644 +index 59fda2dbf8c..6e86ac83417 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py @@ -281,6 +281,7 @@ class ServingTokens(GenerateBaseServing): @@ -101,7 +101,7 @@ index 59fda2dbf8..6e86ac8341 100644 usage=final_usage_info, metrics=per_request_metrics, diff --git a/vllm/outputs.py b/vllm/outputs.py -index e9a08f7c46..0507f2555d 100644 +index e9a08f7c462..0507f2555df 100644 --- a/vllm/outputs.py +++ b/vllm/outputs.py @@ -191,6 +191,13 @@ class RequestOutput: @@ -119,7 +119,7 @@ index e9a08f7c46..0507f2555d 100644 next_completion.cumulative_logprob ) diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py -index cb7d3d4718..09618d7eb6 100644 +index cb7d3d47183..09618d7eb66 100644 --- a/vllm/v1/engine/input_processor.py +++ b/vllm/v1/engine/input_processor.py @@ -150,12 +150,6 @@ class InputProcessor: @@ -136,7 +136,7 @@ index cb7d3d4718..09618d7eb6 100644 self.vllm_config.reasoning_config is None or not self.vllm_config.reasoning_config.enabled diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index b4f7338048..e739e83b1c 100644 +index b4f7338048c..e739e83b1c8 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py @@ -434,10 +434,16 @@ class RequestState: diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index f04a9ae8f..fa5ec05f2 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -303,7 +303,7 @@ def test_generate_requests_complete_top_p_probabilities(monkeypatch): monkeypatch.setattr(rollout, "_prepare_prompt_ids", lambda *_: [9]) async def post(url, payload, **kwargs): - assert payload["sampling_params"]["logprobs"] == -1 + assert payload["sampling_params"]["logprobs"] == 1 assert kwargs == {"headers": None} return { "choices": [ @@ -311,21 +311,16 @@ async def post(url, payload, **kwargs): "token_ids": [4, 2], "finish_reason": "stop", "sampling_mask": [[1, 4], [2]], + "sampling_mask_logprobs": [[float(np.log(0.3)), float(np.log(0.7))], [0.0]], "logprobs": { "content": [ { "logprob": float(np.log(0.7)), - "top_logprobs": [ - {"token": "token_id:1", "logprob": float(np.log(0.3))}, - {"token": "token_id:4", "logprob": float(np.log(0.7))}, - ], + "top_logprobs": [{"token": "token_id:4", "logprob": float(np.log(0.7))}], }, { "logprob": -2.0, - "top_logprobs": [ - {"token": "token_id:2", "logprob": -2.0}, - {"token": "token_id:7", "logprob": -3.0}, - ], + "top_logprobs": [{"token": "token_id:2", "logprob": -2.0}], }, ] }, diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index a51031aea..b2f6dcc61 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -282,6 +282,15 @@ def test_compute_server_args_applies_rollout_and_dtype_flags(vllm_args): assert sa["dtype"] == "float16" +@pytest.mark.unit +def test_compute_server_args_enables_sampling_mask_logprobs_for_score_centering(vllm_args): + vllm_args.rollout_top_p = 0.9 + vllm_args.use_score_centering = True + sa, _ = mod._compute_server_args(vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000) + assert sa["return_sampling_mask"] is True + assert sa["return_sampling_mask_logprobs"] is True + + @pytest.mark.unit def test_compute_server_args_applies_max_model_len_from_rollout_context(vllm_args): vllm_args.rollout_max_context_len = 8192 diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index 0230cac5e..c310e451d 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -686,6 +686,8 @@ def _compute_server_args( kwargs["per_request_spec_decode_metrics"] = "summary" if getattr(args, "rollout_top_p", 1.0) != 1.0: kwargs["return_sampling_mask"] = True + if getattr(args, "use_score_centering", False): + kwargs["return_sampling_mask_logprobs"] = True if getattr(args, "use_score_centering", False) and getattr(args, "rollout_top_p", 1.0) == 1.0: kwargs["max_logprobs"] = args.score_centering_top_k + 1 if args.fp16: diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index ab95eae02..ba1d2cd4a 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -260,16 +260,15 @@ def _score_centering_metadata( if len(content) != len(token_ids): raise ValueError("Score centering requires top logprobs for every generated token.") - rows = [] - for item in content: - row = {} - for entry in item.get("top_logprobs") or []: - token = entry.get("token", "") - if token.startswith("token_id:"): - row[int(token.removeprefix("token_id:"))] = float(entry["logprob"]) - rows.append(row) - if top_p == 1.0: + rows = [] + for item in content: + row = {} + for entry in item.get("top_logprobs") or []: + token = entry.get("token", "") + if token.startswith("token_id:"): + row[int(token.removeprefix("token_id:"))] = float(entry["logprob"]) + rows.append(row) heads = [sorted(row.items(), key=lambda item: item[1], reverse=True)[:top_k] for row in rows] if any(len(head) != top_k for head in heads): raise ValueError(f"Score centering requires {top_k} sampler top logprobs per token.") @@ -283,18 +282,21 @@ def _score_centering_metadata( support = choice.get("sampling_mask") if not isinstance(support, list) or len(support) != len(token_ids): raise ValueError("Top-p score centering requires a sampling mask for every generated token.") + support_logprobs = choice.get("sampling_mask_logprobs") + if not isinstance(support_logprobs, list) or len(support_logprobs) != len(support): + raise ValueError("Top-p score centering requires logprobs aligned with the sampling mask.") ids = np.asarray([token_id for row in support for token_id in row], dtype=np.int32) offsets = np.cumsum([0, *(len(row) for row in support)], dtype=np.int32) normalized_logprobs = [] sampled_logprobs = [] - for sampled_token, support_ids, row in zip(token_ids, support, rows, strict=True): + for sampled_token, support_ids, logprobs in zip(token_ids, support, support_logprobs, strict=True): + if len(support_ids) != len(logprobs): + raise ValueError("Top-p score centering requires logprobs aligned with the sampling mask.") try: - values = np.asarray([row[token_id] for token_id in support_ids], dtype=np.float64) - except KeyError as error: - raise ValueError("Top-p score centering requires logprobs for the complete sampling mask.") from error - values -= np.logaddexp.reduce(values) - normalized_logprobs.extend(values) - sampled_logprobs.append(float(values[support_ids.index(sampled_token)])) + sampled_logprobs.append(float(logprobs[support_ids.index(sampled_token)])) + except ValueError as error: + raise ValueError("Sampled token is missing from the top-p sampling mask.") from error + normalized_logprobs.extend(logprobs) return { "score_centering_top_p": (ids, offsets, np.asarray(normalized_logprobs, dtype=np.float32)) }, sampled_logprobs @@ -416,7 +418,7 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A from vime.utils.score_centering import score_centering_request score_centering_request(args, sampling_params) - logprobs = (-1 if args.rollout_top_p < 1 else args.score_centering_top_k + 1) if score_centering else 1 + logprobs = args.score_centering_top_k + 1 if score_centering and args.rollout_top_p == 1 else 1 inference_sampling_params = _build_inference_sampling_params(sampling_params, logprobs) messages = build_multimodal_messages(sample.prompt, sample.multimodal_inputs) From 30ce1fe0898e68eb5847e24f5586ca50bcdc5bfc Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 28 Sep 2026 06:28:31 +0000 Subject: [PATCH 08/58] fix(vllm): enable scale-out endpoint for rollout TITO Signed-off-by: aoshen02 --- tests/utils/test_vllm_engine.py | 1 + vime/backends/vllm_utils/vllm_engine.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index b2f6dcc61..7158f567e 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -153,6 +153,7 @@ def test_launch_config_single_node(vllm_args): assert sa["_pcp_size"] == 1 assert sa["_dp_size"] == 1 assert sa["enable_per_request_metrics"] is True + assert sa["enable_scale_out"] is True @pytest.mark.unit diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index c310e451d..ae5395a2d 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -644,6 +644,7 @@ def _compute_server_args( "tensor_parallel_size": tp, "logprobs_mode": "processed_logprobs", "enable_prompt_tokens_details": True, + "enable_scale_out": True, "enable_per_request_metrics": True, "enable_server_load_tracking": True, } From 30942842f6ac65ba49a4c20fc2db6f612a48ff45 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 28 Sep 2026 08:12:10 +0000 Subject: [PATCH 09/58] fix(rollout): keep requests during sleep and enable external PD TITO Signed-off-by: aoshen02 --- tests/test_qwen3_4B_external_pd.py | 1 + tests/utils/test_vllm_engine.py | 15 +++++++-------- vime/backends/vllm_utils/vllm_engine.py | 6 ++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_qwen3_4B_external_pd.py b/tests/test_qwen3_4B_external_pd.py index 6a603dc00..8e743edc8 100644 --- a/tests/test_qwen3_4B_external_pd.py +++ b/tests/test_qwen3_4B_external_pd.py @@ -153,6 +153,7 @@ def _launch_vllm_server( "processed_logprobs", "--enable-prompt-tokens-details", "--enable-server-load-tracking", + "--enable-scale-out", "--kv-transfer-config", json.dumps({"kv_connector": "NixlConnector", "kv_role": kv_role}), "--weight-transfer-config", diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index 7158f567e..15d8dbca5 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -145,7 +145,9 @@ def test_launch_config_single_node(vllm_args): vllm_args.num_gpus_per_node = 8 vllm_args.rollout_num_gpus_per_engine = 4 vllm_args.vllm_pipeline_parallel_size = 1 - sa, _ = mod._compute_server_args(vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000) + sa, external_check_fields = mod._compute_server_args( + vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000 + ) assert sa["nnodes"] == 1 assert sa["node_rank"] == 0 assert sa["_tp_size"] == 4 @@ -154,6 +156,7 @@ def test_launch_config_single_node(vllm_args): assert sa["_dp_size"] == 1 assert sa["enable_per_request_metrics"] is True assert sa["enable_scale_out"] is True + assert "enable_scale_out" not in external_check_fields @pytest.mark.unit @@ -749,25 +752,21 @@ def fake_post(url, *, params=None, timeout=30, json=None): @pytest.mark.unit -def test_release_memory_occupation_flushes_then_posts_sleep(vllm_engine, monkeypatch): +def test_release_memory_occupation_keeps_requests_during_sleep(vllm_engine, monkeypatch): calls: list[str] = [] - def fake_flush_cache(): - calls.append("flush_cache") - def fake_post(url, *, params=None, timeout=30, json=None): calls.append(url) - assert params == {"level": 2} + assert params == {"level": 2, "mode": "keep"} assert timeout == 30 assert json is None return _MockResponse(json_data={"ok": True, "sleep_mode": True}) vllm_engine.args.vllm_enable_sleep_mode = False - monkeypatch.setattr(vllm_engine, "flush_cache", fake_flush_cache) monkeypatch.setattr(mod.requests, "post", fake_post) assert vllm_engine.release_memory_occupation(level=2) == {"ok": True, "sleep_mode": True} - assert calls == ["flush_cache", "http://127.0.0.1:8765/sleep"] + assert calls == ["http://127.0.0.1:8765/sleep"] @pytest.mark.unit diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index ae5395a2d..f35a164b0 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -353,8 +353,9 @@ def set_weight_version(self, new_version: str): return result def release_memory_occupation(self, level: int = 2): - self.flush_cache() - response = requests.post(f"http://{self.server_host}:{self.server_port}/sleep", params={"level": level}) + response = requests.post( + f"http://{self.server_host}:{self.server_port}/sleep", params={"level": level, "mode": "keep"} + ) response.raise_for_status() if not response.content or not response.content.strip(): return {"ok": True} @@ -784,4 +785,5 @@ def _vllm_server_field_names() -> frozenset[str]: "enable_prompt_tokens_details", "enable_per_request_metrics", "enable_server_load_tracking", + "enable_scale_out", ] From c1e10a5435aba8b0a60e8a3114eefdc6d72a03f4 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Mon, 28 Sep 2026 08:27:45 +0000 Subject: [PATCH 10/58] fix(ci): enable TITO on external OPD teacher Signed-off-by: aoshen02 --- tests/test_qwen2.5_0.5B_opd_vllm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_qwen2.5_0.5B_opd_vllm.py b/tests/test_qwen2.5_0.5B_opd_vllm.py index d08feafc8..fe3cf88da 100644 --- a/tests/test_qwen2.5_0.5B_opd_vllm.py +++ b/tests/test_qwen2.5_0.5B_opd_vllm.py @@ -52,6 +52,7 @@ def _launch_teacher_server(teacher_gpu: str): "--gpu-memory-utilization", "0.6", "--trust-remote-code", + "--enable-scale-out", ], env=env, stdout=log_file, From 927a686a5b5d3eb5f7fb473b9b5e27541f734ead Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 29 Sep 2026 13:41:45 +0000 Subject: [PATCH 11/58] test: enable scale-out render endpoint in Geo3K e2e Signed-off-by: aoshen02 --- tests/test_geo3k_vlm_multi_turn_e2e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_geo3k_vlm_multi_turn_e2e.py b/tests/test_geo3k_vlm_multi_turn_e2e.py index 55e6065da..548878e29 100644 --- a/tests/test_geo3k_vlm_multi_turn_e2e.py +++ b/tests/test_geo3k_vlm_multi_turn_e2e.py @@ -65,6 +65,7 @@ f"vllm serve {MODEL_PATH} --host {HOST} --port {WORKER_PORT} --dtype bfloat16 " f"--tensor-parallel-size {NUM_GPUS} --gpu-memory-utilization {GPU_MEMORY_UTILIZATION} " f"--max-model-len {MAX_MODEL_LEN} --max-num-seqs {MAX_NUM_SEQS} --enforce-eager " + "--enable-scale-out " "--generation-config vllm --logprobs-mode processed_logprobs" ) ROUTER_COMMAND = shlex.split( From 02825efb49ee54e0556f183fafc7486b0eb4eee9 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 29 Sep 2026 03:45:18 +0000 Subject: [PATCH 12/58] feat: mirror Slime persistent rollout queue and distributed fully async Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 7 + README.md | 2 + README_zh.md | 2 + docker/version.txt | 2 +- docs/en/advanced/straw.md | 302 +++ docs/en/get_started/customization.md | 18 + docs/en/get_started/usage.md | 40 + docs/en/index.rst | 2 + docs/zh/advanced/straw.md | 151 ++ docs/zh/get_started/customization.md | 18 + docs/zh/get_started/usage.md | 40 + docs/zh/index.rst | 2 + requirements.txt | 1 + tests/plugin_contracts/_shared.py | 7 - .../test_plugin_generate_contracts.py | 2 + .../test_plugin_rollout_contracts.py | 4 +- .../test_plugin_runtime_hook_contracts.py | 4 +- tests/test_distributed_rollout.py | 2007 +++++++++++++++++ tests/test_fully_async_rollout.py | 16 +- tests/test_megatron_argument_validation.py | 56 + tests/test_optional_straw.py | 141 ++ tests/test_queue_sample_codec.py | 386 ++++ tests/test_r3_spill.py | 7 +- tests/test_rollout_straw_transport.py | 515 +++++ tests/test_routed_experts_disk_layout.py | 227 ++ tests/test_score_centering_transport.py | 22 +- tests/test_straw_fully_async_recovery.py | 291 +++ tests/test_straw_r3.py | 221 ++ tests/test_vllm_rollout.py | 2 + train.py | 7 +- vime/backends/megatron_utils/actor.py | 6 +- vime/backends/megatron_utils/cp_utils.py | 318 ++- vime/backends/megatron_utils/loss.py | 51 +- vime/observability/rollout_data_utils.py | 8 +- vime/observability/rollout_metrics.py | 7 +- vime/observability/train_data_utils.py | 7 +- vime/ray/rollout.py | 361 +-- vime/rollout/base_types.py | 87 +- vime/rollout/batch_builder.py | 515 +++++ vime/rollout/fully_async_distributed.py | 578 +++++ vime/rollout/fully_async_rollout.py | 68 +- vime/rollout/queue_codec.py | 465 ++++ vime/rollout/queue_data_source.py | 1101 +++++++++ vime/rollout/sample_hooks.py | 18 +- vime/rollout/vllm_rollout.py | 86 +- vime/utils/arguments.py | 112 +- vime/utils/async_utils.py | 40 +- vime/utils/data.py | 18 +- vime/utils/http_utils.py | 40 +- vime/utils/memory_utils.py | 1 - vime/utils/rollout_transport.py | 382 ++++ vime/utils/routed_experts.py | 69 +- vime/utils/score_centering.py | 52 +- vime/utils/staleness.py | 4 +- vime/utils/tensor_store.py | 27 + vime/utils/types.py | 54 +- 56 files changed, 8397 insertions(+), 580 deletions(-) create mode 100644 docs/en/advanced/straw.md create mode 100644 docs/zh/advanced/straw.md create mode 100644 tests/test_distributed_rollout.py create mode 100644 tests/test_optional_straw.py create mode 100644 tests/test_queue_sample_codec.py create mode 100644 tests/test_rollout_straw_transport.py create mode 100644 tests/test_routed_experts_disk_layout.py create mode 100644 tests/test_straw_fully_async_recovery.py create mode 100644 tests/test_straw_r3.py create mode 100644 vime/rollout/batch_builder.py create mode 100644 vime/rollout/fully_async_distributed.py create mode 100644 vime/rollout/queue_codec.py create mode 100644 vime/rollout/queue_data_source.py create mode 100644 vime/utils/rollout_transport.py diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 72f3acae9..608ebb07b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -67,6 +67,7 @@ steps: pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb pip install -q -e . --no-deps python tests/test_megatron_argument_validation.py + python tests/test_optional_straw.py python tests/test_value_temperature.py python tests/test_docs_consistency.py python tests/test_placement_group.py @@ -149,10 +150,16 @@ steps: tests/test_block_fp8_zero_block.py \ tests/test_cuda_stack_offload.py \ tests/test_deep_ep_tms_patch.py \ + tests/test_distributed_rollout.py \ tests/test_discounted_returns.py \ tests/test_eval_config.py \ tests/test_filter_long_prompt.py \ tests/test_fully_async_rollout.py \ + tests/test_queue_sample_codec.py \ + tests/test_rollout_straw_transport.py \ + tests/test_routed_experts_disk_layout.py \ + tests/test_straw_fully_async_recovery.py \ + tests/test_straw_r3.py \ tests/test_glm52_layerwise_comparison.py \ tests/test_glm5_indexer_q_norm.py \ tests/test_glm5_indexer_short_context.py \ diff --git a/README.md b/README.md index a5b2b141f..144d7fd3f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ The vLLM community horizontally supports many LLM post-training frameworks, incl - **rollout (vLLM + router)**: Launches vLLM inference engines and routes generation requests; custom generate functions can wrap generation with multi-turn loops, tool calls, environment/sandbox interaction, and verifier-based rewards. - **data buffer**: A bridge module that manages prompt initialization, custom data, and rollout generation methods, including agentic workflows that produce samples through the same interface. +The default payload transport is Ray `object-store`. `--rollout-data-transport straw` persists prompt tasks, rollout continuations, and training batches on shared storage. See the [straw architecture and recovery guide](docs/en/advanced/straw.md). + ## Quick Start For a comprehensive quick start guide covering environment setup, data preparation, training startup, and key code analysis, please refer to: diff --git a/README_zh.md b/README_zh.md index 18927a796..ae9bcdfb3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -53,6 +53,8 @@ vLLM 社区横向支持许多 LLM post-training 框架,包括(按字母顺 - **rollout (vLLM + router)**:启动 vLLM 推理引擎并路由生成请求;自定义生成函数可以在其上封装多轮循环、工具调用、环境/沙盒交互和基于 verifier 的奖励; - **data buffer**:桥梁模块,管理 prompt 初始化、自定义数据与 rollout 生成方法,包括通过同一接口产出样本的 agent 工作流。 +默认载荷传输为 Ray `object-store`。`--rollout-data-transport straw` 可在共享存储上持久化 prompt 任务、rollout continuation 和训练 batch。详见 [straw 架构与恢复指南](docs/zh/advanced/straw.md)。 + ## 快速开始 有关环境配置、数据准备、训练启动和关键代码分析的完整快速开始指南,请参考: diff --git a/docker/version.txt b/docker/version.txt index 5ea860b9f..5172efe37 100644 --- a/docker/version.txt +++ b/docker/version.txt @@ -1 +1 @@ -nightly-dev-20260923a +nightly-dev-20260928a diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md new file mode 100644 index 000000000..bd5df0da6 --- /dev/null +++ b/docs/en/advanced/straw.md @@ -0,0 +1,302 @@ +# Persistent rollout with straw + +[straw](https://github.com/zhuzilin/straw) provides filesystem-based durable +queues and packed tensor storage for vime. With `--rollout-data-transport straw`, +prompt tasks, partial rollouts, accepted results and training batches have +persistent identities. Multiple generation and training processes access their +payloads directly through a shared filesystem; Ray schedules actors and carries +control messages and small references. + +The current shared-filesystem target is JuiceFS. Each node must mount the same +pool at the same absolute path. Other network filesystems, including NFS, need +separate qualification of cross-client locks, visibility and durability. + +## What changes in the architecture + +| Concern | Ray object-store transport (default) | straw transport | +|---|---|---| +| Data source | `RolloutDataSourceWithBuffer` | `QueueDataSource` backed by durable prompt tasks | +| Payload exchange | Ray object references | `RecordSetRef` / tensor references into shared pack files | +| Worker assignment | In-memory producer state | Leases, attempts, durable continuations and accepted receipts | +| Rollout-to-training conversion | `BatchBuilder` applies hooks and DP partitioning | The same conversion, with a persisted selection plan and ready batch | +| Data lifetime | Process/object-store ownership | Explicit task, consumer, reader and checkpoint ownership | +| Cleanup | Object-store lifetime | Optional online GC of sealed packs with no remaining owners | + +There are two logical stages: rollout work/results, and ready training batches. +They share the same straw storage pool, so a tensor retained by either stage or +a checkpoint remains live. These stages do not require two copies of each tensor. +The vime controller coordinates both stages; straw implements the storage, +journal, task acceptance and ownership protocol in Rust. + +```{mermaid} +flowchart LR + D[Dataset producer] -->|cursor + task submission| Q[Durable prompt tasks] + Q -->|lease + input reference| W[Distributed generation workers] + W -->|durable partial continuation| Q + W -->|accepted group reference| R[Rollout results] + R --> B[BatchBuilder: selection plan and DP shards] + B -->|ready batch reference| T[Training ranks] + T -->|training completed| C[Queue controller: progress and ownership] + C --> G[straw online GC] + P[(Shared JuiceFS pack files)] -. payload reads and writes .-> W + P -. shared tensor references .-> B + P -. rank-local reads .-> T +``` + +One non-restarting Ray actor owns the coordinator for the run. It serializes +queue transitions and dataset allocation; payload writes happen in the producing +processes. This permits parallel I/O across machines while retaining one owner +for each queue journal. There is no automatic coordinator election or failover. + +## End-to-end data flow + +1. **Submit prompts.** The dataset cursor and prompt tasks commit together. + Workers claim leases and load prompt groups from the shared pool. A lost + worker does not permanently abandon a preallocated range of dataset indices. +2. **Generate and continue.** Distributed fully async generation uses one worker + per eligible Ray node. Workers generate, score and filter complete groups; + partial groups are saved as durable continuations. R3 routes and SC rows must + cover the saved token prefix. An incomplete server capture retries from the + previous consistent input. +3. **Accept results.** Payloads become durable before their references are + accepted. Receipts identify accepted groups, allowing recovery after an RPC + reply is lost. Reassigned tasks receive new attempts; stale attempts cannot + accept a replacement result. +4. **Build the training batch.** `BatchBuilder` persists the selected inputs and + conversion configuration before calling reward/conversion hooks. It publishes + all DP shards before marking one batch ready. Training readers check batch, + plan and rank identity. The manager still materializes the selected samples + for conversion, so its host memory remains a capacity consideration. +5. **Acknowledge completion.** After all rank training calls return, the manager + reports `training_completed`. This advances runtime consumption and frees + ready-batch capacity. It is distinct from a durable model/optimizer checkpoint. + +Ray remains responsible for scheduling, RPC and failures of execution processes. +vime owns Sample schemas, rewards, filters, batching and training. straw owns +bytes, references, persistence and storage lifetime. Custom rollout functions +can still return Sample lists; the manager publishes a compatibility collection. +See [customization](../get_started/customization.md). + +## Queue scheduling + +`QueueReader` is a worker's queue client: it acquires and returns groups and renews +active leases. `QueueDataSource` adds job-level reader creation, consumer lifecycle +and source checkpoints. It inherits the same sample-access methods; it does not +implement another buffer. With `--rollout-data-transport straw`, the source is +selected automatically. Explicit `--data-source-path` values should use +`vime.rollout.queue_data_source.QueueDataSource`. + +`QueueReader` has no local continuation buffer. `add_samples()` publishes valid +continuations, then `yield_tasks()` atomically replaces their inputs and returns +them to pending in one WAL transaction per bounded batch. The old leases end; +any worker can acquire the saved inputs with a new lease. Readers keep only +active leases and executing samples locally. Closing a reader does not rewrite +returned groups. + +The persistent order is **completed delivery → partial → fresh prompt**. Within +a stage, the oldest numeric value in a group's `weight_versions` comes first +(higher staleness), then FIFO submission/return order. Groups without numeric versions sort last +within the stage. Comparing stored versions avoids rewriting queue priorities +on every model update. This ordering does not drop stale samples automatically. +Staleness is `current serving weight version - oldest generated-token weight version`: fresh samples have staleness 0. +There is one indexed queue, not three independently managed queues. + +`--buffer-filter-path` is rejected for straw. `--buffer-sort-by-staleness` remains +an option for the in-memory data source; straw always applies the order above. +Reward and sample-selection hooks remain available. Already accepted groups +explicitly returned to the source get separate delivery tasks; accepted history +is immutable, and consuming/discarding a delivery also acknowledges its earlier +accepted versions. The fully async scheduler still keeps a bounded ready-results +window, with its own checkpoint state and durable accepted-result references. + +Source snapshots now store the shared pending tasks, their exact immutable input +references and scheduling metadata once; worker snapshots store reader metadata. +The reader snapshot format is version 3; old reader-local v1/v2 buffer snapshots +require migration and are rejected explicitly. This requires an updated +`straw-queue` build with `yield_tasks()` and persistent scheduling keys; the +original PyPI 0.1.0 wheel does not provide these APIs. + +## Shared tensors, R3 and SC + +Each writer process reuses a pack writer. The vime adapter's default rotation +target is **256 MiB**, configured by `--rollout-queue-segment-mib`. Records and +manifests are embedded in packs; there is no file per sample or tensor. This +avoids the small-file metadata traffic that limits shared-filesystem throughput. +Queue and ownership metadata use append logs. + +Completed R3 routes and SC tensors are published with their sample group after +custom sample hooks. Later rollout/continuation/training publications reuse +immutable tensor dependencies. Updates produce new records while earlier +references remain valid: this is tensor-level sharing and copy-on-write, not +GPU shared memory or page-level copy-on-write. R3 training reads only the rows +assigned to its CP/TP rank. Large bundles are split within the native write +budget; SC also works without R3. + +Use `--rollout-data-transport straw` to persist R3/SC tensors. Object-store +transport keeps them in memory unless Vime's optional disk-spill hook is +configured. Straw transport needs no separate spill hook or cleanup step. +References from another straw storage pool +are copied into the destination pool before dependent results are accepted. +Debug dumps embed tensor contents so they remain readable after queue GC; +enabling dumps therefore adds host-memory and I/O overhead. + +## Sample encoding and integrity + +The adapter stores samples as a `vime.v1` JSON record `{version: 1, tree: ...}` +with explicit tags for containers, Sample fields, NumPy arrays, images and +rollout references. It preserves nested groups, supported dynamic fields, +multimodal inputs, R3, SC top-k and ragged top-p. Unsupported Python types and +cycles fail explicitly. The payload protocol uses no pickle and never imports +classes named by stored data. + +Tensor nodes carry shape, dtype, semantic kind, lazy/validated flags and a +`{dependency: index, ordinal: index}` pointer into the enclosing manifest's +dependencies. Physical locations belong to those manifests, so publishing +another sample or training batch can share the same immutable tensor records. +All tensor dependencies must be published before the enclosing sample is accepted. + +straw's `tensor.v1` stores contiguous, row-major, little-endian typed bytes. +Lazy readers verify metadata and the checksum of each chunk covering requested +rows; they do not verify untouched chunks. Full inspection verifies the whole +segment. Tensor descriptors are restored in batches to reuse shared pack indices. + +## Enable it + +`requirements.txt` includes `straw-queue`, so the standard Docker build and +normal vime installation install it automatically. For an existing environment, +install it on every generation and training node, using the same version throughout the job: + +```bash +pip install straw-queue +``` + +Without straw installed, the default Ray `object-store` transport still works +and startup logs the installation command. Selecting `--rollout-data-transport straw` +without the package fails at startup with the same command. + +The Python import name is `straw`. Add these arguments to your existing training +command, using a fresh shared directory: + +```bash +--rollout-data-transport straw \ +--rollout-data-dir /shared/juicefs/jobs/my-run/rollout_data \ +--rollout-queue-run-id my-run \ +--rollout-storage-profile juicefs \ +--rollout-storage-declaration /shared/juicefs/deployment.json +``` + +The declaration records `direct_mount: true`, `writeback: false`, +`open_cache: 0`, `readdir_cache: false`, the deployed `client_version` and a +`durability_description`. It declares verified deployment settings; it does +not discover or configure the mount. See straw's +[filesystem contract](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM.md). +The default `local` storage profile represents the POSIX development contract; +using it on a shared mount does not verify that service's durability. + +Storage and execution mode are separate choices. The default transport remains +`object-store`; the default rollout entrypoint remains synchronous. To select +distributed fully async execution with straw, also add: + +```bash +--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async +``` + +If `--rollout-data-dir` is omitted, straw mode uses `/rollout_data`; without +`--save`, an explicit shared directory is required. `--rollout-io-concurrency` +bounds off-event-loop serialization and I/O (default 4). The adapter uses worker +processes and bounded threads; straw does not create an I/O process pool. +`--rollout-queue-max-pending` and `--rollout-queue-max-inflight` bound task +admission (both default 65,536); they do not cap retained bytes or file count. + +During weight synchronization, distributed producers pause new admissions and +allow in-flight work to finish and persist. They resume if another training +rollout remains. Weight synchronization itself remains a separate subsystem. +On shutdown, all generation workers share a five-minute deadline to finish +durable writes. Exceeding it reports an error rather than successful shutdown. + +## Online GC and ownership + +Online GC is disabled by default. Enable `--rollout-queue-online-gc` to let straw +reclaim unused sealed packs during a job. vime provides completion and discard +signals through straw APIs. A pack is deletable only after all task, publication, +queue, reader and checkpoint owners release it. A lease timeout alone does not +prove that a reader has stopped. A live record retains its entire pack; active +writers, retained checkpoints and WAL history still consume space. + +GC failures stop the background loop, propagate the original cause through +later coordinator operations and surface at shutdown. They do not stop work +already running on remote workers. Retained data stays available for inspection. +Capacity exhaustion must be handled with +backpressure or an explicit operator decision; do not reset a live queue or +unlink pack files to make room. Offline removal requires all participants to +stop. Storage quotas and application retention policies remain necessary. + +Processed positions and filter decisions are recorded explicitly, so gaps in +acceptance order do not hold already-consumed capacity. A separate bounded +control-task allowance lets the manager commit a collection under backpressure. + +## Recovery and checkpoints + +| Failure or restart | Supported behavior and boundary | +|---|---| +| Worker lost before acceptance | Recover the latest durable continuation; reassign with a new attempt | +| Acceptance committed but reply lost | Recover the accepted receipt without a second logical acceptance | +| Whole job stops before its first batch is planned | `--rollout-queue-resume` recovers accepted groups and durable prefixes with the same initial model and rollout configuration | +| Restart after batch planning | Requires matching model/optimizer and rollout checkpoints; queue-only recovery fails explicitly | + +Resume requires the entire previous job, including its coordinator and readers, +to have stopped, and the same root and run ID. The flag is an operator assertion, +not automatic fencing. Unpublished inference may run again; vLLM GPU KV cache +is not restored. + +Checkpoints contain `/rollout/queue_state_.json` for the source +and registered scheduler consumers, and `builder_state_.json` for +the training consumer view. Their references retain the matching storage graph. +Continuations are persisted incrementally, so checkpoints reference +existing data instead of rewriting every returned token. Restoring a checkpoint +does not erase later accepted history from the WAL. + +Partial R3/SC tensors remain lazy references in continuation snapshots and are +loaded when generation appends new rows. Restoring source and builder state +preserves saved warm groups and behavior-policy versions, and records which +later outputs are excluded from the restored branch. Restored pending tasks retain +their storage independently of filtering. The complete source snapshot, +including consumers that have not restarted yet, is retained and durably +recorded before old references are released. Failed saves keep the old references; +background GC starts only after restored consumer state commits. + +The dataset producer cursor is not yet part of the source checkpoint, and +restoring a saved pending snapshot does not exclude every later-created task. +Complete rollback to an arbitrary older training step remains future work. +The adapter does not yet establish a joint final manifest for model, optimizer, +RNG and queue state. Persistent rollout is therefore not a claim of exact +optimizer recovery or bit-identical training after an arbitrary crash. + +## Validation and remaining limits + +CPU coverage includes codecs, R3/SC transport, lazy CP/TP reads, worker failures, +continuations, checkpoint views and GC ownership. straw separately checks its +Rust/Python core, bounded ownership model and native crash boundaries. Physical +multi-client filesystem checks and real GPU training complement these tests. +`tests/test_straw_fully_async_recovery.py` starts two local Ray nodes, interrupts +the entire driver/coordinator/generation process tree with SIGKILL, and starts a +new job against the same straw pool without a graceful checkpoint. It checks +accepted-result replay, unchanged token/logprob/R3/SC prefixes, new leases and +single reward evaluation, with online GC both disabled and enabled. Inference +and rewards are CPU fixtures; this test does not claim optimizer recovery. +It runs automatically in `cpu-unittest`; see [CI setup](../developer_guide/ci.md). + +Run the distributed rollout and interruption recovery tests locally with: + +```bash +PYTHONPATH=. python tests/test_distributed_rollout.py +PYTHONPATH=. python tests/test_straw_fully_async_recovery.py +``` + +The main deployment limits are shared-storage bandwidth, durability latency, +coordinator throughput and manager conversion memory. Live-pack compaction, +journal compaction and automatic coordinator failover are not implemented. +See [usage](../get_started/usage.md#persistent-rollout-queue-and-distributed-fully-async) +for the rollout controls and straw's +[verification guide](https://github.com/zhuzilin/straw/blob/main/docs/VERIFICATION.md) +for the storage checks and their scope. diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index cd1d99363..e8578d06c 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -66,6 +66,24 @@ def generate_rollout(args, rollout_id, data_source, evaluation=False) -> Rollout **Example**: See [examples/fully_async](../_examples_synced/fully_async/README.md) +With `--rollout-data-transport straw` (the default is Ray `object-store`), `RolloutFnTrainOutput.samples` may be a `DiskPayloadRef` pointing to the selected batch. Existing custom functions returning Sample lists (bare lists or `RolloutFnTrainOutput`) still work: the manager stores them once. New functions can persist completed, reward-scored groups during rollout and return a manifest instead of retaining a full batch in memory. For example, given an async iterator of accepted groups: + +```python +import asyncio + +from vime.rollout.base_types import finalize_rollout_groups +from vime.utils.rollout_transport import pack_rollout_group + + +async def generate_stored_batch(args, rollout_id, completed_groups): + refs = [] + async for group in completed_groups: + refs.append(await asyncio.to_thread(pack_rollout_group, group, args, rollout_id)) + return finalize_rollout_groups(args, rollout_id, refs) +``` + +`finalize_rollout_groups` sorts groups, applies the configured batch sample filter once, and stores the batch manifest. If a hook mutates Samples, its result is saved again. A wrapper that needs actual Samples can call `vime.utils.rollout_transport.load_rollout_samples(output.samples)`. Generation/reward hooks and evaluation return values keep their existing contracts. + --- ### `--custom-generate-function-path` diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index 7cbbdf4b8..b8c4fd441 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -389,6 +389,46 @@ vime supports customizing data generation (rollout) to various degrees. - Sometimes, you may also need to support a custom reward model. This can be configured by setting `--custom-rm-path`. +### Persistent rollout queue and distributed fully async + +See [straw architecture and recovery](../advanced/straw.md) for the data flow, shared tensor ownership and checkpoint boundaries. + +Rollout payloads use Ray `object-store` by default, with `vime.rollout.data_source.RolloutDataSourceWithBuffer`. This mode needs neither straw nor a shared rollout directory. `--rollout-data-transport nixl` selects Ray's NIXL tensor transport. + +Install [straw](../advanced/straw.md), then select `--rollout-data-transport straw` to use its persistent queue and packed tensor storage. straw transport defaults to `vime.rollout.queue_data_source.QueueDataSource`. It uses `--rollout-data-dir`, or `/rollout_data` when `--save` is set; otherwise an explicit shared directory is required. Ray still runs actors and RPCs and carries small references. The synchronous rollout entrypoint remains the default. To use distributed fully async explicitly: + +```bash +--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async \ +--rollout-data-transport straw \ +--rollout-data-dir /shared/run/rollout_data +``` + +With straw transport, `--use-rollout-routing-replay` stores completed samples' R3 routes in straw with their owning sample group. SC tensors are also stored in the same publication when `--use-score-centering` is enabled, including SC without R3. The default object-store transport keeps these tensors in memory unless the existing disk-spill hook is configured; that hook remains available for object-store rollouts. User sample hooks run first; R3, SC and sample metadata are published together into shared pack files. Later queue publications reuse their references. Large group bundles are split within the native write budget. Aborted prefixes are persisted by the queue continuation path. `--rollout-queue-online-gc` is a separate opt-in: after training acknowledges completion, straw can reclaim sealed packs whose owners have all released them. It is disabled by default. + +One job-owned, non-restarting Ray actor coordinates task leases and a serialized dataset producer. Dataset cursor advancement and task submission are committed together. Workers receive small task references and read prompt groups directly from shared storage. This replaces the former in-memory distributed index allocator; no per-reader index ranges are abandoned on failure. Seeded shuffle and sample/group identities remain those of the existing data source. + +`get_samples(n)` and `add_samples(groups)` retain their interfaces. Custom producers can pass `source.reader_config("unique_reader_id")` to a remote process and call `config.open()` there. IDs must be unique; `owner` is reserved. Returned partial groups are persisted and become available to every reader; there is no reader-local sample buffer. Readers renew leases; a stopped reader returns unfinished tasks, while lost workers' tasks become available after lease expiry. Newly generated retries have new attempts. The coordinator does not automatically fail over: stop the old job before using `--rollout-queue-resume` with the same root and `--rollout-queue-run-id`. + +Fully async starts one generation process per Ray node with CPU resources. Each reserves one CPU and receives a share of the configured concurrency in complete prompt groups. Faster workers may fill a global batch without waiting for slower ones. Local generation queues and collector prefetch remain bounded. Complete groups pass through the existing dynamic filter before publication; rejected groups contribute filter metrics. Distributed fully async still rejects `--rollout-all-samples-process-path`. The synchronous all-samples hook retains its existing Samples and calling order. + +The default logical result is a complete filtered group; physical segment boundaries do not define training groups. Workers encode Samples explicitly and store large tensors as typed dependencies. The format uses immutable committed extents in append-only `.pack` files, relative references and checksums, with no pickle payload protocol. Unsupported custom field types fail explicitly. See the [straw architecture, deployment and recovery guide](../advanced/straw.md). + +Built-in producers return a collection manifest. Legacy custom rollouts can still return Sample lists; Manager publishes and accepts one compatibility collection. New producers may return an accepted `RawRolloutRef`; Manager validates its receipt and passes it to the same BatchBuilder without rewriting its payload. BatchBuilder preserves reward/conversion hooks and DP scheduling, records the selection plan, and publishes all rank shards before exposing `TrainBatchRef`s. All rank references identify the same batch and plan. + +The vime adapter currently binds the shared root at the same absolute path on all nodes and checks visibility in both directions. The underlying store's references are relocatable. r3/sc dependencies are adopted into queue storage before references escape the producer; old rollout spill cleanup cannot remove them. Queue files are retained by default. Offline inspection/cleanup requires stopped writers, coordinator and readers; ordinary reads or batch-ready events never delete data. Debug dumps remain opt-in `torch.save` files and may contain retained queue tensor references. Evaluation follows the existing path. + +Data-source checkpoints pause registered consumers, drain in-flight requests and save reader buffers and completed groups through `queue_state_.json` under `/rollout`. Buffer snapshots reference already persisted groups and retain current leases separately, avoiding a second encoding of every buffered token during checkpoint or shutdown; older inline snapshots remain readable. Restore checks dataset size, samples per prompt, seed, shuffle, run identity and fully-async worker topology. Global producer history remains in the journal and is not rewound when loading an earlier checkpoint. Custom execution state participates via `register_consumer(name, consumer)` with `pause`, `resume`, `state_dict`, `load_state_dict` and `close` methods. Disabled worker slots remain disabled after restore. This is durable queue/data state; it does not by itself guarantee exact distributed optimizer recovery or bitwise training equivalence. + +R3 training reads only the CP/TP rows assigned to the current rank. Batched continuation publication, loading and state persistence reuse authenticated indices within a bounded read session; receipt lookups use batch RPCs. These optimizations preserve checksums, WAL durability and GC ownership. Read sessions do not replace ownership pins. + +Distributed fully async producers stop admitting new groups while weights synchronize, then resume if another training rollout remains. In-flight results still finish and persist. + +Joint checkpoint restore defers background GC until the saved training consumer state and its restored storage ownership are durable. Unavailable checkpoint data fails restoration before GC starts. + +Restored reader buffers keep an explicit storage reference independent of filter decisions. A later data-source checkpoint moves that reference only after all active and not-yet-started consumers are captured in a complete, retained, durable snapshot. Retiring an older checkpoint can then release its obsolete tensors without losing resumable prefixes. A failed save keeps the previous reference; checkpoint retirement remains the caller's responsibility. + +The manager still materializes the selected batch for conversion, so manager memory and shared-storage bandwidth remain limits. `--rollout-io-concurrency` bounds publication I/O submissions. straw's storage, journal and GC implementation is Rust; vime retains Python sample conversion and training integration. Measure throughput for the intended model, concurrency and shared filesystem before changing deployment defaults. + ## How to Use vLLM vime runs vLLM in server mode and talks to it over HTTP. diff --git a/docs/en/index.rst b/docs/en/index.rst index c5513d8a8..e97ea9210 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -21,6 +21,7 @@ Start by Use Case - Build agentic RL workflows: :doc:`get_started/agent` - Configure production vLLM rollout topology: :doc:`advanced/vllm-config` - Connect external rollout engines: :doc:`advanced/external-rollout-engines` +- Persist distributed rollout and shared tensors with straw: :doc:`advanced/straw` - Sync weights as byte-level deltas: :doc:`advanced/delta-weight-sync` - Use PD disaggregation: :doc:`advanced/pd-disaggregation` - Use BF16 training with FP8 rollout or FP8 KV cache: :doc:`advanced/low-precision` @@ -63,6 +64,7 @@ Start by Use Case advanced/low-precision.md advanced/reproducibility.md advanced/fault-tolerance.md + advanced/straw.md advanced/observability.md advanced/pd-disaggregation.md advanced/external-rollout-engines.md diff --git a/docs/zh/advanced/straw.md b/docs/zh/advanced/straw.md new file mode 100644 index 000000000..c95c5e9ba --- /dev/null +++ b/docs/zh/advanced/straw.md @@ -0,0 +1,151 @@ +# 使用 straw 持久化 rollout + +[straw](https://github.com/zhuzilin/straw) 为 vime 提供基于文件系统的持久化队列和打包张量存储。启用 `--rollout-data-transport straw` 后,prompt 任务、部分 rollout、已接收结果和训练 batch 都拥有持久化身份。多台机器上的生成与训练进程通过共享文件系统直接读写载荷;Ray 调度 actor,传递控制消息和小型引用。 + +当前共享文件系统目标为 JuiceFS。各节点需要在同一个绝对路径挂载同一存储池。包括 NFS 在内的其他网络文件系统,需要单独验证跨客户端锁、数据可见性和持久化语义。 + +## 架构发生了什么变化 + +| 职责 | Ray object-store 传输(默认) | straw 传输 | +|---|---|---| +| 数据源 | `RolloutDataSourceWithBuffer` | 基于持久化 prompt 任务的 `QueueDataSource` | +| 载荷交换 | Ray object reference | 指向共享 pack 的 `RecordSetRef` / 张量引用 | +| Worker 分配 | 内存中的 producer 状态 | Lease、attempt、持久化 continuation 和结果 receipt | +| Rollout 转训练数据 | `BatchBuilder` 执行 hook 和 DP 分片 | 同一转换流程,额外持久化选择计划与 ready batch | +| 数据生命周期 | 进程/object-store 所有权 | 显式 task、consumer、reader 和 checkpoint 所有权 | +| 清理 | Object-store 生命周期 | 可选在线 GC,回收没有所有者的封存 pack | + +逻辑上分成 rollout 工作与结果、ready training batch 两个阶段。它们共享同一个 straw 存储池;只要任意阶段或 checkpoint 仍引用某个张量,该张量就保持存活,不需要为两个阶段各复制一份。vime controller 协调两个阶段,straw 用 Rust 实现存储、日志、任务接收和所有权协议。 + +```{mermaid} +flowchart LR + D[数据集 producer] -->|cursor + 任务提交| Q[持久化 prompt 任务] + Q -->|lease + 输入引用| W[分布式生成 worker] + W -->|持久化部分 continuation| Q + W -->|已接收 group 引用| R[Rollout 结果] + R --> B[BatchBuilder: 选择计划与 DP 分片] + B -->|ready batch 引用| T[训练 rank] + T -->|训练完成| C[Queue controller: 进度与所有权] + C --> G[straw 在线 GC] + P[(JuiceFS 共享 pack 文件)] -. 载荷读写 .-> W + P -. 共享张量引用 .-> B + P -. 当前 rank 所需数据 .-> T +``` + +一个不自动重启的 Ray actor 持有本次运行的 coordinator,串行协调队列状态转换和数据集分配;载荷由各 producer 进程写入。因此,多机 I/O 可以并行,每个队列日志仍只有一个写入所有者。当前没有 coordinator 自动选主或故障接管。 + +## 端到端数据流 + +1. **提交 prompt。** 数据集 cursor 与 prompt 任务一起提交。Worker 领取 lease,从共享存储读取 prompt group;丢失 worker 不会永久丢弃已经预分配的数据集索引区间。 +2. **生成与续跑。** 分布式 fully async 在每个符合资源条件的 Ray 节点上启动生成 worker。Worker 生成、评分并过滤完整 group,将部分 group 保存为持久化 continuation。R3 routes 和 SC rows 必须覆盖已保存的 token 前缀;服务端 capture 不完整时,从上一份一致的输入重试。 +3. **接收结果。** 先持久化载荷,再接收引用。Receipt 标识已接收 group,因此即使 RPC 回复丢失也能恢复结果。任务重新分配会产生新 attempt,旧 attempt 无法接收替代结果。 +4. **构造训练 batch。** `BatchBuilder` 在调用 reward/conversion hook 前持久化输入选择和转换配置,发布所有 DP shard 后才将整个 batch 标记为 ready。训练 reader 检查 batch、plan 和 rank 身份。Manager 仍会将选中的 sample 物化用于转换,其主机内存仍需纳入容量规划。 +5. **确认完成。** 所有 rank 的训练调用返回后,Manager 报告 `training_completed`,推进运行时消费进度并释放 ready batch 容量。这与模型/优化器 checkpoint 持久化是两件事。 + +Ray 继续负责调度、RPC 和执行进程故障;vime 负责 Sample schema、reward、filter、batch 和训练;straw 负责字节、引用、持久化和存储生命周期。自定义 rollout 仍可返回 Sample 列表,由 Manager 发布为兼容 collection。接口见[自定义指南](../get_started/customization.md)。 + +## 队列调度 + +`QueueReader` 是 worker 的队列客户端,负责领取、归还 group 和为正在执行的 lease 续租。`QueueDataSource` 增加整个作业的 reader 创建、consumer 生命周期和 source checkpoint 管理;它继承同一套取样方法,没有另实现一层 buffer。`--rollout-data-transport straw` 会自动选择此数据源;显式指定 `--data-source-path` 时使用 `vime.rollout.queue_data_source.QueueDataSource`。 + +`QueueReader` 不再有本地 continuation buffer。`add_samples()` 先发布可用的续跑前缀,再通过 `yield_tasks()` 在每个有界批次的一次 WAL 事务中替换输入并归还任务。旧 lease 随之结束,任意 worker 都能用新 lease 领取这些输入。reader 本地只保留正在执行的样本和 lease,关闭时无需重写已归还的 group。 + +持久化顺序为 **已完成待交付 → partial → 新 prompt**。同类任务按 group 中最旧的数值型 `weight_versions` 排序:版本越旧,staleness 越高,越先取用;相同版本按提交/归还的 FIFO 顺序,没有有效版本的排在同类末尾。比较已有版本即可,无需每次更新模型权重都改写队列。staleness 定义为“当前 serving 权重版本 − 最旧生成权重版本”,新鲜样本为 0。排序本身不会自动丢弃过旧样本。这是在一个队列内建立索引,不是维护三个独立队列。 + +straw 明确拒绝 `--buffer-filter-path`;`--buffer-sort-by-staleness` 仍用于内存数据源,straw 始终采用上述顺序。reward 和样本筛选 hook 继续可用。已经接收的 group 如果显式放回,会创建独立的 delivery task,保留原 accepted 历史;消费或丢弃 delivery 时也会确认被其替代的已接收版本。fully async scheduler 仍保留有界的 ready result 窗口,其 checkpoint 状态和已接收结果引用单独持久化。 + +source checkpoint 统一保存共享 pending 任务、准确的不可变输入引用和排序 metadata;worker checkpoint 只保存 reader metadata。reader 快照格式升级为 v3,旧 v1/v2 本地 buffer 快照需要迁移,加载时明确报错。此功能需要带 `yield_tasks()` 和持久化排序字段的新版 `straw-queue` 构建,原 PyPI 0.1.0 wheel 尚无这些接口。 + +## 共享张量、R3 与 SC + +每个 writer 进程复用 pack writer。vime adapter 默认按 **256 MiB** 目标大小轮换,由 `--rollout-queue-segment-mib` 配置。记录和 manifest 都嵌入 pack,**不会为每个 sample 或张量单独创建文件**,避免大量小文件带来的共享文件系统元数据开销。队列与所有权元数据使用追加日志。 + +已完成的 R3 routes 和 SC 张量,在 custom sample hook 之后随其 sample group 一起发布。后续 rollout、continuation 和训练数据发布复用不可变张量依赖;修改时写入新记录,旧引用保持有效。这是张量级共享与写时复制,不是 GPU 共享内存或内存页级写时复制。R3 训练只读取分配给当前 CP/TP rank 的行。大 bundle 会按 native 写入预算拆分;SC 也支持不启用 R3 的场景。 + +需要持久化 R3/SC 张量时,使用 `--rollout-data-transport straw`;object-store 传输默认将它们保存在内存中,也可继续使用 Vime 原有的磁盘 spill hook。straw 模式不需要独立的 spill hook 或清理步骤。导入另一个 straw 存储池的引用时,先将数据复制到目标池,再接收依赖这些数据的结果。Debug dump 直接保存张量内容,因此队列 GC 后仍可读取;启用 dump 会增加主机内存和 I/O 开销。 + +## Sample 编码与完整性校验 + +Adapter 将 sample 保存为 `vime.v1` JSON record:`{version: 1, tree: ...}`,通过显式标签编码容器、Sample 字段、NumPy 数组、图像和 rollout 引用。嵌套 group、支持的动态字段、多模态输入、R3、SC top-k 和 ragged top-p 都会保留。未知 Python 类型及循环引用明确报错;载荷协议不使用 pickle,也不会根据存储数据中的名称导入类。 + +Tensor 节点包含 shape、dtype、semantic kind、lazy/validated 标志,以及指向外层 manifest 依赖的 `{dependency: index, ordinal: index}`。物理位置保存在这些依赖 manifest 中,因此后续 sample 或训练 batch 发布可以共享相同的不可变张量记录。所有张量依赖必须先发布,才能接收所属 sample。 + +straw 的 `tensor.v1` 保存连续、行优先、小端的 typed bytes。Lazy reader 校验元数据及所读行覆盖的每个 chunk 的 checksum,不会校验未读取的 chunk;完整检查则校验整个 segment。张量描述符批量恢复,以复用共享 pack 索引。 + +## 启用方式 + +`requirements.txt` 已包含 `straw-queue`,标准 Docker 构建和正常安装 vime 时会自动安装。已有环境可以在每个生成和训练节点执行以下命令,同一任务的所有节点使用相同版本: + +```bash +pip install straw-queue +``` + +未安装 straw 时,默认 Ray `object-store` 传输仍可运行,启动日志会提示安装命令。如果显式选择 `--rollout-data-transport straw`,则在启动检查阶段报错并给出同样的安装命令。 + +Python 导入名为 `straw`。在原有训练命令中追加以下参数,使用新的共享目录: + +```bash +--rollout-data-transport straw \ +--rollout-data-dir /shared/juicefs/jobs/my-run/rollout_data \ +--rollout-queue-run-id my-run \ +--rollout-storage-profile juicefs \ +--rollout-storage-declaration /shared/juicefs/deployment.json +``` + +声明文件包含 `direct_mount: true`、`writeback: false`、`open_cache: 0`、`readdir_cache: false`、已部署的 `client_version` 和 `durability_description`。它记录经过验证的部署设置,不会自动探测或配置挂载。详见 straw 的[文件系统要求](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM_zh.md)。 + +默认 `local` 存储 profile 对应 POSIX 开发契约;在共享挂载上选择它,并不意味着已验证该存储服务的持久性。 + +存储方式和执行模式分别选择。默认传输仍为 `object-store`,默认 rollout 入口仍为同步模式。使用 straw 的分布式 fully async 时,额外添加: + +```bash +--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async +``` + +未指定 `--rollout-data-dir` 时,straw 模式使用 `/rollout_data`;没有 `--save` 则必须显式指定共享目录。`--rollout-io-concurrency` 限制事件循环之外的序列化和 I/O 并发,默认 4。Adapter 使用 worker 进程和有界线程,straw 本身不创建 I/O 进程池。`--rollout-queue-max-pending` 与 `--rollout-queue-max-inflight` 限制任务准入,默认均为 65,536;它们不是保留字节数或文件数配额。 + +权重同步期间,分布式 producer 暂停提交新任务,让在途请求完成并持久化;还有下一次训练 rollout 时恢复。权重同步本身仍是独立子系统。 + +关闭时,所有生成 worker 共用五分钟期限完成持久化写入。超时会报告错误,不会被视为成功关闭。 + +## 在线 GC 与所有权 + +在线 GC 默认关闭,添加 `--rollout-queue-online-gc` 后,straw 可在任务运行期间回收不再使用的封存 pack。vime 通过 straw API 提供使用完成和丢弃信号。只有 task、publication、queue、reader、checkpoint 等所有者全部释放后,pack 才能删除。仅 lease 超时不能证明 reader 已停止。一条存活记录就会保留整个 pack;活跃 writer、保留 checkpoint 和 WAL 历史仍会占用空间。 + +GC 失败会停止后台循环,后续 coordinator 操作传播原始原因,关闭时也会报告错误;它不会停止已在远端 worker 中运行的工作。数据保留供排查。容量用尽时应使用背压或由操作者明确决定处理方式,不能 reset 活跃队列或直接删除 pack 腾空间。离线删除目录要求所有参与者停止;仍需配置存储配额与应用保留策略。 + +已处理位置和过滤决策被显式记录,避免接收顺序中的空洞使已消费数据持续占用容量。独立且有界的 control task 配额允许 Manager 在生产背压时仍能提交 collection。 + +## 恢复与 checkpoint + +| 故障或重启场景 | 支持行为与边界 | +|---|---| +| Worker 在结果接收前丢失 | 恢复最近一次持久化 continuation,以新 attempt 重新分配 | +| 已提交结果但回复丢失 | 恢复已有 receipt,不重复进行逻辑接收 | +| 第一个 batch 计划生成前整个任务停止 | `--rollout-queue-resume` 使用相同初始模型和 rollout 配置恢复已接收 group 与持久化前缀 | +| 已生成 batch 计划后重启 | 需要匹配的模型/优化器及 rollout checkpoint,单独恢复队列会明确报错 | + +Resume 要求整个旧任务(包括 coordinator 和 reader)已停止,并使用同一个 root 与 run ID。该参数是操作者声明,不是自动 fencing。尚未发布的推理可能重新执行;vLLM GPU KV cache 不会恢复。 + +Checkpoint 的 `/rollout/queue_state_.json` 保存 source 和已注册 scheduler consumer;`builder_state_.json` 保存训练 consumer 视图。它们的引用保留相应存储依赖图。Continuation 增量持久化,checkpoint 引用已有数据,不会再次重写全部归还样本的 token。恢复旧 checkpoint 不会抹去 WAL 中后来已接收的历史。 + +部分 R3/SC 张量在 continuation 快照中保持 lazy 引用,生成追加新行时才加载。恢复 source 和 builder 状态会保留已保存的 warm groups 和行为策略版本,并记录哪些后续输出不属于恢复分支。恢复后的 pending task 独立于过滤决策保留存储引用。包含尚未重启 consumer 的完整 source 快照,必须先保留并持久记录,才能释放旧引用;保存失败保留旧引用,后台 GC 在恢复后的 consumer 状态提交后才启动。 + +Adapter 目前尚未建立模型、优化器、RNG 与队列状态的联合最终提交 manifest。因此,持久化 rollout 不代表任意崩溃后的 optimizer 精确恢复,也不承诺训练结果逐位一致。 + +dataset producer 游标尚未随 source checkpoint 保存,恢复 pending 快照也尚未排除所有后来新增的任务。因此,任意旧训练步的完整回退仍属于待实现能力。 + +## 验证与当前限制 + +CPU 测试覆盖 codec、R3/SC 传输、CP/TP 按行读取、worker 故障、continuation、checkpoint 视图和 GC 所有权。straw 独立验证 Rust/Python 核心、有界所有权模型和 native 崩溃边界。物理多客户端文件系统检查及真实 GPU 训练补充这些测试。 + +`tests/test_straw_fully_async_recovery.py` 启动两个本地 Ray 节点,以 SIGKILL 中断整个 driver、coordinator 和 generation 进程树,再启动新任务读取同一个 straw 存储池,不经过正常 checkpoint。测试覆盖已接收结果重放、token/logprob/R3/SC 前缀保持不变、新 lease 和 reward 只计算一次,并分别检查在线 GC 关闭与开启的情况。推理和 reward 使用 CPU fixture,不代表 optimizer 恢复验证。此测试在 `cpu-unittest` 中自动运行,安装方式见 [CI 配置](../developer_guide/ci.md)。 + +本地运行分布式 rollout 与中断恢复测试: + +```bash +PYTHONPATH=. python tests/test_distributed_rollout.py +PYTHONPATH=. python tests/test_straw_fully_async_recovery.py +``` + +主要部署限制包括共享存储带宽、持久化延迟、coordinator 吞吐和 Manager 转换内存。目前尚无存活 pack 整理、日志压缩或 coordinator 自动接管。Rollout 参数见[使用指南](../get_started/usage.md),存储验证及其覆盖边界见 straw 的[验证文档](https://github.com/zhuzilin/straw/blob/main/docs/VERIFICATION_zh.md)。 diff --git a/docs/zh/get_started/customization.md b/docs/zh/get_started/customization.md index 997e02f64..4784739aa 100644 --- a/docs/zh/get_started/customization.md +++ b/docs/zh/get_started/customization.md @@ -66,6 +66,24 @@ def generate_rollout(args, rollout_id, data_source, evaluation=False) -> Rollout **示例**: 参见 [examples/fully_async](../_examples_synced/fully_async/README.md) +显式设置 `--rollout-data-transport straw` 后(默认是 Ray `object-store`),`RolloutFnTrainOutput.samples` 可以是指向本批数据的 `DiskPayloadRef`。已有 custom function 返回 Sample 列表(直接返回或放在 `RolloutFnTrainOutput` 中)仍可使用,manager 会统一保存一次。新函数可以在 rollout 过程中保存已经生成、打分并筛选的 group,最后返回 manifest,避免在内存中积累完整 batch。例如,已有一个逐组产生选中数据的异步迭代器时: + +```python +import asyncio + +from vime.rollout.base_types import finalize_rollout_groups +from vime.utils.rollout_transport import pack_rollout_group + + +async def generate_stored_batch(args, rollout_id, completed_groups): + refs = [] + async for group in completed_groups: + refs.append(await asyncio.to_thread(pack_rollout_group, group, args, rollout_id)) + return finalize_rollout_groups(args, rollout_id, refs) +``` + +`finalize_rollout_groups` 负责排序、调用一次配置的 batch sample filter,并保存 batch manifest。如果 hook 修改 Sample,会重新保存处理结果。Wrapper 需要真实 Sample 时,可调用 `vime.utils.rollout_transport.load_rollout_samples(output.samples)`。生成、reward hook 和 eval 的返回协议不变。 + --- ### `--custom-generate-function-path` diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 6d0a22aac..4cf168e8f 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -390,6 +390,46 @@ vime 支持不同程度的自定义数据生成(rollout)。 - 有的时候,我们还需要支持自定义的 reward model,可以通过配置 `--custom-rm-path` 来进行配置。 +### 持久化 rollout 队列和分布式 fully async + +数据流、共享张量所有权和 checkpoint 边界详见 [straw 架构与恢复](../advanced/straw.md)。 + +Rollout payload 默认使用 Ray `object-store`,数据源为 `vime.rollout.data_source.RolloutDataSourceWithBuffer`;此模式不需要 straw 或共享 rollout 目录。`--rollout-data-transport nixl` 则选择 Ray 的 NIXL 张量传输。 + +安装 [straw](../advanced/straw.md) 后,通过 `--rollout-data-transport straw` 启用持久化队列和打包张量存储。straw 传输默认使用 `vime.rollout.queue_data_source.QueueDataSource`,数据与协调日志存放在 `--rollout-data-dir`;设置了 `--save` 时可省略目录,使用 `/rollout_data`,否则必须显式提供共享目录。Ray 仍负责 actor、RPC 和小引用传递。默认仍是同步 rollout;分布式 fully async 需要显式选择: + +```bash +--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async \ +--rollout-data-transport straw \ +--rollout-data-dir /shared/run/rollout_data +``` + +straw 传输下,开启 `--use-rollout-routing-replay` 后会将已完成 sample 的 R3 routes 随所属 group 一起写入 straw;开启 `--use-score-centering` 时也会在同一次发布中保存 SC 张量,包括只使用 SC 的情况。默认 object-store 传输将这些张量保存在内存中,但仍保留原有的可选磁盘 spill hook。自定义 sample hook 先执行,R3、SC 与 sample 元数据一起发布到共享 pack,后续队列发布复用引用。大批 group 按底层写入预算拆分。Aborted prefix 由队列 continuation 路径持久化。`--rollout-queue-online-gc` 是独立开关,默认关闭:训练确认使用完成后,straw 可回收所有 owner 都已释放的 sealed pack。 + +作业内一个禁止自动重启的 Ray actor 管理任务 lease 和串行 dataset producer。Dataset 游标与任务提交在同一日志事务中保存,worker 通过小引用直接读取共享存储中的 prompt group,替代原来的内存索引分配器。Shuffle、group/sample 编号沿用原有数据源逻辑,故障不会丢弃 reader 预留的索引区间。 + +保留 `get_samples(n)`、`add_samples(groups)` 接口。Custom producer 可以传递 `source.reader_config("unique_reader_id")`,在远端调用 `config.open()`。Reader ID 必须唯一,`owner` 为保留名称。归还的 partial group 持久化后可由任意 reader 领取,不再保留 reader 本地 sample buffer。Reader 定期续租,正常关闭归还未完成任务,故障 worker 的任务在 lease 到期后重试。恢复协调器前必须停止旧作业,再以相同 root/run ID 显式传入 `--rollout-queue-resume`,不自动抢占旧实例。 + +Fully async 在每个有 CPU 资源的 Ray 节点启动一个常驻生成进程,每个占用一个 CPU,并按完整 prompt group 分配总并发。快速 worker 可以独立补足全局 batch,保留原有有界生成队列和 collector 预取。完整 group 通过现有 dynamic filter 后才提交;丢弃结果计入过滤指标。分布式 fully async 仍不支持 `--rollout-all-samples-process-path`;同步入口保留其 Samples 和调用顺序。 + +逻辑提交单位是过滤后的完整 group,物理 segment 不决定训练分组。Sample 使用显式编码,大张量成为 typed blob 依赖;采用`.pack` 追加文件中的不可变已提交区间、相对引用和校验和,不以 pickle 作为 payload 协议。不支持的自定义字段类型会报错。参阅 [straw 架构、部署与恢复指南](../advanced/straw.md)。 + +内置 producer 返回 collection manifest。旧 custom rollout 仍可返回 Sample 列表,由 Manager 兜底持久化并接受一个兼容 collection;新 producer 可以直接返回关联有效 receipt 的 `RawRolloutRef`。两者进入同一个 BatchBuilder,保留 reward/conversion hook 和 DP 调度。Builder 先保存选择计划,全部 rank shard 持久化后才返回同一 batch ID/plan 的 `TrainBatchRef`。 + +Vime adapter 当前要求各节点使用同一个绝对挂载路径,并双向检查可见性;底层引用支持重新绑定 root。r3/sc 的临时文件依赖会先复制进队列,旧 spill 清理不会使已提交数据失效。默认保留队列文件;读取、batch-ready 都不删除数据。离线清理要求停止 coordinator、writer 和 reader。Debug dump 仍按需用 `torch.save` 写入,可能引用保留的队列张量;evaluation 路径保持原有行为。 + +数据源 checkpoint 暂停注册 consumer、等待在途请求,将 buffer 和已完成 group 通过 `/rollout/queue_state_.json` 保存。buffer 快照引用已经持久化的 group,并单独保存当前 lease,避免在 checkpoint 或退出时再次编码所有缓存 token;旧版内嵌 group 的快照仍可读取。恢复检查数据集大小、每个 prompt 的样本数、seed、shuffle、run identity 和 fully async worker 拓扑。恢复早期 checkpoint 不回滚 journal 中的全局生产事实。Custom execution state 通过 `register_consumer(name, consumer)` 参与保存,实现 `pause`、`resume`、`state_dict`、`load_state_dict`、`close`。停用的 worker 槽位在恢复后仍停用。这是持久化队列与数据状态,不等于完整分布式 optimizer 精确恢复或训练结果逐位一致。 + +R3 训练只读取当前 CP/TP rank 分配到的行。续跑批量发布、加载和状态保存通过有界读取会话复用已认证索引,结果回执采用批量 RPC 查询。这些优化保留 checksum、WAL 持久化和 GC 所有权约束;读取会话不能替代所有权 pin。 + +分布式 fully async producer 在权重同步期间停止补发新 group;还有下一次训练 rollout 时,同步后恢复。已开始的请求继续返回并持久化。 + +从联合 checkpoint 恢复时,先持久化恢复后的训练 consumer 状态及存储引用,再启动后台 GC。所需 checkpoint 数据不可用时,恢复失败且不启动 GC。 + +恢复的 reader buffer 通过独立于过滤决策的存储引用受到保护。后续数据源 checkpoint 必须完整保存活跃及尚未启动的 consumer,并保留、持久化新快照,才能交接该引用。之后正常退役旧 checkpoint 时,可以回收过期张量,同时保留可续跑前缀。保存失败会保留原引用;checkpoint 退役仍由调用方负责。 + +Manager 仍会读取选中的 batch 做转换,共享存储带宽和 manager 内存仍可能成为瓶颈。`--rollout-io-concurrency` 限制队列发布的 I/O 待办数量。straw 的存储、日志和 GC 由 Rust 实现,vime 保留 Python sample 转换和训练集成。改变部署默认值前,需针对实际模型、并发和共享文件系统测量吞吐。 + ## vLLM 使用方法 vime 以 server 模式运行 vLLM,通过 HTTP 与之通信。 diff --git a/docs/zh/index.rst b/docs/zh/index.rst index 21cd876e6..4dc9e3454 100644 --- a/docs/zh/index.rst +++ b/docs/zh/index.rst @@ -21,6 +21,7 @@ vime 构建于 `slime `_ 之上,slime 正是 G - 构建 agentic RL workflow::doc:`get_started/agent` - 配置生产级 vLLM rollout topology::doc:`advanced/vllm-config` - 接入 external rollout engines::doc:`advanced/external-rollout-engines` +- 使用 straw 持久化分布式 rollout 与共享张量::doc:`advanced/straw` - 以字节级 delta 同步权重::doc:`advanced/delta-weight-sync` - 使用 PD disaggregation::doc:`advanced/pd-disaggregation` - 使用 BF16 训练 + FP8 rollout 或 FP8 KV cache::doc:`advanced/low-precision` @@ -63,6 +64,7 @@ vime 构建于 `slime `_ 之上,slime 正是 G advanced/low-precision.md advanced/reproducibility.md advanced/fault-tolerance.md + advanced/straw.md advanced/observability.md advanced/pd-disaggregation.md advanced/external-rollout-engines.md diff --git a/requirements.txt b/requirements.txt index 0339d6495..a94c2508c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ pyyaml qwen_vl_utils # for VLM ray[default] safetensors +straw-queue tensorboard transformers vllm-router>=0.1.15 diff --git a/tests/plugin_contracts/_shared.py b/tests/plugin_contracts/_shared.py index c47fd1f85..b24e860c3 100644 --- a/tests/plugin_contracts/_shared.py +++ b/tests/plugin_contracts/_shared.py @@ -20,13 +20,6 @@ def install_paths() -> None: def install_stubs(*, with_vllm_router: bool = False, with_transformers: bool = False) -> None: - if "ray" not in sys.modules: - ray_mod = types.ModuleType("ray") - ray_mod._private = types.SimpleNamespace( - services=types.SimpleNamespace(get_node_ip_address=lambda: "127.0.0.1") - ) - sys.modules["ray"] = ray_mod - if with_vllm_router and "vllm_router" not in sys.modules: mod = types.ModuleType("vllm_router") sys.modules["vllm_router"] = mod diff --git a/tests/plugin_contracts/test_plugin_generate_contracts.py b/tests/plugin_contracts/test_plugin_generate_contracts.py index e6c673ec5..53ad406ae 100644 --- a/tests/plugin_contracts/test_plugin_generate_contracts.py +++ b/tests/plugin_contracts/test_plugin_generate_contracts.py @@ -30,6 +30,7 @@ ) from vime.rollout.vllm_rollout import generate_and_rm +from vime.utils.async_utils import AsyncPacer from vime.utils.misc import load_function from vime.utils.types import Sample @@ -58,6 +59,7 @@ class FakeGenerateState: def __init__(self, args) -> None: self.args = args self.semaphore = types.SimpleNamespace(__aenter__=None) + self.generation_pacer = AsyncPacer() self.pendings = set() self.remaining_batch_size = 0 self.aborted = False diff --git a/tests/plugin_contracts/test_plugin_rollout_contracts.py b/tests/plugin_contracts/test_plugin_rollout_contracts.py index d2d0e22ab..9be1c51b1 100644 --- a/tests/plugin_contracts/test_plugin_rollout_contracts.py +++ b/tests/plugin_contracts/test_plugin_rollout_contracts.py @@ -140,9 +140,9 @@ def assert_rollout_function_matches_default_contract(fn) -> None: data_source = ContractDataSource() train_output = call_rollout_fn(fn, None, 2, data_source, evaluation=False) - eval_output = call_rollout_fn(fn, None, 2, data_source, evaluation=True) - assert_train_rollout_contract(train_output, n_samples_per_prompt=2) + + eval_output = call_rollout_fn(fn, None, 2, data_source, evaluation=True) assert_eval_rollout_contract(eval_output) diff --git a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py index 56310cabf..43b64db3a 100644 --- a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py +++ b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py @@ -151,7 +151,7 @@ def invoke_rollout_data_postprocess(fn): "custom_reward_post_process", "CUSTOM_REWARD_POST_PROCESS_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_reward_post_process", - "vime/ray/rollout.py", + "vime/rollout/batch_builder.py", "self.custom_reward_post_process_func(self.args, samples)", ("args", "samples"), invoke_reward_post_process, @@ -160,7 +160,7 @@ def invoke_rollout_data_postprocess(fn): "custom_convert_samples_to_train_data", "CUSTOM_CONVERT_SAMPLES_TO_TRAIN_DATA_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_convert_samples_to_train_data", - "vime/ray/rollout.py", + "vime/rollout/batch_builder.py", "self.custom_convert_samples_to_train_data_func(self.args, samples)", ("args", "samples"), invoke_convert_samples_to_train_data, diff --git a/tests/test_distributed_rollout.py b/tests/test_distributed_rollout.py new file mode 100644 index 000000000..0ebfe1bfb --- /dev/null +++ b/tests/test_distributed_rollout.py @@ -0,0 +1,2007 @@ +"""Persistent queue input ownership and global batch processing.""" + +import asyncio +import copy +import json +import os +import random +import sys +import threading +import time +import weakref +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from dataclasses import fields +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from vime.rollout.base_types import iter_samples +from vime.rollout.data_source import RolloutDataSource +from vime.rollout.queue_data_source import QueueDataSource, QueueReader, RolloutQueueController +from vime.utils.data import Dataset, process_rollout_data +from vime.utils.rollout_transport import unpack_rollout_payload +from vime.utils.tensor_store import TensorRef +from vime.utils.types import Sample + +NUM_GPUS = 0 + +# No HTTP server is used by these CPU tests, including their Ray workers. +try: + import vllm_router # noqa: F401 +except ImportError: + sys.modules["vllm_router"] = SimpleNamespace(__version__="0.3.0") + + +class _LocalHandle: + def __init__(self, target): + self.target = target + + def __getattr__(self, name): + return SimpleNamespace(remote=getattr(self.target, name)) + + +@pytest.fixture +def source_factory(monkeypatch, tmp_path, request): + import ray + + def init(source, args): + source.args = args + source.metadata = {} + source.sample_offset = source.epoch_id = source.sample_group_index = source.sample_index = 0 + source.dataset = Dataset.__new__(Dataset) + source.dataset.origin_samples = [Sample(prompt=f"prompt-{i}") for i in range(7)] + source.dataset.samples = source.dataset.origin_samples + source.dataset.seed = 31 + source.dataset.epoch_id = -1 + source.dataset.shuffle(0) + + monkeypatch.setattr(RolloutDataSource, "__init__", init) + monkeypatch.setattr(ray, "get", lambda value: value) + args = SimpleNamespace( + n_samples_per_prompt=2, + rollout_seed=31, + rollout_shuffle=True, + buffer_filter_path=None, + buffer_sort_by_staleness=False, + rollout_data_transport="straw", + rollout_queue_online_gc=getattr(request, "param", False), + rollout_data_dir=str(tmp_path), + ) + controller = RolloutQueueController(args) + sources = [] + + def create(worker, take=None): + handle = _LocalHandle(controller) + if take is not None: + handle.take = SimpleNamespace(remote=take) + source = QueueReader(copy.copy(args), handle, str(worker), 7) + sources.append(source) + return source + + create.controller = controller + create.args = args + yield create + for source in sources: + source.close() + controller.close() + + +def test_task_claims_and_producer_cursor_survive_recovery(source_factory): + + controller = source_factory.controller + first = controller.take("first", 2) + second = controller.take("second", 2) + ids = [a.task.task_id for a in first.assignments + second.assignments] + assert len(ids) == len(set(ids)) == 4 + state = controller.queue.producer_state("dataset") + controller.close() + args = copy.copy(source_factory.args) + args.rollout_queue_resume = True + restored = RolloutQueueController(args) + try: + assert restored.queue.producer_state("dataset") == state + assert restored.queue.outstanding_reads() == () + reclaimed = restored.take("recovered", 4) + assert [a.task.task_id for a in reclaimed.assignments] == ids + assert restored.heartbeat([first.assignments[0].lease]) == ["StaleAttempt"] + assert restored.queue.producer_state("dataset") == state + finally: + restored.close() + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_whole_job_recovery_replays_accepted_results_and_durable_continuations( + source_factory, +): + from dataclasses import asdict + + from vime.utils.rollout_transport import pack_rollout_group + + reader = source_factory("fully_async_0") + accepted_group, partial_group = reader.get_samples(2) + accepted = pack_rollout_group(accepted_group, reader.args, 0) + partial_group[0].tokens = [1, 7, 9] + partial_group[0].response_length = 2 + partial_group[0].status = Sample.Status.ABORTED + partial_group[0].rollout_routed_experts = torch.arange(8).reshape(2, 2, 2) + reader.add_samples([partial_group]) + task_id = partial_group[0]._queue_lease["task_id"] + # Drop local state, leaving only durable task progress and accepted receipts. + reader.close() + controller = source_factory.controller + controller.close() + args = copy.copy(reader.args) + args.rollout_queue_resume = True + restored = RolloutQueueController(args) + try: + replay = restored.codec.load(restored.recover_pending_rollout()) + assert replay == [asdict(accepted.receipt)] + assignment = restored.queue.acquire("new-reader", 1, task_ids=[task_id]).assignments[0] + assert assignment.lease.coordinator_epoch != partial_group[0]._queue_lease["coordinator_epoch"] + actual = restored.codec.load(assignment.task.input_ref) + assert actual[0].tokens == [1, 7, 9] + assert actual[0].response_length == 2 + assert actual[0].rollout_routed_experts.load().flatten().tolist() == list(range(8)) + assert len(restored.queue.commits) == 1 + # Once a batch exists, queue state alone cannot select model/optimizer state. + plan = restored.codec.publish({"test": True}, submission_id="recovery-plan") + restored.plan_batch("batch:test", [accepted.receipt.position], plan) + with pytest.raises(RuntimeError, match="matching model/optimizer"): + restored.recover_pending_rollout() + finally: + restored.close() + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_dynamic_rejection_releases_spilled_tensor_staging(source_factory): + from straw.tensor import publish_tensors + + from vime.utils.rollout_transport import discard_rollout_group, rollout_store + + reader = source_factory("filter") + group = reader.get_samples(1)[0] + source_factory.controller.store.seal() + store, _, _ = rollout_store(reader.args) + refs = publish_tensors( + store, + { + "rollout_routed_experts": torch.arange(8), + "rollout_topk_token_ids": torch.arange(4), + }, + submission_id="discarded", + ) + group[0].rollout_routed_experts, group[0].rollout_topk_token_ids = refs + source_factory.controller.store.seal() + discard_rollout_group(group, reader.args) + assert source_factory.controller.queue.collect_garbage()["reclaimed_files"] >= 1 + + +@pytest.mark.parametrize("missing", ["rollout_routed_experts", "rollout_topk_token_ids"]) +def test_incomplete_server_abort_retries_last_valid_durable_prefix(source_factory, missing): + reader = source_factory("partial") + reader.args.use_rollout_routing_replay = True + reader.args.use_score_centering = True + group = reader.get_samples(1)[0] + sample = group[0] + sample.tokens, sample.response_length = [7, 8, 9], 2 + sample.status = Sample.Status.ABORTED + sample.rollout_routed_experts = torch.arange(8).reshape(2, 2, 2) + sample.rollout_topk_token_ids = torch.tensor([[1, 2], [3, 4]], dtype=torch.int32) + sample.rollout_topk_log_probs = torch.zeros(2, 2) + reader.add_samples([group]) + group = reader.get_samples(1)[0] + lease = group[0]._queue_lease + last_input = source_factory.controller.status(lease["task_id"])["spec"]["input_ref"] + setattr(group[0], missing, None) # An abort response lacks part of its capture. + reader.add_samples([group]) + assert reader.get_buffer_length() == 1 + status = source_factory.controller.status(lease["task_id"]) + assert status["state"] == "pending" and status["failures"] == 0 + assert status["spec"]["input_ref"] == last_input + resumed = reader.get_samples(1)[0][0] + assert resumed.tokens == [7, 8, 9] and resumed.response_length == 2 + assert resumed._queue_lease["attempt_id"] != lease["attempt_id"] + assert resumed.rollout_routed_experts.load().flatten().tolist() == list(range(8)) + assert resumed.rollout_topk_token_ids.load().tolist() == [[1, 2], [3, 4]] + + +def test_generation_workers_cannot_claim_manager_collection_tasks(source_factory): + from straw.protocol import TaskSpec + + controller = source_factory.controller + task_id = "collection:owned-by-manager" + controller.queue.submit_tasks("manager-submit", [TaskSpec(task_id, allow_empty=True)]) + groups = source_factory(0).get_samples(2) + assert len(groups) == 2 + assert controller.status(task_id)["state"] == "pending" + collection = controller.queue.acquire("manager", 1, task_ids=[task_id]) + assert collection.assignments[0].task.task_id == task_id + + +def test_group_reply_loss_returns_original_receipt_and_detects_changed_content( + source_factory, +): + from straw.errors import IdempotencyConflict + + from vime.utils.rollout_transport import pack_rollout_group + + reader = source_factory(0) + group = reader.get_samples(1)[0] + group[0].rollout_routed_experts = torch.arange(8).reshape(2, 2, 2) + first = pack_rollout_group(group, reader.args, 0) + retried = pack_rollout_group(group, reader.args, 0) + assert retried == first + assert retried.manifest == retried.receipt.result_ref + group[0].rollout_routed_experts[0, 0, 0] += 1 + with pytest.raises(IdempotencyConflict): + pack_rollout_group(group, reader.args, 0) + assert source_factory.controller.queue.read_commits().cursor == 1 + + +def test_collection_request_reuses_lease_after_response_loss(source_factory): + controller = source_factory.controller + lease = controller.begin_collection("rollout-1") + assert controller.begin_collection("rollout-1") == lease + ref = controller.codec.publish( + [], + submission_id="collection-test", + metadata={"task_id": lease.task_id, "attempt_id": lease.attempt_id}, + ) + receipt = controller.complete(lease, ref) + assert controller.begin_collection("rollout-1") == lease + assert controller.complete(lease, ref) == receipt + assert controller.queue.read_commits().cursor == 1 + + +def test_failed_initial_refill_does_not_advance_dataset_cursor(source_factory, monkeypatch): + controller = source_factory.controller + + def fail_publication(*args, **kwargs): + raise OSError("injected input publication failure") + + with monkeypatch.context() as patch: + patch.setattr(controller.codec, "publish_many", fail_publication) + with pytest.raises(OSError, match="input publication failure"): + controller.take("reader", 1) + assert controller.queue.producer_state("dataset") is None + group = source_factory(0).get_samples(1)[0] + assert [sample.index for sample in group] == [0, 1] + order = list(range(7)) + random.Random(31).shuffle(order) + assert group[0].prompt == f"prompt-{order[0]}" + + +def test_workers_read_locally_across_epochs_without_duplicate_ids(source_factory): + first, second = source_factory(0), source_factory(1) + groups = [] + for source, count in ((first, 4), (second, 9), (first, 12), (second, 3)): + groups.extend(source.get_samples(count)) + indices = [sample.index for group in groups for sample in group] + assert len(indices) == len(set(indices)) + for group in groups: + position = group[0].group_index + epoch, offset = divmod(position, 7) + order = list(range(7)) + random.Random(31 + epoch).shuffle(order) + assert [sample.prompt for sample in group] == [f"prompt-{order[offset]}"] * 2 + assert [sample.index for sample in group] == [2 * position, 2 * position + 1] + + +def test_returned_partial_is_available_to_another_reader(source_factory): + source, other = source_factory(0), source_factory(1) + group = source.get_samples(1)[0] + group[0].tokens = [1, 2, 3] + group[0].response_length = 2 + group[0].status = Sample.Status.ABORTED + lease = group[0]._queue_lease + source.add_samples([group]) + assert not hasattr(source, "buffer") + assert not source._leases + assert source.get_buffer_length() == 1 + restored = other.get_samples(1)[0] + assert restored[0].index == group[0].index + assert restored[0].tokens == [1, 2, 3] + assert restored[0].status == Sample.Status.ABORTED + assert restored[0]._queue_lease["worker_id"] == "1" + assert restored[0]._queue_lease["attempt_id"] != lease["attempt_id"] + assert source.get_buffer_length() == 0 + + +def test_queue_orders_stage_then_staleness_then_fifo(source_factory): + reader, other = source_factory("producer"), source_factory("consumer") + groups = reader.get_samples(6) + for group, version in zip(groups, [8, 2, 2, None, 9, 1], strict=True): + for sample in group: + sample.tokens, sample.response_length = [1, 2], 1 + sample.status = Sample.Status.ABORTED + sample.weight_versions = [str(version)] if version is not None else [] + # Completed groups are deliverable immediately, even if their version is newer. + for sample in groups[4]: + sample.status, sample.reward = Sample.Status.COMPLETED, 1 + # A mixed-version prefix is as stale as its oldest generated segment. + groups[5][0].weight_versions = ["1", "12"] + reader.add_samples(groups) + expected = [groups[i][0].index for i in (4, 5, 1, 2, 0, 3)] + assert [group[0].index for group in other.get_samples(6)] == expected + assert other.get_samples(1)[0][0].response_length == 0 + + +@pytest.mark.parametrize( + "path", + [ + "custom.filter", + "vime.rollout.data_source.pop_first", + "vime.rollout.data_source.pop_oldest", + ], +) +def test_straw_rejects_all_buffer_filter_paths(source_factory, path): + source_factory.args.buffer_filter_path = path + with pytest.raises(ValueError, match="--buffer-filter-path is not supported"): + source_factory("unsupported") + + +def test_lost_return_reply_preserves_one_pending_task(source_factory): + reader = source_factory("producer") + group = reader.get_samples(1)[0] + group[0].tokens, group[0].response_length = [1, 2], 1 + original = reader.controller.return_groups.remote + + def lose_reply(updates, deliveries): + original(updates, deliveries) + raise ConnectionError("reply lost") + + reader.controller.return_groups = SimpleNamespace(remote=lose_reply) + with pytest.raises(ConnectionError, match="reply lost"): + reader.add_samples([group]) + reader.close() + other = source_factory("replacement") + resumed = other.get_samples(1)[0] + assert resumed[0].tokens == [1, 2] + assert resumed[0].index == group[0].index + assert other.get_buffer_length() == 0 + + +def test_completed_return_uses_a_delivery_without_rewriting_accepted_history( + source_factory, +): + from vime.utils.rollout_transport import pack_rollout_group + + reader = source_factory(0) + group = reader.get_samples(1)[0] + for sample in group: + sample.status, sample.reward = Sample.Status.COMPLETED, 1 + result = pack_rollout_group(group, reader.args, 0) + reader.add_samples([group]) + returned = reader.get_samples(1)[0] + assert [sample.index for sample in returned] == [sample.index for sample in group] + assert all(sample._queue_source_positions == [result.receipt.position] for sample in returned) + assert all(not hasattr(sample, "_queue_receipt") for sample in returned) + assert source_factory.controller.status(result.receipt.task_id)["state"] == "completed" + delivery = pack_rollout_group(returned, reader.args, 0) + assert delivery.receipt.task_id != result.receipt.task_id + assert source_factory.controller.queue.read_commits().cursor == 2 + source_factory.controller.args.rollout_queue_resume = True + replay = source_factory.controller.codec.load(source_factory.controller.recover_pending_rollout()) + assert [receipt["position"] for receipt in replay] == [delivery.receipt.position] + + # Discarding the delivery also releases the predecessor's accepted capacity. + from vime.utils.rollout_transport import discard_rollout_group, load_rollout_samples + + discard_rollout_group(load_rollout_samples([delivery])[0], reader.args, "test selection") + assert source_factory.controller._training_state()["processed_cursor"] == 2 + + +def test_older_pending_snapshot_restores_prefix_as_a_delivery(source_factory): + from vime.utils.rollout_transport import pack_rollout_group + + reader = source_factory("owner") + group = reader.get_samples(1)[0] + for sample in group: + sample.tokens, sample.response_length = [1, 2], 1 + sample.status = Sample.Status.ABORTED + reader.add_samples([group]) + state = reader.state_dict() + group = reader.get_samples(1)[0] + for sample in group: + sample.tokens.append(3) + sample.status = Sample.Status.COMPLETED + accepted = pack_rollout_group(group, reader.args, 0) + reader.load_state_dict(state) + restored = reader.get_samples(1)[0] + assert all(sample.tokens == [1, 2] for sample in restored) + assert all(sample._queue_source_positions == [accepted.receipt.position] for sample in restored) + assert all(sample._queue_generation_start == 2 for sample in restored) + assert all(sample._queue_lease["task_id"] != accepted.receipt.task_id for sample in restored) + assert source_factory.controller.queue.read_commits().cursor == 1 + + +def test_legacy_rollout_return_completes_borrowed_inputs(source_factory): + from vime.rollout.base_types import RolloutFnTrainOutput + from vime.utils.rollout_transport import accept_raw_rollout, load_rollout_samples + + reader = source_factory(0) + groups = reader.get_samples(2) + leases = [group[0]._queue_lease for group in groups] + ref = accept_raw_rollout(RolloutFnTrainOutput(samples=groups), reader.args, 0) + reader.close() + assert all(source_factory.controller.status(lease["task_id"])["state"] == "completed" for lease in leases) + restored = load_rollout_samples(ref) + assert [[sample.index for sample in group] for group in restored] == [ + [0, 1], + [2, 3], + ] + + +def test_checkpoint_buffer_and_latest_continuation_survive_reader_release( + source_factory, +): + source = source_factory(0) + used = source.get_samples(2) + used[0][0].tokens = [7, 8] + used[0][0].response_length = 1 + source.add_samples([used[0]]) + state = source.state_dict() + source.close() + restored = source_factory(0) + restored.load_state_dict(state) + group = restored.get_samples(1)[0] + assert [s.index for s in group] == [s.index for s in used[0]] + assert group[0].tokens == [7, 8] + assert group[0]._queue_lease != used[0][0]._queue_lease + # Closing returned the other unfinished task, without reserving/discarding a range. + assert [s.index for s in restored.get_samples(1)[0]] == [s.index for s in used[1]] + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_buffer_checkpoint_reuses_durable_groups_and_protects_them_from_gc( + source_factory, +): + reader = source_factory("compact-buffer") + groups = reader.get_samples(32) + for group in groups: + for sample in group: + sample.tokens = list(range(8192)) + sample.response_length = 8191 + sample.status = Sample.Status.ABORTED + root = Path(reader.args.rollout_data_dir) + + def pack_bytes(): + return sum(path.stat().st_size for path in root.rglob("*.pack")) + + before = pack_bytes() + reader.add_samples(groups) + continuation_bytes = pack_bytes() - before + before = pack_bytes() + saved = reader.state_dict() + assert pack_bytes() - before < continuation_bytes / 10 + checkpoint = saved.load() + assert checkpoint["version"] == 3 + tasks = [task for task in checkpoint["pending"].load()["tasks"] if task["metadata"].get("returned")] + assert len(tasks) == len(groups) + pending = {spec.task_id: spec.input_ref for spec in source_factory.controller.queue.pending_tasks()} + for group, spec in zip(groups, tasks, strict=True): + task_id = group[0]._queue_lease["task_id"] + assert spec["task_id"] == task_id + assert spec["input_ref"].manifest == pending[task_id] + source_factory.controller.reader_state("snapshot", saved.manifest) + reader.close() + source_factory.controller.store.seal() + source_factory.controller.queue.collect_garbage() + restored = source_factory("compact-buffer") + restored.load_state_dict(saved) + assert all(sample.tokens == list(range(8192)) for group in restored.get_samples(32) for sample in group) + + +def test_pending_checkpoint_reuses_inputs_without_reserving_leases(source_factory): + reader = source_factory("lease-snapshot") + group = reader.get_samples(1)[0] + group[0].tokens, group[0].response_length = [1, 7, 9], 2 + reader.add_samples([group]) + saved = reader.state_dict() + reader.close() + restored = source_factory("lease-snapshot") + restored.load_state_dict(saved) + assert not restored._leases + saved_again = restored.state_dict() + assert saved_again.load()["pending"].load()["tasks"] == saved.load()["pending"].load()["tasks"] + restored.load_state_dict(saved_again) + returned = restored.get_samples(1)[0] + assert returned[0].tokens == [1, 7, 9] + assert returned[0]._queue_lease != group[0]._queue_lease + + +def test_legacy_reader_local_checkpoint_requires_explicit_migration(source_factory): + from vime.utils.rollout_transport import pack_rollout_payload + + reader = source_factory("legacy-buffer") + saved = pack_rollout_payload({"version": 1, "buffer": [], "metadata": {}}, reader.args, -1) + with pytest.raises(ValueError, match="require migration"): + reader.load_state_dict(saved) + + +def test_slow_task_acquire_does_not_block_async_generation(source_factory): + entered, release = threading.Event(), threading.Event() + + def take(reader, count): + entered.set() + if not release.wait(10): + raise TimeoutError("test did not release task acquire") + return source_factory.controller.take(reader, count) + + source = source_factory(0, take) + + async def exercise(): + task = asyncio.create_task(source.get_samples_async(1)) + try: + for _ in range(100): + if entered.is_set(): + break + await asyncio.sleep(0.01) + assert entered.is_set() + assert not task.done() + finally: + release.set() + assert len(await task) == 1 + + asyncio.run(exercise()) + + +def test_shuffle_does_not_reseed_user_random_state(source_factory): + source = source_factory(0) + state = random.getstate() + source.get_samples(20) + assert random.getstate() == state + + +@pytest.fixture +def scheduler_factory(monkeypatch): + import ray + + from vime.rollout.filter_hub.base_types import DynamicFilterOutput + from vime.rollout.fully_async_distributed import RolloutScheduler, _GenerationActor + from vime.utils.async_utils import get_async_loop + + schedulers, actors, releases = [], [], [] + loop = get_async_loop().loop + + def remote(method): + return SimpleNamespace(remote=lambda *args: asyncio.run_coroutine_threadsafe(method(*args), loop)) + + def ray_wait(refs, num_returns, timeout): + done, pending = wait(refs, timeout=timeout, return_when=FIRST_COMPLETED) + return list(done)[:num_returns], list(pending) + + def kill(worker, no_restart=True): + if worker.actor.producer is not None: + loop.call_soon_threadsafe(worker.actor.producer.cancel) + + monkeypatch.setattr(ray, "wait", ray_wait) + monkeypatch.setattr(ray, "get", lambda ref: ref.result()) + monkeypatch.setattr(ray, "cancel", lambda ref: ref.cancel()) + monkeypatch.setattr(ray, "kill", kill) + + def create( + batch, + capacities, + *, + slow_worker=None, + reject=lambda index: False, + crash=False, + failed_workers=(), + keep_when_insufficient=False, + ): + release = threading.Event() + releases.append(release) + submitted, workers = [], [] + args = SimpleNamespace( + rollout_batch_size=batch, + rollout_sample_filter_path=None, + rollout_all_samples_process_path=None, + rollout_data_transport="object-store", + ) + + def make_generator(worker): + async def execute(rollout_id, group=None): + index = len(submitted) + submitted.append((worker, index)) + if worker == slow_worker: + while not release.is_set(): + await asyncio.sleep(0.01) + if crash: + raise RuntimeError("generation failed") + if worker in failed_workers: + raise ray.exceptions.RayActorError("worker process exited") + group = [Sample(index=index, group_index=index, metadata={"worker": worker})] + return group, DynamicFilterOutput( + keep=not reject(index), + reason="test", + keep_when_insufficient=keep_when_insufficient, + ) + + return execute + + async def initial_groups(count): + return [[None] for _ in range(count)] + + for i, capacity in enumerate(capacities): + actor = _GenerationActor.__new__(_GenerationActor) + actor.capacity = capacity + actor.producer = None + actor.running = False + actor._generate_group = make_generator(i) + actor.data_source = SimpleNamespace(get_samples_async=initial_groups) + actors.append(actor) + workers.append( + SimpleNamespace( + start=remote(actor.start), + next=remote(actor.next), + pause=remote(actor.pause), + actor=actor, + ) + ) + scheduler = RolloutScheduler(args, workers, capacities) + schedulers.append(scheduler) + return scheduler, submitted, release + + yield create + for release in releases: + release.set() + for scheduler in schedulers: + scheduler.close() + + async def cleanup(): + producers = [actor.producer for actor in actors if actor.producer is not None] + for producer in producers: + producer.cancel() + await asyncio.gather(*producers, return_exceptions=True) + + asyncio.run_coroutine_threadsafe(cleanup(), loop).result(timeout=10) + + +def test_scheduler_replaces_rejected_groups_and_bounds_prefetch(scheduler_factory): + scheduler, submitted, _ = scheduler_factory(17, [3, 3], reject=lambda i: i % 3 == 0) + result = scheduler.generate(0, prefetch=6) + assert len(result.samples) == 17 + assert all(group[0].index % 3 for group in result.samples) + time.sleep(0.05) + # Collector window + local slots + one queued output per worker. + assert len(submitted) <= 17 + result.metrics["rollout/dynamic_filter/dropped_groups"] + 6 + 6 + 2 + scheduler.pause() + assert not scheduler.pending and not scheduler.running_workers + + +def test_rejected_groups_are_released_while_filling_batch(scheduler_factory, monkeypatch): + import ray + + samples = [] + + def reject(index): + if index == 512: + assert sum(ref() is not None for ref in samples) < 8 + return index < 512 + + scheduler, _, _ = scheduler_factory(1, [1], reject=reject) + get = ray.get + + def record(ref): + output = get(ref) + if output is not None: + samples.append(weakref.ref(output[0][0])) + return output + + monkeypatch.setattr(ray, "get", record) + result = scheduler.generate(0, prefetch=1) + assert result.samples[0][0].index == 512 + assert result.metrics == { + "rollout/dynamic_filter/drop_test": 512, + "rollout/dynamic_filter/dropped_groups": 512, + "rollout/dynamic_filter/dropped_ratio": 512 / 513, + } + + +def test_distributed_rollout_rejects_all_samples_hook_before_starting_workers(): + from vime.rollout.fully_async_distributed import DistributedRollout + + args = SimpleNamespace(rollout_all_samples_process_path="test.all_samples") + with pytest.raises(ValueError, match="rollout-all-samples-process-path.*not supported"): + DistributedRollout(args, None) + + +def test_async_scheduler_fast_worker_fills_batch_without_waiting_for_slow_worker( + scheduler_factory, +): + scheduler, submitted, release = scheduler_factory(5, [1, 1], slow_worker=0, reject=lambda i: i > 5) + with ThreadPoolExecutor(1) as consumer: + future = consumer.submit(scheduler.generate, 0, prefetch=2) + try: + result = future.result(timeout=5) + assert len(result.samples) == 5 + assert all(group[0].metadata["worker"] == 1 for group in result.samples) + time.sleep(0.1) + # Even rejected prefetches occupy the bounded completion queue. + assert len(submitted) <= 11 + assert len(scheduler.pending) + len(scheduler.ready) <= 2 + finally: + release.set() + + +@pytest.mark.parametrize("control", ["start", "pause"]) +def test_slow_worker_control_does_not_block_collection_or_close(scheduler_factory, control): + scheduler, _, _ = scheduler_factory(3, [1, 1]) + stalled = Future() + entered = threading.Event() + original = getattr(scheduler.workers[0], control).remote + + def stall(*args): + entered.set() + return stalled + + if control == "pause": + scheduler.generate(0, prefetch=2) + getattr(scheduler.workers[0], control).remote = stall + with ThreadPoolExecutor(2) as callers: + request = ( + callers.submit(scheduler.generate, 0, prefetch=2) + if control == "start" + else callers.submit(scheduler.pause) + ) + closing = None + try: + assert entered.wait(5) + if control == "start": + result = request.result(timeout=2) + assert all(group[0].metadata["worker"] == 1 for group in result.samples) + closing = callers.submit(scheduler.close) + closing.result(timeout=2) + if control == "pause": + with pytest.raises(RuntimeError, match="scheduler is closed"): + request.result(timeout=2) + finally: + # Also let the old, blocking implementation exit after test failure. + if not stalled.done(): + stalled.set_result(None) + getattr(scheduler.workers[0], control).remote = original + scheduler.close() + + +def test_fully_async_filter_never_keeps_rejected_groups_to_fill_batch( + scheduler_factory, +): + scheduler, _, _ = scheduler_factory(12, [1], reject=lambda index: index < 16, keep_when_insufficient=True) + result = scheduler.generate(0, prefetch=2) + assert len(result.samples) == 12 + assert all(group[0].index >= 16 for group in result.samples) + assert result.metrics["rollout/dynamic_filter/dropped_groups"] == 16 + + +def test_scheduler_retires_failed_worker_and_survivor_fills_batches(scheduler_factory): + scheduler, _, _ = scheduler_factory(12, [4, 4], failed_workers=(0,)) + first = scheduler.generate(0, prefetch=8) + second = scheduler.generate(1, prefetch=scheduler.capacity) + scheduler.pause() + assert scheduler.capacity == 4 + assert scheduler.capacities == [0, 4] + assert scheduler.error is None + groups = first.samples + second.samples + assert len(groups) == 24 + assert len({group[0].index for group in groups}) == 24 + assert all(group[0].metadata["worker"] == 1 for group in groups) + + +def test_scheduler_reports_all_workers_lost(scheduler_factory): + scheduler, _, _ = scheduler_factory(12, [4, 4], failed_workers=(0, 1)) + with pytest.raises(RuntimeError, match="All rollout workers are unavailable"): + scheduler.generate(0, prefetch=8) + + +def test_scheduler_surfaces_worker_failure(scheduler_factory): + scheduler, _, _ = scheduler_factory(2, [1, 1], crash=True) + with pytest.raises(RuntimeError, match="generation failed"): + scheduler.generate(0, prefetch=0) + + +def test_scheduler_close_unblocks_waiting_consumer(scheduler_factory): + scheduler, _, release = scheduler_factory(1, [1], slow_worker=0) + with ThreadPoolExecutor(1) as consumer: + future = consumer.submit(scheduler.generate, 0, prefetch=1) + try: + deadline = time.monotonic() + 5 + with scheduler.condition: + while not scheduler.pending: + assert time.monotonic() < deadline, "worker was not dispatched" + scheduler.condition.wait(timeout=0.01) + scheduler.close() + with pytest.raises(RuntimeError, match="scheduler is closed"): + future.result(timeout=5) + with pytest.raises(RuntimeError, match="scheduler is closed"): + scheduler.generate(1, prefetch=1) + finally: + release.set() + + +def test_reader_adopts_checkpoint_tensors_from_another_pool(tmp_path, source_factory): + from straw import SharedFilesystemStore + from straw.tensor import publish_tensors + + with SharedFilesystemStore(tmp_path / "source", "source", codecs=("tensor.v1",)) as store: + (reference,) = publish_tensors(store, {"routes": torch.tensor([1, 2])}, submission_id="buffer") + path = Path(reference.path) + reader = source_factory(0) + group = reader.get_samples(1)[0] + group[0].rollout_routed_experts = reference + group[0].response_length = 1 + reader.add_samples([group]) + saved = reader.state_dict() + path.unlink() + checkpoint = saved.load() + saved_group = unpack_rollout_payload(checkpoint["pending"].load()["tasks"][0]["input_ref"]) + assert saved_group[0].rollout_routed_experts.load().tolist() == [1, 2] + transferred = reader.materialize_samples(reader.get_samples(1)) + assert transferred[0][0].rollout_routed_experts.tolist() == [1, 2] + + +@pytest.mark.parametrize("fanout", [False, True]) +def test_distributed_completed_groups_keep_rewards_and_masks(monkeypatch, fanout): + from vime.rollout import fully_async_distributed, vllm_rollout + from vime.rollout.fully_async_distributed import _GenerationActor + + sample = Sample( + index=1, + rollout_id=1, + tokens=[1, 2, 3], + response="answer", + response_length=2, + reward=0.0, + loss_mask=[1, 1], + status=Sample.Status.COMPLETED, + ) + group = [[sample]] if fanout else [sample] + sample._queue_receipt = {"position": 7} + + async def get_samples(count): + assert count == 1 + return [group] + + async def unexpected_generation(*args): + raise AssertionError("completed groups must bypass generation and group reward") + + async def republish_buffered(*args, **kwargs): + return SimpleNamespace(receipt=None) + + monkeypatch.setattr(vllm_rollout, "generate_and_rm_group", unexpected_generation) + monkeypatch.setattr(fully_async_distributed, "publish_rollout_async", republish_buffered) + worker = _GenerationActor.__new__(_GenerationActor) + worker.running = True + worker.args = SimpleNamespace( + partial_rollout=True, + mask_offpolicy_in_partial_rollout=True, + rollout_data_transport="object-store", + _rollout_queue_controller=object(), + ) + worker.dynamic_filter = None + worker.data_source = SimpleNamespace(get_samples_async=get_samples, materialize_samples=lambda value: value) + result, _ = asyncio.run(worker._generate_group(3)) + assert result is group + assert sample.reward == 0.0 + assert sample.loss_mask == [1, 1] + assert sample._queue_receipt == {"position": 7} + + +def test_worker_refills_locally_and_slow_group_does_not_block_results(): + from vime.rollout.fully_async_distributed import _GenerationActor + + worker = _GenerationActor.__new__(_GenerationActor) + worker.capacity = 3 + worker.producer = None + worker.running = False + release = asyncio.Event() + generated = [] + in_flight = 0 + maximum = 0 + + async def generate_group(rollout_id, group=None): + nonlocal in_flight, maximum + index = len(generated) + generated.append(index) + in_flight += 1 + maximum = max(maximum, in_flight) + try: + if index == 0: + await release.wait() + else: + await asyncio.sleep(0) + return index + finally: + in_flight -= 1 + + worker._generate_group = generate_group + claims = [] + + async def initial_groups(count): + claims.append(count) + return [[None] for _ in range(count)] + + worker.data_source = SimpleNamespace(get_samples_async=initial_groups) + + async def exercise(): + await worker._start(0) + try: + first = await asyncio.wait_for(worker._next(), timeout=5) + second = await asyncio.wait_for(worker._next(), timeout=5) + assert first > 0 and second > 0 and first != second + assert claims == [worker.capacity] + assert not release.is_set() + # No manager-issued credits: consuming results lets slots pull more. + received = [first, second] + for _ in range(10): + output = await asyncio.wait_for(worker._next(), timeout=5) + assert output > 0 + received.append(output) + await asyncio.sleep(0.02) + count = len(generated) + await asyncio.sleep(0.02) + assert len(generated) == count # Backpressure stops local refills. + assert count <= 12 + worker.capacity + 1 + assert maximum <= worker.capacity + await worker._pause() + release.set() + drained = [] + while (output := await asyncio.wait_for(worker._next(), timeout=5)) is not None: + drained.append(output) + assert 0 in drained + assert worker.producer.done() + assert len(generated) == count + assert sorted(received + drained) == generated + finally: + worker.producer.cancel() + await asyncio.gather(worker.producer, return_exceptions=True) + + asyncio.run(exercise()) + + +def _skip_rollout_log(*args): + return True + + +def _eval_locally(args, rollout_id, data_source, evaluation=False): + assert evaluation + sample = Sample(index=0, reward=float(args.rollout_batch_size), tokens=[1, 2], response_length=1) + return {"test": {"rewards": [sample.reward], "samples": [sample]}} + + +async def _generate_locally(args, sample, sampling_params): + while Path(args.test_generation_gate).exists(): + await asyncio.sleep(0.01) + await asyncio.sleep(0.01 * (sample.group_index % 3)) + sample.tokens = [sample.index + 1, 2, 3] + sample.response = "answer" + sample.response_length = 2 + sample.loss_mask = [1, 1] + sample.rollout_log_probs = [-0.5, -0.7] + for key, tensor in ( + ("rollout_topk_token_ids", torch.tensor([[2, 4], [3, 5]], dtype=torch.int32)), + ( + "rollout_topk_log_probs", + torch.tensor([[-0.5, -2.0], [-0.7, -2.5]], dtype=torch.float32), + ), + ): + setattr(sample, key, tensor) + sample.status = Sample.Status.COMPLETED + if sample.index % 2 == 0: + sample.multimodal_train_inputs = {"pixel_values": torch.ones(1, 3, 2, 2)} + sample.rollout_routed_experts = torch.full((2, 1, 1), sample.index % 8, dtype=torch.uint8) + if args.test_fanout: + # A real custom hook can construct new Samples, without private queue fields. + children = [ + Sample(**{field.name: copy.deepcopy(getattr(sample, field.name)) for field in fields(Sample)}) + for _ in range(2) + ] + for offset, child in enumerate(children): + child.rollout_id = sample.index + child.index = 2 * sample.index + offset + child.tokens[0] = child.index + 1 + return children + return sample + + +async def _score_fanout(args, samples): + for sample in samples: + assert "reward_calls" not in sample.metadata + sample.metadata["reward_calls"] = 1 + return [float(sample.index % 2) for sample in samples] + + +async def _score_group(args, samples): + assert len(samples) == args.n_samples_per_prompt + assert len({sample.group_index for sample in samples}) == 1 + for sample in samples: + assert "reward_calls" not in sample.metadata, "completed groups must not be scored twice" + sample.metadata["reward_calls"] = 1 + return [float(sample.index % 2) for sample in samples] + + +def _filter_complete_group(args, group): + from vime.rollout.filter_hub.base_types import DynamicFilterOutput + + assert len(group) == args.n_samples_per_prompt + samples = list(iter_samples(group)) + assert len({sample.group_index for sample in samples}) == 1 + assert all(sample.reward is not None and sample.status == Sample.Status.COMPLETED for sample in samples) + return DynamicFilterOutput(keep=True) + + +def _read_training_locally(refs, rank): + import ray + + data = process_rollout_data(refs, rank, 2) + for key in ( + "rollout_routed_experts", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + ): + for ref in data[key]: + value = ref.load() if isinstance(ref, TensorRef) else ref + assert value.shape[0] == 2 + assert torch.isfinite(value).all() + return { + "node_id": ray.get_runtime_context().get_node_id(), + "samples": len(data["tokens"]), + } + + +def _load_queue_checkpoint(args, rollout_id): + from straw.protocol import RecordSetRef + + from vime.utils.rollout_transport import DiskPayloadRef + + path = Path(args.save) / "rollout" / f"queue_state_{rollout_id}.json" + index = json.loads(path.read_text()) + return DiskPayloadRef(RecordSetRef.from_dict(index["manifest"]), index["root"]).load() + + +def _rollout_args(tmp_path, *, fanout=False, transport="straw"): + """Bounded local dataset, tokenizer and R3/SC configuration shared by Ray tests.""" + from tokenizers import Tokenizer + from tokenizers.models import WordLevel + from transformers import PreTrainedTokenizerFast + + tokenizer_dir = tmp_path / "tokenizer" + PreTrainedTokenizerFast( + tokenizer_object=Tokenizer(WordLevel({"[UNK]": 0, "hello": 1}, unk_token="[UNK]")), + unk_token="[UNK]", + ).save_pretrained(tokenizer_dir) + dataset = tmp_path / "data.jsonl" + dataset.write_text("\n".join(json.dumps({"text": f"hello {i}"}) for i in range(7))) + return SimpleNamespace( + rollout_batch_size=4, + data_source_path="vime.rollout.queue_data_source.QueueDataSource", + debug_train_only=False, + test_fanout=fanout, + test_generation_gate=str(tmp_path / "generation-gate"), + hf_checkpoint=str(tokenizer_dir), + prompt_data=str(dataset), + input_key="text", + rollout_max_prompt_len=None, + multimodal_keys=None, + label_key=None, + metadata_key="metadata", + tool_key=None, + apply_chat_template=False, + apply_chat_template_kwargs=None, + rollout_seed=31, + buffer_filter_path=None, + buffer_sort_by_staleness=False, + rollout_shuffle=True, + n_samples_per_prompt=2, + rollout_num_engines=1, + vllm_server_concurrency=24, + over_sampling_batch_size=4, + eval_function_path="test_distributed_rollout._eval_locally", + custom_rollout_log_function_path="test_distributed_rollout._skip_rollout_log", + custom_eval_rollout_log_function_path="test_distributed_rollout._skip_rollout_log", + log_passrate=False, + wandb_always_use_train_step=False, + custom_reward_post_process_path=None, + custom_convert_samples_to_train_data_path=None, + reward_key=None, + advantage_estimator="grpo", + rewards_normalization=True, + grpo_std_normalization=True, + use_score_centering=True, + score_centering_top_k=2, + rollout_top_p=1.0, + use_rollout_routing_replay=True, + rollout_data_transport=transport, + rollout_data_dir=str(tmp_path / "queue"), + use_distributed_post=False, + debug_rollout_only=False, + save_debug_rollout_data=str(tmp_path / "debug" / "rollout_{rollout_id}.pt"), + ci_test=False, + load_debug_rollout_data=None, + save=str(tmp_path / "checkpoint"), + load=None, + dump_details=None, + global_batch_size=8, + micro_batch_size=1, + use_dynamic_batch_size=False, + balance_data=False, + balance_by_flops=False, + num_experts=8, + num_layers=1, + moe_router_topk=1, + use_wandb=False, + use_tensorboard=False, + rollout_function_path="vime.rollout.fully_async_rollout.generate_rollout_fully_async", + rollout_sample_filter_path=None, + rollout_all_samples_process_path=None, + custom_generate_function_path="test_distributed_rollout._generate_locally", + custom_rm_path=( + "test_distributed_rollout._score_fanout" if fanout else "test_distributed_rollout._score_group" + ), + dynamic_sampling_filter_path="test_distributed_rollout._filter_complete_group", + vllm_dp_size=1, + group_rm=not fanout, + partial_rollout=True, + mask_offpolicy_in_partial_rollout=True, + rollout_temperature=1.0, + rollout_top_k=-1, + rollout_max_response_len=2, + rollout_stop=None, + rollout_stop_token_ids=None, + rollout_skip_special_tokens=False, + rollout_sample_hook_path=None, + ) + + +@pytest.mark.parametrize( + "fanout,transport", + [(False, "straw"), (True, "straw")], +) +def test_two_ray_nodes_generate_transfer_and_restore(tmp_path, fanout, transport): + multiplier = 2 if fanout else 1 + import ray + from ray.cluster_utils import Cluster + + from vime.ray.rollout import RolloutManager + + args = _rollout_args(tmp_path, fanout=fanout, transport=transport) + config = { + "dp_size": 2, + "cp_size": 1, + "vpp_size": 1, + "microbatch_group_size_per_vp_stage": 1, + } + node_count = 2 + cluster = Cluster() + source = None + try: + for _ in range(node_count): + cluster.add_node( + num_cpus=2, + num_gpus=0, + object_store_memory=128 * 1024**2, + include_dashboard=False, + ) + ray.init( + address=cluster.address, + runtime_env={ + "env_vars": { + "PYTHONPATH": os.pathsep.join( + [ + str(Path(__file__).parent), + str(Path(__file__).parent.parent), + os.environ.get("PYTHONPATH", ""), + ] + ), + } + }, + ) + from vime.utils.misc import load_function + from vime.utils.rollout_transport import check_rollout_storage + + if transport == "straw": + check_rollout_storage(args) + source = load_function(args.data_source_path)(args) + assert not source.consumers + returned = source.get_samples(1)[0] + if transport == "straw": + for sample in returned: + sample.tokens = [1, 2, 3] + sample.response_length = 2 + sample.loss_mask = [1, 1] + sample.status = Sample.Status.ABORTED + sample.rollout_routed_experts = torch.tensor([[[1]], [[2]]], dtype=torch.int32) + sample.rollout_topk_token_ids = torch.tensor([[1, 3], [2, 4]], dtype=torch.int32).numpy() + sample.rollout_topk_log_probs = torch.tensor([[-0.5, -2], [-0.7, -2.5]]).numpy() + source.add_samples([returned]) + + cls = RolloutManager.__ray_metadata__.modified_class + manager = cls.__new__(cls) + manager.args = args + from vime.utils.misc import load_function + + rollout_function = load_function(args.rollout_function_path) + from vime.rollout.batch_builder import BatchBuilder + + manager.batch_builder = BatchBuilder(args) + manager.set_train_parallel_config(config) + manager.health_monitoring_resume = lambda: None + manager._get_updatable_server = lambda: None + calls = [] + + def custom_rollout(global_args, rollout_id, data_source, evaluation=False): + assert global_args is args + assert global_args.rollout_batch_size == 4 + assert data_source is source + assert not evaluation + calls.append(("rollout", 4)) + output = rollout_function(global_args, rollout_id, data_source, evaluation=evaluation) + if args.rollout_data_transport == "straw": + from vime.utils.rollout_transport import DiskPayloadRef + + assert isinstance(output.samples, DiskPayloadRef) + return output + + manager.generate_rollout = custom_rollout + manager.eval_generate_rollout = load_function(args.eval_function_path) + + def postprocess(global_args, samples): + assert global_args.rollout_batch_size == 4 + assert len(samples) == 8 * multiplier + assert len({sample.group_index for sample in samples}) == 4 + assert sum(sample.multimodal_train_inputs is not None for sample in samples) == 4 * multiplier + calls.append(("rewards", len(samples))) + raw = [sample.reward for sample in samples] + return raw, [value - sum(raw) / len(raw) for value in raw] + + def convert(global_args, samples): + assert global_args is args + assert len(samples) == 8 * multiplier + calls.append(("convert", len(samples))) + manager.batch_builder.custom_convert_samples_to_train_data_func = None + try: + return manager.batch_builder.convert(samples) + finally: + manager.batch_builder.custom_convert_samples_to_train_data_func = convert + + manager.batch_builder.custom_reward_post_process_func = postprocess + manager.batch_builder.custom_convert_samples_to_train_data_func = convert + + def fetch(rollout_id): + manager.data_source = source + previous = len(calls) + refs = manager.generate(rollout_id) + assert calls[previous:] == [ + ("rollout", 4), + ("convert", 8 * multiplier), + ("rewards", 8 * multiplier), + ] + dump = torch.load(tmp_path / "debug" / f"rollout_{rollout_id}.pt", weights_only=False) + assert len(dump["samples"]) == 8 * multiplier + assert all( + isinstance(sample["rollout_routed_experts"], (torch.Tensor, TensorRef)) for sample in dump["samples"] + ) + assert not list((tmp_path / "debug").glob("worker_*")) + if rollout_id == 0: + from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy + + remote_read = ray.remote(_read_training_locally) + nodes = [node for node in ray.nodes() if node["Alive"]] + assert len(nodes) == node_count + reports = ray.get( + [ + remote_read.options( + num_cpus=0, + scheduling_strategy=NodeAffinitySchedulingStrategy(node["NodeID"], soft=False), + ).remote(refs, i % 2) + for i, node in enumerate(nodes) + ] + ) + assert {report["node_id"] for report in reports} == {node["NodeID"] for node in nodes} + assert all(report["samples"] == 4 * multiplier for report in reports) + batches = [process_rollout_data(refs, rank, 2) for rank in range(2)] + rows = {} + multimodal_samples = 0 + for batch in batches: + assert isinstance(batch["rollout_mask_sums"], torch.Tensor) + assert batch["rollout_mask_sums"].tolist() == [2.0 * multiplier] * (4 * multiplier) + for i, index in enumerate(batch["sample_indices"]): + if batch["multimodal_train_inputs"][i] is not None: + multimodal_samples += 1 + assert batch["multimodal_train_inputs"][i]["pixel_values"].shape == (1, 3, 2, 2) + assert index not in rows + assert isinstance( + batch["rollout_routed_experts"][i], + (torch.Tensor, TensorRef), + ) + rows[index] = batch["tokens"][i].tolist() + assert rows[index][0] == index + 1 + assert len(rows) == 8 * multiplier + assert multimodal_samples == 4 * multiplier + # Mock trainer completion after every rank has read the same batch. + # This tests runtime capacity only, not model/optimizer checkpointing. + manager.training_completed(rollout_id) + if transport == "straw": + assert ray.get(source.controller.metrics.remote())["usage_with_reservations"]["ready_bytes"] == 0 + return rows + + first = fetch(0) + assert source.get_buffer_length() == 0 # Workers claimed the owner's returned group. + runtime = source.consumers["fully_async"] + assert len(runtime.workers) == node_count + manager.save(0) + state = _load_queue_checkpoint(args, 0)["consumers"]["fully_async"] + assert len(state["scheduler"]["ready"]) >= 4 + assert all( + sample.metadata["reward_calls"] == 1 + for group, _ in state["scheduler"]["ready"] + for sample in iter_samples(unpack_rollout_payload(group)) + ) + # Claim priority does not impose completion order across workers. After + # checkpointing drains them, the returned group must be in the first + # batch or the saved ready results, but need not be in the second batch. + returned_indices = {multiplier * sample.index + offset for sample in returned for offset in range(multiplier)} + saved_indices = { + sample.index + for group, _ in state["scheduler"]["ready"] + for sample in iter_samples(unpack_rollout_payload(group)) + } + assert returned_indices <= (first.keys() | saved_indices) + expected = fetch(1) + assert not first.keys() & expected.keys() + source.close() + source = None + args.load = args.save + args.rollout_queue_resume = True + source = QueueDataSource(args) + source.data_config["n_samples_per_prompt"] += 1 + with pytest.raises(ValueError, match="n_samples_per_prompt"): + source.load(0) + source.data_config["n_samples_per_prompt"] -= 1 + source.load(0) + if transport == "straw": + manager.batch_builder.load(0) + assert not source.consumers # Execution state is restored when fully async starts. + source.save(0) # Saving before the first generate must preserve the restored queue. + actual = fetch(1) + assert actual == expected + restored_group = source.get_samples(1)[0] + assert not {sample.index for sample in restored_group} & (first.keys() | actual.keys()) + manager.eval(1) + evaluation = torch.load(tmp_path / "debug" / "rollout_eval_1.pt", weights_only=False) + assert [sample["reward"] for sample in evaluation["samples"]] == [4.0] # Eval keeps the original global quota. + + # Lose an actor (or a whole non-manager Ray node) while both have work. + runtime = source.consumers["fully_async"] + runtime.pause() + runtime.ready.clear() + gate = Path(args.test_generation_gate) + gate.touch() + runtime.resume() + with ThreadPoolExecutor(1) as consumer: + future = consumer.submit(fetch, 2) + try: + deadline = time.monotonic() + 10 + with runtime.condition: + while len(runtime.pending) != node_count: + assert time.monotonic() < deadline, "all workers should receive work" + runtime.condition.wait(timeout=0.01) + failed = 0 + if fanout: + node = next(node for node in cluster.list_all_nodes() if node != cluster.head_node) + nodes = sorted( + ray.nodes(), + key=lambda node: (node["NodeManagerAddress"], node["NodeID"]), + ) + failed = next(i for i, item in enumerate(nodes) if item["NodeID"] == node.node_id) + cluster.remove_node(node, allow_graceful=False) + else: + ray.kill(runtime.workers[failed], no_restart=True) + deadline = time.monotonic() + 30 + with runtime.condition: + while runtime.capacities[failed] and runtime.error is None: + assert time.monotonic() < deadline, "failed worker was not retired" + runtime.condition.wait(timeout=0.01) + assert runtime.error is None + finally: + gate.unlink() + after_failure = future.result(timeout=30) + runtime.pause() + assert runtime.capacities[failed] == 0 + assert runtime.capacity == 12 - 12 // node_count + assert runtime.error is None + assert not after_failure.keys() & actual.keys() + source.save(2) + saved = _load_queue_checkpoint(args, 2) + assert saved["consumers"]["fully_async"]["readers"][failed] is None + assert len(fetch(3)) == 8 * multiplier + + if not fanout: + # A restart with the same topology preserves retired worker slots. + source.close() + source = QueueDataSource(args) + source.load(2) + assert len(fetch(3)) == 8 + runtime = source.consumers["fully_async"] + assert runtime.capacities[failed] == 0 + assert runtime.capacity == 12 - 12 // node_count + runtime.pause() + runtime.ready.clear() + for i, worker in enumerate(runtime.workers): + if runtime.capacities[i]: + ray.kill(worker, no_restart=True) + runtime.resume() + # Killed workers can still have committed results in the journal. + # Wait for death notifications before checking exhausted capacity; + # an earlier generate may correctly deliver a recovered warm batch. + with runtime.condition: + deadline = time.monotonic() + 30 + while runtime.error is None and time.monotonic() < deadline: + runtime.condition.wait(timeout=0.1) + assert runtime.capacity == 0 + with pytest.raises(RuntimeError, match="All rollout workers are unavailable"): + runtime.generate(4, prefetch=runtime.capacity) + finally: + if source is not None: + source.close() + ray.shutdown() + cluster.shutdown() + + +def test_filtered_accepts_and_lost_delivery_are_accounted(source_factory, monkeypatch): + from vime.rollout.base_types import finalize_rollout_groups + from vime.utils import misc + from vime.utils.rollout_transport import DiskPayloadRef, pack_rollout_group, pack_rollout_payload + + source = source_factory("worker") + controller = source_factory.controller + groups = source.get_samples(4) + refs = [pack_rollout_group(group, source.args, 0) for group in groups[:3]] + source.args.rollout_sample_filter_path = "test.filter" + monkeypatch.setattr(misc, "load_function", lambda _: lambda args, groups: groups.pop(0)) + output = finalize_rollout_groups(source.args, 0, refs[:2]) + assert len(output.samples.load()) == 1 + assert controller.training_state()["processed_cursor"] == 1 + # One reply arrived; another was accepted but the process died before replying. + delivered = pack_rollout_payload([refs[1].receipt.position], source.args, 0) + recovered = controller.recover_reader_results("worker", delivered.manifest) + receipts = DiskPayloadRef(recovered, source.args.rollout_data_dir).load() + assert [r["position"] for r in receipts] == [refs[2].receipt.position] + assert controller.status(groups[3][0]._queue_lease["task_id"])["state"] == "pending" + from straw.errors import StaleAttempt + + with pytest.raises(StaleAttempt): + pack_rollout_group(groups[3], source.args, 0) + assert len(controller.queue.read_commits().commits) == 3 + from vime.utils.rollout_transport import discard_rollout_group, load_rollout_samples + + # A previously accepted warm group can fail a later dynamic filter too. + discard_rollout_group(load_rollout_samples([refs[2]])[0], source.args, "warm group rejected") + assert controller.queue._usage()["accepted_unprocessed"]["records"] == 1 + assert len(controller.queue.read_commits().commits) == 3 + + +def test_fresh_samples_inherit_authorization_and_reject_conflicting_hooks( + source_factory, +): + from vime.utils.rollout_transport import inherit_queue_context, pack_rollout_group + + source = source_factory("worker") + group = source.get_samples(1)[0] + children = [[Sample(index=2 * parent.index + i, tokens=[1, 2]) for i in range(2)] for parent in group] + for parent, output in zip(group, children, strict=True): + parent.queue_generation_requests = [{"sampling_params": {"temperature": 0.7}}] + inherit_queue_context(parent, output) + ref = pack_rollout_group(children, source.args, 0) + assert ref.receipt is not None + assert all( + s.queue_generation_requests[0]["sampling_params"]["temperature"] == 0.7 for s in iter_samples(ref.load()) + ) + children[0][0]._queue_lease = {"task_id": "another-task"} + with pytest.raises(ValueError, match="different queue task authorization"): + inherit_queue_context(group[0], children) + + +def _restore_source_fixture(controller, payload): + store, codec = controller.store, controller.codec + source = codec.publish({"old_buffer": payload}, submission_id="old-source") + state = codec.publish( + dict( + version=1, + fetch_cursor=0, + processed_cursor=0, + processed_positions=[], + batches=[], + finished_batches=[], + ), + submission_id="old-training-state", + ) + store.retain("checkpoint:old", [source, state]) + store.release_publications([source, state]) + store.seal() + controller.restore_training_state(state, 0, 0, retained_ref=source) + return source, state + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_restore_source_handoff_releases_obsolete_snapshot_graphs(source_factory): + from dataclasses import asdict + + from vime.utils.rollout_transport import DiskPayloadRef + + controller = source_factory.controller + store, codec = controller.store, controller.codec + old = codec.publish({"tensor": torch.arange(4096)}, submission_id="obsolete") + old_pack = store.backend.root / old.manifest.segment.path + store.seal() + source, saved_state = _restore_source_fixture(controller, DiskPayloadRef(old, str(store.backend.root))) + restored = controller._training_state() + decision = restored["disposition"].load() + assert decision["checkpoint_state"] == asdict(saved_state) + assert decision["retained_source"] == asdict(source) + assert restored["restored_source"].manifest == source + # Replacing the latest filter audit must not drop recovery ownership. + filtered = codec.publish({"reason": "filter", "positions": []}, submission_id="filter") + controller.record_dispositions(filtered) + assert controller._training_state()["restored_source"].manifest == source + for index in range(2): + batch = f"completed-{index}" + plan = codec.publish({"batch_id": batch}, submission_id=f"plan-{index}") + controller.plan_batch(batch, [], plan) + ready = codec.publish({"batch_id": batch}, submission_id=f"ready-{index}") + controller.ready_batch(batch, ready) + controller.finish_batch(batch) + replacement = codec.publish({"buffer": []}, submission_id="empty-source") + store.retain("checkpoint:new", [replacement]) + store.release_publications([replacement]) + controller.handoff_restored_source(replacement) + store.seal() + controller._collect_storage() + assert old_pack.exists() # The original checkpoint has not been retired. + store.release("checkpoint:old") + result = controller._collect_storage() + assert result["reclaimed_files"] > 0 + assert not old_pack.exists() + state = controller._training_state() + assert state["restored_source"].manifest == replacement + assert state["batches"] == [] + assert state["disposition"].load() == {"reason": "filter", "positions": []} + + +@pytest.mark.parametrize("failure", [None, "retain", "report", "persist"]) +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_restore_source_handoff_preserves_lazy_consumers_and_failed_save( + source_factory, monkeypatch, tmp_path, failure +): + import json + from dataclasses import asdict + + import straw.reporting + + from vime.rollout.queue_data_source import QueueDataSource + from vime.utils.rollout_transport import DiskPayloadRef + + controller = source_factory.controller + store, codec = controller.store, controller.codec + source = source_factory("owner") + source.__class__ = QueueDataSource + source._owns_controller = False + source.data_config = {"test": True} + source.args.save = str(tmp_path / "checkpoints") + source.consumers = {} + prefix = codec.publish({"r3": torch.arange(4096)}, submission_id="lazy-prefix") + prefix_pack = store.backend.root / prefix.manifest.segment.path + store.seal() + source._restored_consumers = {"not_started": {"prefix": DiskPayloadRef(prefix, str(store.backend.root))}} + original, _ = _restore_source_fixture(controller, source._restored_consumers) + source.args._queue_restored_source_ref = original + before = controller.training_state() + with monkeypatch.context() as patch: + + def fail(*args, **kwargs): + raise RuntimeError("injected save failure") + + if failure == "retain": + patch.setattr(store, "retain", fail) + elif failure == "report": + patch.setattr(straw.reporting, "write_report", fail) + elif failure == "persist": + patch.setattr(controller._queue, "save_consumer_state", fail) + if failure: + with pytest.raises(RuntimeError, match="injected save failure"): + source.save(0) + assert controller.training_state() == before + store.seal() + controller._collect_storage() + assert prefix_pack.exists() + source.save(1) + saved = json.loads((Path(source.args.save) / "rollout/queue_state_1.json").read_text()) + assert asdict(controller._training_state()["restored_source"].manifest) == saved["manifest"] + store.release("checkpoint:old") + store.seal() + controller._collect_storage() + current = controller._training_state()["restored_source"].load() + assert current["consumers"].keys() == {"not_started"} + actual = current["consumers"]["not_started"]["prefix"].load()["r3"] + torch.testing.assert_close(actual, torch.arange(4096), rtol=0, atol=0) + # Once the lazy consumer has consumed its buffer, a subsequent cut drops it. + source._restored_consumers = {} + source.save(2) + store.release(saved["storage_owner"]) + if failure in {"persist", None}: + # A failed prepared snapshot remains protected until explicitly retired. + store.seal() + controller._collect_storage() + assert prefix_pack.exists() == (failure == "persist") + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_restore_source_handoff_preserves_prefix_before_later_accepted_result( + source_factory, +): + from vime.rollout.queue_data_source import QueueDataSource + from vime.utils.rollout_transport import pack_rollout_group + + controller = source_factory.controller + reader = source_factory("old-reader") + group = reader.get_samples(1)[0] + for sample in group: + sample.tokens = [7, 8, 9] + sample.response_length = 2 + sample.status = Sample.Status.ABORTED + sample.rollout_routed_experts = torch.arange(8).reshape(2, 2, 2) + reader.add_samples([group]) + prefix = reader.state_dict() + original, _ = _restore_source_fixture(controller, prefix) + later = reader.get_samples(1)[0] + for sample in later: + sample.tokens = [7, 8, 9, 10] + sample.response_length = 3 + sample.rollout_routed_experts = torch.arange(12).reshape(3, 2, 2) + accepted = pack_rollout_group(later, reader.args, 0) + restored = source_factory("owner") + restored.__class__ = QueueDataSource + restored._owns_controller = False + restored.data_config = {"test": True} + restored.consumers, restored._restored_consumers = {}, {} + restored.args.save = str(Path(reader.args.rollout_data_dir) / "saved") + restored.args._queue_restored_source_ref = original + restored.load_state_dict(prefix) + pending = controller.queue.pending_tasks()[0] + assert pending.metadata["source_positions"] == [accepted.receipt.position] + assert not restored._leases + restored.save(0) + controller.store.release("checkpoint:old") + controller.store.seal() + controller._collect_storage() + current = controller._training_state()["restored_source"].load() + checkpoint = current["reader"].load()["pending"].load()["tasks"][0]["input_ref"].load() + assert [sample.tokens for sample in checkpoint] == [[7, 8, 9]] * 2 + for sample in checkpoint: + torch.testing.assert_close( + sample.rollout_routed_experts.load(), + torch.arange(8).reshape(2, 2, 2), + rtol=0, + atol=0, + ) + + +def test_rebuffer_traffic_is_linear_and_continuations_survive_worker_loss( + source_factory, +): + from vime.utils.rollout_transport import pack_rollout_payload + + reader = source_factory("buffered") + groups = reader.get_samples(64) + for group in groups: + for sample in group: + sample.tokens = [1, 2, 3] + sample.response_length = 2 + sample.status = Sample.Status.ABORTED + root = Path(reader.args.rollout_data_dir) + + def size(): + return sum(p.stat().st_size for p in root.rglob("*.pack")) + + before = size() + for group in groups[:32]: + reader.add_samples([group]) + halfway = size() - before + for group in groups[32:]: + reader.add_samples([group]) + assert size() - before < halfway * 2.5 + controller = source_factory.controller + delivered = pack_rollout_payload([], reader.args, -1) + controller.recover_reader_results("buffered", delivered.manifest) + replacement = source_factory("replacement") + resumed = replacement.get_samples(64) + assert all(sample.tokens == [1, 2, 3] for group in resumed for sample in group) + + +def test_cancelled_rebuffer_waiter_does_not_drop_its_continuation(): + from vime.rollout.fully_async_distributed import _GenerationActor + + actor = _GenerationActor.__new__(_GenerationActor) + persisted = [] + actor.data_source = SimpleNamespace(add_samples=lambda groups: persisted.append(groups)) + + async def exercise(): + waiters = [asyncio.create_task(actor._rebuffer(i)) for i in range(130)] + await asyncio.sleep(0.001) + waiters[0].cancel() + await asyncio.gather(*waiters, return_exceptions=True) + assert sorted(group for batch in persisted for group in batch) == list(range(130)) + assert all(len(batch) <= 64 for batch in persisted) + assert len(persisted) == 3 + + asyncio.run(exercise()) + + +def test_reader_retries_when_another_reader_takes_the_just_refilled_batch( + source_factory, +): + from straw.protocol import AcquireResult + + calls = [] + controller = source_factory.controller + + def contested_take(reader_id, count): + calls.append(count) + if len(calls) == 1: + return AcquireResult("empty") + return controller.take(reader_id, count) + + reader = source_factory("contended", take=contested_take) + groups = reader.get_samples(64) + assert len(groups) == 64 + assert len({group[0].group_index for group in groups}) == 64 + assert calls[:2] == [64, 64] + + +@pytest.mark.parametrize("timeout", [False, True]) +def test_shutdown_allows_large_buffers_and_propagates_deadline_failure(monkeypatch, timeout): + import ray + + from vime.rollout.fully_async_distributed import DistributedRollout, RolloutScheduler + + scheduler = DistributedRollout.__new__(DistributedRollout) + scheduler.workers = [SimpleNamespace(close=SimpleNamespace(remote=lambda i=i: i)) for i in range(2)] + scheduler.capacities = [4096, 4096] + monkeypatch.setattr(RolloutScheduler, "close", lambda self: None) + clock = iter([100, 100, 140]) + monkeypatch.setattr( + "vime.rollout.fully_async_distributed.time", + SimpleNamespace(monotonic=lambda: next(clock)), + ) + waits, killed = [], [] + + def get(ref, *, timeout): + waits.append(timeout) + if fail: + raise ray.exceptions.GetTimeoutError("unfinished durable writes") + assert timeout > 40 # A valid large flush exceeds the old 30-second limit. + + fail = timeout + monkeypatch.setattr(ray, "get", get) + monkeypatch.setattr(ray, "kill", lambda actor, **kwargs: killed.append(actor)) + if timeout: + with pytest.raises(ray.exceptions.GetTimeoutError): + scheduler.close() + else: + scheduler.close() + assert waits == [300, 260] + assert killed == scheduler.workers + + +@pytest.mark.parametrize("source_factory", [True], indirect=True) +def test_online_gc_waits_for_training_completion_and_checkpoint_release(source_factory): + from straw import SharedFilesystemStore + from straw.protocol import RecordSetRef + from straw.tensor import publish_tensors + + from vime.rollout.queue_codec import CODECS, SampleCodec + from vime.utils.rollout_transport import DiskPayloadRef + + controller = source_factory.controller + assignment = controller.take("worker", 1).assignments[0] + lease = assignment.lease + writer = SharedFilesystemStore(controller.store.backend.root, controller.store.run_id, codecs=CODECS) + tensor = publish_tensors(writer, {"routes": torch.arange(1024)}, submission_id="routes")[0] + raw = SampleCodec(writer).publish( + {"routes": tensor}, + submission_id="result", + metadata={"task_id": lease.task_id, "attempt_id": lease.attempt_id}, + ) + receipt = controller.complete(lease, raw) + writer.seal() + plan = controller.codec.publish({"raw": DiskPayloadRef(raw, str(writer.backend.root))}, submission_id="plan") + controller.plan_batch("batch", [receipt.position], plan) + ready = controller.codec.publish( + {"batch_id": "batch", "raw": DiskPayloadRef(raw, str(writer.backend.root))}, + submission_id="ready", + ) + controller.ready_batch("batch", ready) + controller._collect_storage() + assert torch.equal(tensor.load(), torch.arange(1024)) + state = RecordSetRef.from_dict(controller.training_state()["state_ref"]) + controller.store.retain("checkpoint:test", [state]) + controller.finish_batch("batch") + assert controller._training_state()["batches"] == [] + assert torch.equal(tensor.load(), torch.arange(1024)) + controller.store.release("checkpoint:test") + report = controller._collect_storage() + assert report["reclaimed_files"] >= 1 + assert not Path(tensor.path).exists() + + +@pytest.mark.parametrize("failure", [None, "cursor", "persist"]) +def test_joint_restore_starts_gc_only_after_consumer_state_is_durable(tmp_path, monkeypatch, failure): + from straw.errors import StorageUnavailable + + args = SimpleNamespace( + rollout_data_transport="straw", + rollout_data_dir=str(tmp_path), + rollout_queue_online_gc=True, + _joint_resume={"rollout_id": 0}, + ) + controller = RolloutQueueController(args) + try: + assert controller._gc_thread is None + state = dict( + version=1, + fetch_cursor=0, + processed_cursor=0, + processed_positions=[], + batches=[], + finished_batches=[], + ) + ref = controller.codec.publish(state, submission_id="saved-training") + save = controller._queue.save_consumer_state + persisted = [] + + def save_before_gc(*a, **kw): + assert controller._gc_thread is None + if failure == "persist": + raise StorageUnavailable("injected storage failure") + result = save(*a, **kw) + persisted.append(result) + return result + + monkeypatch.setattr(controller._queue, "save_consumer_state", save_before_gc) + if failure: + with pytest.raises((ValueError, StorageUnavailable)): + controller.restore_training_state(ref, 1 if failure == "cursor" else 0, 0) + assert controller._gc_thread is None + assert not persisted + else: + restored = controller.restore_training_state(ref, 0, 0) + assert persisted and controller.codec.load(restored) == state + assert controller._gc_thread.is_alive() + thread = controller._gc_thread + controller._start_gc() + assert controller._gc_thread is thread + finally: + controller.close() + + +@pytest.mark.parametrize("error_name", ["UnsafeRecovery", "StorageUnavailable"]) +def test_background_gc_failure_stops_queue_work_and_fails_close(tmp_path, monkeypatch, error_name): + from straw import Record, TaskSpec, errors + + args = SimpleNamespace(rollout_data_transport="straw", rollout_data_dir=str(tmp_path)) + controller = RolloutQueueController(args) + queue = controller.queue + ref = controller.store.publish([Record("input", b"still protected")], submission_id="input") + queue.submit_tasks("input", [TaskSpec("prompt:0", input_ref=ref)]) + lease = queue.acquire("reader").assignments[0].lease + controller.store.seal() + before = queue.journal.path.read_bytes() + error = getattr(errors, error_name)("injected GC failure") + calls = [] + + def fail(): + calls.append("collect") + raise error + + monkeypatch.setattr(queue, "collect_garbage", fail) + monkeypatch.setattr(controller._gc_stop, "wait", lambda timeout: False) + controller._gc_thread = threading.Thread(target=controller._gc_loop) + controller._gc_thread.start() + controller._gc_thread.join(5) + assert not controller._gc_thread.is_alive() + assert controller._gc_stop.is_set() and calls == ["collect"] + + for operation in ( + controller.configuration, + controller.identity, + lambda: controller.take("another-reader", 1), + lambda: controller.complete(lease, ref), + lambda: controller.return_groups([{"lease": lease, "input_ref": ref}], []), + lambda: controller.finish_batch("batch"), + controller.training_state, + lambda: controller._save_training_state({}), + ): + with pytest.raises(RuntimeError, match="GC failed") as raised: + operation() + assert raised.value.__cause__ is error + assert queue.journal.path.read_bytes() == before + closed = [] + original_close = queue.close + + def close_queue(): + original_close() + closed.append(True) + + monkeypatch.setattr(queue, "close", close_queue) + with pytest.raises(RuntimeError, match="GC failed") as raised: + controller.close() + assert raised.value.__cause__ is error and closed == [True] + assert next(controller.store.read(ref)).payload == b"still protected" + + +def test_admission_pause_acknowledges_workers_without_waiting_for_responses( + scheduler_factory, +): + scheduler, submitted, release = scheduler_factory(1, [1, 1], slow_worker=0) + scheduler.generate(0, prefetch=2) + with ThreadPoolExecutor(1) as pool: + paused = pool.submit(scheduler.pause, drain=False) + try: + assert paused.result(timeout=3) is False + assert not release.is_set() + assert all(not worker.actor.running for worker in scheduler.workers) + count = len(submitted) + finally: + release.set() + assert scheduler.pause() is True + assert len(submitted) == count + assert not scheduler.pending and not scheduler.running_workers + scheduler.resume() + assert len(scheduler.generate(1, prefetch=2).samples) == 1 + + +def test_queue_fetch_finishing_after_pause_preserves_group_without_new_generation( + monkeypatch, +): + from vime.rollout import vllm_rollout + from vime.rollout.fully_async_distributed import _GenerationActor + + async def run(): + entered, release = asyncio.Event(), asyncio.Event() + sample = Sample(tokens=[1, 2], status=Sample.Status.ABORTED) + sample._queue_lease = {"task_id": "borrowed"} + group = [sample] + buffered = [] + + async def fetch(count): + entered.set() + await release.wait() + return [group] + + async def rebuffer(value): + buffered.append(value) + + async def unexpected(*args): + raise AssertionError("Paused worker admitted a generation request") + + monkeypatch.setattr(vllm_rollout, "generate_and_rm_group", unexpected) + worker = _GenerationActor.__new__(_GenerationActor) + worker.running = True + worker.data_source = SimpleNamespace(get_samples_async=fetch) + worker._rebuffer = rebuffer + task = asyncio.create_task(worker._generate_group(0)) + await entered.wait() + await worker._pause() + release.set() + assert await task is None + assert buffered == [group] + assert sample._queue_lease == {"task_id": "borrowed"} + assert sample.tokens == [1, 2] + + asyncio.run(run()) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_fully_async_rollout.py b/tests/test_fully_async_rollout.py index ebc79ff35..eeeadd699 100644 --- a/tests/test_fully_async_rollout.py +++ b/tests/test_fully_async_rollout.py @@ -92,7 +92,7 @@ def test_rollout_takes_target_groups_and_leaves_surplus_queued(monkeypatch): worker.output_queue.put((gid, _make_group(gid))) monkeypatch.setattr(fa, "_get_global_worker", lambda args, data_buffer: worker) - args = SimpleNamespace(rollout_batch_size=4) + args = SimpleNamespace(rollout_batch_size=4, rollout_data_transport="object-store", rollout_sample_filter_path=None) out = asyncio.run(fa._generate_rollout_async(args, rollout_id=0, data_buffer=None)) assert len(out) == 4 @@ -137,14 +137,16 @@ def keep_odd(args, group): monkeypatch.setattr(fa, "load_function", lambda path: keep_odd) args = SimpleNamespace( rollout_batch_size=3, + rollout_data_transport="object-store", + rollout_sample_filter_path=None, dynamic_sampling_filter_path="test.keep_odd", ) result = asyncio.run(fa._generate_rollout_async(args, rollout_id=7, data_buffer=None)) assert [group[0].index for group in result.samples] == [1, 3, 5] - dropped = result.metrics["_dropped_samples"] - assert [group[0].index for group in dropped] == [0, 2, 4] - assert all(sample.remove_sample for group in dropped for sample in group) + assert "_dropped_samples" not in result.metrics + assert result.metrics["rollout/dynamic_filter/dropped_groups"] == 3 + assert result.metrics["rollout/dynamic_filter/dropped_ratio"] == 0.5 assert result.metrics["rollout/dynamic_filter/drop_even"] == 3 assert [gid for gid, _ in worker.get_completed_groups()] == [6, 7] @@ -152,7 +154,7 @@ def keep_odd(args, group): @pytest.mark.unit @pytest.mark.parametrize( "versions,expected", - [(["10"], 1), (["8"], 3), (["3", "10"], 8), ([], None), (["bad"], None), (["11"], None), (["²"], None)], + [(["10"], 0), (["8"], 2), (["3", "10"], 7), ([], None), (["bad"], None), (["11"], None), (["²"], None)], ) def test_sample_staleness_uses_oldest_valid_version(versions, expected): assert sample_staleness(SimpleNamespace(weight_versions=versions), 10) == expected @@ -167,8 +169,8 @@ def test_staleness_metrics_use_serving_snapshot(): ] assert compute_staleness_metrics(samples, 10) == { "staleness/unknown_count": 1, - "staleness/mean": 8.5, - "staleness/max": 9, + "staleness/mean": 7.5, + "staleness/max": 8, } assert compute_staleness_metrics(samples, None) == {} diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index 3a274c7b7..47d6f09f7 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -179,6 +179,15 @@ def test_update_weight_disk_dir_required_for_disk_transport(monkeypatch): def make_vime_validate_args(**overrides): values = dict( + rollout_data_transport="object-store", + rollout_data_dir=None, + rollout_queue_lease_seconds=300, + rollout_queue_max_pending=65536, + rollout_queue_max_inflight=65536, + rollout_queue_segment_mib=256, + rollout_io_concurrency=4, + use_distributed_post=False, + data_source_path=None, eval_config=None, eval_prompt_data=None, kl_coef=0, @@ -262,6 +271,53 @@ def make_vime_validate_args(**overrides): return types.SimpleNamespace(**values) +def test_distributed_fully_async_is_opt_in(monkeypatch): + module = load_vime_arguments_module(monkeypatch) + parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) + defaults = parser.parse_args(["--rollout-batch-size", "1"]) + assert defaults.rollout_function_path == "vime.rollout.vllm_rollout.generate_rollout" + assert defaults.data_source_path is None + assert defaults.rollout_data_transport == "object-store" + assert defaults.rollout_data_dir is None + assert not defaults.rollout_queue_online_gc + path = "vime.rollout.queue_data_source.QueueDataSource" + enabled = parser.parse_args(["--rollout-batch-size", "1", "--data-source-path", path]) + assert enabled.data_source_path == path + + +@pytest.mark.parametrize("transport", ["object-store", "nixl", "straw"]) +def test_rollout_transport_selects_source_and_only_straw_needs_storage(monkeypatch, tmp_path, transport): + module = load_vime_arguments_module(monkeypatch) + parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) + parsed = parser.parse_args(["--rollout-batch-size", "1", "--rollout-data-transport", transport]) + args = make_vime_validate_args(rollout_data_transport=parsed.rollout_data_transport) + if transport == "straw": + with pytest.raises(ValueError, match="--rollout-data-dir or --save"): + module.vime_validate_args(args) + args.save = str(tmp_path) + module.vime_validate_args(args) + if transport == "straw": + assert args.data_source_path == "vime.rollout.queue_data_source.QueueDataSource" + assert args.rollout_data_dir == str(tmp_path / "rollout_data") + else: + assert args.data_source_path == "vime.rollout.data_source.RolloutDataSourceWithBuffer" + assert args.rollout_data_dir is None + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "overrides", + [ + {"rollout_queue_online_gc": True}, + {"rollout_queue_resume": True}, + {"data_source_path": "vime.rollout.queue_data_source.QueueDataSource"}, + ], +) +def test_queue_options_require_straw_transport(monkeypatch, overrides): + module = load_vime_arguments_module(monkeypatch) + with pytest.raises(ValueError, match="requires --rollout-data-transport straw"): + module.vime_validate_args(make_vime_validate_args(**overrides)) + def test_global_dataset_flag_is_removed(monkeypatch): module = load_vime_arguments_module(monkeypatch) parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) diff --git a/tests/test_optional_straw.py b/tests/test_optional_straw.py new file mode 100644 index 000000000..59f66d664 --- /dev/null +++ b/tests/test_optional_straw.py @@ -0,0 +1,141 @@ +"""The default Ray data path remains usable in environments without straw.""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from test_megatron_argument_validation import load_vime_arguments_module, make_vime_validate_args + +NUM_GPUS = 0 + + +@pytest.mark.parametrize("transport", ["object-store", "nixl", "straw"]) +def test_missing_straw_startup_hint(monkeypatch, caplog, transport): + module = load_vime_arguments_module(monkeypatch) + find_spec = module.importlib.util.find_spec + monkeypatch.setattr(module.importlib.util, "find_spec", lambda name: None if name == "straw" else find_spec(name)) + args = make_vime_validate_args(rollout_data_transport=transport) + if transport == "straw": + with pytest.raises(ModuleNotFoundError, match="pip install straw-queue") as raised: + module.vime_validate_args(args) + assert raised.value.name == "straw" + else: + module.vime_validate_args(args) + assert args.rollout_data_transport == transport + assert args.data_source_path == "vime.rollout.data_source.RolloutDataSourceWithBuffer" + assert args.rollout_data_dir is None + assert f"continuing with {transport}" in caplog.text + assert "pip install straw-queue" in caplog.text + + +def _run_default_without_straw(): + import importlib.abc + from types import SimpleNamespace + + class NoStraw(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "straw" or fullname.startswith("straw."): + raise ModuleNotFoundError("straw is not installed", name="straw") + + # Keep the regression effective even when invoked from an installed environment. + sys.meta_path.insert(0, NoStraw()) + + import ray + import torch + + from vime.rollout import fully_async_rollout + from vime.rollout.base_types import finalize_rollout_groups + from vime.rollout.batch_builder import BatchBuilder + from vime.utils.data import process_rollout_data + from vime.utils.rollout_transport import pack_rollout_group, pack_rollout_payload, rollout_store + from vime.utils.types import Sample + + args = SimpleNamespace( + rollout_data_transport="object-store", + rollout_sample_filter_path=None, + custom_reward_post_process_path=None, + custom_convert_samples_to_train_data_path=None, + global_batch_size=2, + micro_batch_size=1, + use_dynamic_batch_size=False, + balance_data=False, + balance_by_flops=False, + ) + groups = [[Sample(index=i, tokens=[i + 1, i + 2], response_length=1)] for i in range(2)] + assert pack_rollout_payload(groups, args, 0) is groups + assert pack_rollout_group(groups[0], args, 0) is groups[0] + assert finalize_rollout_groups(args, 0, groups).samples is groups + builder = BatchBuilder(args) + assert builder.begin(groups) is None + builder.save(0) + builder.load(0) + + async def generate(*values): + return groups + + fully_async_rollout._generate_rollout_async = generate + assert fully_async_rollout.generate_rollout_fully_async(args, 0, object()) is groups + + ray.init(address="local", num_cpus=1, include_dashboard=False, object_store_memory=128 * 1024**2) + try: + builder.train_parallel_config = { + "dp_size": 1, + "cp_size": 1, + "vpp_size": 1, + "microbatch_group_size_per_vp_stage": 1, + } + refs = builder.split_by_dp( + { + "tokens": [group[0].tokens for group in groups], + "rollout_ids": [0, 1], + "response_lengths": [1, 1], + "loss_masks": [[1], [1]], + "rewards": [1.0, 2.0], + "raw_reward": [1.0, 2.0], + } + ) + assert isinstance(refs[0].inner, ray.ObjectRef) + batch = process_rollout_data(refs, 0, 1) + assert [tokens.tolist() for tokens in batch["tokens"]] == [[1, 2], [2, 3]] + assert batch["local_raw_reward"] == [1.0, 2.0] + + # Exercise a CPU optimizer step on the received tensors, without a model download. + weight = torch.nn.Parameter(torch.zeros(2, 1)) + optimizer = torch.optim.SGD([weight], lr=0.01) + predictions = torch.stack(batch["tokens"]).float() @ weight + loss = (predictions.flatten() - torch.tensor(batch["local_raw_reward"])).square().mean() + loss.backward() + optimizer.step() + assert torch.isfinite(loss) and weight.detach().abs().sum() > 0 + finally: + ray.shutdown() + + with pytest.raises(ModuleNotFoundError, match="pip install straw-queue"): + rollout_store(args) + assert not any(name == "straw" or name.startswith("straw.") for name in sys.modules) + + +def test_default_rollout_and_training_work_without_straw(tmp_path): + subprocess.run( + [ + sys.executable, + "-c", + "from test_optional_straw import _run_default_without_straw; _run_default_without_straw()", + ], + check=True, + timeout=120, + cwd=tmp_path, + env={ + **os.environ, + "PYTHONPATH": os.pathsep.join([str(Path(__file__).parent), str(Path(__file__).resolve().parents[1])]), + "OMP_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + }, + ) + assert not list(tmp_path.iterdir()) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, *sys.argv[1:]])) diff --git a/tests/test_queue_sample_codec.py b/tests/test_queue_sample_codec.py new file mode 100644 index 000000000..e6ca34db7 --- /dev/null +++ b/tests/test_queue_sample_codec.py @@ -0,0 +1,386 @@ +import os +import struct +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from straw import SharedFilesystemStore +from straw.errors import CorruptData +from straw.protocol import decode +from straw.tensor import CHUNK_BYTES, publish_tensors +from vime.rollout.queue_codec import CODECS, QueueTensorRef, SampleCodec +from vime.utils.score_centering import validate_sampler_topk +from vime.utils.tensor_store import TensorRef +from vime.utils.types import Sample + +NUM_GPUS = 0 +# Independent v1 frame fixture for deliberate on-disk corruption: u32 metadata +# length, u64 payload length, both little-endian. Storage framing lives in Rust. +FRAME = struct.Struct(" 1 + for expected, ref in zip(values, refs, strict=True): + actual = codec.load(ref) + assert actual["first"] is actual["same"] + for key in expected: + torch.testing.assert_close(expected[key], actual[key]) + + +@pytest.mark.parametrize("status", [Sample.Status.ABORTED, Sample.Status.COMPLETED]) +def test_unvalidated_capture_roundtrip_never_narrows_values(codec, status): + sample = Sample( + status=status, + rollout_topk_token_ids=torch.tensor([[2**40]], dtype=torch.int64), + rollout_topk_log_probs=torch.tensor([[-0.123456789012345]], dtype=torch.float64), + ) + restored = codec.load(codec.publish(sample, submission_id="unvalidated")) + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + ref = getattr(restored, key) + assert not ref.validated + torch.testing.assert_close(ref.load(), getattr(sample, key), rtol=0, atol=0) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) + + +def test_batch_reader_rejects_another_storage_pool(codec, tmp_path): + from straw.errors import InvalidReference + + ref = codec.publish({"value": 7}, submission_id="identity") + other = SharedFilesystemStore(tmp_path / "other", codec.store.run_id, codecs=CODECS) + try: + with other.read_session() as reader: + with pytest.raises(InvalidReference, match="different storage"): + codec.load(ref, reader=reader) + finally: + other.close() diff --git a/tests/test_r3_spill.py b/tests/test_r3_spill.py index e29819958..bfec9bfd5 100644 --- a/tests/test_r3_spill.py +++ b/tests/test_r3_spill.py @@ -111,9 +111,6 @@ def test_lazy_prefetch_reloads_between_forward_and_backward(monkeypatch, tmp_pat def load_and_prepare(): tensor = ref.load() - nbytes = tensor.numel() * tensor.element_size() - source._loaded_nbytes = nbytes - prefetcher.on_loaded(nbytes) loads.append(1) return tensor @@ -123,12 +120,12 @@ def load_and_prepare(): prefetcher.begin_pass("forward") source.layer_to_cuda(3, "forward") source.layer_to_cuda(4, "forward") - assert prefetcher._resident_bytes == 0 + assert source._cpu_tensor is None prefetcher.begin_pass("backward") source.layer_to_cuda(3, "backward") source.layer_to_cuda(4, "backward") - assert prefetcher._resident_bytes == 0 + assert source._cpu_tensor is None prefetcher.close() assert len(loads) == 2 diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py new file mode 100644 index 000000000..aaac66722 --- /dev/null +++ b/tests/test_rollout_straw_transport.py @@ -0,0 +1,515 @@ +import os +import pickle +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from straw.errors import StorageUnavailable +from vime.utils.rollout_transport import ( + DiskPayloadRef, + RolloutGroupRef, + load_rollout_samples, + pack_rollout_group, + pack_rollout_payload, + resolve_rollout_data_dir, + seal_rollout_store, + unpack_rollout_payload, +) +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +def test_cpu_rollout_imports_do_not_require_vllm(): + subprocess.run( + [ + sys.executable, + "-c", + """ +import importlib.abc +import sys + +class NovLLM(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'vllm', 'vllm_router'}: + raise ModuleNotFoundError(f'CPU rollout imported {fullname}', name=fullname) + +sys.meta_path.insert(0, NovLLM()) +from vime.ray.rollout import RolloutManager +from vime.rollout.vllm_rollout import generate_and_rm +from vime.rollout.fully_async_distributed import RolloutScheduler +assert not any(name.split('.')[0] in {'vllm', 'vllm_router'} for name in sys.modules) +""", + ], + check=True, + timeout=60, + ) + + +@pytest.fixture +def args(tmp_path): + return SimpleNamespace( + rollout_data_transport="straw", rollout_data_dir=str(tmp_path), rollout_sample_filter_path=None, save=None + ) + + +def test_directory_requires_explicit_shared_root_or_checkpoint(tmp_path): + args = SimpleNamespace(rollout_data_dir=None, save=None) + with pytest.raises(ValueError, match="--rollout-data-dir or --save"): + resolve_rollout_data_dir(args) + args.save = str(tmp_path) + resolve_rollout_data_dir(args) + assert args.rollout_data_dir == str(tmp_path / "rollout_data") + args.rollout_data_dir = str(tmp_path / "explicit") + resolve_rollout_data_dir(args) + assert args.rollout_data_dir == str(tmp_path / "explicit") + + +@pytest.mark.parametrize( + "options", + [ + "", + "--save-debug-rollout-data x.pt", + "--rollout-data-transport straw", + "--save=/shared/ckpt", + "--rollout-data-dir /shared/data", + ], +) +def test_local_test_launcher_does_not_invent_storage_paths(options, monkeypatch): + from vime.utils.external_utils import command_utils + + commands = [] + monkeypatch.setattr(command_utils, "exec_command", commands.append) + monkeypatch.setattr(command_utils, "check_has_nvlink", lambda: False) + monkeypatch.setenv("SLIME_SCRIPT_EXTERNAL_RAY", "0") + monkeypatch.setenv("SLIME_SCRIPT_ENABLE_RAY_SUBMIT", "1") + command_utils.execute_train(options, num_gpus_per_node=1, megatron_model_type=None) + assert commands[-1].count("--rollout-data-dir") == options.count("--rollout-data-dir") + assert options in commands[-1] + + +def test_payload_reference_is_small_and_readable_by_independent_process(args, tmp_path): + tensor = torch.arange(1_000_000, dtype=torch.int32) + sample = Sample(tokens=[1, 2], response_length=1, rollout_routed_experts=tensor) + packed = pack_rollout_payload(dict(sample=sample, tensor=tensor, alias=tensor, text="x" * 1_000_000), args, 0) + assert isinstance(packed, DiskPayloadRef) + assert len(pickle.dumps(packed)) < 1024 + path = tmp_path / "reference.pkl" + path.write_bytes(pickle.dumps(packed)) + # Reading while the writer is open must work, with no format environment variable. + subprocess.run( + [ + sys.executable, + "-c", + """ +import pickle, sys +from vime.utils.tensor_store import TensorRef +value = pickle.load(open(sys.argv[1], 'rb')).load() +assert value['tensor'] is value['alias'] +assert value['tensor'][-1].item() == 999999 +assert len(value['text']) == 1_000_000 +assert isinstance(value['sample'].rollout_routed_experts, TensorRef) +assert value['sample'].rollout_routed_experts[999990:].tolist() == list(range(999990, 1000000)) +""", + str(path), + ], + check=True, + timeout=60, + env=dict(os.environ), + ) + + +def test_batch_manifest_reuses_sorted_group_refs_without_reading_samples(args, monkeypatch): + from vime.rollout.base_types import finalize_rollout_groups + + groups = [pack_rollout_group([[Sample(index=i, rollout_id=i, response="x" * 1_000_000)]], args, 0) for i in (2, 1)] + + def unexpected_read(ref): + raise AssertionError("batch assembly must not load sample payloads") + + with monkeypatch.context() as m: + m.setattr(DiskPayloadRef, "load", unexpected_read) + result = finalize_rollout_groups(args, 0, groups) + assert pack_rollout_payload(result.samples, args, 0) is result.samples + assert isinstance(result.samples, DiskPayloadRef) + assert result.samples.manifest.manifest.segment.size < 8192 + stored = result.samples.load() + assert all(isinstance(ref, RolloutGroupRef) for ref in stored) + assert [ref.index for ref in stored] == [1, 2] + assert stored == groups + restored = load_rollout_samples(result.samples) + assert [group[0][0].index for group in restored] == [1, 2] + assert len(restored[0][0][0].response) == 1_000_000 + + +def test_batch_hook_changes_are_saved_once_after_loading_groups(args, monkeypatch): + from vime.rollout.base_types import finalize_rollout_groups + from vime.utils import misc + + groups = [pack_rollout_group([Sample(index=i, reward=i)], args, 0) for i in (2, 1)] + calls = [] + + def filter_batch(received_args, samples): + assert received_args is args + calls.append([group[0].index for group in samples]) + samples.pop() + samples[0][0].reward = 42 + + args.rollout_sample_filter_path = "test.batch_filter" + monkeypatch.setattr(misc, "load_function", lambda path: filter_batch) + result = finalize_rollout_groups(args, 0, groups) + assert calls == [[1, 2]] + restored = load_rollout_samples(result.samples) + assert len(restored) == 1 + assert restored[0][0].reward == 42 + + +def test_fully_async_stores_groups_while_collecting_the_batch(args, monkeypatch): + import asyncio + + from vime.rollout import fully_async_rollout as fa + + args.rollout_batch_size = 2 + args.dynamic_sampling_filter_path = None + saved = [] + + def pack(group, *a): + ref = pack_rollout_group(group, *a) + saved.append(ref) + return ref + + def take(limit): + assert limit == 2 - len(saved) + # Producing the next group requires the previous one to be on disk. + if saved: + assert saved[0].load()[0].index == 2 + return [(len(saved), [Sample(index=2 - len(saved), reward=1)])] + + worker = SimpleNamespace(queue_size=lambda: 0, get_completed_groups=take) + monkeypatch.setattr(fa, "_get_global_worker", lambda *a: worker) + from vime.utils import rollout_transport + + monkeypatch.setattr(rollout_transport, "pack_rollout_group", pack) + output = asyncio.run(fa._generate_rollout_async(args, 0, None)) + assert len(saved) == 2 + assert isinstance(output.samples, DiskPayloadRef) + assert [group[0].index for group in load_rollout_samples(output.samples)] == [1, 2] + + +@pytest.mark.parametrize("all_samples_hook", [False, True]) +def test_synchronous_rollout_stores_during_generation_and_preserves_legacy_hook(args, monkeypatch, all_samples_hook): + import asyncio + + from vime.rollout import vllm_rollout as sr + + args.rollout_batch_size = 2 + args.n_samples_per_prompt = 1 + args.over_sampling_batch_size = 2 + args.dynamic_sampling_filter_path = None + args.rollout_all_samples_process_path = "test.all_samples" if all_samples_hook else None + saved = [] + hook_calls = [] + state = SimpleNamespace(remaining_batch_size=0, pendings=set(), reset=lambda: None) + + async def exercise(): + second = asyncio.Event() + loop = asyncio.get_running_loop() + + async def generate(index): + if index == 1 and not all_samples_hook: + await second.wait() + assert saved[0].load()[0].index == 2 + return [Sample(index=index, prompt="p", response="r", reward=index)] + + def submit(groups): + state.remaining_batch_size += len(groups) + state.pendings.update(asyncio.create_task(generate(index)) for index in (2, 1)) + + def pack(group, *a): + ref = pack_rollout_group(group, *a) + saved.append(ref) + loop.call_soon_threadsafe(second.set) + return ref + + async def abort(*a): + return [] + + def hook(received_args, samples, source): + hook_calls.append([group[0].index for group in samples]) + samples[0][0].reward = 42 + + state.submit_generate_tasks = submit + monkeypatch.setattr(sr, "GenerateState", lambda args: state) + from vime.utils import rollout_transport + + monkeypatch.setattr(rollout_transport, "pack_rollout_group", pack) + monkeypatch.setattr(sr, "abort", abort) + monkeypatch.setattr(sr, "load_function", lambda path: hook) + return await asyncio.wait_for(sr.generate_rollout_async(args, 0, lambda count: [None] * count), timeout=20) + + output, aborted = asyncio.run(exercise()) + assert isinstance(output.samples, DiskPayloadRef) + assert aborted == [] + restored = load_rollout_samples(output.samples) + assert [group[0].index for group in restored] == [1, 2] + assert hook_calls == ([[1, 2]] if all_samples_hook else []) + assert restored[0][0].reward == (42 if all_samples_hook else 1) + assert len(saved) == 2 + + +@pytest.mark.parametrize("form", ["raw", "wrapped", "stored", "accepted"]) +def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeypatch, form): + from vime.ray import rollout + from vime.rollout.base_types import RolloutFnTrainOutput, finalize_rollout_groups + from vime.rollout.queue_data_source import RolloutQueueController + from vime.utils.rollout_transport import RawRolloutRef, accept_raw_rollout + + groups = [[Sample(index=1, tokens=[1, 2], response_length=1, reward=1)]] + result = ( + finalize_rollout_groups(args, 0, [pack_rollout_group(groups[0], args, 0)]) + if form in {"stored", "accepted"} + else RolloutFnTrainOutput(samples=groups, metrics={"custom": 1}) + ) + controller = RolloutQueueController(args) + args._rollout_queue_controller = SimpleNamespace( + **{ + name: SimpleNamespace(remote=getattr(controller, name)) + for name in ("begin_collection", "complete", "accepted") + } + ) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager.args = args + args.load_debug_rollout_data = None + manager.data_source = object() + manager.batch_builder = SimpleNamespace() + if form == "accepted": + result = accept_raw_rollout(result, args, 0) + manager.generate_rollout = lambda *a, **kw: groups[0] if form == "raw" else result + previous_files = {path: path.read_bytes() for path in Path(args.rollout_data_dir).rglob("*.pack")} + try: + samples, metrics = manager._get_rollout_data(0) + assert [sample.tokens for sample in samples] == [[1, 2]] + ref = manager.batch_builder.raw_ref + assert isinstance(ref, RawRolloutRef) + assert controller.accepted(ref.receipt) == ref.receipt + assert all(path.read_bytes()[: len(contents)] == contents for path, contents in previous_files.items()) + assert metrics == ({"custom": 1} if form == "wrapped" else None) + if form == "accepted": + assert ref is result + assert len(previous_files) == len(list(Path(args.rollout_data_dir).rglob("*.pack"))) + finally: + controller.close() + + +@pytest.mark.parametrize("transport", ["object-store", "nixl", "straw"]) +def test_train_partitions_preserve_top_p_and_multimodal(args, monkeypatch, transport): + from vime.rollout import batch_builder as rollout + from vime.utils.data import process_rollout_data + + args.rollout_data_transport = transport + if transport != "straw": + args.rollout_data_dir = None + manager = object.__new__(rollout.BatchBuilder) + manager.args = SimpleNamespace(**vars(args), global_batch_size=2) + manager.rollout_id = 0 + manager.train_parallel_config = {"dp_size": 2} + monkeypatch.setattr(rollout, "build_dp_schedule", lambda *a, **kw: ([[1], [0]], [[[0]], [[0]]], [1], [2])) + sent = [] + + def put(value, **kwargs): + if transport == "straw": + assert isinstance(value, DiskPayloadRef) + assert len(pickle.dumps(value)) < 1024 + else: + assert isinstance(value, dict) + assert kwargs == ({"_tensor_transport": "nixl"} if transport == "nixl" else {}) + sent.append(value) + return value + + monkeypatch.setattr(rollout.ray, "put", put) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + refs = manager.split_by_dp( + dict( + tokens=[[10, 11], [20, 21, 22]], + rollout_ids=[0, 1], + response_lengths=[1, 2], + loss_masks=[[1], [0, 1]], + rollout_top_p_token_ids=[[11], [21, 22]], + rollout_top_p_token_offsets=[[0, 1], [0, 1, 2]], + rollout_top_p_log_probs=[[-0.2], [-0.3, -0.4]], + rollout_log_probs=[[-0.2], [-0.3, -0.4]], + raw_reward=[1.0, 2.0], + rewards=[-1.0, 1.0], + multimodal_train_inputs=[None, {"pixel_values": torch.ones(3, 20, 20)}], + ) + ) + assert len(sent) == 2 + batch = process_rollout_data(refs, 0, 2) + assert batch["tokens"][0].tolist() == [20, 21, 22] + assert batch["loss_masks"][0].tolist() == [0, 1] + assert batch["total_lengths"] == [3] + assert batch["local_raw_reward"] == [2.0] + assert batch["rollout_top_p_token_offsets"][0].tolist() == [0, 1, 2] + torch.testing.assert_close(batch["rollout_top_p_log_probs"][0], torch.tensor([-0.3, -0.4])) + assert batch["multimodal_train_inputs"][0]["pixel_values"].shape == (3, 20, 20) + + +def test_durable_batch_replays_one_plan_and_rejects_mixed_ranks(args, monkeypatch): + from dataclasses import replace + + from vime.rollout import batch_builder as module + from vime.rollout.base_types import RolloutFnTrainOutput + from vime.rollout.queue_data_source import RolloutQueueController + from vime.utils.data import process_rollout_data + from vime.utils.misc import Box + from vime.utils.rollout_transport import TrainBatchRef, accept_raw_rollout + + args.custom_reward_post_process_path = None + args.custom_convert_samples_to_train_data_path = None + args.global_batch_size = 2 + samples = [Sample(index=i, rollout_id=i, tokens=[1, 2], response_length=1) for i in range(2)] + controller = RolloutQueueController(args) + args._rollout_queue_controller = SimpleNamespace( + **{ + name: SimpleNamespace(remote=getattr(controller, name)) + for name in ( + "begin_collection", + "complete", + "accepted", + "batch", + "plan_batch", + "ready_batch", + "training_state", + "restore_training_state", + "finish_batch", + ) + } + ) + monkeypatch.setattr(module.ray, "get", lambda value: value) + monkeypatch.setattr(module.ray, "put", lambda value: value) + monkeypatch.setattr(module, "build_dp_schedule", lambda *a, **kw: ([[0], [1]], [[[0]], [[0]]], [1], [2])) + try: + builder = module.BatchBuilder(args) + builder.train_parallel_config = {"dp_size": 2} + builder.raw_ref = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 0) + assert builder.begin(samples) is None + plan = controller.batch(builder.batch_id) + assert not plan["ready"] + refs = builder.split_by_dp({"tokens": [[1, 2], [1, 2]], "rollout_ids": [0, 1], "rewards": [0.0, 1.0]}) + assert controller.batch(builder.batch_id)["ready"] + assert all(isinstance(box.inner, TrainBatchRef) for box in refs) + assert len({box.inner.batch_id for box in refs}) == 1 + before = set(Path(args.rollout_data_dir).rglob("*.pack")) + replay = builder.begin(samples) + assert [box.inner for box in replay] == [box.inner for box in refs] + assert before == set(Path(args.rollout_data_dir).rglob("*.pack")) + assert process_rollout_data(refs, 0, 2)["tokens"][0].tolist() == [1, 2] + mixed = [refs[0], Box(replace(refs[1].inner, batch_id="another-batch"))] + with pytest.raises(ValueError, match="inconsistent batch identities"): + process_rollout_data(mixed, 0, 2) + args.save = str(Path(args.rollout_data_dir) / "checkpoint") + builder.save(0) + assert controller.queue._usage()["ready_bytes"] > 0 + builder.training_completed(builder.rollout_id) + assert controller.queue._usage()["ready_bytes"] == 0 + assert not controller.queue.checkpoints # runtime completion is not a checkpoint + # Later production facts survive restoring the earlier training view. + later = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 1) + assert later.receipt.position == 1 + args.load = args.save + builder.load(0) + assert controller.queue._usage()["ready_bytes"] > 0 # earlier consumer view is restored + assert controller.training_state()["processed_cursor"] == 1 + assert controller.queue.read_commits().cursor == 2 + args.global_batch_size = 4 + with pytest.raises(ValueError, match="different selection or conversion plan"): + builder.begin(samples) + finally: + controller.close() + + +def test_packed_tensors_handle_scalar_empty_and_bfloat16(args): + original = {"scalar": torch.tensor(2.5), "empty": torch.empty(0, 3), "bf16": torch.ones(3, dtype=torch.bfloat16)} + restored = unpack_rollout_payload(pack_rollout_payload(original, args, 0)) + for key in original: + torch.testing.assert_close(restored[key], original[key]) + + +def test_debug_dump_survives_removal_of_queue_storage(args, tmp_path): + from pathlib import Path + from straw.tensor import TensorRef + + from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data + + args.num_layers, args.num_experts, args.moe_router_topk = 1, 8, 2 + args.use_score_centering, args.score_centering_top_k, args.rollout_top_p = True, 2, 1.0 + args.use_rollout_routing_replay = True + sample = Sample( + index=0, + tokens=[1, 2, 3], + response_length=2, + status=Sample.Status.COMPLETED, + rollout_routed_experts=torch.tensor([[[1, 2]], [[3, 4]]], dtype=torch.uint8), + rollout_topk_token_ids=[[1, 2], [3, 4]], + rollout_topk_log_probs=[[-1.0, -2.0], [-1.0, -2.0]], + ) + sample = pack_rollout_payload(sample, args, 0).load() + refs = [sample.rollout_routed_experts, sample.rollout_topk_token_ids, sample.rollout_topk_log_probs] + assert all(isinstance(ref, TensorRef) for ref in refs) + assert len({ref.path for ref in refs}) == 1 + debug_path = str(tmp_path / "debug_{rollout_id}.pt") + save_debug_rollout_data(debug_path, [sample], rollout_id=0, evaluation=False) + seal_rollout_store(args) + assert sample.rollout_topk_token_ids.load().tolist() == [[1, 2], [3, 4]] + paths = {Path(ref.path) for ref in refs} + assert len(paths) == 1 + for path in paths: + path.unlink() + [restored] = load_debug_rollout_data(debug_path, rollout_id=0) + assert restored.tokens == sample.tokens + assert isinstance(restored.rollout_routed_experts, torch.Tensor) + assert restored.rollout_routed_experts.tolist() == [[[1, 2]], [[3, 4]]] + assert restored.rollout_topk_token_ids.tolist() == [[1, 2], [3, 4]] + assert not list(tmp_path.rglob("*.pack")) + assert not list(tmp_path.glob("*.bin")) + + +def test_shared_storage_probe_reports_missing_mount(args, monkeypatch): + from vime.utils import rollout_transport + + monkeypatch.setattr(rollout_transport.ray, "nodes", lambda: []) + + def fail(*a, **kw): + raise FileNotFoundError("node cannot see shared file") + + monkeypatch.setattr(rollout_transport.ray, "get", fail) + with pytest.raises(RuntimeError, match="All rollout/training nodes must share the run"): + rollout_transport.check_rollout_storage(args) + + +def test_failed_flush_does_not_publish_a_reference(args, tmp_path, monkeypatch): + from vime.utils import rollout_transport + + store, _, _ = rollout_transport.rollout_store(args) + + def fail(phase): + if phase == "before_directory_sync": + raise OSError("shared storage flush failed") + + with monkeypatch.context() as m: + m.setattr(store.backend, "fault", fail) + with pytest.raises(StorageUnavailable, match="shared storage flush failed"): + pack_rollout_payload({"tensor": torch.arange(16)}, args, 0) + # No commit marker was published. A later successful write remains readable. + assert not list(tmp_path.rglob("*.pack")) + restored = pack_rollout_payload({"tensor": torch.arange(16)}, args, 0).load() + assert restored["tensor"].tolist() == list(range(16)) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_routed_experts_disk_layout.py b/tests/test_routed_experts_disk_layout.py new file mode 100644 index 000000000..4da607132 --- /dev/null +++ b/tests/test_routed_experts_disk_layout.py @@ -0,0 +1,227 @@ +"""Lazy R3 reads preserve every CP/TP layout without full-sample materialization.""" + +import _cp_dist_helpers # noqa: F401 - install the CPU topology stub first +import pytest +import torch +from straw import SharedFilesystemStore +from straw.tensor import TensorRef, publish_tensors + +from vime.backends.megatron_utils import cp_utils +from vime.backends.megatron_utils.cp_utils import prepare_routed_experts_for_routing_replay +from vime.utils.routed_experts import RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher + +NUM_GPUS = 0 + + +def _reference_pad_routed_experts(experts: torch.Tensor, pad: int, num_experts: int) -> torch.Tensor: + if pad == 0: + return experts + _, num_layers, topk = experts.shape + values = (torch.arange(pad * num_layers * topk, dtype=torch.int64) % num_experts).to(experts.dtype) + pad_experts = values.reshape((pad, num_layers, topk)) + return torch.cat([experts, pad_experts], dim=0) + + +def _reference_slice_routed_experts_with_cp(experts: torch.Tensor, cp_size: int, cp_rank: int, num_experts: int): + if cp_size == 1: + return experts + + token_len = len(experts) + chunk_size = (token_len + 2 * cp_size - 1) // (2 * cp_size) + pad = 2 * cp_size * chunk_size - token_len + experts = _reference_pad_routed_experts(experts, pad, num_experts) + + start_1, end_1 = chunk_size * cp_rank, chunk_size * (cp_rank + 1) + start_2, end_2 = chunk_size * (2 * cp_size - cp_rank - 1), chunk_size * (2 * cp_size - cp_rank) + return torch.cat([experts[start_1:end_1], experts[start_2:end_2]], dim=0) + + +def _reference_prepare_routed_experts( + rollout_routed_experts: list[torch.Tensor], + tokens: list[torch.Tensor], + *, + num_experts: int, + data_pad_size_multiplier: int, + sequence_parallel: bool, + allgather_cp: bool, + cp_size: int, + cp_rank: int, + tp_size: int, + tp_rank: int, +) -> torch.Tensor: + padded_experts = [_reference_pad_routed_experts(experts, 1, num_experts) for experts in rollout_routed_experts] + pad_size = tp_size * data_pad_size_multiplier + + if allgather_cp: + routed_experts = torch.cat(padded_experts, dim=0) + global_pad_size = cp_size * pad_size + pad = (global_pad_size - routed_experts.size(0) % global_pad_size) % global_pad_size + routed_experts = _reference_pad_routed_experts(routed_experts, pad, num_experts) + routed_experts = routed_experts.chunk(cp_size, dim=0)[cp_rank] + else: + routed_experts = [ + _reference_slice_routed_experts_with_cp(experts, cp_size, cp_rank, num_experts) + for experts in padded_experts + ] + routed_experts = torch.cat(routed_experts, dim=0) + pad = (pad_size - routed_experts.size(0) % pad_size) % pad_size + routed_experts = _reference_pad_routed_experts(routed_experts, pad, num_experts) + + if sequence_parallel: + seqlen = routed_experts.size(0) + assert seqlen % tp_size == 0 + start = seqlen // tp_size * tp_rank + end = seqlen // tp_size * (tp_rank + 1) + routed_experts = routed_experts[start:end] + + return routed_experts + + +@pytest.fixture(autouse=True) +def cpu_allocations(monkeypatch): + # This CPU-only equivalence test cannot allocate CUDA-pinned host memory. + empty = torch.empty + + def unpinned(*args, **kwargs): + kwargs.pop("pin_memory", None) + return empty(*args, **kwargs) + + monkeypatch.setattr(torch, "empty", unpinned) + + +@pytest.mark.parametrize("backend", ["straw", "mixed"]) +@pytest.mark.parametrize("cp_size", [1, 2, 32]) +@pytest.mark.parametrize("allgather_cp", [False, True]) +@pytest.mark.parametrize("sequence_parallel", [False, True]) +@pytest.mark.parametrize("dtype", [torch.int32, torch.uint8]) +def test_selective_reads_match_complete_reference( + tmp_path, monkeypatch, backend, cp_size, allgather_cp, sequence_parallel, dtype +): + num_experts = 288 if dtype == torch.int32 else 251 + tensors = [ + (torch.arange(length * 6 * 2).reshape(length, 6, 2) % num_experts).to(dtype) for length in [0, 1, 7, 127, 256] + ] + tokens = [torch.arange(len(x) + 1) for x in tensors] + with SharedFilesystemStore(tmp_path / "straw", "cp-test", codecs=("tensor.v1",)) as store: + refs = publish_tensors(store, {str(i): x for i, x in enumerate(tensors)}, submission_id="r3") + if backend == "mixed": + refs = [x if i % 2 else tensors[i] for i, x in enumerate(refs)] + monkeypatch.setattr(TensorRef, "load", lambda *a, **k: pytest.fail("full straw tensor load")) + mpu = cp_utils.mpu + monkeypatch.setattr(mpu, "get_context_parallel_world_size", lambda: cp_size) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_world_size", lambda: 2, raising=False) + for cp_rank in range(cp_size): + monkeypatch.setattr(mpu, "get_context_parallel_rank", lambda rank=cp_rank: rank) + for tp_rank in range(2 if sequence_parallel else 1): + monkeypatch.setattr( + mpu, + "get_tensor_model_parallel_rank", + lambda rank=tp_rank: rank, + raising=False, + ) + kwargs = dict( + num_experts=num_experts, + data_pad_size_multiplier=2, + sequence_parallel=sequence_parallel, + allgather_cp=allgather_cp, + ) + actual = prepare_routed_experts_for_routing_replay(refs, tokens, **kwargs) + expected = _reference_prepare_routed_experts( + tensors, + tokens, + cp_size=cp_size, + cp_rank=cp_rank, + tp_size=2, + tp_rank=tp_rank, + **kwargs, + ) + assert torch.equal(actual, expected) + + +@pytest.mark.parametrize("resident_type", ["tensor", "numpy", "list"]) +def test_microbatch_passes_lazy_refs_to_layout_preparation(tmp_path, monkeypatch, resident_type): + routes = torch.arange(17 * 6 * 2, dtype=torch.int32).reshape(17, 6, 2) % 288 + with SharedFilesystemStore(tmp_path, "microbatch", codecs=("tensor.v1",)) as store: + (ref,) = publish_tensors(store, {"routes": routes}, submission_id="r3") + monkeypatch.setattr(TensorRef, "load", lambda *a, **k: pytest.fail("full tensor load")) + mpu = cp_utils.mpu + monkeypatch.setattr(mpu, "get_context_parallel_world_size", lambda: 32) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_world_size", lambda: 1, raising=False) + kwargs = dict( + num_experts=288, + data_pad_size_multiplier=2, + sequence_parallel=False, + allgather_cp=True, + ) + resident = {"tensor": routes, "numpy": routes.numpy(), "list": routes.tolist()}[resident_type] + for rank in (0, 16, 31): + monkeypatch.setattr(mpu, "get_context_parallel_rank", lambda rank=rank: rank) + source = RoutedExpertsMicrobatch( + [ref, resident], + [torch.arange(18), torch.arange(18)], + consumer_count=6, + prepare_kwargs=kwargs, + ) + actual = source._load_and_prepare() + expected = _reference_prepare_routed_experts( + [routes, torch.as_tensor(resident)], + [torch.arange(18), torch.arange(18)], + cp_size=32, + cp_rank=rank, + tp_size=1, + tp_rank=0, + **kwargs, + ) + assert torch.equal(actual, expected) + + +def test_prefetch_propagates_corrupt_requested_route_bytes(tmp_path, monkeypatch): + import struct + from pathlib import Path + + pytest.importorskip("straw") + from straw import SharedFilesystemStore + from straw.errors import CorruptData + from straw.tensor import publish_tensors + + with SharedFilesystemStore(tmp_path, "corrupt-r3", codecs=("tensor.v1",)) as store: + routes = torch.arange(17 * 6 * 2, dtype=torch.int32).reshape(17, 6, 2) % 288 + (ref,) = publish_tensors(store, {"routes": routes}, submission_id="r3") + record = store.inspect_segment(ref.record_ref.segment)["records"][ref.record_ref.ordinal] + start = ref.record_ref.segment.offset + record["offset"] + with Path(ref.path).open("r+b") as stream: + stream.seek(start) + envelope_size, _ = struct.unpack(" 1: + await asyncio.Event().wait() + if args.test_recovery_phase == "interrupt": + if sample.response_length: + await asyncio.Event().wait() + sample = await _generate_locally(args, sample, sampling_params) + sample.metadata["generation_pid"] = os.getpid() + if sample.group_index == 0: + sample.tokens = sample.tokens[:2] + sample.response_length = 1 + sample.response = "partial" + sample.loss_mask = [1] + sample.rollout_log_probs = sample.rollout_log_probs[:1] + for key in TENSOR_FIELDS: + setattr(sample, key, getattr(sample, key)[:1]) + sample.status = Sample.Status.ABORTED + return sample + + assert sample.group_index == 0, "An accepted group was generated again instead of replaying its receipt" + assert sample.response_length == 1, "Restart discarded the durable prefix" + sample.metadata["resumed_prefix"] = _prefix(sample) + sample.metadata["resumed_lease"] = dict(sample._queue_lease) + sample.tokens.append(3) + sample.response_length += 1 + sample.response = "answer" + sample.loss_mask.append(1) + sample.rollout_log_probs.append(-0.7) + rows = ( + torch.full((1, 1, 1), sample.index % 8, dtype=torch.uint8), + torch.tensor([[3, 5]], dtype=torch.int32), + torch.tensor([[-0.7, -2.5]], dtype=torch.float32), + ) + for key, row in zip(TENSOR_FIELDS, rows, strict=True): + setattr(sample, key, torch.cat((_tensor(getattr(sample, key)), row))) + sample.status = Sample.Status.COMPLETED + return sample + + +async def _reward_once(args, sample): + assert sample.metadata.get("reward_calls", 0) == 0, "A completed sample was scored twice" + sample.metadata["reward_calls"] = 1 + return float(sample.index % 2) + + +def _run_job(directory, phase, online_gc): + import ray + from ray.cluster_utils import Cluster + from straw.protocol import Lease, RecordSetRef + from straw.reporting import write_report + + from vime.rollout.fully_async_rollout import generate_rollout_fully_async + from vime.rollout.queue_data_source import QueueDataSource + from vime.utils.rollout_transport import DiskPayloadRef, load_rollout_samples + + root = Path(directory) + args = _rollout_args(root) + args.rollout_batch_size = 2 + args.vllm_server_concurrency = 4 + args.rollout_queue_segment_mib = 1 + args.rollout_queue_max_pending = 16 + args.rollout_queue_max_inflight = 4 + args.rollout_queue_online_gc = online_gc + args.rollout_queue_resume = phase == "resume" + args.custom_generate_function_path = "test_straw_fully_async_recovery._generate_with_interrupt" + args.custom_rm_path = "test_straw_fully_async_recovery._reward_once" + args.group_rm = False + args.test_recovery_phase = phase + cluster = Cluster() + source = None + try: + for _ in range(2): + cluster.add_node(num_cpus=2, num_gpus=0, object_store_memory=128 * 1024**2, include_dashboard=False) + ray.init(address=cluster.address, runtime_env={"env_vars": {"PYTHONPATH": os.environ["PYTHONPATH"]}}) + source = QueueDataSource(args) + controller = source.controller + if phase == "interrupt": + failure = [] + + def generate(): + try: + generate_rollout_fully_async(args, 0, source) + except BaseException as error: + failure.append(error) + + thread = threading.Thread(target=generate, daemon=True) + thread.start() + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + if failure: + raise failure[0] + statuses = ray.get([controller.status.remote(f"prompt:{i}") for i in range(2)], timeout=10) + if all(statuses) and statuses[0]["state"] == "leased" and statuses[1]["state"] == "completed": + partial = DiskPayloadRef( + RecordSetRef.from_dict(statuses[0]["spec"]["input_ref"]), args.rollout_data_dir + ).load() + if all(sample.response_length == 1 for sample in partial): + receipt = ray.get(controller.result.remote(Lease(**statuses[1]["lease"])), timeout=10) + accepted = DiskPayloadRef(receipt.result_ref, args.rollout_data_dir).load() + write_report( + root / "interrupted.json", + { + "prefixes": [_prefix(sample) for sample in partial], + "old_lease": statuses[0]["lease"], + "receipt": asdict(receipt), + "worker_pids": sorted( + {sample.metadata["generation_pid"] for sample in [*partial, *accepted]} + ), + }, + ) + # No graceful save/close is allowed before the parent kills us. + threading.Event().wait() + time.sleep(0.05) + raise TimeoutError("Fully async did not persist both a partial and an accepted group") + + before = json.loads((root / "interrupted.json").read_text()) + assert ray.get(controller.heartbeat.remote([Lease(**before["old_lease"])])) == ["StaleAttempt"] + if online_gc: + ray.get(controller._collect_storage.remote()) + result = generate_rollout_fully_async(args, 0, source) + samples = list(iter_samples(load_rollout_samples(result.samples))) + assert sorted(sample.index for sample in samples) == [0, 1, 2, 3] + assert all(sample.response_length == 2 and sample.metadata["reward_calls"] == 1 for sample in samples) + for sample in samples[:2]: + assert sample.metadata["resumed_prefix"] == before["prefixes"][sample.index] + lease = sample.metadata["resumed_lease"] + assert lease["coordinator_epoch"] != before["old_lease"]["coordinator_epoch"] + assert lease["attempt_id"] != before["old_lease"]["attempt_id"] + assert sample.loss_mask == [0, 1] + actual = _prefix(sample) + for key in ("tokens", "log_probs", *TENSOR_FIELDS): + old = before["prefixes"][sample.index][key] + assert actual[key][: len(old)] == old + # Query receipts without republishing the startup recovery snapshot. + # Consecutive positions also detect duplicate acceptance during restart. + statuses = ray.get([controller.status.remote(f"prompt:{i}") for i in range(2)]) + assert all(status["state"] == "completed" for status in statuses) + history = [ + asdict(receipt) + for receipt in ray.get([controller.result.remote(Lease(**status["lease"])) for status in statuses]) + ] + assert {item["task_id"] for item in history} == {"prompt:0", "prompt:1"} + assert sorted(item["position"] for item in history) == [0, 1] + assert ray.get(controller.metrics.remote())["tasks"]["completed"] == 2 + assert next(item for item in history if item["task_id"] == "prompt:1") == before["receipt"] + write_report( + root / "resumed.json", {"samples": len(samples), "accepted_groups": len(history), "online_gc": online_gc} + ) + finally: + if source is not None: + source.close() + ray.shutdown() + cluster.shutdown() + + +def _kill_job(process): + """Freeze and kill only this subprocess tree, including its local Ray nodes.""" + if process.poll() is not None: + return set() + owner = psutil.Process(process.pid) + owned = {owner} + # Stop parents before enumerating again so Ray cannot fork new workers + # between our ownership snapshot and SIGKILL. psutil checks PID reuse. + while True: + for child in owned: + try: + child.suspend() + except psutil.NoSuchProcess: + pass + try: + discovered = set(owner.children(recursive=True)) - owned + except psutil.NoSuchProcess: + discovered = set() + if not discovered: + break + owned.update(discovered) + for child in owned: + try: + child.kill() + except psutil.NoSuchProcess: + pass + process.wait(timeout=20) + deadline = time.monotonic() + 20 + while True: + alive = [] + for child in owned: + try: + if child.is_running() and child.status() != psutil.STATUS_ZOMBIE: + alive.append(child) + except psutil.NoSuchProcess: + pass + if not alive: + break + assert time.monotonic() < deadline, "A process from the old job is still alive" + time.sleep(0.05) + return {child.pid for child in owned} + + +@pytest.mark.parametrize("online_gc", [False, True], ids=["retain", "online-gc"]) +def test_fully_async_sigkill_and_restart_preserves_results_and_prefixes(tmp_path, online_gc): + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join([str(Path(__file__).parent), str(Path(__file__).resolve().parents[1])]), + "OMP_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + } + code = "from test_straw_fully_async_recovery import _run_job; import sys; _run_job(sys.argv[1], sys.argv[2], sys.argv[3] == 'True')" + for phase in ("interrupt", "resume"): + log_path = tmp_path / f"{phase}.log" + with log_path.open("w") as log: + process = subprocess.Popen( + [sys.executable, "-u", "-c", code, str(tmp_path), phase, str(online_gc)], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + if phase == "interrupt": + deadline = time.monotonic() + 180 + while not (tmp_path / "interrupted.json").exists(): + assert process.poll() is None, log_path.read_text()[-12000:] + assert time.monotonic() < deadline, log_path.read_text()[-12000:] + time.sleep(0.1) + before = json.loads((tmp_path / "interrupted.json").read_text()) + assert len(before["worker_pids"]) == 2 + assert not list((tmp_path / "checkpoint").rglob("queue_state_*.json")) + killed = _kill_job(process) + assert set(before["worker_pids"]) <= killed + assert process.returncode == -signal.SIGKILL + else: + assert process.wait(timeout=180) == 0, log_path.read_text()[-12000:] + finally: + _kill_job(process) + assert json.loads((tmp_path / "resumed.json").read_text()) == { + "samples": 4, + "accepted_groups": 2, + "online_gc": online_gc, + } + assert sum(path.is_file() for path in (tmp_path / "queue").rglob("*")) < 64 + assert sum(path.stat().st_size for path in (tmp_path / "queue").rglob("*") if path.is_file()) < 8 * 1024**2 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, *sys.argv[1:]])) diff --git a/tests/test_straw_r3.py b/tests/test_straw_r3.py new file mode 100644 index 000000000..e3223997d --- /dev/null +++ b/tests/test_straw_r3.py @@ -0,0 +1,221 @@ +import asyncio +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch +from straw import SharedFilesystemStore +from straw.tensor import publish_tensors + +from vime.rollout.sample_hooks import apply_rollout_sample_hooks +from vime.utils.routed_experts import RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher +from vime.utils.tensor_store import TensorRef +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +def _args(tmp_path): + return SimpleNamespace( + rollout_data_transport="object-store", + num_layers=6, + moe_router_topk=2, + num_experts=16, + moe_layer_freq=[0, 0, 0, 1, 1, 1], + rollout_data_dir=str(tmp_path), + ) + + +def _routes(rows=4): + routes = torch.zeros((rows, 6, 2), dtype=torch.uint8) + routes[:, 3:, 1] = 7 + return routes + + +@pytest.mark.parametrize("r3,sc", [(True, False), (False, True), (True, True)]) +def test_straw_publishes_replay_tensors_with_the_group_without_hook_or_spill_directory(tmp_path, r3, sc): + from straw.tensor import TensorRef + + args = _args(tmp_path) + args.rollout_data_transport = "straw" + args.rollout_data_dir = str(tmp_path) + args.use_rollout_routing_replay = r3 + args.use_score_centering = sc + args.score_centering_top_k = 2 + args.rollout_sample_hook_path = None + samples = [ + Sample( + index=i, + tokens=[0] * 5, + response_length=1, + loss_mask=[1], + rollout_routed_experts=_routes() if r3 else None, + rollout_topk_token_ids=torch.tensor([[1, 2]], dtype=torch.int32) if sc else None, + rollout_topk_log_probs=torch.tensor([[0.7, 0.3]]).log() if sc else None, + status=Sample.Status.COMPLETED, + ) + for i in range(32) + ] + nested = [samples[:16], samples[16:]] + result = asyncio.run(apply_rollout_sample_hooks(args, nested, rollout_id=3)) + assert result == nested + assert not list(tmp_path.iterdir()) + from vime.utils.rollout_transport import pack_rollout_payload + + durable = pack_rollout_payload(result, args, 3) + samples = [sample for group in durable.load() for sample in group] + for sample in samples: + if r3: + assert isinstance(sample.rollout_routed_experts, TensorRef) + torch.testing.assert_close(sample.rollout_routed_experts.load(), _routes()) + if sc: + assert isinstance(sample.rollout_topk_token_ids, TensorRef) + assert isinstance(sample.rollout_topk_log_probs, TensorRef) + assert sample.rollout_topk_token_ids.load().tolist() == [[1, 2]] + torch.testing.assert_close(sample.rollout_topk_log_probs.load().exp(), torch.tensor([[0.7, 0.3]])) + files = {path: path.stat().st_size for path in tmp_path.rglob("*.pack")} + assert len(files) == 1 + assert not list(tmp_path.rglob("*.safetensors")) + assert not list(tmp_path.rglob("*.bin")) + second = pack_rollout_payload([samples[:16], samples[16:]], args, 4) + restored = [sample for group in second.load() for sample in group] + for before, after in zip(samples, restored, strict=True): + for key in ("rollout_routed_experts", "rollout_topk_token_ids", "rollout_topk_log_probs"): + if getattr(before, key) is not None: + assert getattr(before, key).record_ref == getattr(after, key).record_ref + assert len(list(tmp_path.rglob("*.pack"))) == len(files) + + +@pytest.mark.parametrize( + "transport,evaluation,status", + [ + ("object-store", False, Sample.Status.COMPLETED), + ("straw", True, Sample.Status.COMPLETED), + ("straw", False, Sample.Status.ABORTED), + ], +) +def test_hooks_leave_ray_evaluation_and_aborted_samples_resident(tmp_path, transport, evaluation, status): + args = _args(tmp_path) + args.rollout_data_transport = transport + args.rollout_data_dir = str(tmp_path) + args.use_rollout_routing_replay = True + sample = Sample(tokens=[0] * 5, rollout_routed_experts=_routes(), status=status) + routes = sample.rollout_routed_experts + assert asyncio.run(apply_rollout_sample_hooks(args, sample, evaluation=evaluation)) is sample + assert sample.rollout_routed_experts is routes + assert not list(tmp_path.iterdir()) + + +def test_straw_adopts_r3_from_another_pool(tmp_path): + args = _args(tmp_path) + args.rollout_data_transport = "straw" + args.rollout_data_dir = str(tmp_path / "queue") + args.use_rollout_routing_replay = True + with SharedFilesystemStore(tmp_path / "source", "source", codecs=("tensor.v1",)) as store: + (ref,) = publish_tensors(store, {"rollout_routed_experts": _routes()}, submission_id="r3") + path = Path(ref.path) + sample = Sample( + index=1, + tokens=[0] * 5, + rollout_routed_experts=ref, + status=Sample.Status.COMPLETED, + ) + from vime.utils.rollout_transport import pack_rollout_payload + + sample = pack_rollout_payload(sample, args, 0).load() + path.unlink() + torch.testing.assert_close(sample.rollout_routed_experts.load(), _routes()) + + +def test_cancelled_straw_publication_drains_before_releasing_capacity(tmp_path, monkeypatch): + from vime.utils import rollout_transport + + args = _args(tmp_path) + args.rollout_data_transport = "straw" + args.rollout_data_dir = str(tmp_path) + args.rollout_io_concurrency = 1 + args.use_rollout_routing_replay = True + samples = [ + Sample(index=i, tokens=[0] * 5, rollout_routed_experts=_routes(), status=Sample.Status.COMPLETED) + for i in range(2) + ] + entered, release = threading.Event(), threading.Event() + calls = [] + write = rollout_transport.pack_rollout_payload + published = [] + + def delayed_write(*values): + calls.append(values[0].index) + entered.set() + if not release.wait(5): + raise TimeoutError("test did not release the accepted write") + result = write(*values) + published.append(result) + return result + + monkeypatch.setattr(rollout_transport, "pack_rollout_payload", delayed_write) + + async def run(): + first = asyncio.create_task(rollout_transport.publish_rollout_async(samples[0], args, 0)) + tasks = [first] + try: + assert await asyncio.to_thread(entered.wait, 5) + first.cancel() + second = asyncio.create_task(rollout_transport.publish_rollout_async(samples[1], args, 0)) + tasks.append(second) + await asyncio.sleep(0.05) + first.cancel() # A second shutdown signal must not release the writer early. + await asyncio.sleep(0.05) + assert not first.done() + assert calls == [0] + finally: + release.set() + results = await asyncio.gather(*tasks, return_exceptions=True) + assert isinstance(results[0], asyncio.CancelledError) + assert results[1] is published[1] + assert calls == [0, 1] + + asyncio.run(run()) + for result in published: + sample = result.load() + assert isinstance(sample.rollout_routed_experts, TensorRef) + torch.testing.assert_close(sample.rollout_routed_experts.load(), _routes()) + + +@pytest.mark.unit +def test_lazy_prefetch_reloads_between_forward_and_backward(monkeypatch, tmp_path): + import weakref + + from vime.utils import routed_experts as routed_experts_module + + with SharedFilesystemStore(tmp_path, "prefetch", codecs=("tensor.v1",)) as store: + (ref,) = publish_tensors(store, {"rollout_routed_experts": _routes(rows=3)}, submission_id="r3") + prefetcher = RoutedExpertsMicrobatchPrefetcher(prefetch_microbatches=1) + source = RoutedExpertsMicrobatch([ref], [torch.arange(4)], consumer_count=2, prepare_kwargs={}) + prefetcher.add(source) + loads = [] + + def load_and_prepare(): + tensor = ref.load() + loads.append(weakref.ref(tensor)) + return tensor + + monkeypatch.setattr(source, "_load_and_prepare", load_and_prepare) + monkeypatch.setattr(routed_experts_module.accelerator, "current_device", lambda: torch.device("cpu")) + + prefetcher.begin_pass("forward") + source.layer_to_cuda(3, "forward") + source.layer_to_cuda(4, "forward") + assert loads[-1]() is None + + prefetcher.begin_pass("backward") + source.layer_to_cuda(3, "backward") + source.layer_to_cuda(4, "backward") + assert loads[-1]() is None + prefetcher.close() + assert len(loads) == 2 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index 752e619fe..2322c0acb 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -26,6 +26,7 @@ NUM_GPUS = 0 from vime.utils.eval_config import EvalDatasetConfig +from vime.utils.async_utils import AsyncPacer from vime.utils.types import Sample @@ -64,6 +65,7 @@ def __init__(self, args: Namespace) -> None: self.tokenizer = _FakeTokenizer() self.processor = None self.semaphore = _DummySemaphore() + self.generation_pacer = AsyncPacer() self.aborted = False self.remaining_batch_size = 0 self.pendings: set = set() diff --git a/train.py b/train.py index 52c9998b0..6befae9d0 100644 --- a/train.py +++ b/train.py @@ -68,6 +68,8 @@ def offload_train(actor_trains_this_step): else: ray.get(actor_model.async_train(rollout_id, rollout_data_ref)) + ray.get(rollout_manager.training_completed.remote(rollout_id)) + if release_train or should_run_periodic_action( rollout_id, args.save_interval, num_rollout_per_epoch, args.num_rollout ): @@ -78,12 +80,13 @@ def offload_train(actor_trains_this_step): critic_model.save_model(rollout_id, force_sync=force_sync) ray.get(rollout_manager.save.remote(rollout_id)) - ray.get(rollout_manager.cleanup_rollout_data.remote(rollout_id)) - offload_train(actor_trains) if args.offload_rollout and not release_train: ray.get(rollout_manager.onload_weights.remote()) + was_paused = ray.get(rollout_manager.pause_rollout_admission.remote()) actor_model.update_weights() + if rollout_id + 1 < args.num_rollout: + ray.get(rollout_manager.resume_rollout_admission.remote(was_paused)) if args.offload_rollout: ray.get(rollout_manager.onload_kv.remote()) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 023d52eb3..b6e624f15 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -30,7 +30,7 @@ ) from vime.utils.routed_experts import RoutedExpertsLayerRef, RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher from vime.utils.routing_replay import RoutingReplay -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import RolloutBatch from ...utils.tensor_backper import TensorBackuper @@ -268,7 +268,7 @@ def _get_rollout_data(self, rollout_data_ref: Box) -> RolloutBatch: rollout_data[key] = [ ( value - if isinstance(value, DiskTensorRef) + if isinstance(value, (TensorRef, DiskTensorRef)) else (value if self.args.allgather_cp else slice_log_prob_with_cp(value, total, response)).to( device="cpu", dtype=dtype ) @@ -329,7 +329,7 @@ def fill_routing_replay(self, data_iterator, num_microbatches, rollout_data): batch = iterator.get_next(["rollout_routed_experts", "tokens"]) values = batch["rollout_routed_experts"] - disk_backed = [isinstance(value, DiskTensorRef) for value in values] + disk_backed = [isinstance(value, (TensorRef, DiskTensorRef)) for value in values] if any(disk_backed) and not all(disk_backed): raise ValueError("A routing replay microbatch cannot mix disk-backed and resident route tensors.") diff --git a/vime/backends/megatron_utils/cp_utils.py b/vime/backends/megatron_utils/cp_utils.py index a54da3a7e..7d447c365 100644 --- a/vime/backends/megatron_utils/cp_utils.py +++ b/vime/backends/megatron_utils/cp_utils.py @@ -5,6 +5,10 @@ import torch.nn.functional as F from megatron.core import mpu +from vime.utils.tensor_store import DiskTensorRef, TensorRef + +_RoutedExpertsInput = torch.Tensor | TensorRef | DiskTensorRef + def get_logits_and_tokens_offset_with_cp( total_length: int, @@ -22,7 +26,10 @@ def get_logits_and_tokens_offset_with_cp( # the offset of 2 chunks chunk_0 = (cp_rank * chunk_size, (cp_rank + 1) * chunk_size) - chunk_1 = ((2 * cp_size - cp_rank - 1) * chunk_size, (2 * cp_size - cp_rank) * chunk_size) + chunk_1 = ( + (2 * cp_size - cp_rank - 1) * chunk_size, + (2 * cp_size - cp_rank) * chunk_size, + ) # the offset of 2 logits, note that the logits need a "-1". logits_0 = (max(chunk_0[0], prompt_length - 1), min(chunk_0[1], total_length - 1)) @@ -75,7 +82,10 @@ def sum_of_sample_mean(x: torch.Tensor) -> torch.Tensor: [ (x_i * loss_mask_i).sum() / torch.clamp_min(denom, 1) for x_i, loss_mask_i, denom in zip( - x.split(response_lengths, dim=0), loss_masks, sample_denoms, strict=False + x.split(response_lengths, dim=0), + loss_masks, + sample_denoms, + strict=False, ) ] ) @@ -106,7 +116,10 @@ def sum_of_sample_mean(x: torch.Tensor) -> torch.Tensor: [ (x_i * chunked_loss_mask).sum() / torch.clamp_min(denom, 1) for x_i, chunked_loss_mask, denom in zip( - x.split(cp_chunk_lengths, dim=0), chunked_loss_masks, sample_denoms, strict=False + x.split(cp_chunk_lengths, dim=0), + chunked_loss_masks, + sample_denoms, + strict=False, ) ] ) @@ -116,7 +129,9 @@ def sum_of_token(x: torch.Tensor) -> torch.Tensor: [ (x_i * chunked_loss_mask).sum() for x_i, chunked_loss_mask in zip( - x.split(cp_chunk_lengths, dim=0), chunked_loss_masks, strict=False + x.split(cp_chunk_lengths, dim=0), + chunked_loss_masks, + strict=False, ) ] ) @@ -236,23 +251,212 @@ def slice_log_prob_with_cp( return torch.cat([chunk_1, chunk_2], dim=0) -def _pad_routed_experts(experts: torch.Tensor, pad: int, num_experts: int) -> torch.Tensor: - if pad == 0: - return experts - _, num_layers, topk = experts.shape +def _make_routed_experts_pad( + rows: int, + num_layers: int, + topk: int, + *, + num_experts: int, + device: torch.device, + dtype: torch.dtype, + row_offset: int = 0, +) -> torch.Tensor: + if rows == 0: + return torch.empty((0, num_layers, topk), device=device, dtype=dtype) + + numel_per_row = num_layers * topk pad_experts = ( torch.arange( - pad * num_layers * topk, - device=experts.device, - dtype=experts.dtype, - ).reshape((pad, num_layers, topk)) + rows * numel_per_row, + device=device, + dtype=torch.int64, + ).add_(row_offset * numel_per_row) % num_experts ) - return torch.cat([experts, pad_experts], dim=0) + return pad_experts.to(dtype=dtype).reshape((rows, num_layers, topk)) + + +def _new_routed_experts_buffer( + rows: int, + num_layers: int, + topk: int, + reference: _RoutedExpertsInput, +) -> torch.Tensor: + kwargs = { + "device": (torch.device("cpu") if isinstance(reference, (TensorRef, DiskTensorRef)) else reference.device), + "dtype": (reference.torch_dtype if isinstance(reference, (TensorRef, DiskTensorRef)) else reference.dtype), + } + if kwargs["device"].type == "cpu": + return torch.empty((rows, num_layers, topk), pin_memory=True, **kwargs) + return torch.empty((rows, num_layers, topk), **kwargs) + + +def _fill_routed_experts_pad( + dst: torch.Tensor, + dst_start: int, + rows: int, + *, + num_experts: int, + row_offset: int = 0, +) -> None: + if rows == 0: + return + _, num_layers, topk = dst.shape + dst[dst_start : dst_start + rows].copy_( + _make_routed_experts_pad( + rows, + num_layers, + topk, + num_experts=num_experts, + device=dst.device, + dtype=dst.dtype, + row_offset=row_offset, + ) + ) + + +def _fill_padded_sample_range( + dst: torch.Tensor, + dst_start: int, + experts: _RoutedExpertsInput, + sample_start: int, + sample_end: int, + *, + num_experts: int, +) -> None: + """Copy a range from ``experts + sample-tail-pad + cp-pad`` into ``dst``.""" + if sample_start >= sample_end: + return + + actual_len = experts.shape[0] + token_len = actual_len + 1 + + # Real routed-expert rows. + real_start = max(sample_start, 0) + real_end = min(sample_end, actual_len) + if real_start < real_end: + out_start = dst_start + real_start - sample_start + dst[out_start : out_start + real_end - real_start].copy_(experts[real_start:real_end]) + + # The per-sample row added before CP slicing. + tail_start = max(sample_start, actual_len) + tail_end = min(sample_end, token_len) + if tail_start < tail_end: + out_start = dst_start + tail_start - sample_start + _fill_routed_experts_pad( + dst, + out_start, + tail_end - tail_start, + num_experts=num_experts, + row_offset=tail_start - actual_len, + ) + + # Extra rows added by CP padding. This is a separate pad call in the old + # implementation, so its deterministic expert pattern starts again at 0. + cp_pad_start = max(sample_start, token_len) + cp_pad_end = sample_end + if cp_pad_start < cp_pad_end: + out_start = dst_start + cp_pad_start - sample_start + _fill_routed_experts_pad( + dst, + out_start, + cp_pad_end - cp_pad_start, + num_experts=num_experts, + row_offset=cp_pad_start - token_len, + ) + + +def _fill_routed_experts_allgather_range( + dst: torch.Tensor, + range_start: int, + range_end: int, + rollout_routed_experts: Sequence[_RoutedExpertsInput], + *, + total_rows: int, + num_experts: int, +) -> None: + pos = 0 + for experts in rollout_routed_experts: + sample_len = experts.shape[0] + 1 + overlap_start = max(range_start, pos) + overlap_end = min(range_end, pos + sample_len) + if overlap_start < overlap_end: + _fill_padded_sample_range( + dst, + overlap_start - range_start, + experts, + overlap_start - pos, + overlap_end - pos, + num_experts=num_experts, + ) + pos += sample_len + if pos >= range_end: + return + + overlap_start = max(range_start, total_rows) + overlap_end = range_end + if overlap_start < overlap_end: + _fill_routed_experts_pad( + dst, + overlap_start - range_start, + overlap_end - overlap_start, + num_experts=num_experts, + row_offset=overlap_start - total_rows, + ) + + +def _fill_routed_experts_cp_range( + dst: torch.Tensor, + range_start: int, + range_end: int, + sample_specs: Sequence[tuple[_RoutedExpertsInput, int, list[tuple[int, int]]]], + *, + total_rows: int, + num_experts: int, +) -> None: + pos = 0 + for experts, sample_out_len, chunks in sample_specs: + overlap_start = max(range_start, pos) + overlap_end = min(range_end, pos + sample_out_len) + if overlap_start < overlap_end: + rel_start = overlap_start - pos + rel_end = overlap_end - pos + chunk_out_start = 0 + for chunk_src_start, chunk_src_end in chunks: + chunk_len = chunk_src_end - chunk_src_start + chunk_overlap_start = max(rel_start, chunk_out_start) + chunk_overlap_end = min(rel_end, chunk_out_start + chunk_len) + if chunk_overlap_start < chunk_overlap_end: + dst_offset = overlap_start - range_start + chunk_overlap_start - rel_start + src_start = chunk_src_start + chunk_overlap_start - chunk_out_start + src_end = chunk_src_start + chunk_overlap_end - chunk_out_start + _fill_padded_sample_range( + dst, + dst_offset, + experts, + src_start, + src_end, + num_experts=num_experts, + ) + chunk_out_start += chunk_len + pos += sample_out_len + if pos >= range_end: + return + + overlap_start = max(range_start, total_rows) + overlap_end = range_end + if overlap_start < overlap_end: + _fill_routed_experts_pad( + dst, + overlap_start - range_start, + overlap_end - overlap_start, + num_experts=num_experts, + row_offset=overlap_start - total_rows, + ) def prepare_routed_experts_for_routing_replay( - rollout_routed_experts: Sequence[torch.Tensor], + rollout_routed_experts: Sequence[_RoutedExpertsInput], tokens: Sequence[torch.Tensor], *, num_experts: int, @@ -260,38 +464,84 @@ def prepare_routed_experts_for_routing_replay( sequence_parallel: bool, allgather_cp: bool, ) -> torch.Tensor: - """Align rollout routed-experts metadata with the training token layout.""" + """Align routes with training tokens, reading only this rank's lazy rows.""" assert len(rollout_routed_experts) == len(tokens) + assert len(rollout_routed_experts) > 0 + _, num_layers, topk = rollout_routed_experts[0].shape for experts, token_ids in zip(rollout_routed_experts, tokens, strict=False): assert experts.shape[0] == token_ids.shape[0] - 1, f"{experts.shape}, {token_ids.shape}" + assert experts.shape[1:] == ( + num_layers, + topk, + ), f"{experts.shape}, expected (*, {num_layers}, {topk})" - padded_experts = [_pad_routed_experts(experts, 1, num_experts) for experts in rollout_routed_experts] pad_size = mpu.get_tensor_model_parallel_world_size() * data_pad_size_multiplier + cp_size = mpu.get_context_parallel_world_size() + cp_rank = mpu.get_context_parallel_rank() + range_start = 0 if allgather_cp: - routed_experts = torch.cat(padded_experts, dim=0) - cp_size = mpu.get_context_parallel_world_size() - cp_rank = mpu.get_context_parallel_rank() + total_rows = sum(experts.shape[0] + 1 for experts in rollout_routed_experts) global_pad_size = cp_size * pad_size - pad = (global_pad_size - routed_experts.size(0) % global_pad_size) % global_pad_size - routed_experts = _pad_routed_experts(routed_experts, pad, num_experts) - routed_experts = routed_experts.chunk(cp_size, dim=0)[cp_rank] + pad = (global_pad_size - total_rows % global_pad_size) % global_pad_size + final_rows = total_rows + pad + cp_chunk_size = final_rows // cp_size + range_start = cp_rank * cp_chunk_size + range_end = range_start + cp_chunk_size else: - routed_experts = [ - slice_with_cp(experts, lambda x, pad: _pad_routed_experts(x, pad, num_experts)) - for experts in padded_experts - ] - routed_experts = torch.cat(routed_experts, dim=0) - pad = (pad_size - routed_experts.size(0) % pad_size) % pad_size - routed_experts = _pad_routed_experts(routed_experts, pad, num_experts) + sample_specs: list[tuple[_RoutedExpertsInput, int, list[tuple[int, int]]]] = [] + total_rows = 0 + for experts in rollout_routed_experts: + token_len = experts.shape[0] + 1 + if cp_size == 1: + chunks = [(0, token_len)] + else: + chunk_size = (token_len + 2 * cp_size - 1) // (2 * cp_size) + chunks = [ + (chunk_size * cp_rank, chunk_size * (cp_rank + 1)), + ( + chunk_size * (2 * cp_size - cp_rank - 1), + chunk_size * (2 * cp_size - cp_rank), + ), + ] + sample_out_len = sum(end - start for start, end in chunks) + sample_specs.append((experts, sample_out_len, chunks)) + total_rows += sample_out_len + pad = (pad_size - total_rows % pad_size) % pad_size + final_rows = total_rows + pad + range_end = final_rows if sequence_parallel: tp_rank = mpu.get_tensor_model_parallel_rank() tp_size = mpu.get_tensor_model_parallel_world_size() - seqlen = routed_experts.size(0) + seqlen = range_end - range_start assert seqlen % tp_size == 0 - start = seqlen // tp_size * tp_rank - end = seqlen // tp_size * (tp_rank + 1) - routed_experts = routed_experts[start:end] - + tp_chunk_size = seqlen // tp_size + range_start += tp_chunk_size * tp_rank + range_end = range_start + tp_chunk_size + + routed_experts = _new_routed_experts_buffer( + range_end - range_start, + num_layers, + topk, + rollout_routed_experts[0], + ) + if allgather_cp: + _fill_routed_experts_allgather_range( + routed_experts, + range_start, + range_end, + rollout_routed_experts, + total_rows=total_rows, + num_experts=num_experts, + ) + else: + _fill_routed_experts_cp_range( + routed_experts, + range_start, + range_end, + sample_specs, + total_rows=total_rows, + num_experts=num_experts, + ) return routed_experts diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 8f2260848..00174d486 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -27,7 +27,7 @@ importance_weights, ) from vime.utils.score_centering import get_score_centering_is_config, score_centering_correction -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import RolloutBatch from .cp_utils import ( @@ -331,7 +331,7 @@ def _build_shifted_tokens( def _fill_topp_mask_rows( keep: torch.Tensor, - ids: list[int], + ids: list[int] | torch.Tensor | TensorRef, offsets: list[int], response_start: int, local_start: int, @@ -340,10 +340,18 @@ def _fill_topp_mask_rows( vocab_end: int, ) -> None: end = min(response_start + length, max(len(offsets) - 1, 0)) + if end <= response_start: + return + # Each CP half is contiguous. Read its support in one range, never by + # iterating a TensorRef (which only supports contiguous slices). + base = offsets[response_start] + ids = ids[base : offsets[end]] + if torch.is_tensor(ids): + ids = ids.tolist() for response_idx in range(response_start, end): local_ids = [ token_id - vocab_start - for token_id in ids[offsets[response_idx] : offsets[response_idx + 1]] + for token_id in ids[offsets[response_idx] - base : offsets[response_idx + 1] - base] if vocab_start <= token_id < vocab_end ] row = local_start + response_idx - response_start @@ -356,8 +364,8 @@ def _build_topp_keep_mask( T: int, vocab_local: int, device: torch.device, - top_p_token_ids: list[list[int]], - top_p_token_offsets: list[list[int]], + top_p_token_ids: list[list[int] | torch.Tensor | TensorRef], + top_p_token_offsets: list[list[int] | torch.Tensor | TensorRef], total_lengths: list[int], response_lengths: list[int], allgather_cp: bool, @@ -373,9 +381,11 @@ def _build_topp_keep_mask( vocab_start = tp_rank * vocab_local vocab_end = vocab_start + vocab_local - # Normalize ragged payloads (may arrive as CPU int32 tensors) to python lists. - top_p_token_ids = [t.tolist() if torch.is_tensor(t) else list(t) for t in top_p_token_ids] - top_p_token_offsets = [t.tolist() if torch.is_tensor(t) else list(t) for t in top_p_token_offsets] + # Only offsets are needed in full; ids stay lazy until selecting CP rows. + top_p_token_offsets = [ + t.load().tolist() if isinstance(t, (TensorRef, DiskTensorRef)) else t.tolist() if torch.is_tensor(t) else list(t) + for t in top_p_token_offsets + ] keep = torch.ones((T, vocab_local), dtype=torch.bool, device=device) @@ -1000,13 +1010,28 @@ def get_score_centering_terms(args, batch, logits): batch["rollout_top_p_log_probs"], strict=True, ): + if isinstance(offsets, (TensorRef, DiskTensorRef)): + offsets = offsets.load() offsets = torch.as_tensor(offsets, device="cpu", dtype=torch.long) - spans = [torch.arange(offsets[i], offsets[i + 1]) for i in indices.tolist()] - selected = torch.cat(spans) if spans else torch.empty(0, dtype=torch.long) + # Coalesce adjacent row supports, preserving zigzag/allgather CP + # order while avoiding a filesystem read per response token. + spans = [] + for i in indices.tolist(): + start, end = int(offsets[i]), int(offsets[i + 1]) + if start == end: + continue + if spans and spans[-1][1] == start: + spans[-1] = (spans[-1][0], end) + else: + spans.append((start, end)) lengths = offsets[indices + 1] - offsets[indices] local_offsets = torch.cat((lengths.new_zeros(1), lengths.cumsum(0))).to(logits.device) - head_ids = torch.as_tensor(ids)[selected].to(device=logits.device, dtype=torch.long) - q = torch.as_tensor(q)[selected].to(device=logits.device, dtype=torch.float32) + head_ids, q = [ + ( + torch.cat([torch.as_tensor(value[start:end]) for start, end in spans]) if spans else torch.empty(0) + ).to(device=logits.device, dtype=dtype) + for value, dtype in ((ids, torch.long), (q, torch.float32)) + ] p = calculate_ragged_log_probs( rows, head_ids, local_offsets, mpu.get_tensor_model_parallel_group(), args.rollout_temperature ) @@ -1041,7 +1066,7 @@ def local_heads(values): if mpu.get_context_parallel_world_size() == 1 else slice_log_prob_with_cp(value, total, response) ) - if isinstance(value, DiskTensorRef) + if isinstance(value, (TensorRef, DiskTensorRef)) else value ) for value, total, response in zip(values, total_lengths, response_lengths, strict=True) diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index f2338540b..001f348ae 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -6,7 +6,7 @@ import torch from vime.utils.routed_experts import validate_routed_experts_value -from vime.utils.tensor_store import DiskTensorRef, retain_debug_tensor_refs +from vime.utils.tensor_store import DiskTensorRef, TensorRef, materialize_tensor_refs, retain_debug_tensor_refs from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: for key, dtype in _ROLLOUT_DATA_TENSOR_DTYPES.items(): if key in rollout_data: rollout_data[key] = [ - value if isinstance(value, DiskTensorRef) else _cpu_tensor(value, dtype=dtype) + value if isinstance(value, (TensorRef, DiskTensorRef)) else _cpu_tensor(value, dtype=dtype) for value in rollout_data[key] ] @@ -61,7 +61,7 @@ def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: def validate_rollout_routed_experts_for_replay( - routed_experts: list[torch.Tensor | DiskTensorRef], + routed_experts: list[torch.Tensor | TensorRef | DiskTensorRef], args, expected_rows: list[int] | None = None, ) -> None: @@ -141,4 +141,4 @@ def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: dump_data = {"rollout_id": rollout_id, "samples": samples} retain_debug_tensor_refs(dump_data, path) - torch.save(dump_data, path) + torch.save(materialize_tensor_refs(dump_data), path) diff --git a/vime/observability/rollout_metrics.py b/vime/observability/rollout_metrics.py index 299df7dbf..3df92b605 100644 --- a/vime/observability/rollout_metrics.py +++ b/vime/observability/rollout_metrics.py @@ -13,7 +13,8 @@ has_repetition, ) from vime.utils.misc import group_by, load_function -from vime.utils.staleness import compute_staleness_metrics, fully_async_metrics_enabled +from vime.utils.staleness import compute_staleness_metrics +from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -174,6 +175,8 @@ def _compute_top_p_kept_vocab_metrics(all_samples: list[Sample]): offsets = sample.rollout_top_p_token_offsets if offsets is None or sample.response_length == 0: continue + if isinstance(offsets, (TensorRef, DiskTensorRef)): + offsets = offsets.load() offsets = torch.as_tensor(offsets, dtype=torch.int64) if offsets.numel() == 0: continue @@ -271,8 +274,6 @@ def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_t return log_dict = {**(rollout_extra_metrics or {})} - if fully_async_metrics_enabled(args): - log_dict.pop("_dropped_samples", None) log_dict |= dict_add_prefix( compute_staleness_metrics(samples, getattr(args, "_rollout_weight_version", None)), "rollout/" ) diff --git a/vime/observability/train_data_utils.py b/vime/observability/train_data_utils.py index 2036ac7a3..8098fab95 100644 --- a/vime/observability/train_data_utils.py +++ b/vime/observability/train_data_utils.py @@ -5,7 +5,7 @@ import torch -from vime.utils.tensor_store import DiskTensorRef, retain_debug_tensor_refs +from vime.utils.tensor_store import DiskTensorRef, TensorRef, materialize_tensor_refs logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def restore_context_parallel_fields_to_cpu( full_values = [] if keep_restored else None for value, total_length, response_length in zip(values, total_lengths, response_lengths, strict=True): # Disk references and allgather-CP heads retain full response rows. - if isinstance(value, DiskTensorRef) or allgather_cp: + if isinstance(value, (TensorRef, DiskTensorRef)) or allgather_cp: full_value = value if len(full_value) != response_length: raise ValueError(f"Full sampler head field {key!r} must have {response_length} rows.") @@ -303,5 +303,4 @@ def save_debug_train_data(args, *, rollout_id, rollout_data): logger.info(f"Save debug train data from {dp_size} DP shard(s) to {path}") path.parent.mkdir(parents=True, exist_ok=True) dump_data = _build_dump_payload(dp_shards, rollout_id=rollout_id, writer_rank=rank) - retain_debug_tensor_refs(dump_data, path) - torch.save(dump_data, path) + torch.save(materialize_tensor_refs(dump_data), path) diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 07304ee7b..f8f1a5e67 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -5,30 +5,25 @@ import psutil import ray -import torch -from vime.backends.vllm_utils.deployment import start_rollout_servers from vime.observability import logging_utils from vime.observability.logging_utils import configure_logger, init_tracking from vime.observability.rollout_data_utils import ( load_debug_rollout_data, save_debug_rollout_data, - tensorize_rollout_data_for_training, validate_rollout_id_annotated, - validate_rollout_routed_experts_for_replay, ) from vime.observability.rollout_metrics import log_eval_rollout_data, log_rollout_data from vime.rollout.base_types import call_rollout_fn +from vime.rollout.batch_builder import BatchBuilder from vime.rollout.sample_hooks import set_current_rollout_id -from vime.utils.data import get_source -from vime.utils.dp_schedule import build_dp_schedule from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client from vime.utils.memory_utils import get_process_host_memory_gib -from vime.utils.misc import Box, load_function +from vime.utils.misc import load_function +from vime.utils.rollout_transport import accept_raw_rollout, check_rollout_storage, load_rollout_samples, seal_rollout_store from vime.utils.staleness import fully_async_metrics_enabled from vime.utils.tensor_store import DiskTensorRef -from vime.utils.types import Sample from .utils import Lock, add_default_ray_env_vars @@ -47,11 +42,18 @@ def __init__(self, args, pg): self.pg = pg self.args = args + if args.rollout_data_transport == "straw": + check_rollout_storage(args) + from vime.rollout.queue_data_source import create_queue_controller + + create_queue_controller(args) rollout_init_handles: list[Any] = [] if self.args.debug_train_only: self.servers: dict[str, Any] = {} else: + from vime.backends.vllm_utils.deployment import start_rollout_servers + init_http_client(args) self.servers, rollout_init_handles = start_rollout_servers(args, pg) @@ -60,14 +62,7 @@ def __init__(self, args, pg): self.generate_rollout = load_function(self.args.rollout_function_path) self.eval_generate_rollout = load_function(self.args.eval_function_path) - self.custom_reward_post_process_func = None - if self.args.custom_reward_post_process_path is not None: - self.custom_reward_post_process_func = load_function(self.args.custom_reward_post_process_path) - self.custom_convert_samples_to_train_data_func = None - if self.args.custom_convert_samples_to_train_data_path is not None: - self.custom_convert_samples_to_train_data_func = load_function( - self.args.custom_convert_samples_to_train_data_path - ) + self.batch_builder = BatchBuilder(args) logger.info(f"import {self.args.rollout_function_path} as generate_rollout function.") logger.info(f"import {self.args.eval_function_path} as eval_generate_rollout function.") @@ -137,11 +132,27 @@ def _try_ci_fault_injection(self): except Exception as e: logger.warning(f"CI Fault Injection failed: {e}") + def pause_rollout_admission(self): + """Stop the distributed producer before engines drain for a weight update.""" + worker = getattr(self.data_source, "consumers", {}).get("fully_async") + return worker.pause(drain=False) if worker is not None else True + + def resume_rollout_admission(self, was_paused): + if not was_paused: + self.data_source.consumers["fully_async"].resume() + def dispose(self): for monitor in self._health_monitors: monitor.stop() for rollout_id in list(self._active_routed_experts_rollouts): self.cleanup_rollout_data(rollout_id) + if close := getattr(self.data_source, "close", None): + close() + if controller := getattr(self.args, "_rollout_queue_controller", None): + ray.get(controller.close.remote()) + ray.kill(controller, no_restart=True) + self.args._rollout_queue_controller = None + seal_rollout_store(self.args) engines = [engine for server in self.servers.values() for engine in server.all_engines if engine is not None] if engines: ray.get([engine.shutdown.remote() for engine in engines]) @@ -151,7 +162,8 @@ def cleanup_rollout_data(self, rollout_id: int) -> None: from vime.utils.routed_experts import cleanup_routed_experts_rollout cleanup_routed_experts_rollout(self.args, rollout_id) - self._active_routed_experts_rollouts.discard(rollout_id) + if active := getattr(self, "_active_routed_experts_rollouts", None): + active.discard(rollout_id) @property def server(self) -> Any | None: @@ -197,6 +209,7 @@ def get_num_rollout_per_epoch(self): def generate(self, rollout_id): start_time = time.time() self.rollout_id = rollout_id + self.batch_builder.rollout_id = rollout_id set_current_rollout_id(rollout_id) self.health_monitoring_resume() if self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: @@ -232,8 +245,11 @@ def generate(self, rollout_id): if self.args.debug_rollout_only: # if debug rollout only, we don't convert samples to train data and directly return return - data = self._convert_samples_to_train_data(data) - return self._split_train_data_by_dp(data) + cached = self.batch_builder.begin(data) + if cached is not None: + return cached + data = self.batch_builder.convert(data) + return self.batch_builder.split_by_dp(data) def eval(self, rollout_id): if self.args.debug_train_only: @@ -254,9 +270,15 @@ def eval(self, rollout_id): def save(self, rollout_id): self.data_source.save(rollout_id) + self.batch_builder.save(rollout_id) + + def training_completed(self, rollout_id): + self.batch_builder.training_completed(rollout_id) + self.cleanup_rollout_data(rollout_id) def load(self, rollout_id=None): self.data_source.load(rollout_id) + self.batch_builder.load(rollout_id) def offload(self): self.health_monitoring_pause() @@ -325,8 +347,14 @@ def _get_rollout_data(self, rollout_id): ) self.args._rollout_weight_version = max(map(int, versions)) if valid else None data = call_rollout_fn(self.generate_rollout, self.args, rollout_id, self.data_source, evaluation=False) - metrics = data.metrics - data = data.samples + if self.args.rollout_data_transport == "straw": + data = accept_raw_rollout(data, self.args, rollout_id) + self.batch_builder.raw_ref = data + metrics = data.metrics + data = load_rollout_samples(data) + else: + metrics = data.metrics + data = load_rollout_samples(data.samples) # Enforce the rollout_id contract before flattening: any list[Sample] # encountered in the nested output must have rollout_id set on every # element. Default rollouts inherit it from the data source; compact / @@ -335,297 +363,10 @@ def _get_rollout_data(self, rollout_id): # the rollout once instead of N times. validate_rollout_id_annotated(data) # flatten the data if it is a list of lists - while isinstance(data[0], list): + while data and isinstance(data[0], list): data = list(itertools.chain.from_iterable(data)) return data, metrics - def _post_process_rewards(self, samples: list[Sample] | list[list[Sample]]): - if self.custom_reward_post_process_func is not None: - return self.custom_reward_post_process_func(self.args, samples) - - raw_rewards = [sample.get_reward_value(self.args) for sample in samples] - if ( - self.args.advantage_estimator in ["grpo", "gspo", "cispo", "reinforce_plus_plus_baseline"] - and self.args.rewards_normalization - ): - # group norm - rewards = torch.tensor(raw_rewards, dtype=torch.float) - if rewards.shape[-1] == self.args.n_samples_per_prompt * self.args.rollout_batch_size: - rewards = rewards.reshape(-1, self.args.n_samples_per_prompt) - else: - # when samples count are not equal in each group - rewards = rewards.view(-1, rewards.shape[-1]) - mean = rewards.mean(dim=-1, keepdim=True) - rewards = rewards - mean - - if self.args.advantage_estimator in ["grpo", "gspo", "cispo"] and self.args.grpo_std_normalization: - std = rewards.std(dim=-1, keepdim=True) - rewards = rewards / (std + 1e-6) - - return raw_rewards, rewards.flatten().tolist() - - return raw_rewards, raw_rewards - - def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sample]]): - """ - Convert inference generated samples to training data. - """ - if self.custom_convert_samples_to_train_data_func is not None: - return self.custom_convert_samples_to_train_data_func(self.args, samples) - - raw_rewards, rewards = self._post_process_rewards(samples) - - assert len(raw_rewards) == len(samples) - assert len(rewards) == len(samples) - - rollout_ids = [sample.rollout_id for sample in samples] - existed_rollout_id_values = set(rid for rid in rollout_ids if rid is not None) - tmp_id = 0 - for i in range(len(rollout_ids)): - if rollout_ids[i] is None: - while tmp_id in existed_rollout_id_values: - tmp_id += 1 - rollout_ids[i] = tmp_id - existed_rollout_id_values.add(tmp_id) - - train_data = { - "tokens": [sample.tokens for sample in samples], - "response_lengths": [sample.response_length for sample in samples], - # some reward model, e.g. remote rm, may return multiple rewards, - # we could use key to select the reward. - "rewards": rewards, - "raw_reward": raw_rewards, - "truncated": [1 if sample.status == Sample.Status.TRUNCATED else 0 for sample in samples], - "sample_indices": [sample.index for sample in samples], - "rollout_ids": rollout_ids, - } - - # loss mask - # TODO: compress the loss mask - loss_masks = [] - for sample in samples: - # always instantiate loss_mask if not provided - if sample.loss_mask is None: - sample.loss_mask = [1] * sample.response_length - - assert ( - len(sample.loss_mask) == sample.response_length - ), f"loss mask length {len(sample.loss_mask)} != response length {sample.response_length}" - if sample.remove_sample: - sample.loss_mask = [0] * sample.response_length - loss_masks.append(sample.loss_mask) - train_data["loss_masks"] = loss_masks - - # Per-rollout aggregate, precomputed at the step level (where we can - # see every sample of every rollout) and broadcast per-sample so the - # per-mb loss reducer uses the correct whole-rollout denominator even - # when a rollout's samples land in different micro-batches (first-fit - # packing can split a rollout across mbs): - # - # ``rollout_mask_sums[i]`` — sum of loss-mask totals over every - # sample in sample i's rollout. Used as the reducer's denominator - # so summing partial contributions across mbs yields one - # token-weighted mean per rollout. - rollout_id_list = train_data["rollout_ids"] - mask_sums_per_sample = [sum(m) for m in loss_masks] - rollout_total_mask: dict[int, int] = {} - for rid, ms in zip(rollout_id_list, mask_sums_per_sample, strict=True): - rollout_total_mask[rid] = rollout_total_mask.get(rid, 0) + ms - train_data["rollout_mask_sums"] = [rollout_total_mask[rid] for rid in rollout_id_list] - - # Overwrite raw_reward when available. Mixed-source batches may only - # populate this field for a subset of samples (e.g. SWE but not code). - if any(sample.metadata and "raw_reward" in sample.metadata for sample in samples): - train_data["raw_reward"] = [ - sample.metadata["raw_reward"] if sample.metadata and "raw_reward" in sample.metadata else sample.reward - for sample in samples - ] - - # For rollout buffer - if samples[0].metadata and "round_number" in samples[0].metadata: - train_data["round_number"] = [sample.metadata["round_number"] for sample in samples] - - # Add rollout log probabilities for off-policy correction - if samples[0].rollout_log_probs is not None: - train_data["rollout_log_probs"] = [sample.rollout_log_probs for sample in samples] - - if getattr(self.args, "use_score_centering", False): - from vime.utils.score_centering import validate_sampler_top_p, validate_sampler_topk - - for sample in samples: - if sample.rollout_log_probs is None or len(sample.rollout_log_probs) != sample.response_length: - raise ValueError("Score centering requires sampler logprobs for every response token.") - if self.args.rollout_top_p < 1: - if sample.response_length == 0 and sample.rollout_top_p_token_ids is None: - sample.rollout_top_p_token_ids = torch.empty(0, dtype=torch.int32) - sample.rollout_top_p_token_offsets = torch.zeros(1, dtype=torch.int32) - sample.rollout_top_p_log_probs = torch.empty(0, dtype=torch.float32) - validate_sampler_top_p( - sample.rollout_top_p_token_ids, - sample.rollout_top_p_token_offsets, - sample.rollout_top_p_log_probs, - sample.response_length, - sample.loss_mask, - sample.tokens[-sample.response_length :] if sample.response_length else [], - sample.rollout_log_probs, - ) - else: - validate_sampler_topk(sample, self.args.score_centering_top_k) - if self.args.rollout_top_p < 1: - train_data["rollout_top_p_log_probs"] = [sample.rollout_top_p_log_probs for sample in samples] - else: - train_data["rollout_topk_token_ids"] = [sample.rollout_topk_token_ids for sample in samples] - train_data["rollout_topk_log_probs"] = [sample.rollout_topk_log_probs for sample in samples] - - if getattr(self.args, "rollout_top_p", 1.0) != 1.0: - for sample in samples: - assert sample.rollout_top_p_token_ids is not None - assert sample.rollout_top_p_token_offsets is not None - assert len(sample.rollout_top_p_token_offsets) == sample.response_length + 1, ( - f"top-p token offsets length {len(sample.rollout_top_p_token_offsets)} " - f"!= response length + 1 {sample.response_length + 1}" - ) - offset_end = int(sample.rollout_top_p_token_offsets[-1]) - assert offset_end == len( - sample.rollout_top_p_token_ids - ), f"top-p token offsets[-1] {offset_end} != token ids length {len(sample.rollout_top_p_token_ids)}" - train_data["rollout_top_p_token_ids"] = [sample.rollout_top_p_token_ids for sample in samples] - train_data["rollout_top_p_token_offsets"] = [sample.rollout_top_p_token_offsets for sample in samples] - - routed_experts_present = [sample.rollout_routed_experts is not None for sample in samples] - dead_sample_indices = { - idx - for idx, (present, sample) in enumerate(zip(routed_experts_present, samples, strict=True)) - if not present and sample.loss_mask is not None and not any(sample.loss_mask) - } - live_missing = [ - idx for idx, present in enumerate(routed_experts_present) if not present and idx not in dead_sample_indices - ] - routing_replay_enabled = getattr(self.args, "use_rollout_routing_replay", False) - if live_missing and (routing_replay_enabled or any(routed_experts_present)): - raise ValueError(f"Rollout routed experts are missing for live sample indices {live_missing[:32]}.") - - if any(routed_experts_present) or (routing_replay_enabled and dead_sample_indices): - dtype = torch.uint8 if self.args.num_experts <= 256 else torch.int32 - routed_experts = [ - ( - ( - sample.rollout_routed_experts - if isinstance(sample.rollout_routed_experts, DiskTensorRef) - else sample.materialize_rollout_routed_experts() - ) - if present - else torch.zeros( - (max(0, len(sample.tokens) - 1), self.args.num_layers, self.args.moe_router_topk), dtype=dtype - ) - ) - for sample, present in zip(samples, routed_experts_present, strict=True) - ] - if routing_replay_enabled and any(routed_experts_present): - captured_pairs = [ - (experts, max(0, len(sample.tokens) - 1)) - for sample, experts, present in zip(samples, routed_experts, routed_experts_present, strict=True) - if present - ] - validate_rollout_routed_experts_for_replay( - [experts for experts, _ in captured_pairs], - self.args, - expected_rows=[rows for _, rows in captured_pairs], - ) - if dead_sample_indices: - logger.warning( - "Using all-zero routed experts for %d fully loss-masked samples (indices %s).", - len(dead_sample_indices), - sorted(dead_sample_indices)[:16], - ) - train_data["rollout_routed_experts"] = routed_experts - - if samples[0].train_metadata is not None: - train_data["metadata"] = [sample.train_metadata for sample in samples] - - if any(sample.multimodal_train_inputs is not None for sample in samples): - train_data["multimodal_train_inputs"] = [sample.multimodal_train_inputs for sample in samples] - - if samples[0].teacher_log_probs is not None: - train_data["teacher_log_probs"] = [sample.teacher_log_probs for sample in samples] - - if samples[0].metadata is not None: - train_data["source_names"] = [get_source(sample) for sample in samples] - - return train_data - def set_train_parallel_config(self, config: dict): - self.train_parallel_config = config - - def _split_train_data_by_dp(self, data): - """Compute the DP/mbs schedule and package each rank's rollout_data - into a Ray Box. The schedule itself is computed by - :func:`build_dp_schedule` so it stays unit-testable without Ray/vllm. - - Step split is by rollout id (``samples[i].rollout_id``, falling back - to ``samples[i].index``); each step holds exactly - ``args.global_batch_size`` rollouts so the training-step count per - rollout is fixed at ``rollout_batch_size * n_samples_per_prompt // - global_batch_size`` regardless of how many training samples each - rollout produced. - """ - dp_size = self.train_parallel_config["dp_size"] - total_lengths = [len(t) for t in data["tokens"]] - data["total_lengths"] = total_lengths - - partitions, micro_batch_indices, num_microbatches, global_batch_sizes = build_dp_schedule( - self.args, - self.train_parallel_config, - total_lengths, - global_batch_size=self.args.global_batch_size, - rollout_indices=data["rollout_ids"], - ) - - # Package per-rank rollout_data - rollout_data_refs = [] - for r in range(dp_size): - partition = partitions[r] - rollout_data = {"partition": partition} - for key in [ - "tokens", - "multimodal_train_inputs", - "response_lengths", - "rewards", - "truncated", - "loss_masks", - "round_number", - "sample_indices", - "rollout_ids", - "rollout_mask_sums", - "rollout_log_probs", - "rollout_topk_token_ids", - "rollout_topk_log_probs", - "rollout_top_p_token_ids", - "rollout_top_p_token_offsets", - "rollout_top_p_log_probs", - "rollout_routed_experts", - "source_names", - "prompt", - "teacher_log_probs", - ]: - if key not in data: - continue - rollout_data[key] = [data[key][j] for j in partition] - # keys that need to be splited at train side - for key in ["raw_reward", "total_lengths"]: - if key not in data: - continue - rollout_data[key] = data[key] - rollout_data["global_batch_sizes"] = global_batch_sizes - rollout_data["num_microbatches"] = num_microbatches - rollout_data["micro_batch_indices"] = micro_batch_indices[r] - tensorize_rollout_data_for_training(rollout_data) - transport = getattr(self.args, "rollout_data_transport", "object-store") - if transport == "nixl": - rollout_data_refs.append(Box(ray.put(rollout_data, _tensor_transport="nixl"))) - elif transport == "object-store": - rollout_data_refs.append(Box(ray.put(rollout_data))) - else: - raise ValueError(f"Unsupported rollout data transport: {transport!r}") - return rollout_data_refs + self.batch_builder.train_parallel_config = config diff --git a/vime/rollout/base_types.py b/vime/rollout/base_types.py index 797149675..0510a3ff2 100644 --- a/vime/rollout/base_types.py +++ b/vime/rollout/base_types.py @@ -1,12 +1,18 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from vime.utils.types import Sample +if TYPE_CHECKING: + from vime.utils.rollout_transport import DiskPayloadRef + @dataclass class RolloutFnTrainOutput: - samples: list[list[Sample]] + # straw rollouts return a batch manifest; legacy functions may return Samples. + samples: list[list[Sample]] | DiskPayloadRef metrics: dict[str, Any] = None @@ -17,10 +23,79 @@ class RolloutFnEvalOutput: def call_rollout_fn(fn, *args, evaluation: bool, **kwargs): + from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef + output = fn(*args, **kwargs, evaluation=evaluation) + if isinstance(output, RawRolloutRef): + if evaluation: + raise TypeError("An accepted training collection cannot be used as evaluation output") + return output + + if evaluation: + if isinstance(output, RolloutFnEvalOutput): + return output + if isinstance(output, dict): + return RolloutFnEvalOutput(data=output) + else: + if isinstance(output, RolloutFnTrainOutput): + if not isinstance(output.samples, (list, DiskPayloadRef)): + raise TypeError("Training output must contain Samples or a supported rollout reference") + return output + if isinstance(output, (list, DiskPayloadRef)): + return RolloutFnTrainOutput(samples=output) + raise TypeError(f"Unsupported rollout output: {type(output).__name__}") + + +def finalize_rollout_groups(args, rollout_id, groups, metrics=None): + """Order selected groups, run the batch hook once, and publish the batch.""" + from vime.utils.misc import load_function + from vime.utils.rollout_transport import RolloutGroupRef, load_rollout_samples, pack_rollout_payload + + groups.sort( + key=lambda group: (group.index if isinstance(group, RolloutGroupRef) else next(iter_samples(group)).index) or 0 + ) + dropped = set() + incoming = set() + if args.rollout_data_transport != "straw": + incoming = { + sample._queue_receipt["position"] for sample in iter_samples(groups) if hasattr(sample, "_queue_receipt") + } + if args.rollout_sample_filter_path is not None: + # A custom batch hook can mutate arbitrary Sample fields. Its result + # must be serialized again; without a hook, retain existing group refs. + groups = load_rollout_samples(groups) + incoming = { + sample._queue_receipt["position"] for sample in iter_samples(groups) if hasattr(sample, "_queue_receipt") + } + load_function(args.rollout_sample_filter_path)(args, groups) + selected = { + sample._queue_receipt["position"] for sample in iter_samples(groups) if hasattr(sample, "_queue_receipt") + } + dropped = incoming - selected + samples = pack_rollout_payload(groups, args, rollout_id) if args.rollout_data_transport == "straw" else groups + if dropped and args.rollout_data_transport == "straw": + import ray + + decision = pack_rollout_payload( + {"positions": sorted(dropped), "reason": "rollout_sample_filter", "output": samples}, args, rollout_id + ) + ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + elif incoming and args.rollout_data_transport != "straw": + import ray + + decision = pack_rollout_payload( + {"positions": sorted(incoming), "reason": "legacy object-store delivery after batch filter"}, + args, + rollout_id, + ) + ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + return RolloutFnTrainOutput(samples=samples, metrics=metrics) - # compatibility for legacy version - if not isinstance(output, (RolloutFnTrainOutput, RolloutFnEvalOutput)): - output = RolloutFnEvalOutput(data=output) if evaluation else RolloutFnTrainOutput(samples=output) - return output +def iter_samples(value): + """Visit Sample leaves without changing custom generation's nested shape.""" + if isinstance(value, Sample): + yield value + else: + for child in value: + yield from iter_samples(child) diff --git a/vime/rollout/batch_builder.py b/vime/rollout/batch_builder.py new file mode 100644 index 000000000..de57e5707 --- /dev/null +++ b/vime/rollout/batch_builder.py @@ -0,0 +1,515 @@ +"""The single rollout-to-training conversion boundary, shared by all producers. + +Reward postprocessing, masks, rollout aggregation and DP packing stay in their +established order. The manager retains model/server and data-source lifecycle. +""" + +import json +import logging +import random +from pathlib import Path + +import numpy as np +import ray +import torch + +from vime.observability.rollout_data_utils import ( + tensorize_rollout_data_for_training, + validate_rollout_routed_experts_for_replay, +) +from vime.utils.data import get_source +from vime.utils.dp_schedule import build_dp_schedule +from vime.utils.misc import Box, load_function +from vime.utils.rollout_transport import DiskPayloadRef, RolloutGroupRef, TrainBatchRef, pack_rollout_payload +from vime.utils.tensor_store import DiskTensorRef, TensorRef +from vime.utils.types import Sample + +logger = logging.getLogger(__name__) + + +class BatchBuilder: + def __init__(self, args): + self.args = args + self.rollout_id = -1 + self.raw_ref = None + self.batch_id = None + self.consumer_state = None + self._plan = None + self.custom_reward_post_process_func = None + if self.args.custom_reward_post_process_path is not None: + self.custom_reward_post_process_func = load_function(self.args.custom_reward_post_process_path) + self.custom_convert_samples_to_train_data_func = None + if self.args.custom_convert_samples_to_train_data_path is not None: + self.custom_convert_samples_to_train_data_func = load_function( + self.args.custom_convert_samples_to_train_data_path + ) + + def begin(self, samples): + """Persist the selection and conversion configuration before invoking hooks.""" + if self.raw_ref is None: + self.batch_id = None + return None + from straw.protocol import Lease, RecordSetRef, digest + + controller = self.args._rollout_queue_controller + self.batch_id = f"batch:{self.raw_ref.receipt.commit_id}" + configuration = { + name: getattr(self.args, name, None) + for name in ( + "custom_reward_post_process_path", + "custom_convert_samples_to_train_data_path", + "advantage_estimator", + "rewards_normalization", + "grpo_std_normalization", + "reward_key", + "global_batch_size", + "micro_batch_size", + "use_dynamic_batch_size", + "balance_data", + "balance_by_flops", + "n_samples_per_prompt", + "rollout_batch_size", + "use_score_centering", + "use_rollout_routing_replay", + ) + } + identity = { + "version": 1, + "raw_digest": self.raw_ref.manifest.digest, + "sample_indices": [sample.index for sample in samples], + "rollout_ids": [sample.rollout_id for sample in samples], + "configuration": configuration, + "parallel": self.train_parallel_config, + } + self.plan_digest = digest(identity) + existing = ray.get(controller.batch.remote(self.batch_id)) + if existing: + self._plan = DiskPayloadRef(RecordSetRef.from_dict(existing["plan_ref"]), self.args.rollout_data_dir) + plan = self._plan.load() + if plan["digest"] != self.plan_digest: + raise ValueError("An existing batch ID cannot be reused with a different selection or conversion plan") + if existing["ready"]: + refs = DiskPayloadRef( + RecordSetRef.from_dict(existing["ready_ref"]), self.args.rollout_data_dir + ).load()["ranks"] + return [Box(ray.put(ref)) for ref in refs] + random.setstate(plan["rng"]["python"]) + np.random.set_state(plan["rng"]["numpy"]) + torch.set_rng_state(plan["rng"]["torch"]) + else: + numpy_state = np.random.get_state() + positions = {self.raw_ref.receipt.position} + for sample in samples: + positions.update(getattr(sample, "_queue_source_positions", [])) + if receipt := getattr(sample, "_queue_receipt", None): + positions.add(receipt["position"]) + elif lease := getattr(sample, "_queue_lease", None): + receipt = ray.get(controller.result.remote(Lease(**lease))) + if receipt is not None: + positions.add(receipt.position) + value = self.raw_ref + while isinstance(value, DiskPayloadRef): + value = value.load() + for group in value: + if isinstance(group, RolloutGroupRef) and group.receipt: + positions.add(group.receipt.position) + plan = { + **identity, + "digest": self.plan_digest, + "raw": self.raw_ref, + "input_positions": sorted(positions), + "rng": { + "python": random.getstate(), + "numpy": (numpy_state[0], numpy_state[1].tolist(), *numpy_state[2:]), + "torch": torch.get_rng_state(), + }, + "producer_processing": "generation/reward/dynamic and batch filters already applied", + } + self._plan = pack_rollout_payload(plan, self.args, self.rollout_id) + ray.get(controller.plan_batch.remote(self.batch_id, plan["input_positions"], self._plan.manifest)) + self._positions = plan["input_positions"] + return None + + def _commit_ready(self, ranks, schedule): + controller = self.args._rollout_queue_controller + ready = pack_rollout_payload( + { + "version": 1, + "batch_id": self.batch_id, + "plan": self._plan, + "plan_digest": self.plan_digest, + "schedule": schedule, + "ranks": ranks, + }, + self.args, + self.rollout_id, + ) + state_ref = ray.get(controller.ready_batch.remote(self.batch_id, ready.manifest)) + self.consumer_state = DiskPayloadRef(state_ref, self.args.rollout_data_dir) + + def training_completed(self, rollout_id): + if self.batch_id is not None: + if rollout_id != self.rollout_id: + raise ValueError("Training completion does not match the current batch") + ray.get(self.args._rollout_queue_controller.finish_batch.remote(self.batch_id)) + + def save(self, rollout_id): + if self.args.rollout_data_transport != "straw": + return + from straw.reporting import write_report + + state = ray.get(self.args._rollout_queue_controller.training_state.remote()) + if state is None: + return + from dataclasses import asdict + + from straw.protocol import RecordSetRef, digest + + from vime.utils.rollout_transport import rollout_store + + path = Path(self.args.save) / "rollout" / f"builder_state_{rollout_id}.json" + store, _, lock = rollout_store(self.args) + refs = [RecordSetRef.from_dict(state[key]) for key in ("state_ref", "progress_ref") if state.get(key)] + storage_owner = f"checkpoint:{path.resolve()}:{digest([asdict(ref) for ref in refs])}" + with lock: + store.retain(storage_owner, refs) + write_report( + path, + { + "version": 1, + "run_id": getattr(self.args, "rollout_queue_run_id", None) or "rollout", + "rollout_id": rollout_id, + "consumer": state, + "storage_owner": storage_owner, + }, + ) + + def load(self, rollout_id): + if self.args.rollout_data_transport != "straw" or not self.args.load: + return + from straw.protocol import RecordSetRef + + path = Path(self.args.load) / "rollout" / f"builder_state_{rollout_id}.json" + if not path.exists(): + return + snapshot = json.loads(path.read_text()) + if snapshot["version"] != 1 or snapshot["run_id"] != ( + getattr(self.args, "rollout_queue_run_id", None) or "rollout" + ): + raise ValueError("BatchBuilder checkpoint run/schema differs") + state = snapshot["consumer"] + self.consumer_state = DiskPayloadRef(RecordSetRef.from_dict(state["state_ref"]), self.args.rollout_data_dir) + ray.get( + self.args._rollout_queue_controller.restore_training_state.remote( + self.consumer_state.manifest, + state["fetch_cursor"], + state["processed_cursor"], + getattr(self.args, "_queue_restored_source_ref", None), + ) + ) + + def _post_process_rewards(self, samples: list[Sample] | list[list[Sample]]): + if self.custom_reward_post_process_func is not None: + return self.custom_reward_post_process_func(self.args, samples) + + raw_rewards = [sample.get_reward_value(self.args) for sample in samples] + if ( + self.args.advantage_estimator in ["grpo", "gspo", "cispo", "reinforce_plus_plus_baseline"] + and self.args.rewards_normalization + ): + # group norm + rewards = torch.tensor(raw_rewards, dtype=torch.float) + if rewards.shape[-1] == self.args.n_samples_per_prompt * self.args.rollout_batch_size: + rewards = rewards.reshape(-1, self.args.n_samples_per_prompt) + else: + # when samples count are not equal in each group + rewards = rewards.view(-1, rewards.shape[-1]) + mean = rewards.mean(dim=-1, keepdim=True) + rewards = rewards - mean + + if self.args.advantage_estimator in ["grpo", "gspo", "cispo"] and self.args.grpo_std_normalization: + std = rewards.std(dim=-1, keepdim=True) + rewards = rewards / (std + 1e-6) + + return raw_rewards, rewards.flatten().tolist() + + return raw_rewards, raw_rewards + + def convert(self, samples: list[Sample] | list[list[Sample]]): + """ + Convert inference generated samples to training data. + """ + if self.custom_convert_samples_to_train_data_func is not None: + return self.custom_convert_samples_to_train_data_func(self.args, samples) + + raw_rewards, rewards = self._post_process_rewards(samples) + + assert len(raw_rewards) == len(samples) + assert len(rewards) == len(samples) + + rollout_ids = [sample.rollout_id for sample in samples] + existed_rollout_id_values = set(rid for rid in rollout_ids if rid is not None) + tmp_id = 0 + for i in range(len(rollout_ids)): + if rollout_ids[i] is None: + while tmp_id in existed_rollout_id_values: + tmp_id += 1 + rollout_ids[i] = tmp_id + existed_rollout_id_values.add(tmp_id) + + train_data = { + "tokens": [sample.tokens for sample in samples], + "response_lengths": [sample.response_length for sample in samples], + # some reward model, e.g. remote rm, may return multiple rewards, + # we could use key to select the reward. + "rewards": rewards, + "raw_reward": raw_rewards, + "truncated": [1 if sample.status == Sample.Status.TRUNCATED else 0 for sample in samples], + "sample_indices": [sample.index for sample in samples], + "rollout_ids": rollout_ids, + } + + # loss mask + # TODO: compress the loss mask + loss_masks = [] + for sample in samples: + # always instantiate loss_mask if not provided + if sample.loss_mask is None: + sample.loss_mask = [1] * sample.response_length + + assert ( + len(sample.loss_mask) == sample.response_length + ), f"loss mask length {len(sample.loss_mask)} != response length {sample.response_length}" + if sample.remove_sample: + sample.loss_mask = [0] * sample.response_length + loss_masks.append(sample.loss_mask) + train_data["loss_masks"] = loss_masks + + # Per-rollout aggregate, precomputed at the step level (where we can + # see every sample of every rollout) and broadcast per-sample so the + # per-mb loss reducer uses the correct whole-rollout denominator even + # when a rollout's samples land in different micro-batches (first-fit + # packing can split a rollout across mbs): + # + # ``rollout_mask_sums[i]`` — sum of loss-mask totals over every + # sample in sample i's rollout. Used as the reducer's denominator + # so summing partial contributions across mbs yields one + # token-weighted mean per rollout. + rollout_id_list = train_data["rollout_ids"] + mask_sums_per_sample = [sum(m) for m in loss_masks] + rollout_total_mask: dict[int, int] = {} + for rid, ms in zip(rollout_id_list, mask_sums_per_sample, strict=True): + rollout_total_mask[rid] = rollout_total_mask.get(rid, 0) + ms + train_data["rollout_mask_sums"] = [rollout_total_mask[rid] for rid in rollout_id_list] + + # Overwrite raw_reward when available. Mixed-source batches may only + # populate this field for a subset of samples (e.g. SWE but not code). + if any(sample.metadata and "raw_reward" in sample.metadata for sample in samples): + train_data["raw_reward"] = [ + sample.metadata["raw_reward"] if sample.metadata and "raw_reward" in sample.metadata else sample.reward + for sample in samples + ] + + # For rollout buffer + if samples[0].metadata and "round_number" in samples[0].metadata: + train_data["round_number"] = [sample.metadata["round_number"] for sample in samples] + + # Add rollout log probabilities for off-policy correction + if samples[0].rollout_log_probs is not None: + train_data["rollout_log_probs"] = [sample.rollout_log_probs for sample in samples] + + if getattr(self.args, "use_score_centering", False): + from vime.utils.score_centering import validate_sampler_top_p, validate_sampler_topk + + for sample in samples: + if sample.rollout_log_probs is None or len(sample.rollout_log_probs) != sample.response_length: + raise ValueError("Score centering requires sampler logprobs for every response token.") + if self.args.rollout_top_p < 1: + if sample.response_length == 0 and sample.rollout_top_p_token_ids is None: + sample.rollout_top_p_token_ids = torch.empty(0, dtype=torch.int32) + sample.rollout_top_p_token_offsets = torch.zeros(1, dtype=torch.int32) + sample.rollout_top_p_log_probs = torch.empty(0, dtype=torch.float32) + validate_sampler_top_p( + sample.rollout_top_p_token_ids, + sample.rollout_top_p_token_offsets, + sample.rollout_top_p_log_probs, + sample.response_length, + sample.loss_mask, + sample.tokens[-sample.response_length :] if sample.response_length else [], + sample.rollout_log_probs, + ) + else: + validate_sampler_topk(sample, self.args.score_centering_top_k) + if self.args.rollout_top_p < 1: + train_data["rollout_top_p_log_probs"] = [sample.rollout_top_p_log_probs for sample in samples] + else: + train_data["rollout_topk_token_ids"] = [sample.rollout_topk_token_ids for sample in samples] + train_data["rollout_topk_log_probs"] = [sample.rollout_topk_log_probs for sample in samples] + + if getattr(self.args, "rollout_top_p", 1.0) != 1.0: + for sample in samples: + assert sample.rollout_top_p_token_ids is not None + assert sample.rollout_top_p_token_offsets is not None + assert len(sample.rollout_top_p_token_offsets) == sample.response_length + 1, ( + f"top-p token offsets length {len(sample.rollout_top_p_token_offsets)} " + f"!= response length + 1 {sample.response_length + 1}" + ) + offset_end = int(sample.rollout_top_p_token_offsets[-1:][0]) + assert offset_end == len( + sample.rollout_top_p_token_ids + ), f"top-p token offsets[-1] {offset_end} != token ids length {len(sample.rollout_top_p_token_ids)}" + train_data["rollout_top_p_token_ids"] = [sample.rollout_top_p_token_ids for sample in samples] + train_data["rollout_top_p_token_offsets"] = [sample.rollout_top_p_token_offsets for sample in samples] + + routed_experts_present = [sample.rollout_routed_experts is not None for sample in samples] + dead_sample_indices = { + idx + for idx, (present, sample) in enumerate(zip(routed_experts_present, samples, strict=True)) + if not present and sample.loss_mask is not None and not any(sample.loss_mask) + } + live_missing = [ + idx for idx, present in enumerate(routed_experts_present) if not present and idx not in dead_sample_indices + ] + routing_replay_enabled = getattr(self.args, "use_rollout_routing_replay", False) + if live_missing and (routing_replay_enabled or any(routed_experts_present)): + raise ValueError(f"Rollout routed experts are missing for live sample indices {live_missing[:32]}.") + + if any(routed_experts_present) or (routing_replay_enabled and dead_sample_indices): + dtype = torch.uint8 if self.args.num_experts <= 256 else torch.int32 + routed_experts = [ + ( + ( + sample.rollout_routed_experts + if isinstance(sample.rollout_routed_experts, (TensorRef, DiskTensorRef)) + else sample.materialize_rollout_routed_experts() + ) + if present + else torch.zeros( + (max(0, len(sample.tokens) - 1), self.args.num_layers, self.args.moe_router_topk), + dtype=dtype, + ) + ) + for sample, present in zip(samples, routed_experts_present, strict=True) + ] + if routing_replay_enabled and any(routed_experts_present): + captured_pairs = [ + (experts, max(0, len(sample.tokens) - 1)) + for sample, experts, present in zip(samples, routed_experts, routed_experts_present, strict=True) + if present + ] + validate_rollout_routed_experts_for_replay( + [experts for experts, _ in captured_pairs], + self.args, + expected_rows=[rows for _, rows in captured_pairs], + ) + if dead_sample_indices: + logger.warning( + "Using all-zero routed experts for %d fully loss-masked samples (indices %s).", + len(dead_sample_indices), + sorted(dead_sample_indices)[:16], + ) + train_data["rollout_routed_experts"] = routed_experts + + if samples[0].train_metadata is not None: + train_data["metadata"] = [sample.train_metadata for sample in samples] + + if any(sample.multimodal_train_inputs is not None for sample in samples): + train_data["multimodal_train_inputs"] = [sample.multimodal_train_inputs for sample in samples] + + if samples[0].teacher_log_probs is not None: + train_data["teacher_log_probs"] = [sample.teacher_log_probs for sample in samples] + + if samples[0].metadata is not None: + train_data["source_names"] = [get_source(sample) for sample in samples] + + return train_data + + def split_by_dp(self, data): + """Compute the DP/mbs schedule and package each rank's rollout_data + into a Ray Box. The schedule itself is computed by + :func:`build_dp_schedule` so it stays unit-testable without Ray/vLLM. + + Step split is by rollout id (``samples[i].rollout_id``, falling back + to ``samples[i].index``); each step holds exactly + ``args.global_batch_size`` rollouts so the training-step count per + rollout is fixed at ``rollout_batch_size * n_samples_per_prompt // + global_batch_size`` regardless of how many training samples each + rollout produced. + """ + dp_size = self.train_parallel_config["dp_size"] + total_lengths = [len(t) for t in data["tokens"]] + data["total_lengths"] = total_lengths + + partitions, micro_batch_indices, num_microbatches, global_batch_sizes = build_dp_schedule( + self.args, + self.train_parallel_config, + total_lengths, + global_batch_size=self.args.global_batch_size, + rollout_indices=data["rollout_ids"], + ) + + # Package per-rank rollout_data + rollout_data_refs = [] + stored_ranks = [] + for r in range(dp_size): + partition = partitions[r] + rollout_data = {"partition": partition} + for key in [ + "tokens", + "multimodal_train_inputs", + "response_lengths", + "rewards", + "truncated", + "loss_masks", + "round_number", + "sample_indices", + "rollout_ids", + "rollout_mask_sums", + "rollout_log_probs", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + "rollout_top_p_token_ids", + "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", + "rollout_routed_experts", + "source_names", + "prompt", + "teacher_log_probs", + ]: + if key not in data: + continue + rollout_data[key] = [data[key][j] for j in partition] + # keys that need to be splited at train side + for key in ["raw_reward", "total_lengths"]: + if key not in data: + continue + rollout_data[key] = data[key] + rollout_data["global_batch_sizes"] = global_batch_sizes + rollout_data["num_microbatches"] = num_microbatches + rollout_data["micro_batch_indices"] = micro_batch_indices[r] + tensorize_rollout_data_for_training(rollout_data) + transport = self.args.rollout_data_transport + if transport == "straw": + ref = pack_rollout_payload(rollout_data, self.args, self.rollout_id) + if getattr(self, "batch_id", None): + ref = TrainBatchRef(ref.manifest, ref.root, self.batch_id, r, self.plan_digest) + stored_ranks.append(ref) + elif transport == "nixl": + rollout_data_refs.append(Box(ray.put(rollout_data, _tensor_transport="nixl"))) + elif transport == "object-store": + rollout_data_refs.append(Box(ray.put(rollout_data))) + else: + raise ValueError(f"Unsupported rollout data transport: {transport!r}") + if transport == "straw": + if getattr(self, "batch_id", None): + self._commit_ready( + stored_ranks, + { + "partitions": partitions, + "micro_batch_indices": micro_batch_indices, + "num_microbatches": num_microbatches, + "global_batch_sizes": global_batch_sizes, + }, + ) + rollout_data_refs = [Box(ray.put(ref)) for ref in stored_ranks] + return rollout_data_refs diff --git a/vime/rollout/fully_async_distributed.py b/vime/rollout/fully_async_distributed.py new file mode 100644 index 000000000..df447b21c --- /dev/null +++ b/vime/rollout/fully_async_distributed.py @@ -0,0 +1,578 @@ +"""Process execution and bounded prefetch owned by the fully-async rollout function.""" + +import asyncio +import copy +import logging +import threading +import time +from collections import Counter, deque +from dataclasses import asdict + +import ray +from ray.exceptions import RayActorError +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy +from straw.protocol import CommitReceipt + +from vime.observability import logging_utils +from vime.observability.rollout_data_utils import validate_rollout_id_annotated +from vime.ray.utils import add_default_ray_env_vars +from vime.rollout.base_types import finalize_rollout_groups, iter_samples +from vime.rollout.filter_hub.base_types import DynamicFilterOutput, call_dynamic_filter +from vime.rollout.sample_hooks import rollout_context +from vime.utils.async_utils import get_async_loop +from vime.utils.http_utils import get_rollout_num_engines, init_http_client +from vime.utils.misc import load_function +from vime.utils.rollout_transport import ( + DiskPayloadRef, + RolloutGroupRef, + discard_rollout_group, + pack_rollout_payload, + publish_rollout_async, + unpack_rollout_payload, +) +from vime.utils.types import Sample + + +class RolloutScheduler: + """Collect results from autonomous workers into global training batches. + + Each worker maintains its own generation concurrency. This collector bounds + completed prefetch, so slow training applies backpressure to local pools. + """ + + def __init__(self, args, workers, capacities): + self.args = args + self.workers = workers + self.capacities = capacities + self.capacity = sum(capacities) + self.ready = deque() + self.delivered = set() + self.pending = {} + self.controls = {} + self.configurations = {} + self.running_workers = set() + self.demand = self.prefetch = 0 + self.rollout_id = 0 + self.paused = True + self.closed = False + self.error = None + self.condition = threading.Condition() + self.thread = None + + def _retire_worker(self, worker, reason): + """Retire unavailable execution slots while preserving queue history. + + Called under the condition lock. Checkpoint calls here run after + pause() has drained all pending result requests. + """ + if not self.capacities[worker]: + return + for ref, owner in list(self.pending.items()): + if owner == worker: + ray.cancel(ref) + del self.pending[ref] + for ref, (owner, _) in list(self.controls.items()): + if owner == worker: + ray.cancel(ref) + del self.controls[ref] + self.running_workers.discard(worker) + self.capacities[worker] = 0 + self.capacity = sum(self.capacities) + self.prefetch = min(self.prefetch, self.capacity) + ray.kill(self.workers[worker], no_restart=True) + self._recover_worker_results(worker) + logging.getLogger(__name__).warning( + "Retired rollout worker %d: %s. Queue history remains durable; remaining capacity=%d", + worker, + reason, + self.capacity, + ) + self.condition.notify_all() + if not self.capacity: + raise RuntimeError("All rollout workers are unavailable") + + def _recover_worker_results(self, worker): + pass + + def _run(self): + configurations = self.configurations + try: + while True: + with self.condition: + if self.closed: + return + desired = (self.paused, self.rollout_id) + configuring = {worker for worker, _ in self.controls.values()} + for i, worker in enumerate(self.workers): + if not self.capacities[i] or i in configuring or configurations.get(i) == desired: + continue + if self.paused and i not in self.running_workers: + configurations[i] = desired + continue + # Control RPCs share the wait loop with results. Never + # hold the condition lock waiting for a remote worker. + ref = worker.pause.remote() if self.paused else worker.start.remote(self.rollout_id) + self.controls[ref] = (i, desired) + # One outstanding result request per worker. These requests + # only consume results; workers refill their pools locally. + waiting = set(self.pending.values()) + for i in self.running_workers - waiting: + if not self.paused and len(self.pending) + len(self.ready) >= self.demand + self.prefetch: + break + self.pending[self.workers[i].next.remote()] = i + refs = [*self.controls, *self.pending] + if not refs: + self.condition.notify_all() + self.condition.wait(timeout=0.05) + continue + done, _ = ray.wait(refs, num_returns=1, timeout=0.01) + with self.condition: + for ref in done: + control = self.controls.pop(ref, None) + worker = control[0] if control is not None else self.pending.pop(ref) + try: + output = ray.get(ref) + except RayActorError as error: + self._retire_worker(worker, error) + continue + if control is not None: + configurations[worker] = control[1] + if not control[1][0]: + self.running_workers.add(worker) + elif output is None: # The paused worker has drained its pool. + self.running_workers.remove(worker) + else: + self.ready.append(output) + group, _ = output + if isinstance(group, RolloutGroupRef) and group.receipt: + self.delivered.add(group.receipt.position) + self.condition.notify_all() + except Exception as error: + with self.condition: + self.error = error + self.condition.notify_all() + finally: + with self.condition: + for ref in (*self.controls, *self.pending): + ray.cancel(ref) + self.controls.clear() + self.pending.clear() + self.running_workers.clear() + self.condition.notify_all() + + def generate(self, rollout_id, *, prefetch): + groups = [] + dropped_count = 0 + drop_reasons = Counter() + with self.condition: + if self.error is not None: + raise self.error + if self.demand: + raise RuntimeError("A rollout batch is already being collected") + self.rollout_id = rollout_id + self.demand = self.args.rollout_batch_size + self.prefetch = min(prefetch, self.capacity) + self.paused = False + if self.thread is None: + self.thread = threading.Thread(target=self._run, name="rollout-collect", daemon=True) + self.thread.start() + self.condition.notify_all() + while self.demand: + if self.error is not None: + raise self.error + if not self.ready: + self.condition.wait() + continue + group, verdict = self.ready.popleft() + # Fully async continuously replaces rejected groups. The + # finite-batch keep_when_insufficient fallback does not apply. + if not verdict.keep: + reason = verdict.reason or "dynamic_filter" + dropped_count += 1 + drop_reasons[reason] += 1 + else: + groups.append(group) + self.demand -= 1 + self.condition.notify_all() + + metrics = {f"rollout/dynamic_filter/drop_{reason}": count for reason, count in drop_reasons.items()} + metrics["rollout/dynamic_filter/dropped_groups"] = dropped_count + metrics["rollout/dynamic_filter/dropped_ratio"] = dropped_count / (len(groups) + dropped_count) + # Publish a manifest of group references outside the scheduler lock. + return finalize_rollout_groups(self.args, rollout_id, groups, metrics) + + def pause(self, *, drain=True): + """Stop admission; optionally wait for in-flight results to become durable.""" + with self.condition: + was_paused = self.paused + self.paused = True + self.condition.notify_all() + while self.error is None: + unsettled = bool(self.controls) or any( + self.configurations.get(worker) != (True, self.rollout_id) for worker in self.running_workers + ) + if drain: + unsettled |= bool(self.pending or self.running_workers) + if not unsettled: + break + self.condition.wait() + if self.error is not None: + raise self.error + return was_paused + + def resume(self): + with self.condition: + self.paused = False + self.condition.notify_all() + + def state_dict(self): + with self.condition: + if self.controls or self.pending or self.running_workers or not self.paused: + raise RuntimeError("Pause scheduling before saving rollout state") + return {"ready": list(self.ready)} + + def load_state_dict(self, state): + with self.condition: + if self.thread is not None: + raise RuntimeError("Restore rollout state before starting generation") + self.ready = deque(state["ready"]) + self.delivered = { + group.receipt.position + for group, _ in self.ready + if isinstance(group, RolloutGroupRef) and group.receipt + } + + def close(self): + with self.condition: + self.closed = True + # Unblock consumers and pause() callers waiting for in-flight work. + if self.error is None: + self.error = RuntimeError("Rollout scheduler is closed") + self.condition.notify_all() + if self.thread is not None: + self.thread.join() + + +class _GenerationActor: + """Maintain a local generation pool and expose completed groups to the collector.""" + + def __init__(self, reader_config, concurrency): + from vime.rollout.vllm_rollout import GenerateState + + logging_utils.configure_logger(prefix=f" fully-async-{reader_config.reader_id}") + self.args = copy.copy(reader_config.args) + self.args.use_distributed_post = False + self.args.use_wandb = self.args.use_tensorboard = False + self.args.dump_details = None + init_http_client(self.args, concurrency=concurrency) + self.state = GenerateState(self.args, concurrency=concurrency) + self.data_source = reader_config.open(self.args) + self.dynamic_filter = ( + load_function(self.args.dynamic_sampling_filter_path) if self.args.dynamic_sampling_filter_path else None + ) + + self.capacity = concurrency // self.args.n_samples_per_prompt + self.running = False + self.rollout_id = 0 + self.producer = None + + def dataset_size(self): + return len(self.data_source) + + async def start(self, rollout_id): + future = asyncio.run_coroutine_threadsafe(self._start(rollout_id), get_async_loop().loop) + await asyncio.wrap_future(future) + + async def _start(self, rollout_id): + self.rollout_id = rollout_id + self.running = True + if self.producer is None or self.producer.done(): + if self.producer is not None: + self.producer.result() + # Keep completed results bounded even when training stops consuming. + # At most one queued result plus one result per pool slot stays local. + self.outputs = asyncio.Queue(maxsize=1) + self.producer = asyncio.create_task(self._produce()) + + async def pause(self): + future = asyncio.run_coroutine_threadsafe(self._pause(), get_async_loop().loop) + await asyncio.wrap_future(future) + + async def _pause(self): + self.running = False + + async def next(self): + future = asyncio.run_coroutine_threadsafe(self._next(), get_async_loop().loop) + return await asyncio.wrap_future(future) + + async def _next(self): + take = asyncio.create_task(self.outputs.get()) + try: + await asyncio.wait((take, self.producer), return_when=asyncio.FIRST_COMPLETED) + if take.done(): + return take.result() + self.producer.result() # Propagate custom generation errors. + if not self.outputs.empty(): + return await take + return None + finally: + take.cancel() + await asyncio.gather(take, return_exceptions=True) + + async def _produce(self): + async def generate_slot(group): + while self.running or group is not None: + output = await self._generate_group(self.rollout_id, group) + group = None + if output is not None: # Aborted groups have been returned to the durable queue. + await self.outputs.put(output) + del output + + tasks = [] + try: + for offset in range(0, self.capacity, 64): + if not self.running: + break + groups = await self.data_source.get_samples_async(min(64, self.capacity - offset)) + tasks.extend(asyncio.create_task(generate_slot(group)) for group in groups) + # Spread startup callbacks across loop turns to reduce asyncio + # pressure. Each slot then pulls its own next group immediately. + await asyncio.sleep(0.001) + await asyncio.gather(*tasks) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + async def _generate_group(self, rollout_id, group=None): + from vime.rollout.vllm_rollout import generate_and_rm_group + + if group is None: + group = (await self.data_source.get_samples_async(1))[0] + if not self.running: + # A queue fetch may finish after the pause acknowledgement. Preserve + # its borrowed group without admitting a new generation request. + await self._rebuffer(group) + return None + # Completed local-buffer groups keep their rewards and loss masks. + if not all( + sample.status in (Sample.Status.COMPLETED, Sample.Status.TRUNCATED) and sample.reward is not None + for sample in iter_samples(group) + ): + with rollout_context(rollout_id): + group = await generate_and_rm_group(self.args, group, self.state.sampling_params.copy()) + validate_rollout_id_annotated([group]) + samples = list(iter_samples(group)) + if any(sample.status == Sample.Status.ABORTED for sample in samples): + await self._rebuffer(group) + return None + for sample in samples: + if sample.index is None: + raise ValueError("Distributed generation must preserve the source sample index") + if sample.rollout_id is None: + sample.rollout_id = sample.index + verdict = call_dynamic_filter(self.dynamic_filter, self.args, group) + if not verdict.keep: + await asyncio.to_thread( + discard_rollout_group, + group, + self.args, + verdict.reason or "dynamic_filter", + ) + return None, verdict + if self.args.rollout_data_transport == "straw": + group = await publish_rollout_async(group, self.args, rollout_id, group=True) + else: + if getattr(self.args, "_rollout_queue_controller", None) is not None: + accepted = await publish_rollout_async(group, self.args, rollout_id, group=True) + if accepted.receipt is not None: + for sample in iter_samples(group): + sample.__dict__.pop("_queue_lease", None) + sample._queue_receipt = asdict(accepted.receipt) + group = await asyncio.to_thread(self.data_source.materialize_samples, group) + return group, verdict + + async def _rebuffer(self, group): + if not hasattr(self, "_pending_rebuffers"): + self._pending_rebuffers = deque() + self._rebuffer_task = None + future = asyncio.get_running_loop().create_future() + self._pending_rebuffers.append((group, future)) + if self._rebuffer_task is None: + self._rebuffer_task = asyncio.create_task(self._flush_rebuffers()) + await asyncio.shield(future) + + async def _flush_rebuffers(self): + try: + await asyncio.sleep(0.01) + while self._pending_rebuffers: + batch = [self._pending_rebuffers.popleft() for _ in range(min(64, len(self._pending_rebuffers)))] + try: + await asyncio.to_thread(self.data_source.add_samples, [group for group, _ in batch]) + except Exception as error: + for _, future in [*batch, *self._pending_rebuffers]: + future.set_exception(error) + self._pending_rebuffers.clear() + return + for _, future in batch: + future.set_result(None) + finally: + self._rebuffer_task = None + + def state_dict(self): + state = self.data_source.state_dict(include_pending=False) + if self.args.rollout_data_transport == "straw": + state = pack_rollout_payload(state, self.args, self.rollout_id) + return state + + def load_state_dict(self, state): + self.data_source.load_state_dict(unpack_rollout_payload(state)) + + async def close(self): + future = asyncio.run_coroutine_threadsafe(self._close(), get_async_loop().loop) + await asyncio.wrap_future(future) + + async def _close(self): + self.running = False + if self.producer is not None: + self.producer.cancel() + await asyncio.gather(self.producer, return_exceptions=True) + if getattr(self, "_rebuffer_task", None) is not None: + await self._rebuffer_task + # to_thread writes can outlive cancelled awaiters. Drain them before + # closing their files or killing this process. + await asyncio.get_running_loop().shutdown_default_executor() + self.data_source.close() + from vime.utils.rollout_transport import seal_rollout_store + + seal_rollout_store(self.args) + + +class DistributedRollout(RolloutScheduler): + """Persistent fully-async execution; the data source coordinates checkpointing.""" + + def _recover_worker_results(self, worker): + if self.args.rollout_data_transport != "straw": + return + delivered = pack_rollout_payload(sorted(self.delivered), self.args, self.rollout_id) + ref = ray.get( + self.args._rollout_queue_controller.recover_reader_results.remote( + f"fully_async_{worker}", delivered.manifest + ) + ) + for value in DiskPayloadRef(ref, self.args.rollout_data_dir).load(): + receipt = CommitReceipt.from_dict(value) + payload = DiskPayloadRef(receipt.result_ref, self.args.rollout_data_dir) + index = next(iter_samples(payload.load())).index + group = RolloutGroupRef(receipt.result_ref, payload.root, index, receipt) + self.ready.append((group, DynamicFilterOutput(keep=True))) + self.delivered.add(receipt.position) + + def __init__(self, args, data_source): + if args.rollout_all_samples_process_path is not None: + raise ValueError("--rollout-all-samples-process-path is not supported by distributed fully-async rollout") + recovered = [] + if getattr(args, "rollout_queue_resume", False) and "fully_async" not in data_source._restored_consumers: + ref = ray.get(args._rollout_queue_controller.recover_pending_rollout.remote()) + for value in DiskPayloadRef(ref, args.rollout_data_dir).load(): + receipt = CommitReceipt.from_dict(value) + payload = DiskPayloadRef(receipt.result_ref, args.rollout_data_dir) + index = next(iter_samples(payload.load())).index + recovered.append( + ( + RolloutGroupRef(receipt.result_ref, payload.root, index, receipt), + DynamicFilterOutput(keep=True), + ) + ) + logging.getLogger(__name__).info( + "Recovered %d accepted groups from the interrupted rollout", + len(recovered), + ) + nodes = sorted( + (node for node in ray.nodes() if node["Alive"] and node["Resources"].get("CPU", 0) >= 1), + key=lambda node: (node["NodeManagerAddress"], node["NodeID"]), + ) + total_concurrency = args.vllm_server_concurrency * get_rollout_num_engines(args) + if not nodes or total_concurrency < len(nodes) * args.n_samples_per_prompt: + raise ValueError("Distributed rollout needs concurrency for at least one prompt group per node") + groups_per_worker, remainder = divmod(total_concurrency // args.n_samples_per_prompt, len(nodes)) + workers, capacities = [], [] + try: + for i, node in enumerate(nodes): + capacity = groups_per_worker + (i < remainder) + capacities.append(capacity) + reader = data_source.reader_config(f"fully_async_{i}") + workers.append( + ray.remote(_GenerationActor) + .options( + num_cpus=1, + max_concurrency=3, # Result consumption, lifecycle control, and checkpoint access. + max_restarts=0, + max_task_retries=0, + scheduling_strategy=NodeAffinitySchedulingStrategy(node["NodeID"], soft=False), + runtime_env={"env_vars": add_default_ray_env_vars()}, + ) + .remote(reader, capacity * args.n_samples_per_prompt) + ) + sizes = ray.get([worker.dataset_size.remote() for worker in workers]) + if any(size != len(data_source) for size in sizes): + raise ValueError(f"Replicated datasets differ in size: owner={len(data_source)}, workers={sizes}") + except Exception: + for worker in workers: + ray.kill(worker) + raise + super().__init__(args, workers, capacities) + self.ready.extend(recovered) + self.delivered.update(group.receipt.position for group, _ in recovered) + logging.getLogger(__name__).info( + "Fully-async rollout: %d nodes, dataset=%d, concurrency=%d", + len(nodes), + len(data_source), + total_concurrency, + ) + + def state_dict(self): + scheduler = super().state_dict() + readers = [] + for i, worker in enumerate(self.workers): + reader = None + if self.capacities[i]: + try: + reader = ray.get(worker.state_dict.remote()) + except RayActorError as error: + with self.condition: + self._retire_worker(i, error) + readers.append(reader) + return {"scheduler": scheduler, "readers": readers} + + def load_state_dict(self, state): + if len(state["readers"]) != len(self.workers): + raise ValueError("Restore fully-async rollout with the same number of worker nodes") + super().load_state_dict(state["scheduler"]) + for i, (worker, reader) in enumerate(zip(self.workers, state["readers"], strict=True)): + with self.condition: + if reader is None: + self._retire_worker(i, "disabled in checkpoint") + continue + try: + ray.get(worker.load_state_dict.remote(reader)) + except RayActorError as error: + self._retire_worker(i, error) + + def close(self): + super().close() + try: + refs = [ + worker.close.remote() + for worker, capacity in zip(self.workers, self.capacities, strict=True) + if capacity + ] + deadline = time.monotonic() + 300 + for ref in refs: + try: + ray.get(ref, timeout=max(0, deadline - time.monotonic())) + except RayActorError: + logging.getLogger(__name__).warning("Rollout worker unavailable during shutdown", exc_info=True) + finally: + for worker in self.workers: + ray.kill(worker, no_restart=True) diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index 333b2e234..2a8e2fdf6 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -35,12 +35,13 @@ import threading import time -from vime.rollout.base_types import RolloutFnTrainOutput +from vime.rollout.base_types import RolloutFnTrainOutput, finalize_rollout_groups from vime.rollout.filter_hub.base_types import call_dynamic_filter from vime.rollout.vllm_rollout import GenerateState, generate_and_rm_group from vime.utils.async_utils import run from vime.utils.http_utils import get_rollout_num_engines from vime.utils.misc import load_function +from vime.utils.rollout_transport import discard_rollout_group, publish_rollout_async from vime.utils.types import Sample __all__ = [ @@ -232,8 +233,8 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout worker.queue_size(), ) - collected: dict[int, list[Sample]] = {} - dropped: list[list[Sample]] = [] + collected = [] + dropped_count = 0 drop_reasons: dict[str, int] = {} dynamic_filter = ( load_function(args.dynamic_sampling_filter_path) @@ -248,23 +249,18 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout # Pull only what this rollout still needs; the surplus stays queued for # the next rollout (that is the "queue stays warm" contract). drained = 0 - for gid, group in worker.get_completed_groups(limit=target - len(collected)): + for _group_id, group in worker.get_completed_groups(limit=target - len(collected)): drained += 1 - if not filters_enabled: - collected[gid] = group - continue - verdict = call_dynamic_filter(dynamic_filter, args, group) if verdict.keep: - collected[gid] = group + if args.rollout_data_transport == "straw": + group = await publish_rollout_async(group, args, rollout_id, group=True) + collected.append(group) continue + await asyncio.to_thread(discard_rollout_group, group, args, verdict.reason or "dynamic_filter") reason = verdict.reason or "dynamic_filter" - for sample in group: - sample.remove_sample = True - if sample.metadata is not None: - sample.metadata["removed_reason"] = reason - dropped.append(group) + dropped_count += 1 drop_reasons[reason] = drop_reasons.get(reason, 0) + 1 if not drained: @@ -277,40 +273,31 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout rollout_id, len(collected), target, - len(dropped), + dropped_count, worker.queue_size(), now - started, ) last_log = now - # Order by sample.index for determinism (vime convention). - def _key(group: list[Sample]) -> int: - for s in group: - idx = getattr(s, "index", None) - if idx is not None: - return int(idx) - return 0 - - out = sorted(collected.values(), key=_key) - dropped = sorted(dropped, key=_key) - if (filter_path := getattr(args, "rollout_sample_filter_path", None)) is not None: - load_function(filter_path)(args, out) logger.info( "fully-async rollout %d: done in %.1fs, kept=%d dropped=%d (%s), queue_left=%d", rollout_id, time.time() - started, - len(out), - len(dropped), + len(collected), + dropped_count, drop_reasons, worker.queue_size(), ) - if not filters_enabled: - return out metrics = {f"rollout/dynamic_filter/drop_{reason}": count for reason, count in drop_reasons.items()} - metrics["rollout/dynamic_filter/dropped_groups"] = len(dropped) - metrics["rollout/dynamic_filter/dropped_ratio"] = len(dropped) / (len(out) + len(dropped)) - metrics["_dropped_samples"] = dropped - return RolloutFnTrainOutput(samples=out, metrics=metrics) + metrics["rollout/dynamic_filter/dropped_groups"] = dropped_count + metrics["rollout/dynamic_filter/dropped_ratio"] = dropped_count / (len(collected) + dropped_count) + if args.rollout_sample_filter_path is not None: + output = finalize_rollout_groups(args, rollout_id, collected, metrics) + else: + output = await asyncio.to_thread( + finalize_rollout_groups, args, rollout_id, collected, metrics if filters_enabled else None + ) + return output if filters_enabled or args.rollout_data_transport == "straw" else output.samples def generate_rollout_fully_async(args, rollout_id, data_buffer, evaluation: bool = False): @@ -318,4 +305,15 @@ def generate_rollout_fully_async(args, rollout_id, data_buffer, evaluation: bool if evaluation: raise ValueError("fully-async rollout doesn't support evaluation mode") + if getattr(args, "rollout_data_transport", "object-store") == "straw": + from vime.rollout.queue_data_source import QueueDataSource + + if isinstance(data_buffer, QueueDataSource): + from vime.rollout.fully_async_distributed import DistributedRollout + + worker = data_buffer.consumers.get("fully_async") + if worker is None: + worker = DistributedRollout(args, data_buffer) + data_buffer.register_consumer("fully_async", worker) + return worker.generate(rollout_id, prefetch=worker.capacity) return run(_generate_rollout_async(args, rollout_id, data_buffer)) diff --git a/vime/rollout/queue_codec.py b/vime/rollout/queue_codec.py new file mode 100644 index 000000000..cf248e1e1 --- /dev/null +++ b/vime/rollout/queue_codec.py @@ -0,0 +1,465 @@ +"""Explicit Sample/tensor codec for durable raw groups and training collections. + +The representation is tagged JSON plus immutable typed byte records. It never +imports a class named by a payload and never executes pickle. Unknown Python +objects fail at publication. Replay tensors are stored lazily with their owning +Sample; buffers, training batches and checkpoints share immutable records. +""" + +from __future__ import annotations + +import base64 +import dataclasses +import math +import sys + +import numpy as np +import torch +from straw import Publication, Record +from straw.errors import CorruptData, InvalidReference, UnsupportedSchema +from straw.protocol import RecordRef, RecordSetRef, decode, encode +from straw.store import MAX_RECORDS +from straw.tensor import TensorRef as QueueTensorRef +from straw.tensor import tensor_record + +from vime.utils.types import Sample + +CODECS = ("bytes.v1", "json.v1", "vime.v1", "tensor.v1") + + +class SampleCodec: + def __init__(self, store, *, args=None): + if sys.byteorder != "little": + raise UnsupportedSchema("Version 1 tensor codec requires a little-endian host") + if not set(CODECS).issubset(store.codecs): + raise ValueError("SampleCodec requires explicit vime.v1 and tensor.v1 store codecs") + self.store = store + self.args = args + # Leave room for framing and manifests within the native write budget. + self.payload_budget = store.max_buffer_bytes - min(8 * 1024**2, store.max_buffer_bytes // 8) + + def _prepare_sample(self, sample): + """Validate completed captures before publication; partial prefixes can resume.""" + validated = set() + if self.args is None or sample.status not in (Sample.Status.COMPLETED, Sample.Status.TRUNCATED): + return validated + if getattr(self.args, "use_rollout_routing_replay", False): + from vime.utils.routed_experts import validate_routed_experts_value + + routes = sample.rollout_routed_experts + if routes is not None and (sample.loss_mask is None or any(sample.loss_mask)): + if not isinstance(routes, QueueTensorRef): + routes = sample.materialize_rollout_routed_experts() + validate_routed_experts_value( + routes, self.args, sample_index=sample.index, expected_rows=max(0, len(sample.tokens) - 1) + ) + validated.add("rollout_routed_experts") + if getattr(self.args, "use_score_centering", False): + from vime.utils.score_centering import SAMPLER_TOPK_FIELDS, validate_sampler_top_p, validate_sampler_topk + + top_p = getattr(self.args, "rollout_top_p", 1.0) < 1 + fields = ( + ("rollout_top_p_token_ids", "rollout_top_p_token_offsets", "rollout_top_p_log_probs") + if top_p + else SAMPLER_TOPK_FIELDS + ) + # Evaluation and prompt-only samples need no sampler capture. + # Training conversion still requires enabled replay fields. + if all(getattr(sample, key, None) is None for key in fields) and sample.response_length: + return validated + if top_p: + values = [getattr(sample, key, None) for key in fields] + if sample.response_length == 0 and all(value is None for value in values): + values = [ + torch.empty(0, dtype=torch.int32), + torch.zeros(1, dtype=torch.int32), + torch.empty(0, dtype=torch.float32), + ] + for key, value in zip(fields, values, strict=True): + setattr(sample, key, value) + validate_sampler_top_p( + *values, + sample.response_length, + loss_mask=sample.loss_mask, + ) + else: + validate_sampler_topk(sample, self.args.score_centering_top_k) + validated.update(fields) + return validated + + def publish(self, value, *, submission_id, metadata=None): + publications = self.prepare(value, submission_id=submission_id, metadata=metadata) + return self._publish(publications, submission_id=submission_id)[-1] + + def publish_many(self, values, *, submission_ids, metadata=None): + publications, roots = [], [] + metadata = metadata if metadata is not None else [None] * len(values) + for value, identity, fields in zip(values, submission_ids, metadata, strict=True): + prepared = self.prepare(value, submission_id=identity, metadata=fields) + offset = len(publications) + for publication in prepared: + publications.append( + Publication( + publication.records, + tuple(dep + offset if type(dep) is int else dep for dep in publication.dependencies), + ) + ) + roots.append(len(publications) - 1) + refs = self._publish(publications, submission_id=f"bundle:{submission_ids[0]}") + return [refs[i] for i in roots] + + def _publish(self, publications, *, submission_id): + """Bound each native write, exposing roots only after their dependencies commit.""" + refs = [] + start = 0 + while start < len(publications): + stop, size, count = start, 0, 0 + while stop < len(publications): + records = publications[stop].records + added = sum(len(record.payload) for record in records) + if stop > start and (size + added > self.payload_budget or count + len(records) + 1 > MAX_RECORDS): + break + size += added + count += len(records) + 1 + stop += 1 + batch = [ + Publication( + publication.records, + tuple( + (refs[dep] if dep < start else dep - start) if type(dep) is int else dep + for dep in publication.dependencies + ), + ) + for publication in publications[start:stop] + ] + refs.extend(self.store.publish_many(batch, submission_id=f"{submission_id}:part:{start}")) + start = stop + return refs + + def prepare(self, value, *, submission_id, metadata=None): + blobs, aliases, dependencies = [], {}, [] + dependency_positions = {} + tensor_sources = [] + token_count = 0 + + def tensor(value, *, lazy=False, kind=None, validated=False): + identity = (id(value), lazy, kind, validated) + if identity in aliases: + return aliases[identity] + # Converted partial route chunks must stay alive while aliases are + # indexed by object identity across all samples in this publication. + tensor_sources.append(value) + if isinstance(value, QueueTensorRef) and value.shares_storage(self.store): + dependency, ordinal = value.share( + self.store, + submission_id=f"{submission_id}:retain:{len(dependencies)}", + ) + if dependency not in dependency_positions: + dependency_positions[dependency] = len(dependencies) + dependencies.append(dependency) + node = [ + "tensor", + { + "dependency": dependency_positions[dependency], + "ordinal": ordinal, + }, + list(value.shape), + value.dtype, + lazy, + value.kind, + validated or value.validated, + ] + aliases[identity] = node + return node + if isinstance(value, QueueTensorRef): + kind, validated = kind or value.kind, validated or value.validated + value = value.load() + if isinstance(value, np.ndarray): + value = torch.from_numpy(np.array(value, copy=True)) + record = tensor_record(value, f"{submission_id}:tensor:{len(blobs)}", kind=kind) + node = [ + "tensor", + len(blobs), + record.metadata["shape"], + record.metadata["dtype"], + lazy, + kind, + validated, + ] + aliases[identity] = node + blobs.append(record) + return node + + active = set() + + def visit(value): + nonlocal token_count + from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef + + if isinstance(value, DiskPayloadRef): + dependency_index = len(dependencies) + dependency_positions.setdefault(value.manifest, dependency_index) + dependencies.append(value.manifest) + extra = {} + if isinstance(value, RolloutGroupRef): + extra = { + "index": value.index, + "receipt": (dataclasses.asdict(value.receipt) if value.receipt else None), + } + elif isinstance(value, RawRolloutRef): + extra = { + "receipt": dataclasses.asdict(value.receipt), + "metrics": value.metrics, + } + elif isinstance(value, TrainBatchRef): + extra = { + "batch_id": value.batch_id, + "rank": value.rank, + "plan_digest": value.plan_digest, + } + return [ + "rollout-ref", + type(value).__name__, + dependency_index, + visit(extra), + ] + if value is None or type(value) in (str, bool, int): + return value + if type(value) is float: + return value if math.isfinite(value) else ["float", repr(value)] + if isinstance(value, (QueueTensorRef, torch.Tensor)): + return tensor(value, lazy=isinstance(value, QueueTensorRef)) + if isinstance(value, np.ndarray): + return ["ndarray", tensor(value)] + if isinstance(value, np.generic): + return visit(value.item()) + if isinstance(value, (bytes, bytearray)): + return ["bytes", base64.b64encode(value).decode("ascii")] + if isinstance(value, Sample.Status): + return ["status", value.value] + if isinstance(value, RecordSetRef): + dependency_positions.setdefault(value, len(dependencies)) + dependencies.append(value) + return ["record-set", len(dependencies) - 1] + identity = id(value) + if identity in active: + raise TypeError("Cyclic custom fields are not supported by the durable Sample codec") + active.add(identity) + try: + if isinstance(value, Sample): + # Include dynamic fields; unsupported values fail explicitly. + token_count += len(value.tokens) + validated = self._prepare_sample(value) + fields = [] + for key, child in vars(value).items(): + if child is not None and key in { + "rollout_routed_experts", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + "rollout_top_p_token_ids", + "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", + }: + if key == "rollout_routed_experts" and not isinstance(child, QueueTensorRef): + child = value.materialize_rollout_routed_experts(replace=False) + elif not isinstance(child, QueueTensorRef): + array = np.asarray(child) + # Partial/unvalidated captures must survive exactly; + # narrow only after validation, or type an empty field. + dtype = None + if key in validated or array.size == 0: + dtype = torch.float32 if key.endswith("log_probs") else torch.int32 + child = torch.as_tensor(array.copy(), dtype=dtype) + node = tensor(child, lazy=True, kind=key, validated=key in validated) + else: + node = visit(child) + fields.append([visit(key), node]) + return ["sample", ["dict", fields]] + if isinstance(value, Sample.SpecInfo): + return ["spec-info", visit(vars(value))] + if isinstance(value, Sample.PrefixCacheInfo): + return ["prefix-cache-info", visit(vars(value))] + from vime.rollout.filter_hub.base_types import DynamicFilterOutput + + if isinstance(value, DynamicFilterOutput): + return ["dynamic-filter", visit(vars(value))] + if isinstance(value, dict): + return [ + "dict", + [[visit(key), visit(item)] for key, item in value.items()], + ] + if isinstance(value, (list, tuple)): + return [ + "tuple" if isinstance(value, tuple) else "list", + [visit(item) for item in value], + ] + # Preserve raw multimodal images losslessly using an explicit codec. + from PIL import Image + + if isinstance(value, Image.Image): + import io + + output = io.BytesIO() + value.save(output, format="PNG") + return [ + "image-png", + base64.b64encode(output.getvalue()).decode("ascii"), + ] + raise TypeError( + f"Unsupported durable Sample field type: {type(value).__module__}.{type(value).__qualname__}" + ) + finally: + active.remove(identity) + + tree = visit(value) + publications = [] + if blobs: + positions = {} + start = 0 + while start < len(blobs): + stop, size = start, 0 + while stop < len(blobs) and stop - start < MAX_RECORDS - 1: + added = len(blobs[stop].payload) + if stop > start and size + added > self.payload_budget: + break + size += added + stop += 1 + dependency_index = len(dependencies) + dependencies.append(len(publications)) + publications.append(Publication(tuple(blobs[start:stop]))) + for index in range(start, stop): + positions[index] = {"dependency": dependency_index, "ordinal": index - start} + start = stop + for node in aliases.values(): + if isinstance(node[1], int): + node[1] = positions[node[1]] + payload = encode({"version": 1, "tree": tree}) + publications.append( + Publication( + ( + Record( + submission_id, + payload, + "vime.v1", + metadata or {}, + tokens=token_count, + ), + ), + tuple(dependencies), + ) + ) + return publications + + def load(self, ref, *, reader=None): + if reader is None: + with self.store.read_session() as reader: + return self.load(ref, reader=reader) + if reader.run_id != self.store.run_id or reader.backend.root != self.store.backend.root: + raise InvalidReference("Sample reader belongs to a different storage root or run") + records = list(reader.read(ref)) + if len(records) != 1 or records[0].codec != "vime.v1": + raise UnsupportedSchema("Expected one vime.v1 collection record") + payload = decode(records[0].payload) + if payload.get("version") != 1: + raise UnsupportedSchema("Unsupported Sample codec version") + aliases = {} + tensor_sets = {} + manifest = reader.manifest(ref) + dependencies = [RecordSetRef.from_dict(value) for value in manifest["dependencies"]] + + def dependency(index): + if type(index) is not int or not 0 <= index < len(dependencies): + raise CorruptData("Sample codec dependency index is outside the manifest") + return dependencies[index] + + def visit(node): + if not isinstance(node, list): + return node + tag = node[0] + if tag == "rollout-ref": + from straw.protocol import CommitReceipt + + from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef + + classes = { + cls.__name__: cls + for cls in ( + DiskPayloadRef, + RawRolloutRef, + RolloutGroupRef, + TrainBatchRef, + ) + } + if node[1] not in classes: + raise UnsupportedSchema(f"Unknown rollout reference type: {node[1]}") + extra = visit(node[3]) if isinstance(node[3], list) else dict(node[3]) + if extra.get("receipt"): + extra["receipt"] = CommitReceipt.from_dict(extra["receipt"]) + ref = dependency(node[2]) if isinstance(node[2], int) else RecordSetRef.from_dict(node[2]) + return classes[node[1]](ref, str(self.store.backend.root), **extra) + if tag == "dict": + return {visit(key): visit(value) for key, value in node[1]} + if tag in {"list", "tuple"}: + value = [visit(item) for item in node[1]] + return tuple(value) if tag == "tuple" else value + if tag == "sample": + values = visit(node[1]) + fields = {field.name for field in dataclasses.fields(Sample)} + sample = Sample(**{key: value for key, value in values.items() if key in fields}) + for key, value in values.items(): + if key not in fields: + setattr(sample, key, value) + return sample + if tag == "spec-info": + return Sample.SpecInfo(**visit(node[1])) + if tag == "prefix-cache-info": + return Sample.PrefixCacheInfo(**visit(node[1])) + if tag == "dynamic-filter": + from vime.rollout.filter_hub.base_types import DynamicFilterOutput + + return DynamicFilterOutput(**visit(node[1])) + if tag == "status": + return Sample.Status(node[1]) + if tag == "float": + if node[1] not in {"inf", "-inf", "nan"}: + raise CorruptData("Invalid special float") + return float(node[1]) + if tag == "bytes": + return base64.b64decode(node[1], validate=True) + if tag == "record-set": + value = dependency(node[1]) if isinstance(node[1], int) else RecordSetRef.from_dict(node[1]) + reader.validate(value) + return value + if tag == "tensor": + key = (encode(node[1]), node[4]) + if key not in aliases: + if "dependency" in node[1]: + dep = node[1]["dependency"] + if dep not in tensor_sets: + tensor_sets[dep] = QueueTensorRef.from_record_set_many( + self.store, dependency(dep), reader=reader + ) + ordinal = node[1]["ordinal"] + if type(ordinal) is not int or ordinal not in tensor_sets[dep]: + raise CorruptData("Tensor ordinal does not name a tensor in the publication") + value = dataclasses.replace(tensor_sets[dep][ordinal], validated=node[6]) + else: + value = QueueTensorRef.from_record(self.store, RecordRef.from_dict(node[1]), validated=node[6]) + if value.shape != tuple(node[2]) or value.dtype != node[3]: + raise CorruptData("Sample tensor descriptor disagrees with its storage record") + value = dataclasses.replace(value, kind=node[5]) + aliases[key] = value if node[4] else value.load(reader=reader) + return aliases[key] + if tag == "ndarray": + return visit(node[1]).numpy() + if tag == "image-png": + import io + + from PIL import Image + + image = Image.open(io.BytesIO(base64.b64decode(node[1], validate=True))) + image.load() + return image + raise UnsupportedSchema(f"Unknown Sample codec tag: {tag!r}") + + return visit(payload["tree"]) diff --git a/vime/rollout/queue_data_source.py b/vime/rollout/queue_data_source.py new file mode 100644 index 000000000..f0f62742c --- /dev/null +++ b/vime/rollout/queue_data_source.py @@ -0,0 +1,1101 @@ +"""Persistent prompt, continuation and delivery tasks shared by all readers. + +One job-owned Ray actor hosts the coordinator and a serialized dataset producer. +Readers load task inputs directly from the shared store; datasets and generated +Samples never travel through the coordinator RPC. Producer writes run outside +the coordinator lock, allowing existing workers to complete during refill. +""" + +from __future__ import annotations + +import asyncio +import copy +import logging +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict, dataclass +from pathlib import Path + +import ray +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy +from straw.coordinator import Coordinator +from straw.errors import LeaseExpired, StaleAttempt +from straw.protocol import Lease, Limits, Record, RecordSetRef, TaskSpec, encode + +from vime.rollout.base_types import iter_samples +from vime.rollout.data_source import DataSource, RolloutDataSource +from vime.utils.rollout_transport import ( + DiskPayloadRef, + group_lease, + pack_rollout_payload, + record_generation_provenance, + release_rollout_publications, + resolve_rollout_data_dir, + rollout_store, + unpack_rollout_payload, +) +from vime.utils.types import Sample + +logger = logging.getLogger(__name__) + + +class RolloutQueueController: + """Plain class for unit testing; the host wraps it in one concurrent Ray actor.""" + + def __init__(self, args, *, producer=None): + self.args = args + self.store, self.codec, self._writer_lock = rollout_store(args) + self._gc_error = None + if not hasattr(Coordinator, "yield_tasks"): + raise RuntimeError( + "This straw transport requires a straw-queue build with yield_tasks and priority scheduling; the original PyPI 0.1.0 wheel does not support these APIs." + ) + self._queue = Coordinator( + self.store, + exclusive_owner="job owns this non-restarting actor; explicit resume confirms prior owner stopped", + recover=getattr(args, "rollout_queue_resume", False), + lease_seconds=getattr(args, "rollout_queue_lease_seconds", 300), + limits=Limits( + pending_tasks=getattr(args, "rollout_queue_max_pending", 65536), + inflight_tasks=getattr(args, "rollout_queue_max_inflight", 65536), + ), + ) + self.producer = producer + self._initial_cursor = None + self._producer_lock = threading.Lock() + self._reader_tokens = {} + self._retired_readers = set() + self.branch_id = uuid.uuid4().hex + self._training_token = None + self._training_lock = threading.RLock() + self._gc_stop = threading.Event() + self._gc_thread = None + if getattr(args, "rollout_queue_resume", False): + # This flag explicitly requires the entire prior job to have stopped; + # unlike a lease timeout, that supervisor assertion ends old reads. + readers = self.queue.outstanding_reads() + for offset in range(0, len(readers), 128): + self.queue.release_task_reads(readers[offset : offset + 128]) + # Joint restore can rewind the training cursor. Reconcile its durable + # storage ownership before GC inspects restored live result roots. + if not getattr(args, "_joint_resume", None): + self._start_gc() + # Recovery invalidates leases; unfinished collections from the stopped + # manager cannot produce a new branch's batch. Accepted facts stay intact. + for task_id, task in list(self.queue.tasks.items()): + if task_id.startswith("collection:") and task["state"] == "pending": + self.queue.cancel_task(task_id, request_id=f"abandon:{self.branch_id}:{task_id}") + + def _check_gc_error(self): + if self._gc_error is not None: + raise RuntimeError("Straw online GC failed; queue work has stopped") from self._gc_error + + def _start_gc(self): + if getattr(self.args, "rollout_queue_online_gc", False) and self._gc_thread is None: + self._gc_thread = threading.Thread(target=self._gc_loop, name="straw-gc", daemon=True) + self._gc_thread.start() + + @property + def queue(self): + # Check every native queue access, including a call already in flight + # that reaches its next queue operation after the background failure. + self._check_gc_error() + return self._queue + + def _collect_storage(self): + result = self.queue.collect_garbage() + print(f"straw_online_gc: {result}", flush=True) + return result + + def _gc_loop(self): + while not self._gc_stop.wait(60): + try: + self._collect_storage() + except Exception as error: + self._gc_error = error + self._gc_stop.set() + logger.exception("straw online GC failed; retained storage must be inspected") + return + + def _source(self): + self._check_gc_error() + if self.producer is None: + self.producer = RolloutDataSource(self.args) + if self.producer.dataset is not None and not len(self.producer.dataset): + raise ValueError("Queue rollout dataset is empty after filtering") + if self._initial_cursor is None: + self._initial_cursor = { + key: copy.deepcopy(getattr(self.producer, key)) + for key in ( + "sample_offset", + "epoch_id", + "sample_group_index", + "sample_index", + "metadata", + ) + } + return self.producer + + def configuration(self): + with self._producer_lock: + source = self._source() + return { + "dataset_size": len(source), + "n_samples_per_prompt": self.args.n_samples_per_prompt, + "seed": self.args.rollout_seed, + "shuffle": self.args.rollout_shuffle, + "run_id": self.store.run_id, + "branch_id": self.branch_id, + } + + def identity(self): + self._check_gc_error() + return {"run_id": self.store.run_id, "branch_id": self.branch_id} + + def pending_snapshot(self): + """Capture shared pending work once, with explicit storage dependencies.""" + tasks = [] + for spec in self.queue.pending_tasks(task_prefix="prompt:"): + value = asdict(spec) + value["input_ref"] = DiskPayloadRef(spec.input_ref, str(self.store.backend.root)) + tasks.append(value) + with self._writer_lock: + return self.codec.publish({"tasks": tasks}, submission_id=f"pending:{uuid.uuid4().hex}") + + def pending_count(self): + return sum(spec.metadata.get("returned", False) for spec in self.queue.pending_tasks(task_prefix="prompt:")) + + def return_groups(self, updates, deliveries): + """Return partials atomically; accepted results use separate delivery tasks.""" + from straw.protocol import digest + + with self._producer_lock: + if updates: + self.queue.yield_tasks(updates, request_id="return:" + digest(updates)) + for delivery in deliveries: + ref = RecordSetRef.from_dict(delivery["input_ref"]) + task_id = f"prompt:delivery:{digest(delivery)}" + if self.queue.task_status(task_id) is not None: + continue + spec = TaskSpec( + task_id, + ref, + metadata=delivery["metadata"], + priority=delivery["priority"], + scheduling_key=delivery["scheduling_key"], + estimated_tokens=ref.tokens, + ) + self.queue.submit_tasks(task_id, [spec]) + + def restore_pending(self, snapshot): + """Restore saved input versions without mutating accepted result history. + + Restore is called before rollout readers start. Full dataset/model + rollback is a separate checkpoint protocol; this restores pending work. + """ + updates, deliveries = [], [] + for saved in self.codec.load(snapshot)["tasks"]: + ref = saved["input_ref"].manifest + status = self.queue.task_status(saved["task_id"]) + fields = { + key: saved.get(key, default) + for key, default in ( + ("metadata", {}), + ("priority", 0), + ("scheduling_key", 0), + ) + } + if status and status["state"] in {"pending", "leased"}: + if status["state"] == "leased": + self.release([Lease(**status["lease"])]) + page = self.queue.acquire("checkpoint-restore", 1, task_ids=[saved["task_id"]]) + if not page.assignments: + raise RuntimeError(f"Cannot restore pending task: {saved['task_id']} ({page.status})") + updates.append( + { + "lease": asdict(page.assignments[0].lease), + "input_ref": asdict(ref), + **fields, + } + ) + else: + receipt = self.result(Lease(**status["lease"])) if status and status["state"] == "completed" else None + metadata = dict(fields["metadata"]) + if receipt: + metadata["source_positions"] = sorted( + set(metadata.get("source_positions", [])) | {receipt.position} + ) + deliveries.append( + { + "input_ref": asdict(ref), + **fields, + "metadata": metadata, + } + ) + if len(updates) == 64: + self.return_groups(updates, []) + updates = [] + self.return_groups(updates, deliveries) + + def reader_metadata(self, reader_id, metadata=None): + if metadata is not None: + with self._writer_lock: + ref = self.codec.publish( + {"metadata": metadata}, + submission_id=f"reader-metadata:{uuid.uuid4().hex}", + ) + self.reader_state(reader_id, ref) + state = self.reader_state(reader_id) + return self.codec.load(RecordSetRef.from_dict(state["state_ref"])).get("metadata", {}) if state else {} + + def take(self, reader_id, count): + if reader_id in self._retired_readers: + raise StaleAttempt("Reader was retired by the scheduler") + requested = count + page = self.queue.acquire(reader_id, count, task_prefix="prompt:") + if page.status != "empty": + return page + with self._producer_lock: + # Another reader may have refilled while this one waited. + page = self.queue.acquire(reader_id, count, task_prefix="prompt:") + if page.status != "empty": + return page + source = self._source() + state = self.queue.producer_state("dataset") + for key, value in (state["cursor"] if state else self._initial_cursor).items(): + setattr(source, key, copy.deepcopy(value)) + if source.dataset is not None and self.args.rollout_shuffle: + source.dataset.shuffle(source.epoch_id) + start = source.sample_group_index + # Bounded refill, without unused index reservations held in readers. + count = min(max(count, 8), 64, self.queue.limits.pending_tasks) + groups = source.get_samples(count) + group_ids = [next(iter_samples(group)).group_index for group in groups] + task_ids = [f"prompt:{group_id}" for group_id in group_ids] + with self._writer_lock: + refs = self.codec.publish_many( + groups, + submission_ids=[f"input:{task_id}" for task_id in task_ids], + metadata=[{"task_id": task_id} for task_id in task_ids], + ) + tasks = [ + TaskSpec( + task_id, + ref, + metadata={"group_index": group_id}, + estimated_tokens=ref.tokens, + ) + for task_id, group_id, ref in zip(task_ids, group_ids, refs, strict=True) + ] + cursor = { + key: copy.deepcopy(getattr(source, key)) + for key in ( + "sample_offset", + "epoch_id", + "sample_group_index", + "sample_index", + "metadata", + ) + } + self.queue.submit_tasks( + f"dataset:{start}", + tasks, + producer_id="dataset", + producer_state={"version": 1, "cursor": cursor}, + ) + return self.queue.acquire(reader_id, requested, task_prefix="prompt:") + + def heartbeat(self, leases): + return self.queue.heartbeat(leases) + + def complete(self, lease, result_ref): + if lease.worker_id in self._retired_readers and self.result(lease) is None: + raise StaleAttempt("Reader was retired by the scheduler") + return self.queue.complete_task( + lease, + submission_id=f"result:{lease.attempt_id}", + result_ref=result_ref, + result_digest=result_ref.digest, + ) + + def reject(self, lease, reason): + result = self.queue.fail_task( + lease, + request_id=f"reject:{lease.attempt_id}", + failure={"category": "Filtered", "reason": reason}, + retryable=False, + ) + positions = self.queue.task_status(lease.task_id)["spec"]["metadata"].get("source_positions", []) + if positions: + # A failed delivery cannot replay its earlier accepted versions. + # Advance their retention only after fencing this task's lease. + with self._writer_lock: + ref = self.codec.publish( + {"positions": positions, "reason": reason}, + submission_id=f"reject:{lease.attempt_id}", + ) + self.record_dispositions(ref) + return result + + def result(self, lease): + return self.queue.lookup_submission(f"result:{lease.attempt_id}") + + def results(self, leases): + return [self.result(lease) if lease else None for lease in leases] + + def release(self, leases): + from straw.protocol import digest + + for offset in range(0, len(leases), 128): + batch = leases[offset : offset + 128] + self.queue.release_tasks( + batch, + request_id="release:" + digest([asdict(lease) for lease in batch]), + ) + + def recover_reader_results(self, reader_id, delivered_ref): + """Fence a stopped reader and recover accepted results whose reply was lost.""" + delivered = set(self.codec.load(delivered_ref)) + with self._producer_lock: + self._retired_readers.add(reader_id) + accepted = self.queue.retire_worker(reader_id) + state = self._training_state() + processed = set(state["processed_positions"]) + superseded = { + position + for task in self.queue.tasks.values() + for position in task["spec"]["metadata"].get("source_positions", []) + } + receipts = [ + receipt + for receipt in accepted + if receipt["position"] >= state["processed_cursor"] + and receipt["position"] not in delivered | processed + and receipt["position"] not in superseded + ] + with self._writer_lock: + return self.codec.publish(receipts, submission_id=f"recover-reader:{uuid.uuid4().hex}") + + def recover_pending_rollout(self): + """Replay accepted groups after a whole-job failure before the first batch. + + Once conversion/training has started, queue progress alone cannot identify + the model/optimizer version. That recovery requires a joint checkpoint. + --rollout-queue-resume asserts that all prior readers have stopped. + """ + if not getattr(self.args, "rollout_queue_resume", False): + raise RuntimeError("Whole-job recovery requires --rollout-queue-resume") + with self._producer_lock, self._training_lock: + state = self._training_state() + if self.queue.batches or state["processed_cursor"] or state["processed_positions"]: + raise RuntimeError( + "Queue-only recovery is limited to an interrupted first rollout; " + "restore matching model/optimizer and rollout checkpoints after batch conversion begins" + ) + superseded = { + position + for task in self.queue.tasks.values() + for position in task["spec"]["metadata"].get("source_positions", []) + } + receipts = [ + receipt + for receipt in self.queue.commits + if receipt["task_id"].startswith("prompt:") and receipt["position"] not in superseded + ] + with self._writer_lock: + return self.codec.publish(receipts, submission_id=f"recover-job:{self.branch_id}") + + def begin_collection(self, request_id): + task_id = f"collection:{self.branch_id}:{request_id}" + self.queue.submit_tasks( + task_id, + [TaskSpec(task_id, allow_empty=True, control=True, estimated_records=0)], + ) + task = self.queue.task_status(task_id) + if task["state"] in {"leased", "completed"} and task["lease"]["worker_id"] == "manager": + return Lease(**task["lease"]) + page = self.queue.acquire("manager", 1, task_ids=[task_id], control=True) + if not page.assignments: + raise RuntimeError(f"Collection task is not available: {task_id} ({page.status})") + return page.assignments[0].lease + + def accepted(self, receipt): + actual = self.queue.lookup_submission(receipt.submission_id) + if actual != receipt: + raise ValueError("Raw rollout reference is not in this run's accepted log") + self.store.validate(receipt.result_ref) + return actual + + def batch(self, batch_id): + return self.queue.get_batch(batch_id) + + def plan_batch(self, batch_id, positions, plan_ref): + with self._training_lock: + if self._training_token is None: + self._training_token = self.queue.open_consumer( + "training", exclusive_owner="job owns one BatchBuilder" + ) + return self.queue.plan_batch( + "training", + token=self._training_token, + batch_id=batch_id, + input_positions=positions, + plan_ref=plan_ref, + ) + + def _training_state(self): + previous = self.queue.load_consumer_state("training") + return ( + self.codec.load(RecordSetRef.from_dict(previous["state_ref"])) + if previous + else { + "version": 1, + "processed_cursor": 0, + "fetch_cursor": 0, + "processed_positions": [], + "batches": [], + "finished_batches": [], + } + ) + + def _advance(self, state, positions): + positions = set(state["processed_positions"]) | set(positions) + # A delivery acknowledges the earlier accepted versions it replaced, + # whether it reaches training or is rejected by a selection hook. + for position in list(positions - set(state["processed_positions"])): + if position < state["processed_cursor"]: + continue + receipt = self.queue.read_commits(position, 1).commits[0] + task = self.queue.task_status(receipt.task_id) + positions.update(task["spec"]["metadata"].get("source_positions", [])) + cursor = state["processed_cursor"] + end = max(positions, default=-1) + 1 + while cursor in positions: + positions.remove(cursor) + cursor += 1 + state.update( + processed_cursor=cursor, + fetch_cursor=max(state["fetch_cursor"], end), + processed_positions=sorted(position for position in positions if position >= cursor), + ) + + def _save_training_state(self, state, *, batch_id=None, ready_ref=None): + self._check_gc_error() + if self._training_token is None: + self._training_token = self.queue.open_consumer("training", exclusive_owner="job owns one BatchBuilder") + with self._writer_lock: + state_ref = self.codec.publish(state, submission_id=f"training-state:{uuid.uuid4().hex}") + progress_ref = self.store.publish( + [ + Record( + "progress", + encode( + { + "version": 1, + "processed_positions": state["processed_positions"], + "finished_batches": state.get("finished_batches", []), + } + ), + codec="json.v1", + ) + ], + submission_id=f"progress:{state_ref.digest}", + dependencies=[state_ref], + ) + kwargs = dict( + token=self._training_token, + state_ref=state_ref, + progress_ref=progress_ref, + fetch_cursor=state["fetch_cursor"], + processed_cursor=state["processed_cursor"], + ) + if batch_id is not None: + self.queue.batch_ready("training", batch_id=batch_id, ready_ref=ready_ref, **kwargs) + else: + self.queue.save_consumer_state( + "training", + request_id=f"state:{self.branch_id}:{state_ref.digest}", + **kwargs, + ) + return state_ref + + def ready_batch(self, batch_id, ready_ref): + with self._training_lock: + batch = self.queue.get_batch(batch_id) + if batch["ready"]: + if batch["ready_ref"] != asdict(ready_ref): + raise ValueError("Batch was already published with another ready reference") + return RecordSetRef.from_dict(self.training_state()["state_ref"]) + state = self._training_state() + self._advance(state, batch["input_positions"]) + state["batches"].append(DiskPayloadRef(ready_ref, str(self.store.backend.root))) + return self._save_training_state(state, batch_id=batch_id, ready_ref=ready_ref) + + def finish_batch(self, batch_id): + """Acknowledge completed reads on every training rank; checkpoints retain their own roots.""" + with self._training_lock: + batch = self.queue.get_batch(batch_id) + if not batch or not batch["ready"]: + raise ValueError("Cannot finish an unknown or unready training batch") + state = self._training_state() + finished = state.setdefault("finished_batches", []) + if batch_id not in finished: + finished.append(batch_id) + if getattr(self.args, "rollout_queue_online_gc", False): + state["batches"] = [ref for ref in state["batches"] if asdict(ref.manifest) != batch["ready_ref"]] + self._save_training_state(state) + if getattr(self.args, "rollout_queue_online_gc", False): + self._collect_storage() + + def record_dispositions(self, ref): + """Journal why accepted outputs will not be selected by this training view.""" + with self._training_lock: + decision = self.codec.load(ref) + positions = decision["positions"] + if not decision.get("reason") or any(not 0 <= p < len(self.queue.commits) for p in positions): + raise ValueError("Invalid accepted-output disposition") + state = self._training_state() + self._advance(state, positions) + state["disposition"] = DiskPayloadRef(ref, str(self.store.backend.root)) + self._save_training_state(state) + + def training_state(self): + return self.queue.load_consumer_state("training") + + def handoff_restored_source(self, source_ref): + """Move restore ownership after pending tasks and consumers have been snapshotted. + + The caller retains the complete replacement source before this call, + with its readers paused. Old checkpoint owners remain independent until + a successor joint commit allows their normal retirement. + """ + with self._training_lock: + state = self._training_state() + if "restored_source" in state: + state["restored_source"] = DiskPayloadRef(source_ref, str(self.store.backend.root)) + self._save_training_state(state) + + def restore_training_state(self, state_ref, fetch_cursor, processed_cursor, retained_ref=None): + with self._training_lock: + state = self.codec.load(state_ref) + if (state["fetch_cursor"], state["processed_cursor"]) != ( + fetch_cursor, + processed_cursor, + ): + raise ValueError("Consumer checkpoint cursors differ from its state") + if retained_ref is not None: + from vime.utils.rollout_transport import RolloutGroupRef + + retained, visited = set(), set() + + def visit(value): + if isinstance(value, RolloutGroupRef) and value.receipt: + retained.add(value.receipt.position) + elif isinstance(value, DiskPayloadRef): + if value.manifest not in visited: + visited.add(value.manifest) + visit(self.codec.load(value.manifest)) + elif isinstance(value, Sample): + retained.update(getattr(value, "_queue_source_positions", [])) + if receipt := getattr(value, "_queue_receipt", None): + retained.add(receipt["position"]) + elif lease := getattr(value, "_queue_lease", None): + receipt = self.result(Lease(**lease)) + if receipt is None: + task = self.queue.task_status(lease["task_id"]) + if task["state"] == "completed": + receipt = self.result(Lease(**task["lease"])) + if receipt is not None: + retained.add(receipt.position) + elif isinstance(value, dict): + retained.update(value.get("source_positions", [])) + for item in value.values(): + visit(item) + elif isinstance(value, (list, tuple)): + for item in value: + visit(item) + + visit(DiskPayloadRef(retained_ref, str(self.store.backend.root))) + processed = set(state["processed_positions"]) + abandoned = [ + commit["position"] + for commit in self.queue.commits[processed_cursor:] + if commit["position"] not in retained | processed + ] + old_batches = {ref.load()["batch_id"] for ref in state["batches"]} + abandoned_batches = [ + batch_id + for batch_id, batch in self.queue.batches.items() + if batch["ready"] and batch_id not in old_batches + ] + with self._writer_lock: + decision = self.codec.publish( + { + "reason": "checkpoint branch excludes outputs absent from its saved source", + "positions": abandoned, + "batch_ids": abandoned_batches, + "checkpoint_state": asdict(state_ref), + "retained_source": asdict(retained_ref), + "branch_id": self.branch_id, + }, + submission_id=f"restore-decision:{uuid.uuid4().hex}", + ) + self._advance(state, abandoned) + state["finished_batches"] = sorted(set(state.get("finished_batches", [])) | set(abandoned_batches)) + state["disposition"] = DiskPayloadRef(decision, str(self.store.backend.root)) + # Filter dispositions may replace the audit record, but cannot + # release old reader prefixes before a complete snapshot exists. + state["restored_source"] = DiskPayloadRef(retained_ref, str(self.store.backend.root)) + restored = self._save_training_state(state) + self._start_gc() + return restored + + def reader_state(self, reader_id, ref=None): + consumer_id = f"reader:{reader_id}" + if ref is not None: + if reader_id not in self._reader_tokens: + self._reader_tokens[reader_id] = self.queue.open_consumer( + consumer_id, exclusive_owner="registered reader" + ) + self.queue.save_consumer_state( + consumer_id, + token=self._reader_tokens[reader_id], + request_id=ref.digest, + state_ref=ref, + fetch_cursor=0, + processed_cursor=0, + ) + return self.queue.load_consumer_state(consumer_id) + + def status(self, task_id): + return self.queue.task_status(task_id) + + def metrics(self): + return self.queue.metrics() + + def close(self): + self._gc_stop.set() + if self._gc_thread is not None: + self._gc_thread.join() + try: + self._queue.close() + finally: + with self._writer_lock: + self.store.seal() + self._check_gc_error() + + +def create_queue_controller(args): + resolve_rollout_data_dir(args) + if getattr(args, "_rollout_queue_controller", None) is None: + args._rollout_queue_controller = ( + ray.remote(RolloutQueueController) + .options( + num_cpus=0, + max_concurrency=4, + max_restarts=0, + scheduling_strategy=NodeAffinitySchedulingStrategy( + ray.get_runtime_context().get_node_id(), soft=False + ), + ) + .remote(copy.copy(args)) + ) + args._rollout_queue_branch = ray.get(args._rollout_queue_controller.identity.remote())["branch_id"] + return args._rollout_queue_controller + + +@dataclass(frozen=True) +class QueueReaderConfig: + args: object + controller: object + reader_id: str + dataset_size: int + + def open(self, args=None): + return QueueReader( + self.args if args is None else args, + self.controller, + self.reader_id, + self.dataset_size, + ) + + +class QueueReader(DataSource): + """One process's queue client: task acquisition, returns and lease renewal. + + Readers share the coordinator and durable inputs, but track their own active + leases. Job-wide consumers and checkpoint files belong to QueueDataSource. + """ + + def __init__(self, args, controller, reader_id, dataset_size): + self.args, self.controller, self.reader_id = args, controller, reader_id + self.args._rollout_queue_controller = controller + self.dataset_size = dataset_size + # Only actively executing leases are local. Returned work lives in the + # durable queue and can be acquired by any reader after this one stops. + self._leases = {} + self._closed = False + self._lock = threading.RLock() + self._reader = ThreadPoolExecutor(max_workers=1, thread_name_prefix="queue-input") + self._stop = threading.Event() + self._heartbeat_error = None + filter_path = getattr(args, "buffer_filter_path", None) + if filter_path is not None: + raise ValueError( + "--buffer-filter-path is not supported by straw; the queue prioritizes ready groups, partials, " + "then fresh prompts, using older weight versions first within each stage" + ) + self._heartbeats = threading.Thread(target=self._heartbeat_loop, name="queue-leases", daemon=True) + self._heartbeats.start() + + def _heartbeat_loop(self): + interval = max(0.1, getattr(self.args, "rollout_queue_lease_seconds", 300) / 3) + while not self._stop.wait(interval): + with self._lock: + leases = list(self._leases.values()) + try: + for offset in range(0, len(leases), 128): + batch = leases[offset : offset + 128] + outcomes = ray.get(self.controller.heartbeat.remote(batch)) + with self._lock: + for lease, outcome in zip(batch, outcomes, strict=True): + if self._leases.get(lease.task_id) != lease: + continue # A released attempt may already have been reacquired. + if outcome == "StaleAttempt": + self._leases.pop(lease.task_id, None) + elif outcome != "extended": + raise LeaseExpired(f"Reader lost lease for {lease.task_id}: {outcome}") + except Exception as error: + self._heartbeat_error = error + return + + def get_samples(self, num_samples): + """Acquire groups from durable scheduling state. + + The queue serves ready deliveries, then partials, then untouched prompts. + Within each stage it applies the stored scheduling key and FIFO order. + Payloads are read directly from shared storage; RPC carries references. + """ + if num_samples < 0: + raise ValueError("num_samples must be nonnegative") + if self._closed: + raise RuntimeError("Queue reader is closed") + if self._heartbeat_error: + raise RuntimeError("Queue lease heartbeat failed") from self._heartbeat_error + groups = [] + while len(groups) < num_samples: + if self._closed: + raise RuntimeError("Queue reader is closed") + if self._heartbeat_error: + raise RuntimeError("Queue lease heartbeat failed") from self._heartbeat_error + page = ray.get(self.controller.take.remote(self.reader_id, min(num_samples - len(groups), 64))) + if page.status in {"end_of_input", "draining"}: + break + if not page.assignments: + if page.status not in {"backpressured", "empty"}: + raise RuntimeError(f"Queue cannot supply rollout inputs: {page.status}") + time.sleep(0.05) + continue + store, codec, _ = rollout_store(self.args) + with store.read_session() as session: + for assignment in page.assignments: + lease = assignment.lease + group = codec.load(assignment.task.input_ref, reader=session) + for sample in iter_samples(group): + # A delivery task has its own authorization. Prior + # receipts only contribute to retention accounting. + sample.__dict__.pop("_queue_receipt", None) + sample._queue_lease = asdict(lease) + sample._queue_generation_start = len(sample.tokens) + sample._queue_source_positions = assignment.task.metadata.get("source_positions", []) + with self._lock: + self._leases[lease.task_id] = lease + groups.append(group) + return groups + + async def get_samples_async(self, num_samples): + """Run blocking queue/file reads on the reader thread, keeping asyncio responsive.""" + return await asyncio.get_running_loop().run_in_executor(self._reader, self.get_samples, num_samples) + + def add_samples(self, groups): + """Persist returned groups and make them claimable by any reader. + + A usable partial yields its task with a new input_ref. Incomplete R3/SC + capture returns the task with its previous input instead. Already accepted + results get delivery tasks; their original completion remains immutable. + """ + if len(groups) > 64: + for offset in range(0, len(groups), 64): + self.add_samples(groups[offset : offset + 64]) + return + if not groups: + return + groups_to_save, leases_to_retry, retry_mismatches = [], [], [] + for group in groups: + mismatches = self._continuation_mismatches(group) + if not mismatches: + groups_to_save.append(group) + continue + lease = group_lease(group) + if lease is None: + raise ValueError(f"Incomplete continuation has no durable task input to retry: {mismatches}") + release_rollout_publications(group, self.args) + leases_to_retry.append(lease) + retry_mismatches.extend(mismatches) + if leases_to_retry: + ray.get(self.controller.release.remote(leases_to_retry)) + with self._lock: + for lease in leases_to_retry: + if self._leases.get(lease.task_id) == lease: + self._leases.pop(lease.task_id) + logger.warning( + "Requeued %d incomplete continuations from their last durable inputs; (sample, field, actual, expected): %s", + len(leases_to_retry), + retry_mismatches[:8], + ) + if not groups_to_save: + return + leases = [group_lease(group) for group in groups_to_save] + receipts = ray.get(self.controller.results.remote(leases)) + for group, receipt in zip(groups_to_save, receipts, strict=True): + record_generation_provenance(group, self.args) + if receipt: + for sample in iter_samples(group): + sample.__dict__.pop("_queue_lease", None) + sample._queue_receipt = asdict(receipt) + store, codec, writer_lock = rollout_store(self.args) + with writer_lock: + refs = codec.publish_many( + groups_to_save, + submission_ids=[f"continuation:{uuid.uuid4().hex}" for _ in groups_to_save], + ) + updates, deliveries = [], [] + for group, lease, receipt, ref in zip(groups_to_save, leases, receipts, refs, strict=True): + samples = list(iter_samples(group)) + ready = all( + s.status in (Sample.Status.COMPLETED, Sample.Status.TRUNCATED) and s.reward is not None + for s in samples + ) + partial = any(s.response_length for s in samples) + versions = [ + int(str(v)) + for s in samples + for v in (s.weight_versions or []) + if str(v).isascii() and str(v).removeprefix("-").isdigit() + ] + # current_version - oldest_version orders exactly like oldest_version + # ascending, without rewriting every task after each weight update. + scheduling_key = min(versions, default=2**63 - 1) + scheduling_key = max(-(2**63), min(2**63 - 1, scheduling_key)) + origin = asdict(receipt) if receipt else getattr(samples[0], "_queue_receipt", None) + source_positions = {p for s in samples for p in getattr(s, "_queue_source_positions", [])} + if origin: + source_positions.add(origin["position"]) + fields = { + "input_ref": asdict(ref), + "priority": 2 if ready else 1 if partial else 0, + "scheduling_key": scheduling_key, + "metadata": { + "stage": "ready" if ready else "partial" if partial else "fresh", + "returned": True, + "source_positions": sorted(source_positions), + }, + } + if lease and not receipt: + updates.append({"lease": asdict(lease), **fields}) + else: + deliveries.append(fields) + # Publish before yielding: the WAL transition adopts the new input and + # ends the old lease in one transaction. A lost reply can be retried. + ray.get(self.controller.return_groups.remote(updates, deliveries)) + with self._lock: + for lease in leases: + if lease and self._leases.get(lease.task_id) == lease: + self._leases.pop(lease.task_id) + # Retain no Sample objects or input references after handing work back. + + def _continuation_mismatches(self, group): + """Return (sample, field, actual rows, expected rows) for R3/top-k SC captures.""" + mismatches = [] + for sample in iter_samples(group): + if not sample.response_length: + continue + if getattr(self.args, "use_rollout_routing_replay", False): + rows = sample.get_rollout_routed_experts_length() + if rows != len(sample.tokens) - 1: + mismatches.append((sample.index, "routes", rows, len(sample.tokens) - 1)) + if getattr(self.args, "use_score_centering", False) and getattr(self.args, "rollout_top_p", 1) >= 1: + for name in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + value = getattr(sample, name) + rows = len(value) if value is not None else 0 + if rows != sample.response_length: + mismatches.append((sample.index, name, rows, sample.response_length)) + return mismatches + + def state_dict(self, *, include_pending=True): + state = {"version": 3, "metadata": self.get_metadata()} + if include_pending: + ref = ray.get(self.controller.pending_snapshot.remote()) + state["pending"] = DiskPayloadRef(ref, self.args.rollout_data_dir) + return pack_rollout_payload(state, self.args, -1) + + def load_state_dict(self, state): + value = unpack_rollout_payload(state) + if value["version"] != 3: + raise ValueError( + "Reader-local buffer checkpoints require migration before using the durable queue scheduler" + ) + if "pending" in value: + ray.get(self.controller.restore_pending.remote(value["pending"].manifest)) + self.update_metadata(value["metadata"]) + + def materialize_samples(self, samples, *, release_files=True): + from vime.utils.tensor_store import TensorRef + + if isinstance(samples, list): + return [self.materialize_samples(child) for child in samples] + sample = copy.copy(samples) + for key, value in vars(sample).items(): + if isinstance(value, TensorRef): + setattr(sample, key, value.load()) + return sample + + def get_buffer_length(self): + return ray.get(self.controller.pending_count.remote()) + + def update_metadata(self, metadata): + current = self.get_metadata() + current.update(metadata) + ray.get(self.controller.reader_metadata.remote(self.reader_id, current)) + + def get_metadata(self): + return ray.get(self.controller.reader_metadata.remote(self.reader_id)) + + def __len__(self): + return self.dataset_size + + def save(self, rollout_id): + raise RuntimeError("Save queue readers through their owning data source") + + def load(self, rollout_id=None): + raise RuntimeError("Restore queue readers through their owning data source") + + def close(self): + if self._closed: + return + self._closed = True + self._reader.shutdown(wait=True) + with self._lock: + leases = list(self._leases.values()) + ray.get(self.controller.release.remote(leases)) + self._stop.set() + self._heartbeats.join(timeout=5) + + +class QueueDataSource(QueueReader): + """Job-level data source: reader creation, consumers and source checkpoints. + + The manager can read through the inherited QueueReader interface; distributed + workers open separate readers against the same controller. There is no extra + sample buffer or second implementation of get_samples/add_samples here. + """ + + def __init__(self, args): + self._owns_controller = getattr(args, "_rollout_queue_controller", None) is None + controller = create_queue_controller(args) + self.data_config = ray.get(controller.configuration.remote()) + super().__init__(args, controller, "owner", self.data_config["dataset_size"]) + self.consumers, self._restored_consumers = {}, {} + + def reader_config(self, reader_id): + if reader_id == "owner": + raise ValueError("Reader ID 'owner' is reserved") + return QueueReaderConfig(self.args, self.controller, reader_id, len(self)) + + def register_consumer(self, name, consumer): + if name in self.consumers: + raise ValueError(f"Consumer {name!r} already registered") + if name in self._restored_consumers: + consumer.load_state_dict(self._restored_consumers.pop(name)) + self.consumers[name] = consumer + + def save(self, rollout_id): + from straw.reporting import write_report + + paused = [] + try: + for consumer in self.consumers.values(): + paused.append((consumer, consumer.pause())) + state = { + "version": 1, + "configuration": self.data_config, + "reader": self.state_dict(), + "consumers": { + **self._restored_consumers, + **{key: value.state_dict() for key, value in self.consumers.items()}, + }, + } + ref = pack_rollout_payload(state, self.args, rollout_id) + path = Path(self.args.save) / "rollout" / f"queue_state_{rollout_id}.json" + store, _, lock = rollout_store(self.args) + with lock: + from straw.protocol import digest + + storage_owner = f"checkpoint:{path.resolve()}:{digest(asdict(ref.manifest))}" + store.retain(storage_owner, [ref.manifest]) + store.release_publications([ref.manifest]) + write_report( + path, + { + "version": 1, + "root": ref.root, + "manifest": asdict(ref.manifest), + "storage_owner": storage_owner, + }, + ) + if getattr(self.args, "_queue_restored_source_ref", None) is not None: + ray.get(self.controller.handoff_restored_source.remote(ref.manifest)) + finally: + for consumer, was_paused in paused: + if not was_paused: + consumer.resume() + + def load(self, rollout_id=None): + import json + + from straw.protocol import RecordSetRef + + if not self.args.load: + return + if self.consumers: + raise RuntimeError("Restore before starting rollout consumers") + path = Path(self.args.load) / "rollout" / f"queue_state_{rollout_id}.json" + if not path.exists(): + return + value = json.loads(path.read_text()) + state = DiskPayloadRef(RecordSetRef.from_dict(value["manifest"]), value["root"]).load() + for key in ( + "dataset_size", + "n_samples_per_prompt", + "seed", + "shuffle", + "run_id", + ): + if state["configuration"][key] != self.data_config[key]: + raise ValueError(f"Data source checkpoint differs in {key}") + self.load_state_dict(state["reader"]) + self._restored_consumers = state["consumers"] + self.args._queue_restored_source_ref = RecordSetRef.from_dict(value["manifest"]) + + def close(self): + if self._closed: + return + for consumer in self.consumers.values(): + consumer.close() + super().close() + if self._owns_controller: + ray.get(self.controller.close.remote()) + ray.kill(self.controller, no_restart=True) + self.args._rollout_queue_controller = None diff --git a/vime/rollout/sample_hooks.py b/vime/rollout/sample_hooks.py index bce870d9f..668d079b5 100644 --- a/vime/rollout/sample_hooks.py +++ b/vime/rollout/sample_hooks.py @@ -1,12 +1,15 @@ from __future__ import annotations import inspect +from contextlib import contextmanager +from contextvars import ContextVar from typing import Any from vime.utils.misc import load_function from vime.utils.types import Sample _current_rollout_id: int | None = None +_task_rollout_id = ContextVar("rollout_sample_hook_id", default=None) def set_current_rollout_id(rollout_id: int | None) -> None: @@ -14,6 +17,16 @@ def set_current_rollout_id(rollout_id: int | None) -> None: _current_rollout_id = rollout_id +@contextmanager +def rollout_context(rollout_id): + """Keep concurrent worker requests' hook contexts independent.""" + token = _task_rollout_id.set(rollout_id) + try: + yield + finally: + _task_rollout_id.reset(token) + + def _accepted_kwargs(function, kwargs: dict[str, Any]) -> dict[str, Any]: signature = inspect.signature(function) if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()): @@ -37,12 +50,13 @@ async def _apply_to_sample(args, sample: Sample, paths: list[str], **kwargs) -> async def apply_rollout_sample_hooks(args, value, **kwargs): - """Apply configured hooks to every Sample leaf while preserving list shape.""" + """Apply user hooks; the owning queue publication persists replay tensors.""" paths = getattr(args, "rollout_sample_hook_path", None) or [] if not paths: return value - kwargs.setdefault("rollout_id", _current_rollout_id) + rollout_id = _task_rollout_id.get() + kwargs.setdefault("rollout_id", rollout_id if rollout_id is not None else _current_rollout_id) if isinstance(value, Sample): return await _apply_to_sample(args, value, paths, **kwargs) if isinstance(value, list): diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index ba1d2cd4a..f54d6fb0b 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -12,15 +12,14 @@ from typing import Any import numpy as np -import vllm_router # noqa: F401 — ensures vllm-router is importable on startup from tqdm import tqdm from vime.backends.vllm_utils.server_control import abort_servers_until_idle from vime.observability.trace_utils import build_vllm_meta_trace_attrs, trace_function, trace_span -from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput +from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput, finalize_rollout_groups from vime.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter, should_drop_dynamic_filter_output from vime.rollout.sample_hooks import apply_rollout_sample_hooks -from vime.utils.async_utils import run +from vime.utils.async_utils import AsyncPacer, run from vime.utils.data import Dataset from vime.utils.eval_config import EvalDatasetConfig from vime.utils.http_utils import get, get_rollout_num_engines, post @@ -31,6 +30,7 @@ load_processor, load_tokenizer, ) +from vime.utils.rollout_transport import discard_rollout_group, inherit_queue_context, publish_rollout_async from vime.utils.types import Sample from .rm_hub import async_rm, batched_async_rm @@ -144,13 +144,16 @@ class GenerateState(metaclass=SingletonMeta): The global state for the generation process. """ - def __init__(self, args: Namespace) -> None: + def __init__(self, args: Namespace, *, concurrency: int | None = None) -> None: # persistent state for the generation process self.args = args self.tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True) self.processor = load_processor(args.hf_checkpoint, trust_remote_code=True) - self.semaphore = asyncio.Semaphore(args.vllm_server_concurrency * get_rollout_num_engines(args)) + self.semaphore = asyncio.Semaphore( + concurrency if concurrency is not None else args.vllm_server_concurrency * get_rollout_num_engines(args) + ) + self.generation_pacer = AsyncPacer() self.sampling_params: dict[str, Any] = dict( temperature=args.rollout_temperature, top_p=args.rollout_top_p, @@ -420,6 +423,15 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A score_centering_request(args, sampling_params) logprobs = args.score_centering_top_k + 1 if score_centering and args.rollout_top_p == 1 else 1 inference_sampling_params = _build_inference_sampling_params(sampling_params, logprobs) + routed_experts_start = 0 + if args.use_rollout_routing_replay and sample.response_length: + routed_experts_start = sample.get_rollout_routed_experts_length() + if routed_experts_start != len(sample.tokens) - 1: + raise ValueError( + f"R3 continuation {sample.index} has {routed_experts_start} routes, " + f"expected {len(sample.tokens) - 1}" + ) + inference_sampling_params["routed_experts_prompt_start"] = routed_experts_start messages = build_multimodal_messages(sample.prompt, sample.multimodal_inputs) @@ -468,6 +480,8 @@ async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, A # Parse token_ids and logprobs from vLLM response new_response_tokens, new_response_log_probs = _inference_generate_tokens_and_logprobs(choice) meta_info = _inference_generate_meta_info(output) + if routed_experts_start: + meta_info["routed_experts_start_len"] = routed_experts_start if score_centering: score_metadata, normalized_logprobs = _score_centering_metadata( choice, @@ -601,8 +615,21 @@ async def generate_and_rm( args = copy.copy(args) args.use_score_centering = False + queue_input = copy.copy(sample) + if hasattr(sample, "_queue_lease") or hasattr(sample, "_queue_receipt"): + queue_input.queue_generation_requests = list(getattr(sample, "queue_generation_requests", [])) + [ + { + "sampling_params": copy.deepcopy(sampling_params), + "generate_function": sample.generate_function_path or args.custom_generate_function_path, + "input_index": sample.index, + "input_group_index": sample.group_index, + "branch": getattr(args, "_rollout_queue_branch", None), + } + ] + # generate async with state.semaphore: + await state.generation_pacer.wait() if state.aborted: sample.status = Sample.Status.ABORTED return sample @@ -621,7 +648,9 @@ async def generate_and_rm( else: sample = await _run_server_abort_generate(state, generate_call) + sample = inherit_queue_context(queue_input, sample) sample = await apply_rollout_sample_hooks(args, sample, evaluation=evaluation) + sample = inherit_queue_context(queue_input, sample) # for the rm that need the whole group, we will not do the rm here if args.group_rm: @@ -771,12 +800,17 @@ async def generate_rollout_async( data = [] all_data = [] + keep_all_samples = args.rollout_all_samples_process_path is not None do_print = True pbar = tqdm(total=target_data_size * args.n_samples_per_prompt, desc="Rollout generation") while len(data) < target_data_size: while state.remaining_batch_size < target_data_size: # get samples from the buffer and submit the generation requests. - samples = data_source(args.over_sampling_batch_size) + reader = getattr(data_source, "__self__", None) + if hasattr(reader, "get_samples_async"): + samples = await reader.get_samples_async(args.over_sampling_batch_size) + else: + samples = data_source(args.over_sampling_batch_size) state.submit_generate_tasks(samples) # wait for the generation to finish @@ -792,7 +826,8 @@ async def generate_rollout_async( do_print = False assert len(group) == args.n_samples_per_prompt - all_data.append(group) + if keep_all_samples: + all_data.append(group) dynamic_filter_output = call_dynamic_filter(dynamic_filter, args, group) if should_drop_dynamic_filter_output( @@ -800,6 +835,10 @@ async def generate_rollout_async( remaining_batch_size=state.remaining_batch_size, target_data_size=target_data_size, ): + if not keep_all_samples: + await asyncio.to_thread( + discard_rollout_group, group, args, dynamic_filter_output.reason or "dynamic_filter" + ) metric_gatherer.on_dynamic_filter_drop(reason=dynamic_filter_output.reason) state.remaining_batch_size -= 1 continue @@ -807,11 +846,13 @@ async def generate_rollout_async( # add the samples to the data # NOTE: here we have not stored all the unused samples back to the data buffer. if len(data) < target_data_size: + sample = group[0][0] if isinstance(group[0], list) else group[0] + if args.rollout_data_transport == "straw" and not keep_all_samples: + group = await publish_rollout_async(group, args, rollout_id, group=True) data.append(group) pbar.update(args.n_samples_per_prompt) pbar.close() - sample = data[-1][0][0] if isinstance(data[-1][0], list) else data[-1][0] logger.info( f"Finish rollout: {[str(sample.prompt) + sample.response]}, label: {str(sample.label)[:100]}, reward: {sample.reward}", ) @@ -820,23 +861,24 @@ async def generate_rollout_async( aborted_samples = await abort(args, rollout_id) assert len(data) == args.rollout_batch_size, f"Got {len(data)} samples, expected {args.rollout_batch_size}" - data = sorted(data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index) - all_samples = sorted( - all_data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index - ) - # reset the global state to prevent effects on the next rollout or eval. state.reset() - if args.rollout_sample_filter_path is not None: - filter_func = load_function(args.rollout_sample_filter_path) - filter_func(args, data) - - # There can be circumstances where users want to process all samples including filtered ones. - if args.rollout_all_samples_process_path is not None: + if keep_all_samples: + data.sort(key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index) + all_data.sort(key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index) + if args.rollout_sample_filter_path is not None: + load_function(args.rollout_sample_filter_path)(args, data) process_func = load_function(args.rollout_all_samples_process_path) - process_func(args, all_samples, data_source) - - return RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()), aborted_samples + process_func(args, all_data, data_source) + if args.rollout_data_transport == "straw": + data = [await publish_rollout_async(group, args, rollout_id, group=True) for group in data] + data = await publish_rollout_async(data, args, rollout_id) + output = RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()) + elif args.rollout_sample_filter_path is not None: + output = finalize_rollout_groups(args, rollout_id, data, metric_gatherer.collect()) + else: + output = await asyncio.to_thread(finalize_rollout_groups, args, rollout_id, data, metric_gatherer.collect()) + return output, aborted_samples EVAL_PROMPT_DATASET = {} diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index a2f82f561..9b7fac03d 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -1,5 +1,6 @@ import argparse import copy +import importlib.util import json import logging import os @@ -345,7 +346,9 @@ def add_rollout_arguments(parser): "and then set this to the path of your custom rollout function. " "The signature of the function should be " "`def generate_rollout(args, rollout_id, data_source, evaluation=False) -> RolloutFnTrainOutput | RolloutFnEvalOutput`" - "and within the output sample, you should at least set `tokens`, `response_length`, `reward` " + ". With straw transport, training output.samples may be a stored batch reference; " + "legacy Sample lists are stored automatically by the manager." + " Each sample must at least set `tokens`, `response_length`, `reward` " "and `status`." ), ) @@ -529,7 +532,7 @@ def add_rollout_arguments(parser): type=str, default=None, help=( - "Path to the buffer filter function. " + "Path to the in-memory buffer filter function (not supported by straw). " "It should be able to select the samples in the buffer. " "The function should take list[list[Sample]] and return list[list[Sample]]." ), @@ -539,7 +542,8 @@ def add_rollout_arguments(parser): action="store_true", help=( "Resume buffered groups with the oldest generated-token weight version first. " - "Disabled by default; an explicit --buffer-filter-path takes precedence." + "For in-memory buffers this is disabled by default and --buffer-filter-path takes precedence. " + "The straw queue always uses this order within ready and partial groups." ), ) # update weight @@ -564,14 +568,55 @@ def add_rollout_arguments(parser): parser.add_argument( "--rollout-data-transport", type=str, - choices=["object-store", "nixl"], + choices=["straw", "object-store", "nixl"], default="object-store", help=( - "Transport for rollout data refs sent from rollout manager to trainer. Large rollout " - "fields are tensorized on CPU before the refs are stored. Set to nixl to transfer " - "those torch tensors via Ray NIXL." + "Rollout payload transport. Defaults to Ray object-store. straw uses packed storage " + "under --rollout-data-dir for rollout and training payloads, sending only references through " + "Ray. nixl uses Ray's NIXL tensor transport. Ray still manages actors and RPCs in every mode." ), ) + parser.add_argument( + "--rollout-data-dir", + type=str, + default=None, + help=( + "Shared directory for straw rollout payloads, mounted at the same absolute path on all nodes. " + "Defaults to /rollout_data. Required for straw transport when --save is unset. " + "Files are retained for buffered samples, checkpoints and debug dumps." + ), + ) + parser.add_argument("--rollout-storage-profile", choices=["local", "juicefs"], default="local") + parser.add_argument( + "--rollout-storage-declaration", + help="JSON file declaring JuiceFS mount and backing-store durability settings; see the straw project README.", + ) + parser.add_argument("--rollout-queue-run-id", default="rollout", help="Persistent queue run identity within rollout-data-dir.") + parser.add_argument( + "--rollout-queue-resume", + action="store_true", + help="Recover an existing run; requires the prior coordinator and its job to be stopped.", + ) + parser.add_argument( + "--rollout-queue-online-gc", + action="store_true", + help="Reclaim sealed straw packs after acknowledged use; retain checkpoints explicitly.", + ) + parser.add_argument("--rollout-queue-lease-seconds", type=float, default=300) + parser.add_argument("--rollout-queue-max-pending", type=int, default=65536) + parser.add_argument("--rollout-queue-max-inflight", type=int, default=65536) + parser.add_argument( + "--rollout-queue-segment-mib", + type=int, + default=256, + help="Append queue publications to a pack file until this target size; a single larger publication is kept intact.", + ) + parser.add_argument( + "--rollout-io-concurrency", + type=int, + default=4, + help="Bounded off-event-loop rollout serialization and filesystem I/O.", + ) parser.add_argument( "--rollout-external-engine-addrs", type=str, @@ -633,8 +678,12 @@ def add_data_arguments(parser): parser.add_argument( "--data-source-path", type=str, - default="vime.rollout.data_source.RolloutDataSourceWithBuffer", - help="The data source class for rollout data.", + default=None, + help=( + "The data source class. straw transport defaults to " + "vime.rollout.queue_data_source.QueueDataSource; other transports use " + "vime.rollout.data_source.RolloutDataSourceWithBuffer. Custom classes remain supported." + ), ) parser.add_argument( "--prompt-data", @@ -2002,6 +2051,51 @@ def vime_validate_args(args): if args.eval_interval is not None: assert args.eval_datasets, "Evaluation datasets must be configured when eval_interval is set." + if importlib.util.find_spec("straw") is None: + if args.rollout_data_transport == "straw": + raise ModuleNotFoundError( + "--rollout-data-transport straw requires straw-queue. " + "Install it on every rollout/training node: pip install straw-queue", + name="straw", + ) + logger.warning( + "straw-queue is not installed; continuing with %s rollout transport. " + "To enable --rollout-data-transport straw, run on every rollout/training node: pip install straw-queue", + args.rollout_data_transport, + ) + + if args.data_source_path is None: + args.data_source_path = ( + "vime.rollout.queue_data_source.QueueDataSource" + if args.rollout_data_transport == "straw" + else "vime.rollout.data_source.RolloutDataSourceWithBuffer" + ) + if args.rollout_data_transport != "straw": + if args.data_source_path == "vime.rollout.queue_data_source.QueueDataSource": + raise ValueError("QueueDataSource requires --rollout-data-transport straw") + for name in ("rollout_queue_resume", "rollout_queue_online_gc"): + if getattr(args, name, False): + raise ValueError(f"--{name.replace('_', '-')} requires --rollout-data-transport straw") + for name in ( + "rollout_queue_lease_seconds", + "rollout_queue_max_pending", + "rollout_queue_max_inflight", + "rollout_queue_segment_mib", + "rollout_io_concurrency", + ): + if getattr(args, name) <= 0: + raise ValueError(f"--{name.replace('_', '-')} must be positive") + + if args.rollout_data_transport == "straw": + from vime.utils.rollout_transport import resolve_rollout_data_dir + + if getattr(args, "buffer_filter_path", None) is not None: + raise ValueError( + "--buffer-filter-path is not supported by straw; scheduling is persisted in the queue " + "and prioritizes older weight versions within ready and partial groups" + ) + resolve_rollout_data_dir(args) + if args.save_interval is not None: assert args.save is not None, "'--save' is required when save_interval is set." diff --git a/vime/utils/async_utils.py b/vime/utils/async_utils.py index a2cd9c7db..26a752431 100644 --- a/vime/utils/async_utils.py +++ b/vime/utils/async_utils.py @@ -1,7 +1,45 @@ import asyncio import threading +from collections import deque -__all__ = ["get_async_loop", "run"] +__all__ = ["AsyncPacer", "get_async_loop", "run"] + + +class AsyncPacer: + """Reduce asyncio scheduling pressure by resuming waiting tasks in batches. + + At high concurrency, waking many tasks at once floods the loop's ready + queue with callbacks, delaying socket I/O and timers. Release at most 64 + waiters per tick, with a 1 ms delay between batches, to spread that work + across loop iterations. This paces task starts without limiting in-flight + concurrency or waiting for previously released tasks to finish. + + Use each instance within a single event loop. + """ + + def __init__(self): + self.pending = deque() + self.scheduled = False + + async def wait(self): + loop = asyncio.get_running_loop() + future = loop.create_future() + self.pending.append(future) + if not self.scheduled: + self.scheduled = True + loop.call_soon(self._release) + await future + + def _release(self): + for _ in range(min(64, len(self.pending))): + future = self.pending.popleft() + if not future.done(): + future.set_result(None) + if self.pending: + # Let socket callbacks and timers run before waking more tasks. + asyncio.get_running_loop().call_later(0.001, self._release) + else: + self.scheduled = False # Create a background event loop thread diff --git a/vime/utils/data.py b/vime/utils/data.py index a8e52f753..91d1a2b01 100644 --- a/vime/utils/data.py +++ b/vime/utils/data.py @@ -288,9 +288,8 @@ def shuffle(self, new_epoch_id): if self.epoch_id == new_epoch_id: return - random.seed(self.seed + new_epoch_id) permutation = list(range(len(self.samples))) - random.shuffle(permutation) + random.Random(self.seed + new_epoch_id).shuffle(permutation) self.samples = [self.origin_samples[i] for i in permutation] self.epoch_id = new_epoch_id @@ -303,7 +302,20 @@ def __len__(self): def process_rollout_data(rollout_data_ref, dp_rank, dp_size): assert len(rollout_data_ref) == dp_size - rollout_data = ray.get(rollout_data_ref[dp_rank].inner) + from vime.utils.rollout_transport import TrainBatchRef, unpack_rollout_payload + + reference = ray.get(rollout_data_ref[dp_rank].inner) + if isinstance(reference, TrainBatchRef): + references = [ray.get(box.inner) for box in rollout_data_ref] + if any( + not isinstance(ref, TrainBatchRef) + or ref.batch_id != reference.batch_id + or ref.plan_digest != reference.plan_digest + or ref.rank != rank + for rank, ref in enumerate(references) + ): + raise ValueError("Training ranks received inconsistent batch identities or DP plans") + rollout_data = unpack_rollout_payload(reference) # Keep `partition` in rollout_data: each local sample's position in the # flattened rollout batch (== its index in the rollout debug dump's diff --git a/vime/utils/http_utils.py b/vime/utils/http_utils.py index deaa73857..22339f435 100644 --- a/vime/utils/http_utils.py +++ b/vime/utils/http_utils.py @@ -5,6 +5,7 @@ import os import random import socket +import ssl import httpx @@ -144,6 +145,35 @@ def _next_actor(): return actor +class _ShardedHTTPTransport(httpx.AsyncBaseTransport): + """Limit the per-request httpcore connection scan to small pools.""" + + def __init__(self, max_connections: int): + count = max(1, (max_connections + 127) // 128) + size, remainder = divmod(max(1, max_connections), count) + tls = ssl.create_default_context() + self._transports = [ + httpx.AsyncHTTPTransport( + verify=tls, + trust_env=False, + limits=httpx.Limits( + max_connections=size + (index < remainder), + max_keepalive_connections=size + (index < remainder), + ), + ) + for index in range(count) + ] + self._next = 0 + + async def handle_async_request(self, request): + transport = self._transports[self._next] + self._next = (self._next + 1) % len(self._transports) + return await transport.handle_async_request(request) + + async def aclose(self): + await asyncio.gather(*(transport.aclose() for transport in self._transports)) + + async def _post(client, url, payload, max_retries=60, headers=None, timeout=None): retry_count = 0 while retry_count < max_retries: @@ -168,7 +198,7 @@ async def _post(client, url, payload, max_retries=60, headers=None, timeout=None response_text = None logger.info( - f"Error: {e}, retrying... (attempt {retry_count}/{max_retries}, url={url}, response={response_text})" + f"Error: {e!r}, retrying... (attempt {retry_count}/{max_retries}, url={url}, response={response_text})" ) if retry_count >= max_retries: logger.info(f"Max retries ({max_retries}) reached, failing... (url={url})") @@ -195,17 +225,17 @@ def get_rollout_num_engines(args) -> int: return max(1, rollout_num_gpus // rollout_num_gpus_per_engine) -def init_http_client(args): +def init_http_client(args, *, concurrency: int | None = None): """Initialize HTTP client and optionally enable distributed POST via Ray.""" global _http_client, _client_concurrency, _distributed_post_enabled num_engines = get_rollout_num_engines(args) if num_engines <= 0: return - _client_concurrency = args.vllm_server_concurrency * num_engines + _client_concurrency = concurrency if concurrency is not None else args.vllm_server_concurrency * num_engines if _http_client is None: _http_client = httpx.AsyncClient( - limits=httpx.Limits(max_connections=_client_concurrency), + transport=_ShardedHTTPTransport(_client_concurrency), timeout=httpx.Timeout(None), trust_env=False, # internal vLLM comm only — never route through system proxy ) @@ -242,7 +272,7 @@ class _HttpPosterActor: def __init__(self, concurrency: int): # Lazy creation to this actor's event loop self._client = httpx.AsyncClient( - limits=httpx.Limits(max_connections=max(1, concurrency)), + transport=_ShardedHTTPTransport(concurrency), timeout=httpx.Timeout(None), trust_env=False, # internal vLLM comm only — never route through system proxy ) diff --git a/vime/utils/memory_utils.py b/vime/utils/memory_utils.py index 8aa3e2d8b..1fc9f6e71 100644 --- a/vime/utils/memory_utils.py +++ b/vime/utils/memory_utils.py @@ -47,7 +47,6 @@ def get_process_host_memory_gib(process: psutil.Process | None = None) -> tuple[ process = process or psutil.Process() rss_gib = process.memory_info().rss / 1024**3 max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - # Linux reports KiB; macOS reports bytes. hwm_gib = max_rss / (1024**2 if sys.platform != "darwin" else 1024**3) return rss_gib, hwm_gib diff --git a/vime/utils/rollout_transport.py b/vime/utils/rollout_transport.py new file mode 100644 index 000000000..7cac59056 --- /dev/null +++ b/vime/utils/rollout_transport.py @@ -0,0 +1,382 @@ +"""Rollout references over immutable queue segments; no pickle payload protocol.""" + +from __future__ import annotations + +import asyncio +import json +import os +import threading +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import ray +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy + +if TYPE_CHECKING: + from straw.protocol import CommitReceipt, RecordSetRef + +_writers = {} +_writers_lock = threading.Lock() +_async_limits = {} + + +def seal_rollout_store(args): + """Seal local packs after generation/publication work has been drained.""" + if args.rollout_data_transport == "straw": + store, _, lock = rollout_store(args) + with lock: + store.seal() + + +def resolve_rollout_data_dir(args): + if args.rollout_data_dir is None: + if args.save is None: + raise ValueError("straw rollout transport requires --rollout-data-dir or --save on shared storage") + args.rollout_data_dir = str(Path(args.save) / "rollout_data") + args.rollout_data_dir = str(Path(args.rollout_data_dir).expanduser().resolve()) + + +def rollout_store(args): + """One physical writer incarnation per process/run, shared by producer calls.""" + try: + from straw.backend import FilesystemBackend + from straw.store import SharedFilesystemStore + from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES + except ModuleNotFoundError as error: + if error.name != "straw": + raise + raise ModuleNotFoundError( + "straw rollout transport requires straw-queue. " + "Install it on every rollout/training node: pip install straw-queue", + name="straw", + ) from error + + from vime.rollout.queue_codec import CODECS, SampleCodec + + root = str(Path(args.rollout_data_dir).resolve()) + run_id = getattr(args, "rollout_queue_run_id", None) or "rollout" + profile = getattr(args, "rollout_storage_profile", "local") + declaration_path = getattr(args, "rollout_storage_declaration", None) + declaration = json.loads(Path(declaration_path).read_text()) if declaration_path else None + segment_mib = getattr(args, "rollout_queue_segment_mib", 256) + online_gc = getattr(args, "rollout_queue_online_gc", False) + key = (os.getpid(), root, run_id, profile, segment_mib, online_gc, json.dumps(declaration, sort_keys=True)) + with _writers_lock: + if key not in _writers: + store = SharedFilesystemStore( + root, + run_id, + online_gc=online_gc, + codecs=CODECS, + segment_target_bytes=segment_mib * 1024**2, + max_record_bytes=MAX_TENSOR_BYTES, + max_buffer_bytes=MAX_PUBLICATION_BYTES, + backend=FilesystemBackend(root, profile=profile, declaration=declaration), + ) + _writers[key] = (store, SampleCodec(store, args=args), threading.RLock()) + return _writers[key] + + +@dataclass(frozen=True) +class DiskPayloadRef: + """Bounded protocol ref plus a deployment mount hint, overridable by readers. + + Only ``manifest`` is persisted by SampleCodec. Its paths are relative; root + is a local mount binding, never a dependency embedded in a durable record. + """ + + manifest: RecordSetRef + root: str + + def load(self, *, root=None): + from straw.store import SharedFilesystemStore + + from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES + + from vime.rollout.queue_codec import CODECS, SampleCodec + + store = SharedFilesystemStore( + root or self.root, + self.manifest.manifest.segment.run_id, + codecs=CODECS, + max_record_bytes=MAX_TENSOR_BYTES, + max_buffer_bytes=MAX_PUBLICATION_BYTES, + ) + return SampleCodec(store).load(self.manifest) + + +@dataclass(frozen=True) +class RolloutGroupRef(DiskPayloadRef): + index: int + receipt: CommitReceipt | None = None + + +@dataclass(frozen=True) +class RawRolloutRef(DiskPayloadRef): + receipt: CommitReceipt + metrics: dict | None = None + + +@dataclass(frozen=True) +class TrainBatchRef(DiskPayloadRef): + batch_id: str + rank: int + plan_digest: str + + +def group_lease(group): + from vime.rollout.base_types import iter_samples + + samples = list(iter_samples(group)) + leases = [getattr(sample, "_queue_lease", None) for sample in samples] + if not any(leases): + return None + if not all(value == leases[0] for value in leases): + raise ValueError("A queue group must preserve one task authorization across all trajectories") + from straw.protocol import Lease + + return Lease(**leases[0]) + + +def inherit_queue_context(source, output): + """Carry the input authorization across hooks that construct new trajectories.""" + import copy + + from vime.rollout.base_types import iter_samples + + fields = ( + "_queue_lease", + "_queue_receipt", + "_queue_source_positions", + "_queue_resume_origin", + "_queue_generation_start", + "queue_policy_segments", + "queue_generation_requests", + ) + for sample in iter_samples(output): + for name in fields: + if not hasattr(source, name): + continue + value = getattr(source, name) + if name in {"_queue_lease", "_queue_receipt"} and getattr(sample, name, value) != value: + raise ValueError("A generation hook returned a different queue task authorization") + if name in {"_queue_lease", "_queue_receipt", "queue_generation_requests"} or not hasattr(sample, name): + setattr(sample, name, copy.deepcopy(value)) + return output + + +def record_generation_provenance(group, args): + from vime.rollout.base_types import iter_samples + + for sample in iter_samples(group): + start = getattr(sample, "_queue_generation_start", None) + branch = getattr(args, "_rollout_queue_branch", None) + if start is not None and branch is not None and len(sample.tokens) > start: + segments = list(getattr(sample, "queue_policy_segments", [])) + if not segments and start: + segments.append({"start": 0, "stop": start, "branch": None, "reported_versions": None}) + segments.append( + { + "start": start, + "stop": len(sample.tokens), + "branch": branch, + "reported_versions": sample.weight_versions or None, + } + ) + sample.queue_policy_segments = segments + sample._queue_generation_start = len(sample.tokens) + + +def pack_rollout_group(group, args, rollout_id): + if args.rollout_data_transport != "straw": + return group + from vime.rollout.base_types import iter_samples + + first = next(iter_samples(group)) + lease = group_lease(group) + record_generation_provenance(group, args) + metadata = {"task_id": lease.task_id, "attempt_id": lease.attempt_id} if lease else {} + ref = pack_rollout_payload( + group, args, rollout_id, metadata=metadata, submission_id=f"group:{lease.attempt_id}" if lease else None + ) + receipt = None + if lease: + controller = getattr(args, "_rollout_queue_controller", None) + if controller is None: + raise ValueError("Queue group has a lease but no coordinator binding") + receipt = ray.get(controller.complete.remote(lease, ref.manifest)) + return RolloutGroupRef(receipt.result_ref if receipt else ref.manifest, ref.root, first.index, receipt) + + +def release_rollout_publications(group, args): + """Relinquish tensor staging before ending the group's current read lifetime.""" + if args.rollout_data_transport == "straw": + from straw.tensor import TensorRef, release_tensor_publications + from vime.rollout.base_types import iter_samples + + tensors = [] + + def visit(value): + if isinstance(value, TensorRef): + tensors.append(value) + elif isinstance(value, dict): + for item in value.values(): + visit(item) + elif isinstance(value, (list, tuple)): + for item in value: + visit(item) + + for sample in iter_samples(group): + visit(vars(sample)) + if tensors: + store, _, lock = rollout_store(args) + with lock: + release_tensor_publications(store, tensors) + + +def discard_rollout_group(group, args, reason="dynamic filter"): + lease = group_lease(group) + release_rollout_publications(group, args) + # Release staging while the task/accepted-result owner still protects any + # previously adopted continuation, then acknowledge that reading is done. + if lease: + ray.get(args._rollout_queue_controller.reject.remote(lease, reason)) + else: + from vime.rollout.base_types import iter_samples + + positions = { + sample._queue_receipt["position"] for sample in iter_samples(group) if hasattr(sample, "_queue_receipt") + } + if positions: + decision = pack_rollout_payload({"positions": sorted(positions), "reason": reason}, args, -1) + ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + + +def load_rollout_samples(value): + """Read raw collections without changing group or trajectory nesting.""" + from vime.rollout.base_types import iter_samples + + groups = [] + for reference in unpack_rollout_payload(value): + group = unpack_rollout_payload(reference) + if isinstance(reference, RolloutGroupRef) and reference.receipt: + for sample in iter_samples(group): + sample.__dict__.pop("_queue_lease", None) + sample._queue_receipt = asdict(reference.receipt) + groups.append(group) + return groups + + +def pack_rollout_payload(value, args, rollout_id, *, metadata=None, submission_id=None): + if args.rollout_data_transport != "straw": + return value + store, codec, lock = rollout_store(args) + if isinstance(value, DiskPayloadRef): + store.validate(value.manifest) + return value + with lock: + ref = codec.publish( + value, submission_id=submission_id or f"payload:{rollout_id}:{uuid.uuid4().hex}", metadata=metadata + ) + return DiskPayloadRef(ref, str(store.backend.root)) + + +async def publish_rollout_async(value, args, rollout_id, *, group=False): + return await run_rollout_io(args, pack_rollout_group if group else pack_rollout_payload, value, args, rollout_id) + + +async def run_rollout_io(args, function, *values): + """Bound the executor backlog before submitting encoding/sync work.""" + loop = asyncio.get_running_loop() + key = (os.getpid(), loop) + if key not in _async_limits: + _async_limits[key] = asyncio.Semaphore(getattr(args, "rollout_io_concurrency", 4)) + async with _async_limits[key]: + pending = asyncio.create_task(asyncio.to_thread(function, *values)) + try: + return await asyncio.shield(pending) + except asyncio.CancelledError: + # A cancelled coroutine cannot stop a filesystem commit in its thread. + # Keep capacity and input ownership until the accepted write finishes. + while not pending.done(): + try: + await asyncio.shield(pending) + except asyncio.CancelledError: + pass + pending.result() + raise + + +def unpack_rollout_payload(value): + while isinstance(value, DiskPayloadRef): + value = value.load() + return value + + +def accept_raw_rollout(output, args, rollout_id): + """Validate accepted producers, or commit a legacy collection once. + + A small wrapper adds task provenance to existing sealed collections without + rewriting their Sample or tensor payloads. + """ + from straw.protocol import Lease + + controller = args._rollout_queue_controller + if isinstance(output, RawRolloutRef): + receipt = ray.get(controller.accepted.remote(output.receipt)) + if output.manifest != receipt.result_ref: + raise ValueError("Raw rollout manifest differs from its accepted receipt") + return output + # Legacy custom producers may borrow queue inputs and return Samples without + # publishing task results themselves. Complete those inputs before accepting + # the compatibility collection, so closing the reader cannot requeue them. + from vime.rollout.base_types import iter_samples + + borrowed = {} + for group in unpack_rollout_payload(output.samples): + if isinstance(group, RolloutGroupRef) and group.receipt: + continue + group = unpack_rollout_payload(group) + for sample in iter_samples(group): + if value := getattr(sample, "_queue_lease", None): + lease = Lease(**value) + borrowed.setdefault(lease, []).append(sample) + for lease, samples in borrowed.items(): + if ray.get(controller.result.remote(lease)) is None: + pack_rollout_group(samples, args, rollout_id) + lease = ray.get(controller.begin_collection.remote(str(rollout_id))) + store, codec, lock = rollout_store(args) + with lock: + ref = codec.publish( + output.samples, + submission_id=f"collection:{lease.attempt_id}", + metadata={"task_id": lease.task_id, "attempt_id": lease.attempt_id}, + ) + receipt = ray.get(controller.complete.remote(lease, ref)) + return RawRolloutRef(receipt.result_ref, str(store.backend.root), receipt, output.metrics) + + +def _storage_probe(reference, args): + return pack_rollout_payload(reference.load(root=args.rollout_data_dir), args, 0) + + +def check_rollout_storage(args): + marker = uuid.uuid4().hex + reference = pack_rollout_payload(marker, args, 0) + probes = [ + ray.remote(_storage_probe) + .options(num_cpus=0, scheduling_strategy=NodeAffinitySchedulingStrategy(node["NodeID"], soft=False)) + .remote(reference, args) + for node in ray.nodes() + if node["Alive"] and (node["Resources"].get("CPU", 0) or node["Resources"].get("GPU", 0)) + ] + try: + for result in ray.get(probes, timeout=60): + if result.load(root=args.rollout_data_dir) != marker: + raise ValueError("Rollout storage probe content differs across nodes") + except Exception as error: + raise RuntimeError( + f"All rollout/training nodes must share the run at --rollout-data-dir={args.rollout_data_dir}" + ) from error diff --git a/vime/utils/routed_experts.py b/vime/utils/routed_experts.py index 7ba7f4b4d..89fe81a8d 100644 --- a/vime/utils/routed_experts.py +++ b/vime/utils/routed_experts.py @@ -11,8 +11,7 @@ import torch from vime.utils import accelerator -from vime.utils.memory_utils import get_process_host_memory_gib -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -25,7 +24,7 @@ def validate_routed_experts_tensor( sample_index: int | None = None, expected_rows: int | None = None, ) -> None: - """Validate one rollout route tensor before R3 consumes or spills it.""" + """Validate one rollout route tensor before publication or R3 consumption.""" num_layers = int(args.num_layers) topk = int(args.moe_router_topk) @@ -57,7 +56,7 @@ def validate_routed_experts_tensor( if missing_layers: raise ValueError( "R3 routed-experts capture is all zero for MoE layers " - f"{missing_layers} in sample {sample_index}. This usually means SGLang pipeline stages " + f"{missing_layers} in sample {sample_index}. This usually means vLLM pipeline stages " "did not aggregate their disjoint routing captures; refusing to replay expert 0 everywhere." ) missing_token_layers = ~token_layer_present @@ -107,13 +106,13 @@ def validate_routed_experts_tensor( def validate_routed_experts_value( - value: torch.Tensor | DiskTensorRef, + value: torch.Tensor | TensorRef | DiskTensorRef, args, *, sample_index: int | None, expected_rows: int | None = None, ) -> None: - if isinstance(value, DiskTensorRef) and value.validated: + if isinstance(value, TensorRef) and value.validated: expected_tail = (int(args.num_layers), int(args.moe_router_topk)) if len(value.shape) != 3 or tuple(value.shape[1:]) != expected_tail or value.shape[0] <= 0: raise ValueError( @@ -127,7 +126,7 @@ def validate_routed_experts_value( ) return - tensor = value.load() if isinstance(value, DiskTensorRef) else value + tensor = value.load() if isinstance(value, (TensorRef, DiskTensorRef)) else value validate_routed_experts_tensor(tensor, args, sample_index=sample_index, expected_rows=expected_rows) @@ -224,7 +223,7 @@ def cleanup_routed_experts_rollout(args, rollout_id: int) -> None: def materialize_routed_experts(value: Any, *, pin_memory: bool = False) -> torch.Tensor: - if isinstance(value, DiskTensorRef): + if isinstance(value, (TensorRef, DiskTensorRef)): return value.load(pin_memory=pin_memory) if torch.is_tensor(value): return value @@ -243,22 +242,19 @@ def __init__(self, prefetch_microbatches: int) -> None: # every microbatch from disk between forward and backward. self.release_stage = "forward" self._executor = ( - ThreadPoolExecutor(max_workers=min(2, self.prefetch_microbatches + 1), thread_name_prefix="r3-prefetch") + ThreadPoolExecutor( + max_workers=min(2, self.prefetch_microbatches + 1), + thread_name_prefix="r3-prefetch", + ) if self.prefetch_microbatches > 0 else None ) self._closed = False - self._stats_lock = threading.Lock() - self._resident_bytes = 0 - self._peak_resident_bytes = 0 - self._load_count = 0 - self._disk_bytes = 0 def add(self, source: RoutedExpertsMicrobatch) -> None: source.index = len(self.sources) source.prefetcher = self self.sources.append(source) - self._disk_bytes += sum(value.nbytes for value in source.values) def start(self) -> None: for index in range(min(len(self.sources), self.prefetch_microbatches)): @@ -281,16 +277,6 @@ def prefetch_after(self, index: int) -> None: def executor(self) -> ThreadPoolExecutor | None: return None if self._closed else self._executor - def on_loaded(self, nbytes: int) -> None: - with self._stats_lock: - self._load_count += 1 - self._resident_bytes += nbytes - self._peak_resident_bytes = max(self._peak_resident_bytes, self._resident_bytes) - - def on_released(self, nbytes: int) -> None: - with self._stats_lock: - self._resident_bytes -= nbytes - def close(self) -> None: if self._closed: return @@ -299,18 +285,6 @@ def close(self) -> None: self._executor.shutdown(wait=True, cancel_futures=True) for source in self.sources: source.release() - rss_gib, hwm_gib = get_process_host_memory_gib() - logger.info( - "R3 disk prefetch profile: microbatches=%d prefetch=%d source_bytes=%.3f GiB " - "loads=%d peak_prepared_cpu=%.3f GiB rss=%.3f GiB hwm=%.3f GiB", - len(self.sources), - self.prefetch_microbatches, - self._disk_bytes / 1024**3, - self._load_count, - self._peak_resident_bytes / 1024**3, - rss_gib, - hwm_gib, - ) class RoutedExpertsMicrobatch: @@ -318,7 +292,7 @@ class RoutedExpertsMicrobatch: def __init__( self, - values: list[DiskTensorRef], + values: list[TensorRef | DiskTensorRef], tokens: list[torch.Tensor], *, consumer_count: int, @@ -332,25 +306,22 @@ def __init__( self.prefetcher: RoutedExpertsMicrobatchPrefetcher | None = None self._future: Future | None = None self._cpu_tensor: torch.Tensor | None = None - self._loaded_nbytes = 0 self._lock = threading.Lock() self._consumed = {"forward": 0, "backward": 0} def _load_and_prepare(self) -> torch.Tensor: from vime.backends.megatron_utils.cp_utils import prepare_routed_experts_for_routing_replay - tensors = [value.load() for value in self.values] - tensor = prepare_routed_experts_for_routing_replay( + # Read only the CP/TP rows owned by this rank in the layout helper. + tensors = [ + (value if isinstance(value, (TensorRef, DiskTensorRef)) else materialize_routed_experts(value)) + for value in self.values + ] + return prepare_routed_experts_for_routing_replay( tensors, self.tokens, **self.prepare_kwargs, ) - nbytes = tensor.numel() * tensor.element_size() - with self._lock: - self._loaded_nbytes = nbytes - if self.prefetcher is not None: - self.prefetcher.on_loaded(nbytes) - return tensor def prefetch(self) -> None: with self._lock: @@ -393,12 +364,8 @@ def release(self) -> None: future = self._future self._future = None self._cpu_tensor = None - loaded_nbytes = self._loaded_nbytes - self._loaded_nbytes = 0 if future is not None and not future.done(): future.cancel() - if loaded_nbytes and self.prefetcher is not None: - self.prefetcher.on_released(loaded_nbytes) class RoutedExpertsLayerRef: diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py index d550edbd3..b32716281 100644 --- a/vime/utils/score_centering.py +++ b/vime/utils/score_centering.py @@ -9,7 +9,7 @@ import torch from vime.utils.ppo_utils import get_pg_loss_type, importance_weights -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, TensorRef SAMPLER_TOPK_FIELDS = ("rollout_topk_token_ids", "rollout_topk_log_probs") @@ -155,9 +155,13 @@ def extract_sampler_top_p(meta_info, count): def validate_sampler_top_p(ids, offsets, logps, count, loss_mask=None, tokens=None, sampled_logps=None): if ids is None or offsets is None or logps is None: raise ValueError("Top-p score centering requires complete sampler top-p ids, offsets, and logprobs.") - ids, offsets, logps = np.asarray(ids), np.asarray(offsets), np.asarray(logps) + # Keep large supports lazy and validate bounded ranges. The sampled token + # and loss mask can change independently of a previously validated tensor. + ids = ids if isinstance(ids, TensorRef) else np.asarray(ids) + logps = logps if isinstance(logps, TensorRef) else np.asarray(logps) + offsets = np.asarray(offsets.load() if isinstance(offsets, TensorRef) else offsets) if ( - ids.ndim != 1 + len(ids.shape) != 1 or logps.shape != ids.shape or offsets.shape != (count + 1,) or not np.issubdtype(ids.dtype, np.integer) @@ -167,21 +171,33 @@ def validate_sampler_top_p(ids, offsets, logps, count, loss_mask=None, tokens=No or (np.diff(offsets) < 0).any() ): raise ValueError("Invalid top-p score-centering ids/logprobs/offsets.") - for row, (start, end) in enumerate(zip(offsets[:-1], offsets[1:], strict=True)): - if loss_mask is not None and not loss_mask[row] and start == end: - continue - row_ids, row_logps = ids[start:end], logps[start:end].astype(np.float64) - _validate_head_arrays(row_ids, row_logps) - if not np.isclose(np.exp(row_logps).sum(), 1.0, rtol=1e-4, atol=1e-6): - raise ValueError("Top-p score centering requires the complete normalized support, not a truncated head.") - if tokens is not None: - selected = row_logps[row_ids == tokens[row]] - if len(selected) != 1 or not np.isclose(selected[0], sampled_logps[row], rtol=1e-4, atol=1e-5): - raise ValueError("Top-p sampler distribution must include the sampled token with its rollout logprob.") + for value in (ids, logps): + if isinstance(value, TensorRef): + value.validate() + for first_row in range(0, count, 1024): + last_row = min(first_row + 1024, count) + base, stop = int(offsets[first_row]), int(offsets[last_row]) + chunk_ids, chunk_logps = np.asarray(ids[base:stop]), np.asarray(logps[base:stop]) + for row in range(first_row, last_row): + start, end = offsets[row : row + 2] - base + if loss_mask is not None and not loss_mask[row] and start == end: + continue + row_ids, row_logps = chunk_ids[start:end], chunk_logps[start:end].astype(np.float64) + _validate_head_arrays(row_ids, row_logps) + if not np.isclose(np.exp(row_logps).sum(), 1.0, rtol=1e-4, atol=1e-6): + raise ValueError( + "Top-p score centering requires the complete normalized support, not a truncated head." + ) + if tokens is not None: + selected = row_logps[row_ids == tokens[row]] + if len(selected) != 1 or not np.isclose(selected[0], sampled_logps[row], rtol=1e-4, atol=1e-5): + raise ValueError( + "Top-p sampler distribution must include the sampled token with its rollout logprob." + ) def validate_sampler_topk(sample, k): - """Validate arrays once before spilling; subsequent transport checks only references.""" + """Validate arrays before publication; subsequent transport checks only references.""" ids, logps = sample.rollout_topk_token_ids, sample.rollout_topk_log_probs if sample.response_length == 0 and ids is None and logps is None: sample.rollout_topk_token_ids = torch.empty((0, k), dtype=torch.int32) @@ -190,7 +206,7 @@ def validate_sampler_topk(sample, k): if ids is None or logps is None: raise ValueError("Score centering requires rollout_topk_token_ids and rollout_topk_log_probs on every sample.") shape = (sample.response_length, k) - disk = [isinstance(value, DiskTensorRef) for value in (ids, logps)] + disk = [isinstance(value, (TensorRef, DiskTensorRef)) for value in (ids, logps)] if any(disk): if not all(disk): raise ValueError("Sampler top-k ids and logprobs must both be disk references.") @@ -199,7 +215,9 @@ def validate_sampler_topk(sample, k): raise ValueError(f"Sampler top-k disk reference must have shape {shape} and dtype {dtype}.") if value.kind != key: raise ValueError(f"Unexpected sampler top-k disk reference kind: {value.kind}") - if not Path(value.path).is_file(): + if isinstance(value, TensorRef): + value.validate() + elif not Path(value.path).is_file(): raise FileNotFoundError(value.path) if ids.validated and logps.validated: return diff --git a/vime/utils/staleness.py b/vime/utils/staleness.py index 33f43d48e..3d83c7f72 100644 --- a/vime/utils/staleness.py +++ b/vime/utils/staleness.py @@ -1,4 +1,4 @@ -"""Policy age in weight-update units (fresh samples have age one).""" +"""Policy age in weight-update units (fresh samples have age zero).""" from vime.utils.types import Sample @@ -14,7 +14,7 @@ def sample_staleness(sample: Sample, current_weight_version: int) -> int | None: numeric_versions = [int(v) for v in versions] if max(numeric_versions) > current_weight_version: return None - return current_weight_version + 1 - min(numeric_versions) + return current_weight_version - min(numeric_versions) def compute_staleness_metrics(samples: list[Sample], current_weight_version: int | None) -> dict[str, float | int]: diff --git a/vime/utils/tensor_store.py b/vime/utils/tensor_store.py index 7744db9b3..3fb69e83e 100644 --- a/vime/utils/tensor_store.py +++ b/vime/utils/tensor_store.py @@ -1,3 +1,5 @@ +"""Optional straw tensor references used by rollout and training.""" + from __future__ import annotations import errno @@ -12,6 +14,31 @@ from safetensors import safe_open from safetensors.torch import load_file, save_file +try: + from straw.tensor import TensorRef +except ModuleNotFoundError as error: + if error.name != "straw": + raise + + class TensorRef: + """Keep in-memory paths importable when straw is not installed.""" + + def __new__(cls, *args, **kwargs): + raise ModuleNotFoundError("Install straw with: pip install straw-queue", name="straw") + + +def materialize_tensor_refs(value): + """Make debug dumps self-contained, independent of queue ownership and GC.""" + if isinstance(value, TensorRef): + return value.load() + if isinstance(value, dict): + return {key: materialize_tensor_refs(item) for key, item in value.items()} + if isinstance(value, list): + return [materialize_tensor_refs(item) for item in value] + if isinstance(value, tuple): + return tuple(materialize_tensor_refs(item) for item in value) + return value + def retain_debug_tensor_refs(dump_data: dict, dump_path: Path) -> None: """Give a debug dump its own file lifetime, independent of rollout cleanup.""" diff --git a/vime/utils/types.py b/vime/utils/types.py index d09f2b44f..c8ff2fdd5 100644 --- a/vime/utils/types.py +++ b/vime/utils/types.py @@ -7,7 +7,7 @@ import torch from vime.utils.misc import decode_int32_meta_array -from vime.utils.tensor_store import DiskTensorRef +from vime.utils.tensor_store import DiskTensorRef, TensorRef _TOP_P_TOKEN_ID_META_KEYS = ("top_p_token_ids", "top_p_kept_token_ids") _TOP_P_TOKEN_OFFSET_META_KEYS = ("top_p_token_offsets", "top_p_kept_token_offsets") @@ -40,11 +40,15 @@ def _extract_rollout_top_p_token_data( def _merge_rollout_top_p_token_data( - base_token_ids: list[int] | torch.Tensor | None, - base_offsets: list[int] | torch.Tensor | None, + base_token_ids: list[int] | torch.Tensor | TensorRef | None, + base_offsets: list[int] | torch.Tensor | TensorRef | None, token_ids: torch.Tensor, offsets: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: + if isinstance(base_token_ids, (TensorRef, DiskTensorRef)): + base_token_ids = base_token_ids.load() + if isinstance(base_offsets, (TensorRef, DiskTensorRef)): + base_offsets = base_offsets.load() base_token_ids = torch.as_tensor([] if base_token_ids is None else base_token_ids, dtype=torch.int32).reshape(-1) base_offsets = torch.as_tensor([0] if base_offsets is None else base_offsets, dtype=torch.int32).reshape(-1) base_offset = int(base_offsets[-1]) @@ -55,15 +59,19 @@ def _merge_rollout_top_p_token_data( def _pad_rollout_top_p_offsets( - token_ids: list[int] | torch.Tensor | None, - offsets: list[int] | torch.Tensor | None, + token_ids: list[int] | torch.Tensor | TensorRef | None, + offsets: list[int] | torch.Tensor | TensorRef | None, num_tokens: int, -) -> tuple[torch.Tensor, torch.Tensor]: +) -> tuple[torch.Tensor | TensorRef, torch.Tensor]: if offsets is None or token_ids is None: raise ValueError("Cannot append empty top-p spans without existing token ids and offsets.") if num_tokens < 0: raise ValueError(f"num_tokens must be non-negative, got {num_tokens}.") - token_ids = torch.as_tensor(token_ids, dtype=torch.int32).reshape(-1) + # Masked tool tokens only extend offsets; keep the immutable ids shared. + if not isinstance(token_ids, (TensorRef, DiskTensorRef)): + token_ids = torch.as_tensor(token_ids, dtype=torch.int32).reshape(-1) + if isinstance(offsets, (TensorRef, DiskTensorRef)): + offsets = offsets.load() offsets = torch.as_tensor(offsets, dtype=torch.int32).reshape(-1) if offsets.numel() == 0: raise ValueError("Cannot append empty top-p spans to empty offsets.") @@ -90,6 +98,8 @@ def _to_float_list(values) -> list[float] | None: def _numel(value) -> int: + if isinstance(value, (TensorRef, DiskTensorRef)): + return math.prod(value.shape) return int(torch.as_tensor(value).reshape(-1).numel()) @@ -122,14 +132,14 @@ class Sample: loss_mask: list[int] | None = None weight_versions: list[str] = field(default_factory=list) rollout_log_probs: list[float] | None = None # Log probabilities from rollout engine - rollout_topk_token_ids: np.ndarray | torch.Tensor | list[list[int]] | DiskTensorRef | None = None - rollout_topk_log_probs: np.ndarray | torch.Tensor | list[list[float]] | DiskTensorRef | None = None + rollout_topk_token_ids: np.ndarray | torch.Tensor | list[list[int]] | TensorRef | DiskTensorRef | None = None + rollout_topk_log_probs: np.ndarray | torch.Tensor | list[list[float]] | TensorRef | DiskTensorRef | None = None # Ragged top-p nucleus token ids replayed from rollout sampling. For response # token i, kept ids are rollout_top_p_token_ids[offsets[i]:offsets[i + 1]]. - rollout_top_p_token_ids: list[int] | torch.Tensor | None = None - rollout_top_p_token_offsets: list[int] | torch.Tensor | None = None - rollout_top_p_log_probs: np.ndarray | torch.Tensor | list[float] | None = None - rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | DiskTensorRef | None = ( + rollout_top_p_token_ids: list[int] | torch.Tensor | TensorRef | None = None + rollout_top_p_token_offsets: list[int] | torch.Tensor | TensorRef | None = None + rollout_top_p_log_probs: np.ndarray | torch.Tensor | list[float] | TensorRef | None = None + rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | TensorRef | DiskTensorRef | None = ( None # Routed experts from rollout engine ) remove_sample: bool = False @@ -295,7 +305,7 @@ def append_response_tokens( k = args.score_centering_top_k for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): value = getattr(self, key) - if isinstance(value, DiskTensorRef): + if isinstance(value, (TensorRef, DiskTensorRef)): setattr(self, key, value.load().numpy()) if trainable: ids, logps = extract_sampler_topk(meta_info or {}, len(tokens), k) @@ -339,7 +349,10 @@ def append_response_tokens( torch.as_tensor(ids), torch.as_tensor(offsets), ) - self.rollout_top_p_log_probs = np.concatenate((np.asarray(self.rollout_top_p_log_probs), logps)) + previous_logps = self.rollout_top_p_log_probs + if isinstance(previous_logps, (TensorRef, DiskTensorRef)): + previous_logps = previous_logps.load() + self.rollout_top_p_log_probs = np.concatenate((np.asarray(previous_logps), logps)) # Already appended the validated replay data above. meta_info = { key: value @@ -486,7 +499,7 @@ def _append_rollout_routed_experts_chunk(self, routed_experts: torch.Tensor) -> existing = self.rollout_routed_experts if existing is None: self.rollout_routed_experts = routed_experts - elif isinstance(existing, DiskTensorRef): + elif isinstance(existing, (TensorRef, DiskTensorRef)): self.rollout_routed_experts = [existing.load(), routed_experts] elif isinstance(existing, list) and all(torch.is_tensor(item) for item in existing): existing.append(routed_experts) @@ -497,7 +510,7 @@ def get_rollout_routed_experts_length(self) -> int: routed_experts = self.rollout_routed_experts if routed_experts is None: return 0 - if isinstance(routed_experts, DiskTensorRef): + if isinstance(routed_experts, (TensorRef, DiskTensorRef)): return int(routed_experts.shape[0]) if torch.is_tensor(routed_experts): return int(routed_experts.shape[0]) @@ -513,7 +526,7 @@ def materialize_rollout_routed_experts(self, *, replace: bool = True) -> torch.T routed_experts = self.rollout_routed_experts if routed_experts is None: return None - if isinstance(routed_experts, DiskTensorRef): + if isinstance(routed_experts, (TensorRef, DiskTensorRef)): tensor = routed_experts.load() elif torch.is_tensor(routed_experts): tensor = routed_experts.reshape(*routed_experts.shape) @@ -544,7 +557,10 @@ def _validate_response_metadata_lengths(self): if self.rollout_top_p_token_ids is None or self.rollout_top_p_token_offsets is None: raise ValueError("rollout top-p replay must include both token ids and offsets.") - offsets = torch.as_tensor(self.rollout_top_p_token_offsets, dtype=torch.int32).reshape(-1) + offsets = self.rollout_top_p_token_offsets + if isinstance(offsets, (TensorRef, DiskTensorRef)): + offsets = offsets.load() + offsets = torch.as_tensor(offsets, dtype=torch.int32).reshape(-1) if offsets.numel() != self.response_length + 1: raise ValueError( "rollout_top_p_token_offsets length must equal response_length + 1: " From 651b30b16effa7b85a8751aedd75e23107747c12 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 29 Sep 2026 03:49:13 +0000 Subject: [PATCH 13/58] test: exercise straw transport in mirrored GPU suites Signed-off-by: aoshen02 --- tests/test_mimo_7B_mtp_only_grad.py | 15 ++++--- tests/test_moonlight_16B_A3B_r3.py | 16 ++++--- ...t_qwen2.5_0.5B_debug_rollout_then_train.py | 13 +++--- tests/test_qwen2.5_0.5B_fanout_short.py | 28 +++++++------ tests/test_qwen2.5_0.5B_fully_async_short.py | 16 ++++--- tests/test_qwen2.5_0.5B_score_centering.py | 3 ++ ...st_qwen2.5_0.5B_vllm_config_distributed.py | 14 ++++--- tests/test_qwen3.6_35B_A3B_pd_mooncake.py | 14 ++++--- tests/test_qwen3_30B_A3B_r3.py | 17 +++++--- tests/test_qwen3_4B_ckpt.py | 7 +++- tests/test_qwen3_4B_ppo.py | 14 ++++--- tests/test_release_train.py | 2 + tests/test_vllm_config_mixed_offload_ft.py | 14 ++++--- tests/test_vllm_rollout.py | 42 ++++++++++++++++++- 14 files changed, 155 insertions(+), 60 deletions(-) diff --git a/tests/test_mimo_7B_mtp_only_grad.py b/tests/test_mimo_7B_mtp_only_grad.py index 2c49cd911..2e4d4eb81 100644 --- a/tests/test_mimo_7B_mtp_only_grad.py +++ b/tests/test_mimo_7B_mtp_only_grad.py @@ -9,6 +9,8 @@ """ import os +import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -41,6 +43,7 @@ def execute(): # Use very short rollout-max-response-len to ensure all outputs are truncated # This should result in zero loss for the main model, leaving only MTP loss rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -129,11 +132,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_moonlight_16B_A3B_r3.py b/tests/test_moonlight_16B_A3B_r3.py index bcc5699d8..0c4c2882c 100644 --- a/tests/test_moonlight_16B_A3B_r3.py +++ b/tests/test_moonlight_16B_A3B_r3.py @@ -1,4 +1,6 @@ import os +import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U ENABLE_EVAL = bool(int(os.environ.get("VIME_TEST_ENABLE_EVAL", "1"))) @@ -23,6 +25,8 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME} " f"--ref-load /root/{MODEL_NAME}_torch_dist " rollout_args = ( + "--rollout-data-transport straw " + "--rollout-queue-online-gc " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -112,11 +116,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py b/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py index 53df35172..09434c660 100644 --- a/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py +++ b/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py @@ -10,6 +10,7 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -32,6 +33,7 @@ def _common_args(debug_data_dir: str): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/models/{MODEL_NAME}/ " rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/gsm8k/train.parquet " "--input-key messages " "--label-key label " @@ -93,6 +95,7 @@ def execute_rollout_only(debug_data_dir: str): f"{_common_args(debug_data_dir)} " f"{vllm_args} " "--debug-rollout-only " + f"--rollout-data-dir {quote(os.path.join(debug_data_dir, 'rollout_queue'))} " f"--save-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.pt " ) @@ -112,6 +115,7 @@ def execute_train_only(debug_data_dir: str): phase2_args = ( f"{_common_args(debug_data_dir)} " + f"--rollout-data-dir {quote(os.path.join(debug_data_dir, 'train_queue'))} " f"--load-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.pt " "--ci-test " ) @@ -128,11 +132,10 @@ def execute_train_only(debug_data_dir: str): def execute(): - debug_data_dir = tempfile.mkdtemp(prefix="vime_debug_rollout_") - print(f"Using temp dir for rollout data: {debug_data_dir}") - - execute_rollout_only(debug_data_dir) - execute_train_only(debug_data_dir) + with tempfile.TemporaryDirectory(prefix="vime_debug_rollout_") as debug_data_dir: + print(f"Using temp dir for rollout data: {debug_data_dir}") + execute_rollout_only(debug_data_dir) + execute_train_only(debug_data_dir) print("=" * 60) print("Both phases completed successfully!") diff --git a/tests/test_qwen2.5_0.5B_fanout_short.py b/tests/test_qwen2.5_0.5B_fanout_short.py index 2609d0c2b..adbbdad58 100644 --- a/tests/test_qwen2.5_0.5B_fanout_short.py +++ b/tests/test_qwen2.5_0.5B_fanout_short.py @@ -40,6 +40,7 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -87,6 +88,7 @@ def execute(): # ``list[list[Sample]]`` and crashes ``async_rm`` # (`'list' object has no attribute 'metadata'`). rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -180,18 +182,20 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - extra_env_vars={ - # Make the helper importable by both the Ray driver and workers - # without installing test modules as part of the vime package. - "PYTHONPATH": f"{TESTS_DIR}:{U.repo_base_dir}:/root/Megatron-LM/", - # The helper picks up the shared counter path via os.environ. - "VIME_FANOUT_TEST_COUNTER_FILE": FANOUT_COUNTER_FILE, - }, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + extra_env_vars={ + # Make the helper importable by both the Ray driver and workers + # without installing test modules as part of the vime package. + "PYTHONPATH": f"{TESTS_DIR}:{U.repo_base_dir}:/root/Megatron-LM/", + # The helper picks up the shared counter path via os.environ. + "VIME_FANOUT_TEST_COUNTER_FILE": FANOUT_COUNTER_FILE, + }, + ) # Post-train assertion: compact_generate must have been called exactly # ``num_rollout * rollout_batch_size`` = 2 * 4 = 8 times. A regression diff --git a/tests/test_qwen2.5_0.5B_fully_async_short.py b/tests/test_qwen2.5_0.5B_fully_async_short.py index fd60e83ae..ec5e6e0e3 100644 --- a/tests/test_qwen2.5_0.5B_fully_async_short.py +++ b/tests/test_qwen2.5_0.5B_fully_async_short.py @@ -9,6 +9,8 @@ """ import os +import tempfile +from shlex import quote import torch @@ -44,6 +46,8 @@ def execute(): rollout_args = ( # Select the public fully-async rollout function. "--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async " + "--rollout-data-transport straw " + "--rollout-queue-online-gc " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -129,11 +133,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_qwen2.5_0.5B_score_centering.py b/tests/test_qwen2.5_0.5B_score_centering.py index a507ae81b..68504affa 100644 --- a/tests/test_qwen2.5_0.5B_score_centering.py +++ b/tests/test_qwen2.5_0.5B_score_centering.py @@ -3,6 +3,7 @@ import os import tempfile from pathlib import Path +from shlex import quote import torch @@ -30,6 +31,8 @@ def execute(top_p=1.0): "--num-rollout 2 --rollout-batch-size 2 --n-samples-per-prompt 4 " f"--rollout-max-response-len 256 --rollout-temperature 0.8 --rollout-top-p {top_p} --rollout-top-k -1 " "--global-batch-size 8 --use-score-centering --score-centering-top-k 128 " + "--rollout-data-transport straw --rollout-queue-online-gc " + f"--rollout-data-dir {quote(str(Path(directory) / 'queue'))} " "--pg-loss-type reinforce --advantage-estimator grpo --disable-grpo-std-normalization " "--calculate-per-token-loss --entropy-coef 0 --kl-coef 0 " "--optimizer adam --lr 1e-6 --lr-decay-style constant --weight-decay 0 " diff --git a/tests/test_qwen2.5_0.5B_vllm_config_distributed.py b/tests/test_qwen2.5_0.5B_vllm_config_distributed.py index 45fe6139c..54c307186 100644 --- a/tests/test_qwen2.5_0.5B_vllm_config_distributed.py +++ b/tests/test_qwen2.5_0.5B_vllm_config_distributed.py @@ -1,5 +1,6 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -43,6 +44,7 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/models/{MODEL_NAME}/ " rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/gsm8k/train.parquet " "--input-key messages " "--label-key label " @@ -132,11 +134,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py index fbb262cac..3e19b4abc 100644 --- a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py +++ b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py @@ -1,5 +1,6 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -37,6 +38,7 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME} " f"--ref-load {TORCH_DIST_CKPT} " rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -132,11 +134,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_qwen3_30B_A3B_r3.py b/tests/test_qwen3_30B_A3B_r3.py index 3aa2f7121..42888825d 100644 --- a/tests/test_qwen3_30B_A3B_r3.py +++ b/tests/test_qwen3_30B_A3B_r3.py @@ -1,4 +1,6 @@ import os +import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -29,6 +31,8 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME} " f"--ref-load /root/{MODEL_NAME}_torch_dist " rollout_args = ( + "--rollout-data-transport straw " + "--rollout-queue-online-gc " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -40,7 +44,6 @@ def execute(): "--n-samples-per-prompt 4 " "--rollout-max-response-len 8192 " "--rollout-temperature 1 " - "--rollout-data-transport nixl " "--global-batch-size 16 " "--balance-data " ) @@ -132,11 +135,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_qwen3_4B_ckpt.py b/tests/test_qwen3_4B_ckpt.py index 6fa132eb4..5cceb17d5 100644 --- a/tests/test_qwen3_4B_ckpt.py +++ b/tests/test_qwen3_4B_ckpt.py @@ -1,5 +1,6 @@ import os from argparse import ArgumentParser +from pathlib import Path from shlex import quote import vime.utils.external_utils.command_utils as U @@ -63,9 +64,11 @@ def execute(mode: str = "", optimizer: str = "cpu", checkpoint_dir: str = ""): ckpt_args += "--async-save " elif mode == "load": ckpt_args += f"--load {checkpoint_dir_arg} " - ckpt_args += "--ckpt-step 1 " + ckpt_args += "--ckpt-step 1 --rollout-queue-resume " rollout_args = ( + "--rollout-data-transport straw " + f"--rollout-data-dir {quote(str(Path(checkpoint_dir) / 'rollout_data'))} " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -153,4 +156,6 @@ def execute(mode: str = "", optimizer: str = "cpu", checkpoint_dir: str = ""): optimizer=args.save_optimizer, checkpoint_dir=checkpoint_dir, ) + for filename in ("queue_state_1.json", "builder_state_1.json"): + assert (Path(checkpoint_dir) / "rollout" / filename).is_file(), f"Missing straw checkpoint: {filename}" execute("load", optimizer=args.load_optimizer, checkpoint_dir=checkpoint_dir) diff --git a/tests/test_qwen3_4B_ppo.py b/tests/test_qwen3_4B_ppo.py index 7c4ee72d8..716506c1b 100644 --- a/tests/test_qwen3_4B_ppo.py +++ b/tests/test_qwen3_4B_ppo.py @@ -1,5 +1,6 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -40,6 +41,7 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/{MODEL_NAME}_torch_dist " rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/dapo-math-17k/dapo-math-17k.jsonl " "--input-key prompt " "--label-key label " @@ -134,11 +136,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_release_train.py b/tests/test_release_train.py index a20caa0f9..0157df2d4 100644 --- a/tests/test_release_train.py +++ b/tests/test_release_train.py @@ -46,6 +46,8 @@ def execute(): ) rollout_args = ( + "--rollout-data-transport straw " + f"--rollout-data-dir {quote(str(save_dir / 'rollout_data'))} " "--prompt-data /root/datasets/gsm8k/train.parquet " "--input-key messages " "--label-key label " diff --git a/tests/test_vllm_config_mixed_offload_ft.py b/tests/test_vllm_config_mixed_offload_ft.py index d3e652593..c837eb0dd 100644 --- a/tests/test_vllm_config_mixed_offload_ft.py +++ b/tests/test_vllm_config_mixed_offload_ft.py @@ -12,6 +12,7 @@ import os import tempfile +from shlex import quote import vime.utils.external_utils.command_utils as U @@ -52,6 +53,7 @@ def execute(): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/models/{MODEL_NAME}/ " rollout_args = ( + "--rollout-data-transport straw " "--prompt-data /root/datasets/gsm8k/train.parquet " "--input-key messages " "--label-key label " @@ -145,11 +147,13 @@ def execute(): f"{misc_args} " ) - U.execute_train( - train_args=train_args, - num_gpus_per_node=NUM_GPUS, - megatron_model_type=MODEL_TYPE, - ) + with tempfile.TemporaryDirectory(prefix="vime_straw_") as rollout_dir: + train_args += f"--rollout-data-dir {quote(rollout_dir)} " + U.execute_train( + train_args=train_args, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + ) if __name__ == "__main__": diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index 2322c0acb..327e672dd 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -25,8 +25,8 @@ from vime.rollout import vllm_rollout as mod NUM_GPUS = 0 -from vime.utils.eval_config import EvalDatasetConfig from vime.utils.async_utils import AsyncPacer +from vime.utils.eval_config import EvalDatasetConfig from vime.utils.types import Sample @@ -740,6 +740,46 @@ def test_generate_applies_routed_experts(patch_generate_state, monkeypatch): assert sample.rollout_routed_experts.shape[0] == len(sample.tokens) - 1 +@pytest.mark.unit +def test_generate_continuation_appends_only_new_routed_experts(patch_generate_state, monkeypatch): + prefix = np.array([[[1], [2]]], dtype=np.int32) + suffix = np.array([[[3], [4]]], dtype=np.int32) + sample = Sample( + index=0, + prompt="x", + tokens=[9, 3], + response_length=1, + rollout_log_probs=[-0.2], + status=Sample.Status.ABORTED, + ) + sample.rollout_routed_experts = prefix.copy() + post_mock = AsyncMock( + return_value={ + "choices": [ + { + "token_ids": [4], + "finish_reason": "stop", + "routed_experts": _encode_routed(suffix), + "logprobs": {"content": [{"logprob": -0.5}]}, + } + ], + "usage": {}, + } + ) + monkeypatch.setattr(mod, "post", post_mock) + + result = asyncio.run( + mod.generate( + _rollout_args(use_rollout_routing_replay=True, num_layers=2, moe_router_topk=1), + sample, + _default_sampling_params(), + ) + ) + + assert post_mock.await_args.args[1]["sampling_params"]["routed_experts_prompt_start"] == 1 + np.testing.assert_array_equal(result.materialize_rollout_routed_experts(), np.concatenate((prefix, suffix))) + + @pytest.mark.unit def test_generate_and_rm_skips_completed_sample(patch_generate_state, monkeypatch): called = False From 2733663d83e4630ef4c8f5ebc41c12921b6084d5 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 29 Sep 2026 03:54:03 +0000 Subject: [PATCH 14/58] style: match repository formatter for straw sync Signed-off-by: aoshen02 --- tests/test_fully_async_rollout.py | 4 +++- tests/test_megatron_argument_validation.py | 1 + vime/backends/megatron_utils/loss.py | 6 +++++- vime/ray/rollout.py | 7 ++++++- vime/utils/arguments.py | 6 +++++- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/test_fully_async_rollout.py b/tests/test_fully_async_rollout.py index eeeadd699..f6f24e2a1 100644 --- a/tests/test_fully_async_rollout.py +++ b/tests/test_fully_async_rollout.py @@ -92,7 +92,9 @@ def test_rollout_takes_target_groups_and_leaves_surplus_queued(monkeypatch): worker.output_queue.put((gid, _make_group(gid))) monkeypatch.setattr(fa, "_get_global_worker", lambda args, data_buffer: worker) - args = SimpleNamespace(rollout_batch_size=4, rollout_data_transport="object-store", rollout_sample_filter_path=None) + args = SimpleNamespace( + rollout_batch_size=4, rollout_data_transport="object-store", rollout_sample_filter_path=None + ) out = asyncio.run(fa._generate_rollout_async(args, rollout_id=0, data_buffer=None)) assert len(out) == 4 diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index 47d6f09f7..e2acb684b 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -318,6 +318,7 @@ def test_queue_options_require_straw_transport(monkeypatch, overrides): with pytest.raises(ValueError, match="requires --rollout-data-transport straw"): module.vime_validate_args(make_vime_validate_args(**overrides)) + def test_global_dataset_flag_is_removed(monkeypatch): module = load_vime_arguments_module(monkeypatch) parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 00174d486..589c810af 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -383,7 +383,11 @@ def _build_topp_keep_mask( # Only offsets are needed in full; ids stay lazy until selecting CP rows. top_p_token_offsets = [ - t.load().tolist() if isinstance(t, (TensorRef, DiskTensorRef)) else t.tolist() if torch.is_tensor(t) else list(t) + ( + t.load().tolist() + if isinstance(t, (TensorRef, DiskTensorRef)) + else t.tolist() if torch.is_tensor(t) else list(t) + ) for t in top_p_token_offsets ] diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index f8f1a5e67..2f92e9afe 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -21,7 +21,12 @@ from vime.utils.http_utils import init_http_client from vime.utils.memory_utils import get_process_host_memory_gib from vime.utils.misc import load_function -from vime.utils.rollout_transport import accept_raw_rollout, check_rollout_storage, load_rollout_samples, seal_rollout_store +from vime.utils.rollout_transport import ( + accept_raw_rollout, + check_rollout_storage, + load_rollout_samples, + seal_rollout_store, +) from vime.utils.staleness import fully_async_metrics_enabled from vime.utils.tensor_store import DiskTensorRef diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index 9b7fac03d..c8c4e6c3b 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -591,7 +591,11 @@ def add_rollout_arguments(parser): "--rollout-storage-declaration", help="JSON file declaring JuiceFS mount and backing-store durability settings; see the straw project README.", ) - parser.add_argument("--rollout-queue-run-id", default="rollout", help="Persistent queue run identity within rollout-data-dir.") + parser.add_argument( + "--rollout-queue-run-id", + default="rollout", + help="Persistent queue run identity within rollout-data-dir.", + ) parser.add_argument( "--rollout-queue-resume", action="store_true", From a9d013e8e5fa94ccb62003470c921d474b32a489 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 29 Sep 2026 04:11:31 +0000 Subject: [PATCH 15/58] ci: install straw in CPU contract container Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 608ebb07b..2a9411570 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -64,7 +64,7 @@ steps: python:3.11 bash -c ' set -euo pipefail pip install -q torch --index-url https://download.pytorch.org/whl/cpu - pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb + pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb straw-queue pip install -q -e . --no-deps python tests/test_megatron_argument_validation.py python tests/test_optional_straw.py From e95a5b577f5e3875daa4ac30f1e0d85729071c26 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 01:31:16 +0000 Subject: [PATCH 16/58] sync: mirror Slime #2427 straw checkpoint rollback Signed-off-by: aoshen02 --- .buildkite/gpu_suites.py | 1 + .buildkite/pipeline.yml | 2 +- .claude/skills/add-dynamic-filter/SKILL.md | 4 +- .../vime-code-review-preferences/SKILL.md | 7 + docker/patch/latest/megatron.patch | 33 + docs/en/advanced/straw.md | 130 ++- docs/en/developer_guide/ci.md | 3 + docs/en/get_started/customization.md | 38 +- docs/en/get_started/usage.md | 15 +- docs/zh/advanced/straw.md | 63 +- docs/zh/developer_guide/ci.md | 3 + docs/zh/get_started/customization.md | 23 +- docs/zh/get_started/usage.md | 12 +- .../test_plugin_path_loading_contracts.py | 8 +- .../test_plugin_runtime_hook_contracts.py | 4 +- tests/test_distributed_rollout.py | 422 ++++++++-- tests/test_megatron_argument_validation.py | 55 +- tests/test_optional_straw.py | 18 +- tests/test_queue_sample_codec.py | 8 +- tests/test_qwen3_4B_ckpt.py | 12 +- tests/test_r3_spill.py | 2 +- tests/test_rollout_buffer_order.py | 6 +- tests/test_rollout_straw_transport.py | 763 +++++++++++++++++- tests/test_score_centering.py | 2 +- tests/test_score_centering_transport.py | 13 +- tests/test_straw_checkpoint_fork.py | 307 +++++++ tests/test_straw_fully_async_recovery.py | 10 +- tests/test_straw_r3.py | 8 +- train.py | 24 +- vime/backends/megatron_utils/actor.py | 2 +- vime/backends/megatron_utils/checkpoint.py | 8 +- vime/backends/megatron_utils/cp_utils.py | 2 +- vime/backends/megatron_utils/loss.py | 2 +- vime/backends/megatron_utils/model.py | 4 +- vime/data/__init__.py | 4 + vime/data/archive.py | 180 +++++ vime/{rollout => data}/batch_builder.py | 40 +- vime/data/checkpoint.py | 370 +++++++++ .../{rollout/queue_codec.py => data/codec.py} | 7 +- vime/{rollout => data}/data_source.py | 0 vime/{rollout => data}/queue_data_source.py | 475 ++++++++--- .../{utils/tensor_store.py => data/tensor.py} | 0 .../transport.py} | 47 +- vime/observability/rollout_data_utils.py | 22 +- vime/observability/rollout_metrics.py | 8 +- vime/observability/train_data_utils.py | 2 +- vime/ray/actor_group.py | 14 +- vime/ray/placement_group.py | 4 +- vime/ray/rollout.py | 74 +- vime/rollout/base_types.py | 14 +- vime/rollout/fully_async_distributed.py | 57 +- vime/rollout/fully_async_rollout.py | 27 +- vime/rollout/vllm_rollout.py | 2 +- vime/utils/arguments.py | 45 +- vime/utils/data.py | 2 +- vime/utils/routed_experts.py | 2 +- vime/utils/score_centering.py | 2 +- vime/utils/types.py | 2 +- 58 files changed, 2929 insertions(+), 485 deletions(-) create mode 100644 tests/test_straw_checkpoint_fork.py create mode 100644 vime/data/__init__.py create mode 100644 vime/data/archive.py rename vime/{rollout => data}/batch_builder.py (95%) create mode 100644 vime/data/checkpoint.py rename vime/{rollout/queue_codec.py => data/codec.py} (98%) rename vime/{rollout => data}/data_source.py (100%) rename vime/{rollout => data}/queue_data_source.py (67%) rename vime/{utils/tensor_store.py => data/tensor.py} (100%) rename vime/{utils/rollout_transport.py => data/transport.py} (89%) diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index ba2e38789..6a33d0ef3 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -63,6 +63,7 @@ ("test_moonlight_16B_A3B_r3.py", 8, "", {"ENABLE_EVAL": "0"}), ("test_mimo_7B_mtp_only_grad.py", 8, "", {}), ("test_qwen2.5_0.5B_debug_rollout_then_train.py", 8, "", {}), + ("test_straw_checkpoint_fork.py", 4, "", {}), ("test_qwen2.5_0.5B_opd_vllm.py", 8, "", {}), ("test_qwen2.5_0.5B_fanout_short.py", 4, "", {}), ("test_qwen2.5_0.5B_score_centering.py", 2, "", {}), diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 2a9411570..965635224 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -235,7 +235,7 @@ steps: value: short - label: "run-ci-vllm-config — 8 GPU, 4 tests" value: vllm-config - - label: "run-ci-megatron — up to 8 GPU, 21 runs" + - label: "run-ci-megatron — up to 8 GPU, 23 runs" value: megatron - label: "run-ci-vime-customized — 1–8 GPU, 5 tests" value: vime-customized diff --git a/.claude/skills/add-dynamic-filter/SKILL.md b/.claude/skills/add-dynamic-filter/SKILL.md index 27390a9b6..86197c9cd 100644 --- a/.claude/skills/add-dynamic-filter/SKILL.md +++ b/.claude/skills/add-dynamic-filter/SKILL.md @@ -42,7 +42,7 @@ from vime.rollout.filter_hub.base_types import DynamicFilterOutput return DynamicFilterOutput(keep=True, reason=None) ``` -Buffer filter (called in `vime/rollout/data_source.py`): +Buffer filter (called in `vime/data/data_source.py`): ```python def buffer_filter(args, rollout_id, buffer, num_samples): @@ -98,4 +98,4 @@ Example wiring: - Dynamic filter types: `vime/rollout/filter_hub/base_types.py` - Dynamic filter example: `vime/rollout/filter_hub/dynamic_sampling_filters.py` - Rollout generation hook points: `vime/rollout/vllm_rollout.py` -- Buffer filter hook point: `vime/rollout/data_source.py` +- Buffer filter hook point: `vime/data/data_source.py` diff --git a/.claude/skills/vime-code-review-preferences/SKILL.md b/.claude/skills/vime-code-review-preferences/SKILL.md index 5f3be3fd1..195f87687 100644 --- a/.claude/skills/vime-code-review-preferences/SKILL.md +++ b/.claude/skills/vime-code-review-preferences/SKILL.md @@ -27,3 +27,10 @@ Apply these lightweight review heuristics when changing vime code. - Add an abstraction only when it removes real duplication, hides fragile mechanics, or clarifies ownership. - When a review comment points out repeated indirection, look for a smaller public surface rather than adding another alias. - Preserve existing behavior intentionally. If cleanup changes error handling, logging, or failure visibility, call that out in the final response. + +## Keep Runtime State Out of Args + +- Treat `args` as configuration. Do not add ad hoc `args._xxx` attributes to pass actor handles, caches, recovery progress, current versions, or other runtime state between components. Renaming these to public attributes does not fix the ownership problem. +- Put live state on the component that owns its lifecycle. Pass dependencies explicitly, and use typed return values for startup plans or restoration results shared between components. +- Avoid replacing scattered temporary fields with a generic `args.context` bag. Keep each state object's responsibility and lifetime clear. +- Preserve existing custom hook signatures, call arguments, and return conventions when refactoring internal state. User implementations must not need new parameters merely to accommodate an internal cleanup. diff --git a/docker/patch/latest/megatron.patch b/docker/patch/latest/megatron.patch index 3be8152b8..2e21b9751 100644 --- a/docker/patch/latest/megatron.patch +++ b/docker/patch/latest/megatron.patch @@ -1005,3 +1005,36 @@ index e9736ac08..6567ed426 100644 ) for (model_chunk_idx, model_chunk) in enumerate(model) ] +diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py +index 7c87eca191a525915502a1deb7622f03990c89de..bdb62df91b9ca2fe6e13d166a21f38e1b1e90e64 100644 +--- a/megatron/training/checkpointing.py ++++ b/megatron/training/checkpointing.py +@@ -212,8 +212,8 @@ + iteration, release = read_metadata(tracker_filename) + + # Allow user to specify the loaded iteration. +- if getattr(args, "ckpt_step", None): +- iteration = args.ckpt_step ++ if getattr(args, "ckpt_step", None) is not None: ++ iteration, release = args.ckpt_step, False + + return get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) + +@@ -1185,14 +1185,14 @@ + iteration, release = read_metadata(tracker_filename) + + # Allow user to specify the loaded iteration. +- if getattr(args, "ckpt_step", None): +- iteration = args.ckpt_step ++ if getattr(args, "ckpt_step", None) is not None: ++ iteration, release = args.ckpt_step, False + + # Record the iteration loaded (stored separately from args to avoid + # polluting checkpoints, since args is saved in checkpoints). + set_loaded_iteration(iteration) + +- if non_persistent_iteration != -1: # there is a non-persistent checkpoint ++ if non_persistent_iteration != -1 and getattr(args, "ckpt_step", None) is None: + if non_persistent_iteration >= iteration: + return _load_non_persistent_base_checkpoint( + non_persistent_global_dir, diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md index bd5df0da6..aca841531 100644 --- a/docs/en/advanced/straw.md +++ b/docs/en/advanced/straw.md @@ -84,7 +84,7 @@ active leases. `QueueDataSource` adds job-level reader creation, consumer lifecy and source checkpoints. It inherits the same sample-access methods; it does not implement another buffer. With `--rollout-data-transport straw`, the source is selected automatically. Explicit `--data-source-path` values should use -`vime.rollout.queue_data_source.QueueDataSource`. +`vime.data.queue_data_source.QueueDataSource`. `QueueReader` has no local continuation buffer. `add_samples()` publishes valid continuations, then `yield_tasks()` atomically replaces their inputs and returns @@ -237,40 +237,102 @@ control-task allowance lets the manager commit a collection under backpressure. ## Recovery and checkpoints -| Failure or restart | Supported behavior and boundary | +Save model and rollout state together through normal training checkpoints. +They include the dataset cursor, sample/group counters, pending and partial +inputs, ready groups, and training progress. Existing straw payloads are +referenced rather than copied; retaining a checkpoint keeps its data available. + +Stop the previous job, including remote workers, before restarting. The +save-directory lock rejects concurrent coordinators but does not stop orphaned +readers; there is no automatic coordinator failover. + +### Resume or select a step + +Resume the latest completed checkpoint using the same logical directories: + +```bash +--rollout-data-transport straw \ +--load /shared/checkpoints/run \ +--save /shared/checkpoints/run \ +--save-interval 1 +``` + +Megatron requires a positive `--save-interval` when `--save` is set. To restore +the state saved after rollout 7, add `--ckpt-step 7`; the next rollout is 8. +Keep the dataset, model/tokenizer configuration, straw run ID, storage profile, +and fully async worker topology consistent. Training recovery requires saved +optimizer and RNG state. + +Restore creates an isolated queue that shares the checkpoint's immutable +payloads. Pending/partial inputs, ready-group order, and dataset cursor come +from that checkpoint; later samples from the source run are excluded. Further +writes do not modify the source checkpoint. If `--save` already contains a run, +outputs go to a unique `branches/` directory tracked by +`rollout/current.json`. A later restart can use the same logical `--load` and +`--save`; omitting `--load` resumes the active branch. Use a fresh path for each +immutable `.straw.json` debug archive. + +| Selection | Arguments | |---|---| -| Worker lost before acceptance | Recover the latest durable continuation; reassign with a new attempt | -| Acceptance committed but reply lost | Recover the accepted receipt without a second logical acceptance | -| Whole job stops before its first batch is planned | `--rollout-queue-resume` recovers accepted groups and durable prefixes with the same initial model and rollout configuration | -| Restart after batch planning | Requires matching model/optimizer and rollout checkpoints; queue-only recovery fails explicitly | - -Resume requires the entire previous job, including its coordinator and readers, -to have stopped, and the same root and run ID. The flag is an operator assertion, -not automatic fencing. Unpublished inference may run again; vLLM GPU KV cache -is not restored. - -Checkpoints contain `/rollout/queue_state_.json` for the source -and registered scheduler consumers, and `builder_state_.json` for -the training consumer view. Their references retain the matching storage graph. -Continuations are persisted incrementally, so checkpoints reference -existing data instead of rewriting every returned token. Restoring a checkpoint -does not erase later accepted history from the WAL. - -Partial R3/SC tensors remain lazy references in continuation snapshots and are -loaded when generation appends new rows. Restoring source and builder state -preserves saved warm groups and behavior-policy versions, and records which -later outputs are excluded from the restored branch. Restored pending tasks retain -their storage independently of filtering. The complete source snapshot, -including consumers that have not restarted yet, is retained and durably -recorded before old references are released. Failed saves keep the old references; -background GC starts only after restored consumer state commits. - -The dataset producer cursor is not yet part of the source checkpoint, and -restoring a saved pending snapshot does not exclude every later-created task. -Complete rollback to an arbitrary older training step remains future work. -The adapter does not yet establish a joint final manifest for model, optimizer, -RNG and queue state. Persistent rollout is therefore not a claim of exact -optimizer recovery or bit-identical training after an arbitrary crash. +| Step in current branch history | `--load /shared/checkpoints/run --ckpt-step 7` | +| A specific branch | `--load /shared/checkpoints/run/branches/ --ckpt-step 7` | +| Exact checkpoint | `--load /shared/checkpoints/run/rollout/committed_7.json` | +| Separate output directory | `--save /shared/checkpoints/another-run` | + +Editing `latest_checkpointed_iteration.txt` in the logical save directory or +active branch also selects an earlier step. Explicit `--ckpt-step` or an exact +commit file takes precedence. Automatic selection uses completed model-and-queue +checkpoints, follows branch ancestry only to each fork point, and ignores newer +incomplete model saves. + +If a model checkpoint has no queue snapshot, recovery starts an empty queue and +restores `rollout/global_dataset_state_dict_.pt` when present; otherwise +the dataset restarts at offset 0 with a warning. Missing models, incomplete or +corrupt snapshots, and missing payloads raise instead of silently falling back. +Before the first model checkpoint, the original run can recover persisted work +only before its first training batch is planned. Recovery uses the original +straw pool; copying the checkpoint directory alone does not copy payloads. The +boundary is a completed rollout batch, not an optimizer microstep. GPU KV +caches and generation RNG are not restored. When extending `--num-rollout`, +use `--use-checkpoint-opt-param-scheduler` to retain the saved optimizer schedule. + +## Debug archives and sample lookup + +`.pt` and indexed `.straw.json` archives both work with the existing debug flags: + +```bash +--save-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' +# In a separate train-only job, without vLLM: +--load-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' +``` + +An archive retains its data independently of queue consumption and GC. With +straw transport it reuses existing tensors; otherwise it creates a `straw-data` +pool beside the index. Each rollout has one immutable index, with samples +stored in chunks. Evaluation uses `eval_`. Copying only the JSON index does +not copy payloads; train-only replay with straw needs a separate writable queue. +`--load-debug-rollout-data-subsample` also applies to archives. + +```python +from vime.data.archive import RolloutArchive +from vime.observability.rollout_data_utils import load_debug_rollout_data + +with RolloutArchive('/shared/debug/rollout_7.straw.json') as archive: + print(archive.keys()) + samples = archive.load_samples(sample_key='sample:42') + group = archive.load_samples(task_key='prompt:21') + archive.export_pt('/shared/debug/rollout_7.pt') + +samples = load_debug_rollout_data('/shared/debug/rollout_7.pt', rollout_id=7) +RolloutArchive.save('/shared/debug/imported_7.straw.json', samples, rollout_id=7) +``` + +Lookups return lists because compact trajectories can share a sample index. +Samples without an index use their archive position; samples without queue +provenance have no task key. Closing an archive closes its reader but retains +the data. After all readers finish, call `archive.release()`; other checkpoints, +queues, and archives keep their own references. An exported `.pt` remains +readable after straw reclaims the archive payloads. ## Validation and remaining limits diff --git a/docs/en/developer_guide/ci.md b/docs/en/developer_guide/ci.md index 0d40b3083..9591ae637 100644 --- a/docs/en/developer_guide/ci.md +++ b/docs/en/developer_guide/ci.md @@ -35,6 +35,9 @@ per test. Set `VIME_CI_IMAGE` to an immutable candidate digest when validating Dockerfile or vLLM patch changes. Jobs otherwise use `vllm/vime:latest`, which must not be updated before the change merges. +The `megatron` suite includes `test_straw_checkpoint_fork.py` for checkpoint +step selection, rollback branches and indexed debug archives. + ## Registering tests - Add always-on CPU tests to the appropriate command in diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index e8578d06c..39d66b25d 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -66,23 +66,41 @@ def generate_rollout(args, rollout_id, data_source, evaluation=False) -> Rollout **Example**: See [examples/fully_async](../_examples_synced/fully_async/README.md) -With `--rollout-data-transport straw` (the default is Ray `object-store`), `RolloutFnTrainOutput.samples` may be a `DiskPayloadRef` pointing to the selected batch. Existing custom functions returning Sample lists (bare lists or `RolloutFnTrainOutput`) still work: the manager stores them once. New functions can persist completed, reward-scored groups during rollout and return a manifest instead of retaining a full batch in memory. For example, given an async iterator of accepted groups: +With `--rollout-data-transport straw`, custom rollout functions can return +Sample lists directly or in `RolloutFnTrainOutput`; the manager persists them. +`RolloutFnTrainOutput.samples` also accepts a `DiskPayloadRef`. To avoid holding +an entire batch in memory, publish generated, scored and selected groups as they +finish. The following helper takes the rollout function's `data_source` and an +async iterator of completed groups: ```python -import asyncio - +from vime.data.transport import publish_rollout_async from vime.rollout.base_types import finalize_rollout_groups -from vime.utils.rollout_transport import pack_rollout_group -async def generate_stored_batch(args, rollout_id, completed_groups): +async def generate_stored_batch(args, rollout_id, data_source, completed_groups): refs = [] async for group in completed_groups: - refs.append(await asyncio.to_thread(pack_rollout_group, group, args, rollout_id)) - return finalize_rollout_groups(args, rollout_id, refs) + ref = await publish_rollout_async( + group, args, rollout_id, group=True, controller=data_source.controller + ) + refs.append(ref) + return finalize_rollout_groups(args, rollout_id, refs, controller=data_source.controller) ``` -`finalize_rollout_groups` sorts groups, applies the configured batch sample filter once, and stores the batch manifest. If a hook mutates Samples, its result is saved again. A wrapper that needs actual Samples can call `vime.utils.rollout_transport.load_rollout_samples(output.samples)`. Generation/reward hooks and evaluation return values keep their existing contracts. +The controller from the data source commits leased groups to their queue. +`finalize_rollout_groups` sorts groups, applies the configured batch sample +filter once and stores the batch manifest. If the hook changes Samples, its +result is saved again. Use `vime.data.transport.load_rollout_samples(output.samples)` +when a wrapper needs the Sample objects. + +The default straw data source is `vime.data.queue_data_source.QueueDataSource`. +It provides `get_samples(n)` and `add_samples(groups)` to acquire prompt groups +and return work for continuation. Returned groups are persisted and available +to any reader. For a custom remote worker, pass `source.reader_config("worker_id")` +and call `config.open()` in that process. Reader IDs must be unique (`owner` is +reserved); close the reader after its requests and writes finish. Readers renew +leases automatically and return unfinished work when closed. --- @@ -394,11 +412,11 @@ def log_eval_rollout_data(rollout_id, args, data, extra_metrics) -> bool ### `--data-source-path` -**Default**: `vime.rollout.data_source.RolloutDataSourceWithBuffer` +**Default**: `vime.data.data_source.RolloutDataSourceWithBuffer` **Purpose**: Override the data source for rollout prompts. -**Base Class**: `vime.rollout.data_source.DataSource` +**Base Class**: `vime.data.data_source.DataSource` **Required Methods**: ```python diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index b8c4fd441..d34d6a802 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -151,7 +151,7 @@ For details on some of vLLM's customizations and the principles behind how vime ### Data Format -Raw data is always managed by the DataSource. The built-in DataSource loads `--prompt-data` when provided; use `--data-source-path` for custom data management. The former `--disable-rollout-global-dataset` flag has been removed; remove it from older launch scripts. +Raw data is managed by the DataSource. The built-in DataSource loads `--prompt-data` when provided; use `--data-source-path` for custom data management. vime supports `.jsonl` and `.parquet` files; reading Parquet requires `pyarrow`. Each record in either format should contain the fields selected by `--input-key` and `--label-key`. An expanded JSONL record looks like this: @@ -391,11 +391,12 @@ vime supports customizing data generation (rollout) to various degrees. ### Persistent rollout queue and distributed fully async -See [straw architecture and recovery](../advanced/straw.md) for the data flow, shared tensor ownership and checkpoint boundaries. +The default rollout transport is Ray `object-store`, which does not require a +shared directory. `--rollout-data-transport nixl` selects Ray's NIXL tensor transport. +For persistent queues and packed tensor storage across machines, use +[straw](../advanced/straw.md) with a shared JuiceFS directory. -Rollout payloads use Ray `object-store` by default, with `vime.rollout.data_source.RolloutDataSourceWithBuffer`. This mode needs neither straw nor a shared rollout directory. `--rollout-data-transport nixl` selects Ray's NIXL tensor transport. - -Install [straw](../advanced/straw.md), then select `--rollout-data-transport straw` to use its persistent queue and packed tensor storage. straw transport defaults to `vime.rollout.queue_data_source.QueueDataSource`. It uses `--rollout-data-dir`, or `/rollout_data` when `--save` is set; otherwise an explicit shared directory is required. Ray still runs actors and RPCs and carries small references. The synchronous rollout entrypoint remains the default. To use distributed fully async explicitly: +To enable distributed fully async rollout with straw, add: ```bash --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async \ @@ -407,7 +408,7 @@ With straw transport, `--use-rollout-routing-replay` stores completed samples' R One job-owned, non-restarting Ray actor coordinates task leases and a serialized dataset producer. Dataset cursor advancement and task submission are committed together. Workers receive small task references and read prompt groups directly from shared storage. This replaces the former in-memory distributed index allocator; no per-reader index ranges are abandoned on failure. Seeded shuffle and sample/group identities remain those of the existing data source. -`get_samples(n)` and `add_samples(groups)` retain their interfaces. Custom producers can pass `source.reader_config("unique_reader_id")` to a remote process and call `config.open()` there. IDs must be unique; `owner` is reserved. Returned partial groups are persisted and become available to every reader; there is no reader-local sample buffer. Readers renew leases; a stopped reader returns unfinished tasks, while lost workers' tasks become available after lease expiry. Newly generated retries have new attempts. The coordinator does not automatically fail over: stop the old job before using `--rollout-queue-resume` with the same root and `--rollout-queue-run-id`. +`get_samples(n)` and `add_samples(groups)` retain their interfaces. Custom producers can pass `source.reader_config("unique_reader_id")` to a remote process and call `config.open()` there. IDs must be unique; `owner` is reserved. Returned partial groups are persisted and become available to every reader; there is no reader-local sample buffer. Readers renew leases; a stopped reader returns unfinished tasks, while lost workers' tasks become available after lease expiry. Newly generated retries have new attempts. Recovery uses the normal `--load`/`--save` checkpoint flow, with optional `--ckpt-step`; the selected checkpoint restores the queue branch and dataset cursor, so no separate queue-resume flag is needed. Fully async starts one generation process per Ray node with CPU resources. Each reserves one CPU and receives a share of the configured concurrency in complete prompt groups. Faster workers may fill a global batch without waiting for slower ones. Local generation queues and collector prefetch remain bounded. Complete groups pass through the existing dynamic filter before publication; rejected groups contribute filter metrics. Distributed fully async still rejects `--rollout-all-samples-process-path`. The synchronous all-samples hook retains its existing Samples and calling order. @@ -417,7 +418,7 @@ Built-in producers return a collection manifest. Legacy custom rollouts can stil The vime adapter currently binds the shared root at the same absolute path on all nodes and checks visibility in both directions. The underlying store's references are relocatable. r3/sc dependencies are adopted into queue storage before references escape the producer; old rollout spill cleanup cannot remove them. Queue files are retained by default. Offline inspection/cleanup requires stopped writers, coordinator and readers; ordinary reads or batch-ready events never delete data. Debug dumps remain opt-in `torch.save` files and may contain retained queue tensor references. Evaluation follows the existing path. -Data-source checkpoints pause registered consumers, drain in-flight requests and save reader buffers and completed groups through `queue_state_.json` under `/rollout`. Buffer snapshots reference already persisted groups and retain current leases separately, avoiding a second encoding of every buffered token during checkpoint or shutdown; older inline snapshots remain readable. Restore checks dataset size, samples per prompt, seed, shuffle, run identity and fully-async worker topology. Global producer history remains in the journal and is not rewound when loading an earlier checkpoint. Custom execution state participates via `register_consumer(name, consumer)` with `pause`, `resume`, `state_dict`, `load_state_dict` and `close` methods. Disabled worker slots remain disabled after restore. This is durable queue/data state; it does not by itself guarantee exact distributed optimizer recovery or bitwise training equivalence. +Straw checkpoints commit model, optimizer/RNG, queue, builder and dataset-cursor state together. `--load` restores the latest valid joint checkpoint by default; `--ckpt-step` selects a step in the active branch history. Restore creates an isolated branch from that snapshot, leaving the source run unchanged and excluding later-produced samples. Repeated rollback follows the active branch and cannot cross a fork point. If a model checkpoint predates a straw queue snapshot, recovery starts with an empty queue and restores the saved dataset cursor when available; a damaged or incomplete joint snapshot fails rather than silently falling back. This is durable queue/data state; it does not restore GPU KV cache or generation RNG. R3 training reads only the CP/TP rows assigned to the current rank. Batched continuation publication, loading and state persistence reuse authenticated indices within a bounded read session; receipt lookups use batch RPCs. These optimizations preserve checksums, WAL durability and GC ownership. Read sessions do not replace ownership pins. diff --git a/docs/zh/advanced/straw.md b/docs/zh/advanced/straw.md index c95c5e9ba..50512fe87 100644 --- a/docs/zh/advanced/straw.md +++ b/docs/zh/advanced/straw.md @@ -46,7 +46,7 @@ Ray 继续负责调度、RPC 和执行进程故障;vime 负责 Sample schema ## 队列调度 -`QueueReader` 是 worker 的队列客户端,负责领取、归还 group 和为正在执行的 lease 续租。`QueueDataSource` 增加整个作业的 reader 创建、consumer 生命周期和 source checkpoint 管理;它继承同一套取样方法,没有另实现一层 buffer。`--rollout-data-transport straw` 会自动选择此数据源;显式指定 `--data-source-path` 时使用 `vime.rollout.queue_data_source.QueueDataSource`。 +`QueueReader` 是 worker 的队列客户端,负责领取、归还 group 和为正在执行的 lease 续租。`QueueDataSource` 增加整个作业的 reader 创建、consumer 生命周期和 source checkpoint 管理;它继承同一套取样方法,没有另实现一层 buffer。`--rollout-data-transport straw` 会自动选择此数据源;显式指定 `--data-source-path` 时使用 `vime.data.queue_data_source.QueueDataSource`。 `QueueReader` 不再有本地 continuation buffer。`add_samples()` 先发布可用的续跑前缀,再通过 `yield_tasks()` 在每个有界批次的一次 WAL 事务中替换输入并归还任务。旧 lease 随之结束,任意 worker 都能用新 lease 领取这些输入。reader 本地只保留正在执行的样本和 lease,关闭时无需重写已归还的 group。 @@ -118,22 +118,63 @@ GC 失败会停止后台循环,后续 coordinator 操作传播原始原因, ## 恢复与 checkpoint -| 故障或重启场景 | 支持行为与边界 | +正常训练 checkpoint 会同时保存模型与 rollout 状态,包括 dataset 游标、sample/group 计数、pending/partial 输入、ready group 和训练进度。已经在 straw 中的载荷通过引用保存,不会重复复制;保留 checkpoint 就会保留其依赖数据。 + +重启前应停止整个旧任务,包括远端 worker。Save 目录锁会拒绝并发 coordinator,但不会停止孤立 reader;当前没有自动 coordinator 故障接管。 + +### 续跑或选择 step + +使用相同逻辑目录恢复最近一次完整 checkpoint: + +```bash +--rollout-data-transport straw \ +--load /shared/checkpoints/run \ +--save /shared/checkpoints/run \ +--save-interval 1 +``` + +设置 `--save` 时,Megatron 要求 `--save-interval` 为正数。要恢复 rollout 7 结束后的状态,添加 `--ckpt-step 7`,下一轮从 rollout 8 开始。Dataset、模型/tokenizer 配置、straw run ID、storage profile 和 fully async worker 拓扑应与 checkpoint 一致;训练恢复需要已保存 optimizer 和 RNG 状态。 + +恢复时会创建独立队列,共享 checkpoint 中的不可变载荷。Pending/partial 输入、ready group 顺序和 dataset 游标均来自该 checkpoint;不会混入源任务之后产生的样本。后续写入不会修改源 checkpoint。若 `--save` 已包含一次运行,输出会写入唯一的 `branches/` 目录,并由 `rollout/current.json` 记录当前分支。之后可继续使用相同逻辑 `--load` 和 `--save`;省略 `--load` 时自动续跑活动分支。每次运行的不可变 `.straw.json` debug 归档应使用新路径。 + +| 选择方式 | 参数 | |---|---| -| Worker 在结果接收前丢失 | 恢复最近一次持久化 continuation,以新 attempt 重新分配 | -| 已提交结果但回复丢失 | 恢复已有 receipt,不重复进行逻辑接收 | -| 第一个 batch 计划生成前整个任务停止 | `--rollout-queue-resume` 使用相同初始模型和 rollout 配置恢复已接收 group 与持久化前缀 | -| 已生成 batch 计划后重启 | 需要匹配的模型/优化器及 rollout checkpoint,单独恢复队列会明确报错 | +| 当前分支历史中的某一步 | `--load /shared/checkpoints/run --ckpt-step 7` | +| 指定分支 | `--load /shared/checkpoints/run/branches/ --ckpt-step 7` | +| 指定完整 checkpoint | `--load /shared/checkpoints/run/rollout/committed_7.json` | +| 独立输出目录 | `--save /shared/checkpoints/another-run` | + +也可以修改逻辑 save 目录或活动分支中的 `latest_checkpointed_iteration.txt`,选择更早的 step。显式 `--ckpt-step` 或具体提交文件优先。自动选择只使用完整的模型与队列 checkpoint,沿分支祖先查找时不会越过分叉点,也不会跟随更晚但未完成的模型保存。 + +模型 checkpoint 没有队列快照时,会从空队列恢复;若存在 `rollout/global_dataset_state_dict_.pt`,则恢复其中的 dataset 游标,否则从 offset 0 开始并打印警告。模型缺失、快照不完整或损坏、载荷缺失时会报错,不会静默退化。第一个模型 checkpoint 尚未产生时,原任务只能在第一个训练 batch 规划前恢复已持久化工作。恢复依赖原 straw 存储池,单独复制 checkpoint 目录不会复制载荷。恢复边界是完成的 rollout batch,而不是 optimizer microstep;GPU KV cache 和生成 RNG 不会恢复。增大 `--num-rollout` 时,如需沿用保存的 optimizer schedule,可使用 `--use-checkpoint-opt-param-scheduler`。 -Resume 要求整个旧任务(包括 coordinator 和 reader)已停止,并使用同一个 root 与 run ID。该参数是操作者声明,不是自动 fencing。尚未发布的推理可能重新执行;vLLM GPU KV cache 不会恢复。 +## Debug 归档与按 key 查询 -Checkpoint 的 `/rollout/queue_state_.json` 保存 source 和已注册 scheduler consumer;`builder_state_.json` 保存训练 consumer 视图。它们的引用保留相应存储依赖图。Continuation 增量持久化,checkpoint 引用已有数据,不会再次重写全部归还样本的 token。恢复旧 checkpoint 不会抹去 WAL 中后来已接收的历史。 +现有 debug 保存/加载参数同时支持 `.pt` 和带索引的 `.straw.json`: -部分 R3/SC 张量在 continuation 快照中保持 lazy 引用,生成追加新行时才加载。恢复 source 和 builder 状态会保留已保存的 warm groups 和行为策略版本,并记录哪些后续输出不属于恢复分支。恢复后的 pending task 独立于过滤决策保留存储引用。包含尚未重启 consumer 的完整 source 快照,必须先保留并持久记录,才能释放旧引用;保存失败保留旧引用,后台 GC 在恢复后的 consumer 状态提交后才启动。 +```bash +--save-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' +# 在独立的只训练任务中加载,不启动 vLLM: +--load-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' +``` -Adapter 目前尚未建立模型、优化器、RNG 与队列状态的联合最终提交 manifest。因此,持久化 rollout 不代表任意崩溃后的 optimizer 精确恢复,也不承诺训练结果逐位一致。 +归档独立于队列消费和 GC 保留数据。使用 straw 传输保存时复用已有张量;否则在索引旁创建 `straw-data` 存储池。每个 rollout 一个不可变索引,样本按块打包;Evaluation 使用 `eval_`。单独复制 JSON 索引不会复制载荷;使用 straw 做只训练回放时,应选择独立的可写训练队列。`--load-debug-rollout-data-subsample` 也适用于归档。 + +```python +from vime.data.archive import RolloutArchive +from vime.observability.rollout_data_utils import load_debug_rollout_data + +with RolloutArchive('/shared/debug/rollout_7.straw.json') as archive: + print(archive.keys()) + samples = archive.load_samples(sample_key='sample:42') + group = archive.load_samples(task_key='prompt:21') + archive.export_pt('/shared/debug/rollout_7.pt') + +samples = load_debug_rollout_data('/shared/debug/rollout_7.pt', rollout_id=7) +RolloutArchive.save('/shared/debug/imported_7.straw.json', samples, rollout_id=7) +``` -dataset producer 游标尚未随 source checkpoint 保存,恢复 pending 快照也尚未排除所有后来新增的任务。因此,任意旧训练步的完整回退仍属于待实现能力。 +查询返回列表,因为 compact trajectory 可能共享 sample index。没有 index 的样本使用其归档位置;没有队列来源信息的样本没有 task key。关闭归档只会关闭 reader,数据继续保留。所有 reader 使用完后调用 `archive.release()` 释放归档保留;其他 checkpoint、队列和归档仍保留各自引用。导出的 `.pt` 在 straw 回收归档载荷后仍可读取。 ## 验证与当前限制 diff --git a/docs/zh/developer_guide/ci.md b/docs/zh/developer_guide/ci.md index 5fbd123fb..ed6a1ed28 100644 --- a/docs/zh/developer_guide/ci.md +++ b/docs/zh/developer_guide/ci.md @@ -33,6 +33,9 @@ block step。可以选择一个或多个套件: job。验证 Dockerfile 或 vLLM patch 修改时,通过 `VIME_CI_IMAGE` 指定不可变的 候选镜像 digest;未设置时使用 `vllm/vime:latest`,且 PR 合入前不得更新该标签。 +`megatron` 套件包含 `test_straw_checkpoint_fork.py`,覆盖 checkpoint +步骤选择、回退分支和带索引的 debug 归档。 + ## 注册测试 - 始终运行的 CPU 测试加入 `.buildkite/pipeline.yml` 中对应的命令。 diff --git a/docs/zh/get_started/customization.md b/docs/zh/get_started/customization.md index 4784739aa..1f7855baa 100644 --- a/docs/zh/get_started/customization.md +++ b/docs/zh/get_started/customization.md @@ -66,23 +66,26 @@ def generate_rollout(args, rollout_id, data_source, evaluation=False) -> Rollout **示例**: 参见 [examples/fully_async](../_examples_synced/fully_async/README.md) -显式设置 `--rollout-data-transport straw` 后(默认是 Ray `object-store`),`RolloutFnTrainOutput.samples` 可以是指向本批数据的 `DiskPayloadRef`。已有 custom function 返回 Sample 列表(直接返回或放在 `RolloutFnTrainOutput` 中)仍可使用,manager 会统一保存一次。新函数可以在 rollout 过程中保存已经生成、打分并筛选的 group,最后返回 manifest,避免在内存中积累完整 batch。例如,已有一个逐组产生选中数据的异步迭代器时: +选择 `--rollout-data-transport straw` 后,自定义 rollout 函数可以直接返回 Sample 列表,也可以放在 `RolloutFnTrainOutput` 中,由 manager 持久化。`RolloutFnTrainOutput.samples` 也接受 `DiskPayloadRef`。为了避免在内存中积累整个 batch,可以逐组保存已经生成、打分并筛选的结果。以下辅助函数接收 rollout 函数的 `data_source` 和逐组返回已完成数据的异步迭代器: ```python -import asyncio - +from vime.data.transport import publish_rollout_async from vime.rollout.base_types import finalize_rollout_groups -from vime.utils.rollout_transport import pack_rollout_group -async def generate_stored_batch(args, rollout_id, completed_groups): +async def generate_stored_batch(args, rollout_id, data_source, completed_groups): refs = [] async for group in completed_groups: - refs.append(await asyncio.to_thread(pack_rollout_group, group, args, rollout_id)) - return finalize_rollout_groups(args, rollout_id, refs) + ref = await publish_rollout_async( + group, args, rollout_id, group=True, controller=data_source.controller + ) + refs.append(ref) + return finalize_rollout_groups(args, rollout_id, refs, controller=data_source.controller) ``` -`finalize_rollout_groups` 负责排序、调用一次配置的 batch sample filter,并保存 batch manifest。如果 hook 修改 Sample,会重新保存处理结果。Wrapper 需要真实 Sample 时,可调用 `vime.utils.rollout_transport.load_rollout_samples(output.samples)`。生成、reward hook 和 eval 的返回协议不变。 +数据源的 controller 负责将带 lease 的 group 提交到所属队列。`finalize_rollout_groups` 排序后调用一次配置的 batch sample filter,并保存 batch manifest;hook 修改 Sample 时会重新保存结果。Wrapper 需要 Sample 对象时,可调用 `vime.data.transport.load_rollout_samples(output.samples)`。 + +straw 默认数据源为 `vime.data.queue_data_source.QueueDataSource`,通过 `get_samples(n)` 获取 prompt group,通过 `add_samples(groups)` 归还任务以供续跑。归还的 group 持久化后可由任意 reader 领取。自定义远端 worker 可以接收 `source.reader_config("worker_id")`,在自己的进程中调用 `config.open()`。Reader ID 必须唯一,`owner` 为保留名称;请求和写入结束后应关闭 reader。Reader 自动续租,关闭时归还未完成任务。 --- @@ -394,11 +397,11 @@ def log_eval_rollout_data(rollout_id, args, data, extra_metrics) -> bool ### `--data-source-path` -**默认值**: `vime.rollout.data_source.RolloutDataSourceWithBuffer` +**默认值**: `vime.data.data_source.RolloutDataSourceWithBuffer` **用途**: 覆盖 rollout 提示词的数据源。 -**基类**: `vime.rollout.data_source.DataSource` +**基类**: `vime.data.data_source.DataSource` **必需方法**: ```python diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 4cf168e8f..ba7d7dbdd 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -154,7 +154,7 @@ vLLM 的加载非常简单,只需要: ### 数据格式 -原始数据统一由 DataSource 管理。有 `--prompt-data` 时内置 DataSource 会加载数据;需要自行管理数据时可通过 `--data-source-path` 提供自定义实现。原来的 `--disable-rollout-global-dataset` 已移除,旧启动脚本需要删除该参数。 +原始数据统一由 DataSource 管理。有 `--prompt-data` 时内置 DataSource 会加载数据;需要自行管理数据时可通过 `--data-source-path` 提供自定义实现。 vime 支持加载 `.jsonl` 和 `.parquet` 格式文件;读取 Parquet 需要安装 `pyarrow`。两种格式中的每条记录都应包含 `--input-key` 和 `--label-key` 指定的字段。下面是一条 JSONL 数据展开后的示例: @@ -392,11 +392,9 @@ vime 支持不同程度的自定义数据生成(rollout)。 ### 持久化 rollout 队列和分布式 fully async -数据流、共享张量所有权和 checkpoint 边界详见 [straw 架构与恢复](../advanced/straw.md)。 +默认 rollout 传输为 Ray `object-store`,不需要共享目录。`--rollout-data-transport nixl` 选择 Ray 的 NIXL 张量传输。需要跨机持久化队列和打包张量存储时,使用 [straw](../advanced/straw.md) 和共享 JuiceFS 目录。 -Rollout payload 默认使用 Ray `object-store`,数据源为 `vime.rollout.data_source.RolloutDataSourceWithBuffer`;此模式不需要 straw 或共享 rollout 目录。`--rollout-data-transport nixl` 则选择 Ray 的 NIXL 张量传输。 - -安装 [straw](../advanced/straw.md) 后,通过 `--rollout-data-transport straw` 启用持久化队列和打包张量存储。straw 传输默认使用 `vime.rollout.queue_data_source.QueueDataSource`,数据与协调日志存放在 `--rollout-data-dir`;设置了 `--save` 时可省略目录,使用 `/rollout_data`,否则必须显式提供共享目录。Ray 仍负责 actor、RPC 和小引用传递。默认仍是同步 rollout;分布式 fully async 需要显式选择: +启用使用 straw 的分布式 fully async rollout: ```bash --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async \ @@ -408,7 +406,7 @@ straw 传输下,开启 `--use-rollout-routing-replay` 后会将已完成 sampl 作业内一个禁止自动重启的 Ray actor 管理任务 lease 和串行 dataset producer。Dataset 游标与任务提交在同一日志事务中保存,worker 通过小引用直接读取共享存储中的 prompt group,替代原来的内存索引分配器。Shuffle、group/sample 编号沿用原有数据源逻辑,故障不会丢弃 reader 预留的索引区间。 -保留 `get_samples(n)`、`add_samples(groups)` 接口。Custom producer 可以传递 `source.reader_config("unique_reader_id")`,在远端调用 `config.open()`。Reader ID 必须唯一,`owner` 为保留名称。归还的 partial group 持久化后可由任意 reader 领取,不再保留 reader 本地 sample buffer。Reader 定期续租,正常关闭归还未完成任务,故障 worker 的任务在 lease 到期后重试。恢复协调器前必须停止旧作业,再以相同 root/run ID 显式传入 `--rollout-queue-resume`,不自动抢占旧实例。 +保留 `get_samples(n)`、`add_samples(groups)` 接口。Custom producer 可以传递 `source.reader_config("unique_reader_id")`,在远端调用 `config.open()`。Reader ID 必须唯一,`owner` 为保留名称。归还的 partial group 持久化后可由任意 reader 领取,不再保留 reader 本地 sample buffer。Reader 定期续租,正常关闭归还未完成任务,故障 worker 的任务在 lease 到期后重试。恢复使用常规的 `--load`/`--save` checkpoint 流程,并可用 `--ckpt-step` 选择步骤;队列分支和 dataset 游标从所选 checkpoint 恢复,不再需要单独的 queue-resume 参数。 Fully async 在每个有 CPU 资源的 Ray 节点启动一个常驻生成进程,每个占用一个 CPU,并按完整 prompt group 分配总并发。快速 worker 可以独立补足全局 batch,保留原有有界生成队列和 collector 预取。完整 group 通过现有 dynamic filter 后才提交;丢弃结果计入过滤指标。分布式 fully async 仍不支持 `--rollout-all-samples-process-path`;同步入口保留其 Samples 和调用顺序。 @@ -418,7 +416,7 @@ Fully async 在每个有 CPU 资源的 Ray 节点启动一个常驻生成进程 Vime adapter 当前要求各节点使用同一个绝对挂载路径,并双向检查可见性;底层引用支持重新绑定 root。r3/sc 的临时文件依赖会先复制进队列,旧 spill 清理不会使已提交数据失效。默认保留队列文件;读取、batch-ready 都不删除数据。离线清理要求停止 coordinator、writer 和 reader。Debug dump 仍按需用 `torch.save` 写入,可能引用保留的队列张量;evaluation 路径保持原有行为。 -数据源 checkpoint 暂停注册 consumer、等待在途请求,将 buffer 和已完成 group 通过 `/rollout/queue_state_.json` 保存。buffer 快照引用已经持久化的 group,并单独保存当前 lease,避免在 checkpoint 或退出时再次编码所有缓存 token;旧版内嵌 group 的快照仍可读取。恢复检查数据集大小、每个 prompt 的样本数、seed、shuffle、run identity 和 fully async worker 拓扑。恢复早期 checkpoint 不回滚 journal 中的全局生产事实。Custom execution state 通过 `register_consumer(name, consumer)` 参与保存,实现 `pause`、`resume`、`state_dict`、`load_state_dict`、`close`。停用的 worker 槽位在恢复后仍停用。这是持久化队列与数据状态,不等于完整分布式 optimizer 精确恢复或训练结果逐位一致。 +Straw checkpoint 会将模型、optimizer/RNG、队列、builder 和 dataset 游标作为一个整体提交。默认 `--load` 恢复最近的有效联合 checkpoint;`--ckpt-step` 可选择当前分支历史中的步骤。恢复会从快照创建隔离分支,不改动源运行,也不会带入快照之后产生的样本。多次回退沿当前分支查找,不能越过分叉点。若模型 checkpoint 没有 straw 队列快照,则从空队列恢复,并在可用时恢复保存的 dataset 游标;联合快照损坏或不完整时直接报错,不会静默降级。这会持久化队列和数据状态,但不会恢复 GPU KV cache 或生成 RNG。 R3 训练只读取当前 CP/TP rank 分配到的行。续跑批量发布、加载和状态保存通过有界读取会话复用已认证索引,结果回执采用批量 RPC 查询。这些优化保留 checksum、WAL 持久化和 GC 所有权约束;读取会话不能替代所有权 pin。 diff --git a/tests/plugin_contracts/test_plugin_path_loading_contracts.py b/tests/plugin_contracts/test_plugin_path_loading_contracts.py index ea9096777..cfdefec27 100644 --- a/tests/plugin_contracts/test_plugin_path_loading_contracts.py +++ b/tests/plugin_contracts/test_plugin_path_loading_contracts.py @@ -32,8 +32,8 @@ NUM_GPUS = 0 +from vime.data.data_source import RolloutDataSourceWithBuffer from vime.rollout.base_types import RolloutFnEvalOutput, call_rollout_fn -from vime.rollout.data_source import RolloutDataSourceWithBuffer from vime.rollout.filter_hub.base_types import ( DynamicFilterOutput, call_dynamic_filter, @@ -222,7 +222,7 @@ def test_strict_dynamic_filter_still_drops_at_target_capacity(): def check_buffer_filter_default() -> None: - fn = load_function("vime.rollout.data_source.pop_first") + fn = load_function("vime.data.data_source.pop_first") assert tuple(inspect.signature(fn).parameters)[:4] == ("args", "rollout_id", "buffer", "num_samples") @@ -235,7 +235,7 @@ def check_buffer_filter_path(path: str) -> None: def check_data_source_default() -> None: - cls = load_function("vime.rollout.data_source.RolloutDataSourceWithBuffer") + cls = load_function("vime.data.data_source.RolloutDataSourceWithBuffer") assert tuple(inspect.signature(cls.__init__).parameters)[:2] == ("self", "args") @@ -292,7 +292,7 @@ def check_rollout_all_samples_process_path(path: str) -> None: SyncCase( "buffer_filter", "BUFFER_FILTER_PATH", - "vime.rollout.data_source.pop_first", + "vime.data.data_source.pop_first", check_buffer_filter_default, check_buffer_filter_path, ), diff --git a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py index 43b64db3a..8c0fcf3d2 100644 --- a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py +++ b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py @@ -151,7 +151,7 @@ def invoke_rollout_data_postprocess(fn): "custom_reward_post_process", "CUSTOM_REWARD_POST_PROCESS_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_reward_post_process", - "vime/rollout/batch_builder.py", + "vime/data/batch_builder.py", "self.custom_reward_post_process_func(self.args, samples)", ("args", "samples"), invoke_reward_post_process, @@ -160,7 +160,7 @@ def invoke_rollout_data_postprocess(fn): "custom_convert_samples_to_train_data", "CUSTOM_CONVERT_SAMPLES_TO_TRAIN_DATA_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_convert_samples_to_train_data", - "vime/rollout/batch_builder.py", + "vime/data/batch_builder.py", "self.custom_convert_samples_to_train_data_func(self.args, samples)", ("args", "samples"), invoke_convert_samples_to_train_data, diff --git a/tests/test_distributed_rollout.py b/tests/test_distributed_rollout.py index 0ebfe1bfb..fdfca2ab7 100644 --- a/tests/test_distributed_rollout.py +++ b/tests/test_distributed_rollout.py @@ -17,12 +17,13 @@ import pytest import torch +from vime.data.checkpoint import RestorePlan, SourceRestore +from vime.data.data_source import RolloutDataSource +from vime.data.queue_data_source import QueueDataSource, QueueReader, RolloutQueueController +from vime.data.tensor import TensorRef +from vime.data.transport import unpack_rollout_payload from vime.rollout.base_types import iter_samples -from vime.rollout.data_source import RolloutDataSource -from vime.rollout.queue_data_source import QueueDataSource, QueueReader, RolloutQueueController from vime.utils.data import Dataset, process_rollout_data -from vime.utils.rollout_transport import unpack_rollout_payload -from vime.utils.tensor_store import TensorRef from vime.utils.types import Sample NUM_GPUS = 0 @@ -88,6 +89,21 @@ def create(worker, take=None): controller.close() +def test_queue_readers_keep_connections_and_progress_out_of_args(source_factory): + from vime.data.transport import pack_rollout_group + + original = vars(source_factory.args).copy() + reader = source_factory("producer") + [group] = reader.get_samples(1) + receipt = pack_rollout_group(group, reader.args, 0, controller=reader.controller).receipt + assert receipt is not None + # Plugin code can copy/serialize configuration without inheriting another + # component's actor connection, active branch or current progress. + assert vars(reader.args) == original + assert vars(source_factory.args) == original + assert group[0]._queue_branch == source_factory.controller.branch_id + + def test_task_claims_and_producer_cursor_survive_recovery(source_factory): controller = source_factory.controller @@ -98,8 +114,8 @@ def test_task_claims_and_producer_cursor_survive_recovery(source_factory): state = controller.queue.producer_state("dataset") controller.close() args = copy.copy(source_factory.args) - args.rollout_queue_resume = True - restored = RolloutQueueController(args) + plan_args = RestorePlan(mode="resume") + restored = RolloutQueueController(args, restore_plan=plan_args) try: assert restored.queue.producer_state("dataset") == state assert restored.queue.outstanding_reads() == () @@ -117,11 +133,11 @@ def test_whole_job_recovery_replays_accepted_results_and_durable_continuations( ): from dataclasses import asdict - from vime.utils.rollout_transport import pack_rollout_group + from vime.data.transport import pack_rollout_group reader = source_factory("fully_async_0") accepted_group, partial_group = reader.get_samples(2) - accepted = pack_rollout_group(accepted_group, reader.args, 0) + accepted = pack_rollout_group(accepted_group, reader.args, 0, controller=reader.controller) partial_group[0].tokens = [1, 7, 9] partial_group[0].response_length = 2 partial_group[0].status = Sample.Status.ABORTED @@ -133,8 +149,8 @@ def test_whole_job_recovery_replays_accepted_results_and_durable_continuations( controller = source_factory.controller controller.close() args = copy.copy(reader.args) - args.rollout_queue_resume = True - restored = RolloutQueueController(args) + plan_args = RestorePlan(mode="resume") + restored = RolloutQueueController(args, restore_plan=plan_args) try: replay = restored.codec.load(restored.recover_pending_rollout()) assert replay == [asdict(accepted.receipt)] @@ -158,7 +174,7 @@ def test_whole_job_recovery_replays_accepted_results_and_durable_continuations( def test_dynamic_rejection_releases_spilled_tensor_staging(source_factory): from straw.tensor import publish_tensors - from vime.utils.rollout_transport import discard_rollout_group, rollout_store + from vime.data.transport import discard_rollout_group, rollout_store reader = source_factory("filter") group = reader.get_samples(1)[0] @@ -174,7 +190,7 @@ def test_dynamic_rejection_releases_spilled_tensor_staging(source_factory): ) group[0].rollout_routed_experts, group[0].rollout_topk_token_ids = refs source_factory.controller.store.seal() - discard_rollout_group(group, reader.args) + discard_rollout_group(group, reader.args, controller=reader.controller) assert source_factory.controller.queue.collect_garbage()["reclaimed_files"] >= 1 @@ -225,18 +241,18 @@ def test_group_reply_loss_returns_original_receipt_and_detects_changed_content( ): from straw.errors import IdempotencyConflict - from vime.utils.rollout_transport import pack_rollout_group + from vime.data.transport import pack_rollout_group reader = source_factory(0) group = reader.get_samples(1)[0] group[0].rollout_routed_experts = torch.arange(8).reshape(2, 2, 2) - first = pack_rollout_group(group, reader.args, 0) - retried = pack_rollout_group(group, reader.args, 0) + first = pack_rollout_group(group, reader.args, 0, controller=reader.controller) + retried = pack_rollout_group(group, reader.args, 0, controller=reader.controller) assert retried == first assert retried.manifest == retried.receipt.result_ref group[0].rollout_routed_experts[0, 0, 0] += 1 with pytest.raises(IdempotencyConflict): - pack_rollout_group(group, reader.args, 0) + pack_rollout_group(group, reader.args, 0, controller=reader.controller) assert source_factory.controller.queue.read_commits().cursor == 1 @@ -332,8 +348,8 @@ def test_queue_orders_stage_then_staleness_then_fifo(source_factory): "path", [ "custom.filter", - "vime.rollout.data_source.pop_first", - "vime.rollout.data_source.pop_oldest", + "vime.data.data_source.pop_first", + "vime.data.data_source.pop_oldest", ], ) def test_straw_rejects_all_buffer_filter_paths(source_factory, path): @@ -366,35 +382,37 @@ def lose_reply(updates, deliveries): def test_completed_return_uses_a_delivery_without_rewriting_accepted_history( source_factory, ): - from vime.utils.rollout_transport import pack_rollout_group + from vime.data.transport import pack_rollout_group reader = source_factory(0) group = reader.get_samples(1)[0] for sample in group: sample.status, sample.reward = Sample.Status.COMPLETED, 1 - result = pack_rollout_group(group, reader.args, 0) + result = pack_rollout_group(group, reader.args, 0, controller=reader.controller) reader.add_samples([group]) returned = reader.get_samples(1)[0] assert [sample.index for sample in returned] == [sample.index for sample in group] assert all(sample._queue_source_positions == [result.receipt.position] for sample in returned) assert all(not hasattr(sample, "_queue_receipt") for sample in returned) assert source_factory.controller.status(result.receipt.task_id)["state"] == "completed" - delivery = pack_rollout_group(returned, reader.args, 0) + delivery = pack_rollout_group(returned, reader.args, 0, controller=reader.controller) assert delivery.receipt.task_id != result.receipt.task_id assert source_factory.controller.queue.read_commits().cursor == 2 - source_factory.controller.args.rollout_queue_resume = True + source_factory.controller.restore_plan = RestorePlan(mode="resume") replay = source_factory.controller.codec.load(source_factory.controller.recover_pending_rollout()) assert [receipt["position"] for receipt in replay] == [delivery.receipt.position] # Discarding the delivery also releases the predecessor's accepted capacity. - from vime.utils.rollout_transport import discard_rollout_group, load_rollout_samples + from vime.data.transport import discard_rollout_group, load_rollout_samples - discard_rollout_group(load_rollout_samples([delivery])[0], reader.args, "test selection") + discard_rollout_group( + load_rollout_samples([delivery])[0], reader.args, "test selection", controller=reader.controller + ) assert source_factory.controller._training_state()["processed_cursor"] == 2 def test_older_pending_snapshot_restores_prefix_as_a_delivery(source_factory): - from vime.utils.rollout_transport import pack_rollout_group + from vime.data.transport import pack_rollout_group reader = source_factory("owner") group = reader.get_samples(1)[0] @@ -407,7 +425,7 @@ def test_older_pending_snapshot_restores_prefix_as_a_delivery(source_factory): for sample in group: sample.tokens.append(3) sample.status = Sample.Status.COMPLETED - accepted = pack_rollout_group(group, reader.args, 0) + accepted = pack_rollout_group(group, reader.args, 0, controller=reader.controller) reader.load_state_dict(state) restored = reader.get_samples(1)[0] assert all(sample.tokens == [1, 2] for sample in restored) @@ -417,14 +435,43 @@ def test_older_pending_snapshot_restores_prefix_as_a_delivery(source_factory): assert source_factory.controller.queue.read_commits().cursor == 1 +@pytest.mark.parametrize("legacy", [False, True]) +def test_generation_worker_restores_reader_metadata_without_rng(source_factory, legacy, monkeypatch): + import numpy as np + + from vime.data.transport import pack_rollout_payload + from vime.rollout.fully_async_distributed import _GenerationActor + + def unexpected_rng(*args, **kwargs): + raise AssertionError("Worker checkpointing must not capture or restore RNG") + + for module, names in ( + (random, ("getstate", "setstate")), + (np.random, ("get_state", "set_state")), + (torch, ("get_rng_state", "set_rng_state")), + ): + for name in names: + monkeypatch.setattr(module, name, unexpected_rng) + worker = _GenerationActor.__new__(_GenerationActor) + worker.data_source = source_factory("worker") + worker.data_source.update_metadata({"completed": 7}) + state = worker.state_dict() + assert unpack_rollout_payload(state) == {"version": 3, "metadata": {"completed": 7}} + if legacy: + state = pack_rollout_payload({"worker_state": 1, "reader": state, "rng": {}}, source_factory.args, 0) + worker.data_source.update_metadata({"completed": 10}) + worker.load_state_dict(state) + assert worker.data_source.get_metadata() == {"completed": 7} + + def test_legacy_rollout_return_completes_borrowed_inputs(source_factory): + from vime.data.transport import accept_raw_rollout, load_rollout_samples from vime.rollout.base_types import RolloutFnTrainOutput - from vime.utils.rollout_transport import accept_raw_rollout, load_rollout_samples reader = source_factory(0) groups = reader.get_samples(2) leases = [group[0]._queue_lease for group in groups] - ref = accept_raw_rollout(RolloutFnTrainOutput(samples=groups), reader.args, 0) + ref = accept_raw_rollout(RolloutFnTrainOutput(samples=groups), reader.args, 0, controller=reader.controller) reader.close() assert all(source_factory.controller.status(lease["task_id"])["state"] == "completed" for lease in leases) restored = load_rollout_samples(ref) @@ -513,7 +560,7 @@ def test_pending_checkpoint_reuses_inputs_without_reserving_leases(source_factor def test_legacy_reader_local_checkpoint_requires_explicit_migration(source_factory): - from vime.utils.rollout_transport import pack_rollout_payload + from vime.data.transport import pack_rollout_payload reader = source_factory("legacy-buffer") saved = pack_rollout_payload({"version": 1, "buffer": [], "metadata": {}}, reader.args, -1) @@ -878,10 +925,11 @@ async def republish_buffered(*args, **kwargs): partial_rollout=True, mask_offpolicy_in_partial_rollout=True, rollout_data_transport="object-store", - _rollout_queue_controller=object(), ) worker.dynamic_filter = None - worker.data_source = SimpleNamespace(get_samples_async=get_samples, materialize_samples=lambda value: value) + worker.data_source = SimpleNamespace( + get_samples_async=get_samples, materialize_samples=lambda value: value, controller=object() + ) result, _ = asyncio.run(worker._generate_group(3)) assert result is group assert sample.reward == 0.0 @@ -1054,7 +1102,7 @@ def _read_training_locally(refs, rank): def _load_queue_checkpoint(args, rollout_id): from straw.protocol import RecordSetRef - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.transport import DiskPayloadRef path = Path(args.save) / "rollout" / f"queue_state_{rollout_id}.json" index = json.loads(path.read_text()) @@ -1076,7 +1124,7 @@ def _rollout_args(tmp_path, *, fanout=False, transport="straw"): dataset.write_text("\n".join(json.dumps({"text": f"hello {i}"}) for i in range(7))) return SimpleNamespace( rollout_batch_size=4, - data_source_path="vime.rollout.queue_data_source.QueueDataSource", + data_source_path="vime.data.queue_data_source.QueueDataSource", debug_train_only=False, test_fanout=fanout, test_generation_gate=str(tmp_path / "generation-gate"), @@ -1156,10 +1204,10 @@ def _rollout_args(tmp_path, *, fanout=False, transport="straw"): @pytest.mark.parametrize( - "fanout,transport", - [(False, "straw"), (True, "straw")], + "fanout,transport,fork", + [(False, "straw", False), (True, "straw", False), (True, "straw", True)], ) -def test_two_ray_nodes_generate_transfer_and_restore(tmp_path, fanout, transport): +def test_two_ray_nodes_generate_transfer_and_restore(tmp_path, fanout, transport, fork): multiplier = 2 if fanout else 1 import ray from ray.cluster_utils import Cluster @@ -1198,8 +1246,8 @@ def test_two_ray_nodes_generate_transfer_and_restore(tmp_path, fanout, transport } }, ) + from vime.data.transport import check_rollout_storage from vime.utils.misc import load_function - from vime.utils.rollout_transport import check_rollout_storage if transport == "straw": check_rollout_storage(args) @@ -1220,12 +1268,14 @@ def test_two_ray_nodes_generate_transfer_and_restore(tmp_path, fanout, transport cls = RolloutManager.__ray_metadata__.modified_class manager = cls.__new__(cls) manager.args = args + manager.controller = source.controller + manager.weight_version = None from vime.utils.misc import load_function rollout_function = load_function(args.rollout_function_path) - from vime.rollout.batch_builder import BatchBuilder + from vime.data.batch_builder import BatchBuilder - manager.batch_builder = BatchBuilder(args) + manager.batch_builder = BatchBuilder(args, controller=source.controller) manager.set_train_parallel_config(config) manager.health_monitoring_resume = lambda: None manager._get_updatable_server = lambda: None @@ -1239,7 +1289,7 @@ def custom_rollout(global_args, rollout_id, data_source, evaluation=False): calls.append(("rollout", 4)) output = rollout_function(global_args, rollout_id, data_source, evaluation=evaluation) if args.rollout_data_transport == "straw": - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.transport import DiskPayloadRef assert isinstance(output.samples, DiskPayloadRef) return output @@ -1332,7 +1382,10 @@ def fetch(rollout_id): runtime = source.consumers["fully_async"] assert len(runtime.workers) == node_count manager.save(0) - state = _load_queue_checkpoint(args, 0)["consumers"]["fully_async"] + checkpoint = _load_queue_checkpoint(args, 0) + assert "rng" not in checkpoint + state = checkpoint["consumers"]["fully_async"] + assert all(set(unpack_rollout_payload(reader)) == {"version", "metadata"} for reader in state["readers"]) assert len(state["scheduler"]["ready"]) >= 4 assert all( sample.metadata["reward_calls"] == 1 @@ -1354,15 +1407,19 @@ def fetch(rollout_id): source.close() source = None args.load = args.save - args.rollout_queue_resume = True - source = QueueDataSource(args) + plan_args = RestorePlan(mode="snapshot" if fork else "resume") + if fork: + args.save = str(tmp_path / "fork-checkpoint") + source = QueueDataSource(args, restore_plan=plan_args) source.data_config["n_samples_per_prompt"] += 1 with pytest.raises(ValueError, match="n_samples_per_prompt"): source.load(0) source.data_config["n_samples_per_prompt"] -= 1 - source.load(0) + source_restore = source.load(0) + manager.controller = source.controller + manager.batch_builder.controller = source.controller if transport == "straw": - manager.batch_builder.load(0) + manager.batch_builder.load(0, source_restore=source_restore) assert not source.consumers # Execution state is restored when fully async starts. source.save(0) # Saving before the first generate must preserve the restored queue. actual = fetch(1) @@ -1421,8 +1478,10 @@ def fetch(rollout_id): if not fanout: # A restart with the same topology preserves retired worker slots. source.close() - source = QueueDataSource(args) + source = QueueDataSource(args, restore_plan=plan_args) source.load(2) + manager.controller = source.controller + manager.batch_builder.controller = source.controller assert len(fetch(3)) == 8 runtime = source.consumers["fully_async"] assert runtime.capacities[failed] == 0 @@ -1451,17 +1510,17 @@ def fetch(rollout_id): def test_filtered_accepts_and_lost_delivery_are_accounted(source_factory, monkeypatch): + from vime.data.transport import DiskPayloadRef, pack_rollout_group, pack_rollout_payload from vime.rollout.base_types import finalize_rollout_groups from vime.utils import misc - from vime.utils.rollout_transport import DiskPayloadRef, pack_rollout_group, pack_rollout_payload source = source_factory("worker") controller = source_factory.controller groups = source.get_samples(4) - refs = [pack_rollout_group(group, source.args, 0) for group in groups[:3]] + refs = [pack_rollout_group(group, source.args, 0, controller=source.controller) for group in groups[:3]] source.args.rollout_sample_filter_path = "test.filter" monkeypatch.setattr(misc, "load_function", lambda _: lambda args, groups: groups.pop(0)) - output = finalize_rollout_groups(source.args, 0, refs[:2]) + output = finalize_rollout_groups(source.args, 0, refs[:2], controller=source.controller) assert len(output.samples.load()) == 1 assert controller.training_state()["processed_cursor"] == 1 # One reply arrived; another was accepted but the process died before replying. @@ -1473,12 +1532,14 @@ def test_filtered_accepts_and_lost_delivery_are_accounted(source_factory, monkey from straw.errors import StaleAttempt with pytest.raises(StaleAttempt): - pack_rollout_group(groups[3], source.args, 0) + pack_rollout_group(groups[3], source.args, 0, controller=source.controller) assert len(controller.queue.read_commits().commits) == 3 - from vime.utils.rollout_transport import discard_rollout_group, load_rollout_samples + from vime.data.transport import discard_rollout_group, load_rollout_samples # A previously accepted warm group can fail a later dynamic filter too. - discard_rollout_group(load_rollout_samples([refs[2]])[0], source.args, "warm group rejected") + discard_rollout_group( + load_rollout_samples([refs[2]])[0], source.args, "warm group rejected", controller=source.controller + ) assert controller.queue._usage()["accepted_unprocessed"]["records"] == 1 assert len(controller.queue.read_commits().commits) == 3 @@ -1486,7 +1547,7 @@ def test_filtered_accepts_and_lost_delivery_are_accounted(source_factory, monkey def test_fresh_samples_inherit_authorization_and_reject_conflicting_hooks( source_factory, ): - from vime.utils.rollout_transport import inherit_queue_context, pack_rollout_group + from vime.data.transport import inherit_queue_context, pack_rollout_group source = source_factory("worker") group = source.get_samples(1)[0] @@ -1494,7 +1555,7 @@ def test_fresh_samples_inherit_authorization_and_reject_conflicting_hooks( for parent, output in zip(group, children, strict=True): parent.queue_generation_requests = [{"sampling_params": {"temperature": 0.7}}] inherit_queue_context(parent, output) - ref = pack_rollout_group(children, source.args, 0) + ref = pack_rollout_group(children, source.args, 0, controller=source.controller) assert ref.receipt is not None assert all( s.queue_generation_requests[0]["sampling_params"]["temperature"] == 0.7 for s in iter_samples(ref.load()) @@ -1529,7 +1590,7 @@ def _restore_source_fixture(controller, payload): def test_restore_source_handoff_releases_obsolete_snapshot_graphs(source_factory): from dataclasses import asdict - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.transport import DiskPayloadRef controller = source_factory.controller store, codec = controller.store, controller.codec @@ -1580,15 +1641,17 @@ def test_restore_source_handoff_preserves_lazy_consumers_and_failed_save( import straw.reporting - from vime.rollout.queue_data_source import QueueDataSource - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.queue_data_source import QueueDataSource + from vime.data.transport import DiskPayloadRef controller = source_factory.controller store, codec = controller.store, controller.codec source = source_factory("owner") source.__class__ = QueueDataSource + source.restore_plan = RestorePlan() + source.restored_source = SourceRestore() source._owns_controller = False - source.data_config = {"test": True} + source.data_config = controller.configuration() source.args.save = str(tmp_path / "checkpoints") source.consumers = {} prefix = codec.publish({"r3": torch.arange(4096)}, submission_id="lazy-prefix") @@ -1596,7 +1659,7 @@ def test_restore_source_handoff_preserves_lazy_consumers_and_failed_save( store.seal() source._restored_consumers = {"not_started": {"prefix": DiskPayloadRef(prefix, str(store.backend.root))}} original, _ = _restore_source_fixture(controller, source._restored_consumers) - source.args._queue_restored_source_ref = original + source.restored_source = SourceRestore(source_ref=original) before = controller.training_state() with monkeypatch.context() as patch: @@ -1641,8 +1704,8 @@ def fail(*args, **kwargs): def test_restore_source_handoff_preserves_prefix_before_later_accepted_result( source_factory, ): - from vime.rollout.queue_data_source import QueueDataSource - from vime.utils.rollout_transport import pack_rollout_group + from vime.data.queue_data_source import QueueDataSource + from vime.data.transport import pack_rollout_group controller = source_factory.controller reader = source_factory("old-reader") @@ -1660,14 +1723,16 @@ def test_restore_source_handoff_preserves_prefix_before_later_accepted_result( sample.tokens = [7, 8, 9, 10] sample.response_length = 3 sample.rollout_routed_experts = torch.arange(12).reshape(3, 2, 2) - accepted = pack_rollout_group(later, reader.args, 0) + accepted = pack_rollout_group(later, reader.args, 0, controller=reader.controller) restored = source_factory("owner") restored.__class__ = QueueDataSource + restored.restore_plan = RestorePlan() + restored.restored_source = SourceRestore() restored._owns_controller = False - restored.data_config = {"test": True} + restored.data_config = controller.configuration() restored.consumers, restored._restored_consumers = {}, {} restored.args.save = str(Path(reader.args.rollout_data_dir) / "saved") - restored.args._queue_restored_source_ref = original + restored.restored_source = SourceRestore(source_ref=original) restored.load_state_dict(prefix) pending = controller.queue.pending_tasks()[0] assert pending.metadata["source_positions"] == [accepted.receipt.position] @@ -1691,7 +1756,7 @@ def test_restore_source_handoff_preserves_prefix_before_later_accepted_result( def test_rebuffer_traffic_is_linear_and_continuations_survive_worker_loss( source_factory, ): - from vime.utils.rollout_transport import pack_rollout_payload + from vime.data.transport import pack_rollout_payload reader = source_factory("buffered") groups = reader.get_samples(64) @@ -1801,8 +1866,8 @@ def test_online_gc_waits_for_training_completion_and_checkpoint_release(source_f from straw.protocol import RecordSetRef from straw.tensor import publish_tensors - from vime.rollout.queue_codec import CODECS, SampleCodec - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.codec import CODECS, SampleCodec + from vime.data.transport import DiskPayloadRef controller = source_factory.controller assignment = controller.take("worker", 1).assignments[0] @@ -1844,9 +1909,8 @@ def test_joint_restore_starts_gc_only_after_consumer_state_is_durable(tmp_path, rollout_data_transport="straw", rollout_data_dir=str(tmp_path), rollout_queue_online_gc=True, - _joint_resume={"rollout_id": 0}, ) - controller = RolloutQueueController(args) + controller = RolloutQueueController(args, defer_gc=True) try: assert controller._gc_thread is None state = dict( @@ -2003,5 +2067,225 @@ async def unexpected(*args): asyncio.run(run()) +@pytest.mark.parametrize("source_factory", [True], indirect=True) +@pytest.mark.parametrize("interrupt_import", [False, True]) +def test_checkpoint_fork_restores_partial_ready_and_producer_without_parent_mutation( + source_factory, tmp_path, monkeypatch, interrupt_import +): + from straw.protocol import Lease, RecordSetRef + + from vime.data.transport import DiskPayloadRef, load_rollout_samples, pack_rollout_group + from vime.rollout.filter_hub.base_types import DynamicFilterOutput + + parent = source_factory.controller + source = source_factory("owner") + source.__class__ = QueueDataSource + source.restore_plan = RestorePlan() + source.restored_source = SourceRestore() + source._owns_controller = False + source.args.save = str(tmp_path / "parent-checkpoint") + source.consumers = {} + source.data_config = parent.configuration() + partial, ready = source.get_samples(2) + old_lease = Lease(**partial[0]._queue_lease) + for sample in partial: + sample.tokens = [1, 2, 3] + sample.response_length = 2 + sample.status = Sample.Status.ABORTED + sample.rollout_routed_experts = torch.arange(4).reshape(2, 1, 2) + source.add_samples([partial]) + for sample in ready: + sample.tokens = [4, 5] + sample.response_length = 1 + sample.reward = 1 + sample.status = Sample.Status.COMPLETED + ready_ref = pack_rollout_group(ready, source.args, 7, controller=source.controller) + source._restored_consumers = { + "fully_async": {"scheduler": {"ready": [(ready_ref, DynamicFilterOutput(keep=True))]}, "readers": []} + } + source.save(7) + index = json.loads((Path(source.args.save) / "rollout/queue_state_7.json").read_text()) + checkpoint = RecordSetRef.from_dict(index["manifest"]) + cursor = copy.deepcopy(parent.queue.producer_state("dataset")) + saved_pending = DiskPayloadRef(checkpoint, source.args.rollout_data_dir).load()["reader"].load()["pending"].load() + saved_partial = next(task for task in saved_pending["tasks"] if task["task_id"] == old_lease.task_id) + original_tensor = saved_partial["input_ref"].load()[0].rollout_routed_experts + # Advance the original branch beyond K, including a newer version of A. + [newer] = source.get_samples(1) + for sample in newer: + sample.tokens.append(99) + sample.response_length += 1 + source.add_samples([newer]) + # Refill on the parent after K; the child must not adopt this newer cursor. + source.get_samples(8) + assert ( + parent.queue.producer_state("dataset")["cursor"]["sample_group_index"] > cursor["cursor"]["sample_group_index"] + ) + parent_status = copy.deepcopy(parent.queue.tasks) + + args = copy.copy(source.args) + args.load, args.save = source.args.save, str(tmp_path / "fork-checkpoint") + plan_args = RestorePlan(mode="snapshot") + fork = RolloutQueueController(args, restore_plan=plan_args) + try: + if interrupt_import: + submit = fork.queue.submit_tasks + + def fail_after_pending(request_id, tasks, **kwargs): + result = submit(request_id, tasks, **kwargs) + if request_id.startswith("fork-pending:"): + raise RuntimeError("import interrupted after durable submission") + return result + + with monkeypatch.context() as patch: + patch.setattr(fork.queue, "submit_tasks", fail_after_pending) + with pytest.raises(RuntimeError, match="import interrupted"): + fork.fork_source(checkpoint) + fork.close() + fork = RolloutQueueController(args, restore_plan=plan_args) + restored = fork.fork_source(checkpoint) + assert fork.fork_source(checkpoint) == restored + assert fork.queue.producer_state("dataset") == cursor + [assignment] = fork.take("new-reader", 1).assignments + actual = fork.codec.load(assignment.task.input_ref) + assert actual[0].tokens == [1, 2, 3] + assert actual[0].rollout_routed_experts.record_ref == original_tensor.record_ref + assert assignment.lease.queue_id != old_lease.queue_id + assert fork.heartbeat([old_lease]) == ["StaleAttempt"] + consumers = fork.codec.load(restored) + group, verdict = consumers["fully_async"]["scheduler"]["ready"][0] + assert verdict.keep and group.load().manifest == ready_ref.manifest + assert group.receipt.task_id.startswith("prompt:fork-ready:") + assert group.receipt != ready_ref.receipt + samples = load_rollout_samples([group])[0] + assert all(sample._queue_source_positions == [] for sample in samples) + assert parent.queue.tasks == parent_status + assert not fork.queue.batches + fork.queue.collect_garbage() + parent.queue.collect_garbage() + assert actual[0].rollout_routed_experts.load().tolist() == [[[0, 1]], [[2, 3]]] + fork.release([assignment.lease]) + finally: + fork.close() + with pytest.raises(ValueError, match="already started"): + RolloutQueueController(args, restore_plan=plan_args) + # A stopped child can also resume its own WAL, without reopening the parent. + args.load = args.save + plan_args = RestorePlan(mode="resume") + resumed = RolloutQueueController(args, restore_plan=plan_args) + try: + assert resumed.queue.queue_id == assignment.lease.queue_id + assert resumed.queue.producer_state("dataset") == cursor + assert resumed.codec.load(resumed.take("resumed-reader", 1).assignments[0].task.input_ref)[0].tokens == [ + 1, + 2, + 3, + ] + # Exhaust the restored pending inputs so take() must read the dataset + # again. Checking only the WAL cursor could miss a producer that still + # uses its initial or the parent's newer in-memory offset. + pending = len(resumed.queue.pending_tasks(task_prefix="prompt:")) + if pending: + assert len(resumed.take("drain-saved", pending).assignments) == pending + [fresh] = resumed.take("fresh-after-fork", 1).assignments + samples = resumed.codec.load(fresh.task.input_ref) + saved_cursor = cursor["cursor"] + order = list(range(7)) + random.Random(31 + saved_cursor["epoch_id"]).shuffle(order) + assert [sample.prompt for sample in samples] == [f"prompt-{order[saved_cursor['sample_offset']]}"] * 2 + assert [sample.index for sample in samples] == [saved_cursor["sample_index"], saved_cursor["sample_index"] + 1] + assert all(sample.group_index == saved_cursor["sample_group_index"] for sample in samples) + assert parent.queue.tasks == parent_status + finally: + resumed.close() + + +@pytest.mark.parametrize("has_cursor", [False, True]) +def test_automatic_empty_restore_reads_the_saved_dataset_offset(source_factory, tmp_path, has_cursor): + from vime.data.checkpoint import resolve_checkpoint + + model = tmp_path / "old-model" + (model / "iter_0000007").mkdir(parents=True) + (model / "iter_0000007/weights.pt").write_bytes(b"model") + (model / "latest_checkpointed_iteration.txt").write_text("7") + cursor = dict(sample_offset=3, epoch_id=1, sample_group_index=10, sample_index=20, metadata={"seen": 9}) + if has_cursor: + (model / "rollout").mkdir() + torch.save(cursor, model / "rollout/global_dataset_state_dict_7.pt") + args = copy.copy(source_factory.args) + args.load = args.save = str(model) + args.ckpt_step = 7 + args.start_rollout_id = None + # An unrelated live queue in the same pool must not be reset or consumed. + source_factory("parent").get_samples(1) + parent_state = copy.deepcopy(source_factory.controller.queue.tasks) + plan_args = resolve_checkpoint(args) + controller = RolloutQueueController(args, restore_plan=plan_args) + try: + assert controller.queue.tasks == {} + assert controller.queue.queue_id != source_factory.controller.queue.queue_id + [task] = controller.take("new-reader", 1).assignments + samples = controller.codec.load(task.task.input_ref) + epoch, offset, index, group_index = (1, 3, 20, 10) if has_cursor else (0, 0, 0, 0) + order = list(range(7)) + random.Random(31 + epoch).shuffle(order) + assert [s.prompt for s in samples] == [f"prompt-{order[offset]}"] * 2 + assert [s.index for s in samples] == [index, index + 1] + assert all(s.group_index == group_index for s in samples) + assert controller._source().metadata == (cursor["metadata"] if has_cursor else {}) + assert source_factory.controller.queue.tasks == parent_state + finally: + controller.close() + + +@pytest.mark.parametrize("started", [False, True]) +def test_automatic_restart_before_first_checkpoint_recovers_the_same_queue( + source_factory, tmp_path, monkeypatch, started +): + from vime.data.checkpoint import resolve_checkpoint + + args = copy.copy(source_factory.args) + args.save, args.load = str(tmp_path / "initial-run"), None + args.start_rollout_id = None + plan_args = resolve_checkpoint(args) + if not started: + # Fail after branch publication, before the native queue exists. + from straw.coordinator import Coordinator + + def interrupted(*args, **kwargs): + raise OSError("interrupted queue initialization") + + with monkeypatch.context() as patch: + patch.setattr(Coordinator, "__init__", interrupted) + with pytest.raises(OSError, match="interrupted queue initialization"): + RolloutQueueController(args, restore_plan=plan_args) + else: + controller = RolloutQueueController(args, restore_plan=plan_args) + reader = QueueReader(args, _LocalHandle(controller), "first", 7) + [group] = reader.get_samples(1) + for sample in group: + sample.tokens = [1, 2, 3] + sample.response_length = 2 + sample.status = Sample.Status.ABORTED + reader.add_samples([group]) + cursor = copy.deepcopy(controller.queue.producer_state("dataset")) + reader.close() + controller.close() + resumed = copy.copy(source_factory.args) + resumed.load = resumed.save = args.save + resumed.start_rollout_id = None + plan_resumed = resolve_checkpoint(resumed) + assert plan_resumed.mode == "resume" and plan_resumed.queue_id == plan_args.queue_id + controller = RolloutQueueController(resumed, restore_plan=plan_resumed) + try: + [task] = controller.take("restarted", 1).assignments + samples = controller.codec.load(task.task.input_ref) + assert samples[0].tokens == ([1, 2, 3] if started else []) + if started: + assert controller.queue.producer_state("dataset") == cursor + finally: + controller.close() + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index e2acb684b..f50cd35f6 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -1,5 +1,7 @@ import argparse import importlib.util +import runpy +import shlex import sys import types from pathlib import Path @@ -9,6 +11,41 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("mode", ["save", "async_save", "load"]) +@pytest.mark.parametrize("optimizer", ["cpu", "gpu"]) +def test_checkpoint_e2e_launch_has_valid_save_configuration(monkeypatch, tmp_path, mode, optimizer): + from vime.utils import external_utils + + commands = [] + launcher = types.ModuleType("vime.utils.external_utils.command_utils") + launcher.execute_train = lambda **kwargs: commands.append(kwargs["train_args"]) + launcher.get_default_wandb_args = lambda _: "" + monkeypatch.setitem(sys.modules, launcher.__name__, launcher) + monkeypatch.setattr(external_utils, "command_utils", launcher, raising=False) + test = runpy.run_path(str(Path(__file__).with_name("test_qwen3_4B_ckpt.py"))) + directory = str(tmp_path / "checkpoint with spaces") + test["execute"](mode, optimizer=optimizer, checkpoint_dir=directory) + [command] = commands + + parser = argparse.ArgumentParser() + parser.add_argument("--save") + parser.add_argument("--save-interval", type=int) + parser.add_argument("--load") + parser.add_argument("--ckpt-step", type=int) + parser.add_argument("--async-save", action="store_true") + args, _ = parser.parse_known_args(shlex.split(command)) + + assert args.save == directory + # Megatron requires a positive save interval whenever --save is set, + # including when straw uses the directory to track a restored branch. + assert args.save_interval is not None and args.save_interval > 0 + assert args.async_save == (mode == "async_save") + if mode == "load": + assert args.load == directory and args.ckpt_step == 1 + else: + assert args.load is None + + def load_arguments_module(monkeypatch): megatron_mod = types.ModuleType("megatron") training_mod = types.ModuleType("megatron.training") @@ -280,7 +317,7 @@ def test_distributed_fully_async_is_opt_in(monkeypatch): assert defaults.rollout_data_transport == "object-store" assert defaults.rollout_data_dir is None assert not defaults.rollout_queue_online_gc - path = "vime.rollout.queue_data_source.QueueDataSource" + path = "vime.data.queue_data_source.QueueDataSource" enabled = parser.parse_args(["--rollout-batch-size", "1", "--data-source-path", path]) assert enabled.data_source_path == path @@ -297,10 +334,10 @@ def test_rollout_transport_selects_source_and_only_straw_needs_storage(monkeypat args.save = str(tmp_path) module.vime_validate_args(args) if transport == "straw": - assert args.data_source_path == "vime.rollout.queue_data_source.QueueDataSource" + assert args.data_source_path == "vime.data.queue_data_source.QueueDataSource" assert args.rollout_data_dir == str(tmp_path / "rollout_data") else: - assert args.data_source_path == "vime.rollout.data_source.RolloutDataSourceWithBuffer" + assert args.data_source_path == "vime.data.data_source.RolloutDataSourceWithBuffer" assert args.rollout_data_dir is None assert not list(tmp_path.iterdir()) @@ -309,8 +346,7 @@ def test_rollout_transport_selects_source_and_only_straw_needs_storage(monkeypat "overrides", [ {"rollout_queue_online_gc": True}, - {"rollout_queue_resume": True}, - {"data_source_path": "vime.rollout.queue_data_source.QueueDataSource"}, + {"data_source_path": "vime.data.queue_data_source.QueueDataSource"}, ], ) def test_queue_options_require_straw_transport(monkeypatch, overrides): @@ -330,6 +366,15 @@ def test_global_dataset_flag_is_removed(monkeypatch): module.vime_validate_args(make_vime_validate_args(num_epoch=2, num_rollout=None)) +def test_queue_resume_flag_is_not_exposed(monkeypatch): + module = load_vime_arguments_module(monkeypatch) + parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) + args = parser.parse_args(["--rollout-batch-size", "1"]) + assert not hasattr(args, "rollout_queue_resume") + with pytest.raises(SystemExit): + parser.parse_args(["--rollout-batch-size", "1", "--rollout-queue-resume"]) + + @pytest.mark.unit def test_vime_validate_args_preserves_explicit_start_rollout_id(monkeypatch): """``--start-rollout-id`` is only a fallback when the user did not set it. diff --git a/tests/test_optional_straw.py b/tests/test_optional_straw.py index 59f66d664..327dde8f6 100644 --- a/tests/test_optional_straw.py +++ b/tests/test_optional_straw.py @@ -24,7 +24,7 @@ def test_missing_straw_startup_hint(monkeypatch, caplog, transport): else: module.vime_validate_args(args) assert args.rollout_data_transport == transport - assert args.data_source_path == "vime.rollout.data_source.RolloutDataSourceWithBuffer" + assert args.data_source_path == "vime.data.data_source.RolloutDataSourceWithBuffer" assert args.rollout_data_dir is None assert f"continuing with {transport}" in caplog.text assert "pip install straw-queue" in caplog.text @@ -45,11 +45,12 @@ def find_spec(self, fullname, path=None, target=None): import ray import torch + from vime.data.batch_builder import BatchBuilder + from vime.data.checkpoint import save_checkpoint + from vime.data.transport import pack_rollout_group, pack_rollout_payload, rollout_store from vime.rollout import fully_async_rollout from vime.rollout.base_types import finalize_rollout_groups - from vime.rollout.batch_builder import BatchBuilder from vime.utils.data import process_rollout_data - from vime.utils.rollout_transport import pack_rollout_group, pack_rollout_payload, rollout_store from vime.utils.types import Sample args = SimpleNamespace( @@ -109,6 +110,17 @@ async def generate(*values): loss.backward() optimizer.step() assert torch.isfinite(loss) and weight.detach().abs().sum() > 0 + + # The shared save entrypoint also works without importing straw or + # requiring a weight version on the legacy training group. + calls = [] + actor = SimpleNamespace(save_model=lambda step, *, force_sync: calls.append((step, force_sync))) + manager = SimpleNamespace(save=SimpleNamespace(remote=lambda step: ray.put(step))) + save_args = SimpleNamespace( + rollout_data_transport="object-store", release_train=False, num_rollout=2, use_critic=False + ) + save_checkpoint(save_args, 0, actor, None, manager, actor_trains=True) + assert calls == [(0, False)] finally: ray.shutdown() diff --git a/tests/test_queue_sample_codec.py b/tests/test_queue_sample_codec.py index e6ca34db7..6ebf7289e 100644 --- a/tests/test_queue_sample_codec.py +++ b/tests/test_queue_sample_codec.py @@ -6,14 +6,14 @@ import numpy as np import pytest import torch - from straw import SharedFilesystemStore from straw.errors import CorruptData from straw.protocol import decode from straw.tensor import CHUNK_BYTES, publish_tensors -from vime.rollout.queue_codec import CODECS, QueueTensorRef, SampleCodec + +from vime.data.codec import CODECS, QueueTensorRef, SampleCodec +from vime.data.tensor import TensorRef from vime.utils.score_centering import validate_sampler_topk -from vime.utils.tensor_store import TensorRef from vime.utils.types import Sample NUM_GPUS = 0 @@ -235,7 +235,7 @@ def test_unknown_custom_fields_fail_without_silent_loss(codec): def test_batch_builder_preserves_group_rewards_masks_and_r3_sc_after_replay(codec, tmp_path): import copy - from vime.rollout.batch_builder import BatchBuilder + from vime.data.batch_builder import BatchBuilder args = SimpleNamespace( custom_reward_post_process_path=None, diff --git a/tests/test_qwen3_4B_ckpt.py b/tests/test_qwen3_4B_ckpt.py index 5cceb17d5..f2b11e867 100644 --- a/tests/test_qwen3_4B_ckpt.py +++ b/tests/test_qwen3_4B_ckpt.py @@ -55,16 +55,12 @@ def optimizer_args(optimizer: str): def execute(mode: str = "", optimizer: str = "cpu", checkpoint_dir: str = ""): ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/models/{MODEL_NAME}_torch_dist " checkpoint_dir_arg = quote(checkpoint_dir) - if mode == "save": - ckpt_args += f"--save {checkpoint_dir_arg} " - ckpt_args += "--save-interval 2 " - elif mode == "async_save": - ckpt_args += f"--save {checkpoint_dir_arg} " - ckpt_args += "--save-interval 2 " + if mode in ("save", "async_save", "load"): + ckpt_args += f"--save {checkpoint_dir_arg} --save-interval 2 " + if mode == "async_save": ckpt_args += "--async-save " elif mode == "load": - ckpt_args += f"--load {checkpoint_dir_arg} " - ckpt_args += "--ckpt-step 1 --rollout-queue-resume " + ckpt_args += f"--load {checkpoint_dir_arg} --ckpt-step 1 " rollout_args = ( "--rollout-data-transport straw " diff --git a/tests/test_r3_spill.py b/tests/test_r3_spill.py index bfec9bfd5..e4ce9b438 100644 --- a/tests/test_r3_spill.py +++ b/tests/test_r3_spill.py @@ -5,6 +5,7 @@ import pytest import torch +from vime.data.tensor import DiskTensorRef from vime.observability.rollout_data_utils import tensorize_rollout_data_for_training from vime.utils.routed_experts import ( RoutedExpertsMicrobatch, @@ -12,7 +13,6 @@ cleanup_routed_experts_rollout, spill_routed_experts, ) -from vime.utils.tensor_store import DiskTensorRef from vime.utils.types import Sample NUM_GPUS = 0 diff --git a/tests/test_rollout_buffer_order.py b/tests/test_rollout_buffer_order.py index ff2ca72fb..4d3cff0bd 100644 --- a/tests/test_rollout_buffer_order.py +++ b/tests/test_rollout_buffer_order.py @@ -2,7 +2,7 @@ import pytest -from vime.rollout.data_source import RolloutDataSource, RolloutDataSourceWithBuffer, pop_first, pop_oldest +from vime.data.data_source import RolloutDataSource, RolloutDataSourceWithBuffer, pop_first, pop_oldest from vime.utils.types import Sample NUM_GPUS = 0 @@ -41,13 +41,13 @@ def test_buffer_sort_is_opt_in_and_explicit_filter_takes_precedence(monkeypatch, assert source.get_samples(1) == [expected[0]] assert source.get_samples(1) == [expected[1]] - args.buffer_filter_path = "vime.rollout.data_source.pop_first" + args.buffer_filter_path = "vime.data.data_source.pop_first" source = RolloutDataSourceWithBuffer(args) assert source.buffer_filter is pop_first def test_data_source_reads_and_checkpoints_without_global_dataset_flag(monkeypatch, tmp_path): - import vime.rollout.data_source as module + import vime.data.data_source as module prompts = [Sample(prompt="first"), Sample(prompt="second")] monkeypatch.setattr(module, "load_tokenizer", lambda *args, **kwargs: None) diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py index aaac66722..38b0386d0 100644 --- a/tests/test_rollout_straw_transport.py +++ b/tests/test_rollout_straw_transport.py @@ -2,14 +2,16 @@ import pickle import subprocess import sys +from contextlib import closing from pathlib import Path from types import SimpleNamespace import pytest import torch - from straw.errors import StorageUnavailable -from vime.utils.rollout_transport import ( + +from vime.data.queue_data_source import RolloutQueueController +from vime.data.transport import ( DiskPayloadRef, RolloutGroupRef, load_rollout_samples, @@ -57,6 +59,56 @@ def args(tmp_path): ) +@pytest.mark.parametrize("queue_source", [False, True]) +def test_custom_source_constructor_keeps_args_only_contract(args, monkeypatch, queue_source): + from vime.data import queue_data_source, transport + from vime.data.checkpoint import RestorePlan + from vime.ray import rollout + + calls = [] + handle = SimpleNamespace(close=SimpleNamespace(remote=lambda: calls.append("controller_close"))) + + class CustomSource(queue_data_source.QueueReader if queue_source else object): + def __init__(self, config): + assert config is args + calls.append("source_init") + if queue_source: + self.controller = handle + + def close(self): + calls.append("source_close") + + def create_controller(config, *, restore_plan): + assert config is args and restore_plan is plan + calls.append("controller_init") + return handle + + plan = RestorePlan() + args.debug_train_only = True + args.data_source_path = "user.CustomSource" + args.rollout_function_path = args.eval_function_path = "user.rollout" + args.custom_reward_post_process_path = args.custom_convert_samples_to_train_data_path = None + original = vars(args).copy() + monkeypatch.setattr(rollout, "check_rollout_storage", lambda _: None) + monkeypatch.setattr(queue_data_source, "create_queue_controller", create_controller) + monkeypatch.setattr(rollout, "load_function", lambda path: CustomSource if path == args.data_source_path else None) + monkeypatch.setattr(rollout, "init_tracking", lambda *a, **kw: None) + monkeypatch.setattr(rollout.logging_utils, "finish_tracking", lambda _: None) + monkeypatch.setattr(rollout, "Lock", SimpleNamespace(options=lambda **kw: SimpleNamespace(remote=lambda: None))) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + monkeypatch.setattr(rollout.ray, "kill", lambda *a, **kw: None) + monkeypatch.setattr(transport, "seal_rollout_store", lambda _: None) + manager = rollout.RolloutManager.__ray_metadata__.modified_class(args, None, restore_plan=plan) + assert manager.controller is manager.batch_builder.controller is handle + assert vars(args) == original + manager.dispose() + assert calls == ( + ["source_init", "source_close"] + if queue_source + else ["source_init", "controller_init", "source_close", "controller_close"] + ) + + def test_directory_requires_explicit_shared_root_or_checkpoint(tmp_path): args = SimpleNamespace(rollout_data_dir=None, save=None) with pytest.raises(ValueError, match="--rollout-data-dir or --save"): @@ -107,7 +159,7 @@ def test_payload_reference_is_small_and_readable_by_independent_process(args, tm "-c", """ import pickle, sys -from vime.utils.tensor_store import TensorRef +from vime.data.tensor import TensorRef value = pickle.load(open(sys.argv[1], 'rb')).load() assert value['tensor'] is value['alias'] assert value['tensor'][-1].item() == 999999 @@ -177,8 +229,8 @@ def test_fully_async_stores_groups_while_collecting_the_batch(args, monkeypatch) args.dynamic_sampling_filter_path = None saved = [] - def pack(group, *a): - ref = pack_rollout_group(group, *a) + def pack(group, *a, **kwargs): + ref = pack_rollout_group(group, *a, **kwargs) saved.append(ref) return ref @@ -191,7 +243,7 @@ def take(limit): worker = SimpleNamespace(queue_size=lambda: 0, get_completed_groups=take) monkeypatch.setattr(fa, "_get_global_worker", lambda *a: worker) - from vime.utils import rollout_transport + from vime.data import transport as rollout_transport monkeypatch.setattr(rollout_transport, "pack_rollout_group", pack) output = asyncio.run(fa._generate_rollout_async(args, 0, None)) @@ -229,8 +281,8 @@ def submit(groups): state.remaining_batch_size += len(groups) state.pendings.update(asyncio.create_task(generate(index)) for index in (2, 1)) - def pack(group, *a): - ref = pack_rollout_group(group, *a) + def pack(group, *a, **kwargs): + ref = pack_rollout_group(group, *a, **kwargs) saved.append(ref) loop.call_soon_threadsafe(second.set) return ref @@ -244,7 +296,7 @@ def hook(received_args, samples, source): state.submit_generate_tasks = submit monkeypatch.setattr(sr, "GenerateState", lambda args: state) - from vime.utils import rollout_transport + from vime.data import transport as rollout_transport monkeypatch.setattr(rollout_transport, "pack_rollout_group", pack) monkeypatch.setattr(sr, "abort", abort) @@ -263,10 +315,10 @@ def hook(received_args, samples, source): @pytest.mark.parametrize("form", ["raw", "wrapped", "stored", "accepted"]) def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeypatch, form): + from vime.data.queue_data_source import RolloutQueueController + from vime.data.transport import RawRolloutRef, accept_raw_rollout from vime.ray import rollout from vime.rollout.base_types import RolloutFnTrainOutput, finalize_rollout_groups - from vime.rollout.queue_data_source import RolloutQueueController - from vime.utils.rollout_transport import RawRolloutRef, accept_raw_rollout groups = [[Sample(index=1, tokens=[1, 2], response_length=1, reward=1)]] result = ( @@ -275,7 +327,7 @@ def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeyp else RolloutFnTrainOutput(samples=groups, metrics={"custom": 1}) ) controller = RolloutQueueController(args) - args._rollout_queue_controller = SimpleNamespace( + handle = SimpleNamespace( **{ name: SimpleNamespace(remote=getattr(controller, name)) for name in ("begin_collection", "complete", "accepted") @@ -284,11 +336,12 @@ def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeyp monkeypatch.setattr(rollout.ray, "get", lambda value: value) manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) manager.args = args + manager.controller = handle args.load_debug_rollout_data = None manager.data_source = object() manager.batch_builder = SimpleNamespace() if form == "accepted": - result = accept_raw_rollout(result, args, 0) + result = accept_raw_rollout(result, args, 0, controller=handle) manager.generate_rollout = lambda *a, **kw: groups[0] if form == "raw" else result previous_files = {path: path.read_bytes() for path in Path(args.rollout_data_dir).rglob("*.pack")} try: @@ -308,7 +361,7 @@ def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeyp @pytest.mark.parametrize("transport", ["object-store", "nixl", "straw"]) def test_train_partitions_preserve_top_p_and_multimodal(args, monkeypatch, transport): - from vime.rollout import batch_builder as rollout + from vime.data import batch_builder as rollout from vime.utils.data import process_rollout_data args.rollout_data_transport = transport @@ -362,19 +415,19 @@ def put(value, **kwargs): def test_durable_batch_replays_one_plan_and_rejects_mixed_ranks(args, monkeypatch): from dataclasses import replace - from vime.rollout import batch_builder as module + from vime.data import batch_builder as module + from vime.data.queue_data_source import RolloutQueueController + from vime.data.transport import TrainBatchRef, accept_raw_rollout from vime.rollout.base_types import RolloutFnTrainOutput - from vime.rollout.queue_data_source import RolloutQueueController from vime.utils.data import process_rollout_data from vime.utils.misc import Box - from vime.utils.rollout_transport import TrainBatchRef, accept_raw_rollout args.custom_reward_post_process_path = None args.custom_convert_samples_to_train_data_path = None args.global_batch_size = 2 samples = [Sample(index=i, rollout_id=i, tokens=[1, 2], response_length=1) for i in range(2)] controller = RolloutQueueController(args) - args._rollout_queue_controller = SimpleNamespace( + handle = SimpleNamespace( **{ name: SimpleNamespace(remote=getattr(controller, name)) for name in ( @@ -394,12 +447,16 @@ def test_durable_batch_replays_one_plan_and_rejects_mixed_ranks(args, monkeypatc monkeypatch.setattr(module.ray, "put", lambda value: value) monkeypatch.setattr(module, "build_dp_schedule", lambda *a, **kw: ([[0], [1]], [[[0]], [[0]]], [1], [2])) try: - builder = module.BatchBuilder(args) + builder = module.BatchBuilder(args, controller=handle) builder.train_parallel_config = {"dp_size": 2} - builder.raw_ref = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 0) + builder.raw_ref = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 0, controller=handle) assert builder.begin(samples) is None plan = controller.batch(builder.batch_id) assert not plan["ready"] + assert "rng" not in builder._plan.load() + # A crash before conversion commits reuses the selection without RNG state. + assert builder.begin(samples) is None + assert controller.batch(builder.batch_id) == plan refs = builder.split_by_dp({"tokens": [[1, 2], [1, 2]], "rollout_ids": [0, 1], "rewards": [0.0, 1.0]}) assert controller.batch(builder.batch_id)["ready"] assert all(isinstance(box.inner, TrainBatchRef) for box in refs) @@ -419,7 +476,7 @@ def test_durable_batch_replays_one_plan_and_rejects_mixed_ranks(args, monkeypatc assert controller.queue._usage()["ready_bytes"] == 0 assert not controller.queue.checkpoints # runtime completion is not a checkpoint # Later production facts survive restoring the earlier training view. - later = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 1) + later = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 1, controller=handle) assert later.receipt.position == 1 args.load = args.save builder.load(0) @@ -441,7 +498,6 @@ def test_packed_tensors_handle_scalar_empty_and_bfloat16(args): def test_debug_dump_survives_removal_of_queue_storage(args, tmp_path): - from pathlib import Path from straw.tensor import TensorRef from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data @@ -480,7 +536,7 @@ def test_debug_dump_survives_removal_of_queue_storage(args, tmp_path): def test_shared_storage_probe_reports_missing_mount(args, monkeypatch): - from vime.utils import rollout_transport + from vime.data import transport as rollout_transport monkeypatch.setattr(rollout_transport.ray, "nodes", lambda: []) @@ -493,7 +549,7 @@ def fail(*a, **kw): def test_failed_flush_does_not_publish_a_reference(args, tmp_path, monkeypatch): - from vime.utils import rollout_transport + from vime.data import transport as rollout_transport store, _, _ = rollout_transport.rollout_store(args) @@ -511,5 +567,664 @@ def fail(phase): assert restored["tensor"].tolist() == list(range(16)) +def test_straw_debug_archive_keys_lazy_tensors_gc_and_legacy_export(args, tmp_path): + from vime.data.archive import RolloutArchive + from vime.data.tensor import TensorRef + from vime.data.transport import rollout_store + from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data + + args.rollout_queue_online_gc = True + tensor = torch.arange(8, dtype=torch.int32).reshape(2, 2, 2) + sample = Sample(index=42, tokens=[1, 2, 3], response_length=2, reward=1.0, rollout_routed_experts=tensor) + sample._queue_lease = {"task_id": "prompt:21", "queue_id": "parent"} + sample._queue_source_positions = [999] + original = pack_rollout_payload([sample], args, 0) + [sample] = original.load() + template = str(tmp_path / "archive_{rollout_id}.straw.json") + save_debug_rollout_data(template, [sample], rollout_id=7, evaluation=False, args=args) + store, _, _ = rollout_store(args) + store.release_publications([original.manifest]) + store.seal() + store.collect_garbage() + with RolloutArchive(template.format(rollout_id=7)) as archive: + assert archive.keys() == [("sample:42", "prompt:21")] + [restored] = archive.load_samples(sample_key="sample:42", task_key="prompt:21") + assert not hasattr(restored, "_queue_lease") and not hasattr(restored, "_queue_source_positions") + assert isinstance(restored.rollout_routed_experts, TensorRef) + assert restored.rollout_routed_experts.record_ref == sample.rollout_routed_experts.record_ref + assert torch.equal(restored.rollout_routed_experts.load(), tensor) + with pytest.raises(KeyError): + archive.load_samples(sample_key="sample:404") + legacy = tmp_path / "export.pt" + archive.export_pt(legacy) + [loaded] = load_debug_rollout_data(template, rollout_id=7) + assert loaded.tokens == restored.tokens + archive.release() + store.collect_garbage() + [exported] = load_debug_rollout_data(str(legacy), rollout_id=7) + assert torch.equal(exported.rollout_routed_experts, tensor) + with pytest.raises(FileExistsError): + save_debug_rollout_data(template, [sample], rollout_id=7, evaluation=False, args=args) + + +def test_archive_index_reads_only_selected_chunk_and_preserves_duplicate_keys(args, tmp_path, monkeypatch): + from vime.data.archive import RolloutArchive + + samples = [Sample(index=i, tokens=[i]) for i in range(130)] + samples[129].index = 128 # Compact trajectories can reuse a logical sample ID. + path = tmp_path / "indexed.straw.json" + RolloutArchive.save(path, samples, rollout_id=0, args=args) + with RolloutArchive(path) as archive: + read = [] + load = archive.codec.load + + def tracked(ref, **kwargs): + if not kwargs: + read.append(ref) + return load(ref, **kwargs) + + monkeypatch.setattr(archive.codec, "load", tracked) + assert [s.tokens for s in archive.load_samples(sample_key="sample:128")] == [[128], [129]] + assert len(read) == 1 + + +def test_straw_debug_archive_uses_existing_train_only_conversion(args, tmp_path): + from vime.data.batch_builder import BatchBuilder + from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data + + args.custom_reward_post_process_path = args.custom_convert_samples_to_train_data_path = None + args.reward_key = None + args.advantage_estimator = "grpo" + args.rewards_normalization = False + args.use_score_centering = args.use_rollout_routing_replay = False + sample = Sample(index=4, tokens=[1, 2, 3], response_length=2, reward=0.5, loss_mask=[1, 0]) + paths = [str(tmp_path / "rollout_{rollout_id}.pt"), str(tmp_path / "rollout_{rollout_id}.straw.json")] + batches = [] + for path in paths: + save_debug_rollout_data(path, [sample], rollout_id=7, evaluation=False, args=args) + builder = BatchBuilder(args) + loaded = load_debug_rollout_data(path, rollout_id=7) + assert builder.begin(loaded) is None + batches.append(builder.convert(loaded)) + assert batches[0] == batches[1] + # A real CPU optimizer step on either loader's output must match. + weights = [] + for batch in batches: + model = torch.nn.Embedding(4, 1) + torch.nn.init.ones_(model.weight) + optimizer = torch.optim.SGD(model.parameters(), lr=0.1) + loss = model(torch.tensor(batch["tokens"][0])).sum() * batch["rewards"][0] + loss.backward() + optimizer.step() + weights.append(model.weight.detach().clone()) + torch.testing.assert_close(*weights) + + +@pytest.mark.parametrize("step", [0, 7]) +def test_fork_requires_committed_model_and_source_and_restores_weight_version(tmp_path, step): + import json + + from vime.data.checkpoint import commit_checkpoint, resolve_checkpoint + + parent = tmp_path / "parent" + model = parent / f"iter_{step:07d}" + model.mkdir(parents=True) + (model / "weights.pt").write_bytes(b"model-and-optimizer") + (parent / "latest_checkpointed_iteration.txt").write_text(str(step)) + rollout = parent / "rollout" + rollout.mkdir() + for name in ("queue_state", "builder_state"): + (rollout / f"{name}_{step}.json").write_text(json.dumps({"test": name})) + args = SimpleNamespace(save=str(parent), rollout_data_dir=str(tmp_path / "pool"), rollout_queue_run_id="run") + commit_checkpoint(args, step, model_args=[args]) + restore = SimpleNamespace( + rollout_data_transport="straw", + load=str(parent), + save=str(tmp_path / "child"), + rollout_data_dir=None, + rollout_queue_run_id="run", + ckpt_step=step, + start_rollout_id=None, + ) + resolve_checkpoint(restore) + assert restore.ckpt_step == step and restore.start_rollout_id == step + 1 + assert restore.update_weight_start_version == step + 1 + assert restore.rollout_data_dir == args.rollout_data_dir + assert not (tmp_path / "child").exists() # Selection does not mutate the parent or create a job. + (rollout / f"queue_state_{step}.json").write_text("changed") + with pytest.raises(ValueError, match="changed"): + resolve_checkpoint(restore) + + +@pytest.mark.parametrize("fail_at", [None, "actor", "critic", "rollout"]) +@pytest.mark.parametrize("start", [0, 8]) +def test_training_commits_only_after_save_calls_return(tmp_path, monkeypatch, fail_at, start): + import importlib.util + import json + from unittest.mock import Mock + + # Exercise the real training loop with CPU stand-ins for the GPU services. + # Argument parsing is not used here and must not pull in SGLang on CPU CI. + monkeypatch.setitem(sys.modules, "vime.utils.arguments", SimpleNamespace(parse_args=None)) + spec = importlib.util.spec_from_file_location("checkpoint_train", Path(__file__).parents[1] / "train.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + args = SimpleNamespace( + save=str(tmp_path), + rollout_data_dir=str(tmp_path / "pool"), + rollout_data_transport="straw", + release_train=False, + offload_rollout=False, + offload_train=False, + check_weight_update_equal=False, + num_rollout=start + 3, + start_rollout_id=start, + eval_interval=None, + save_interval=1, + use_critic=True, + num_critic_only_steps=0, + debug_train_only=False, + debug_rollout_only=False, + ) + saved = [] + + def save_model(role, directory, step, *, force_sync): + assert force_sync + saved.append(role) + if fail_at == role: + raise OSError(f"{role} save failed") + model = directory / f"iter_{step:07d}" + model.mkdir(parents=True) + (model / "weights.pt").write_bytes(role.encode()) + + actor, critic = Mock(), Mock() + actor.update_weights.return_value = None + actor.args = SimpleNamespace(save=str(tmp_path)) + critic.args = SimpleNamespace(save=str(tmp_path / "critic")) + actor.save_model.side_effect = lambda step, **kw: save_model("actor", tmp_path, step, **kw) + critic.save_model.side_effect = lambda step, **kw: save_model("critic", tmp_path / "critic", step, **kw) + + def save_rollout(step): + saved.append("rollout") + if fail_at == "rollout": + raise OSError("rollout save failed") + rollout = tmp_path / "rollout" + rollout.mkdir(exist_ok=True) + for name in ("queue_state", "builder_state"): + (rollout / f"{name}_{step}.json").write_text("{}") + + manager = Mock() + manager.save.remote.side_effect = save_rollout + monkeypatch.setattr(module.ray, "get", lambda value: value) + monkeypatch.setattr(module, "configure_logger", lambda: None) + monkeypatch.setattr(module, "init_tracking", lambda args: None) + monkeypatch.setattr(module, "finish_tracking", lambda args: None) + monkeypatch.setattr(module, "create_placement_groups", lambda args: {"rollout": None}) + monkeypatch.setattr(module, "create_rollout_manager", lambda *a, **kw: (manager, None)) + monkeypatch.setattr(module, "create_training_models", lambda *a: (actor, critic)) + marker = tmp_path / "rollout" / f"committed_{start}.json" + if fail_at: + with pytest.raises(OSError, match=f"{fail_at} save failed"): + module.train(args) + assert saved == ["actor", "critic", "rollout"][: ["actor", "critic", "rollout"].index(fail_at) + 1] + assert not marker.exists() + else: + module.train(args) + assert saved == ["actor", "critic", "rollout"] * 3 + assert actor.update_weights.call_count == 4 # Initial sync, then once per rollout. + for step in range(start, start + 3): + checkpoint = json.loads((tmp_path / "rollout" / f"committed_{step}.json").read_text()) + assert checkpoint["weight_version"] == step + 1 + assert set(checkpoint["files"]) == { + f"iter_{step:07d}/weights.pt", + f"critic/iter_{step:07d}/weights.pt", + f"rollout/queue_state_{step}.json", + f"rollout/builder_state_{step}.json", + } + + +@pytest.mark.parametrize("mode", ["nccl", "disk", "release"]) +def test_disk_weight_updates_do_not_require_actor_return_values(tmp_path, monkeypatch, mode): + from unittest.mock import Mock + + from vime.ray.actor_group import RayTrainGroup + + args = SimpleNamespace( + update_weight_start_version=12, + update_weight_mode="full", + update_weight_transport="nccl" if mode == "nccl" else "disk", + update_weight_disk_dir=str(tmp_path / "weights"), + release_train=mode == "release", + save=str(tmp_path / "model"), + no_save_optim=False, + ) + group = RayTrainGroup(args, 1, 1, pg=None) + worker = Mock() + worker.update_weights.remote.return_value = None + worker.save_model.remote.return_value = None + worker.init.remote.return_value = 8 + group._actor_handlers = [worker] + monkeypatch.setattr("vime.ray.actor_group.ray.get", lambda value: value) + reloaded = [] + monkeypatch.setattr( + group, "_reload_rollout_weights_from_disk", lambda path, version: reloaded.append((path, version)) + ) + monkeypatch.setattr(group, "release", lambda: group._actor_handlers.clear()) + monkeypatch.setattr(group, "_allocate_gpus_for_actor", lambda *a: group._actor_handlers.append(worker)) + + for version in (13, 14, 15, 16): + assert group.update_weights() is None + if mode == "release" and not group._actor_handlers: + assert group.create() == [8] + assert worker.init.remote.call_args.args[0].update_weight_start_version == version + assert group.save_model(7, force_sync=True) is None + expected = ( + [] + if mode == "nccl" + else [(tmp_path / "weights" / f"weight_v{version:06d}", str(version)) for version in (13, 14, 15, 16)] + ) + assert reloaded == expected + + +@pytest.mark.parametrize("mode", ["straw", "object-store", "critic-only", "debug-train", "debug-rollout"]) +def test_checkpoint_helper_preserves_save_modes(tmp_path, monkeypatch, mode): + from unittest.mock import Mock + + from vime.data.checkpoint import save_checkpoint + + args = SimpleNamespace( + rollout_data_transport="object-store" if mode == "object-store" else "straw", + release_train=False, + num_rollout=10, + use_critic=mode == "critic-only", + debug_train_only=mode == "debug-train", + debug_rollout_only=mode == "debug-rollout", + save=str(tmp_path), + ) + actor, critic, manager = Mock(), Mock(), Mock() + monkeypatch.setattr("ray.get", lambda value: value) + # A pre-existing joint marker must not affect non-joint save modes. + (tmp_path / "rollout").mkdir() + (tmp_path / "rollout/committed_0.json").write_text("old") + if mode == "straw": + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + save_checkpoint(args, 0, actor, critic, manager, actor_trains=True) + actor.save_model.assert_not_called() + critic.save_model.assert_not_called() + manager.save.remote.assert_not_called() + return + save_checkpoint(args, 0, actor, critic, manager, actor_trains=mode != "critic-only") + if mode == "critic-only": + actor.save_model.assert_not_called() + critic.save_model.assert_called_once_with(0, force_sync=False) + else: + actor.save_model.assert_called_once_with(0, force_sync=False) + critic.save_model.assert_not_called() + manager.save.remote.assert_called_once_with(0) + assert (tmp_path / "rollout/committed_0.json").read_text() == "old" + + +@pytest.mark.parametrize("omitted", ["no_save_optim", "no_save_rng"]) +def test_checkpoint_retains_critic_save_policy(tmp_path, omitted): + import json + + from vime.data.checkpoint import commit_checkpoint, resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + _checkpoint_fixture(root, 0, pool, queue=False) + _checkpoint_fixture(root / "critic", 0, pool, queue=False) + for name in ("queue_state", "builder_state"): + (root / "rollout" / f"{name}_0.json").write_text("{}") + args = SimpleNamespace(save=str(root), rollout_data_dir=str(pool)) + critic = SimpleNamespace(save=str(root / "critic"), **{omitted: True}) + commit_checkpoint(args, 0, model_args=[args, critic]) + assert not json.loads((root / "rollout/committed_0.json").read_text())["resumable"] + with pytest.raises(ValueError, match="omitted optimizer/RNG"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "child", step=0)) + + +def _checkpoint_fixture(root, step, pool, *, queue=True, restore_plan=None): + """Write real joint-commit metadata around tiny model payload fixtures.""" + import json + + from vime.data.checkpoint import commit_checkpoint + + model = root / f"iter_{step:07d}" + model.mkdir(parents=True) + (model / "weights.pt").write_bytes(f"model-{root.name}-{step}".encode()) + (root / "latest_checkpointed_iteration.txt").write_text(str(step)) + rollout = root / "rollout" + rollout.mkdir(exist_ok=True) + if queue: + for name in ("queue_state", "builder_state"): + (rollout / f"{name}_{step}.json").write_text(json.dumps({"test": name})) + args = SimpleNamespace(save=str(root), rollout_data_dir=str(pool), rollout_queue_run_id="rollout") + commit_checkpoint(args, step, model_args=[args], restore_plan=restore_plan) + + +def _checkpoint_args(load, save, *, step=None): + return SimpleNamespace( + rollout_data_transport="straw", + load=str(load) if load is not None else None, + save=str(save), + ckpt_step=step, + start_rollout_id=None, + rollout_data_dir=None, + ) + + +def test_automatic_checkpoint_branches_repeat_save_and_resume_current(tmp_path): + import json + + from vime.data.checkpoint import resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + _checkpoint_fixture(root, 1, pool) + _checkpoint_fixture(root, 2, pool) + old_weight = (root / "iter_0000002/weights.pt").read_bytes() + branches = [] + for _ in range(2): + args = _checkpoint_args(root, root, step=1) + plan_args = resolve_checkpoint(args) + assert args.ckpt_step == 1 and args.start_rollout_id == 2 + assert (plan_args.mode == "snapshot") and not (plan_args.mode == "resume") + destination = Path(args.save) + assert destination.parent == root / "branches" and destination not in branches + with closing(RolloutQueueController(args, restore_plan=plan_args)): + assert json.loads((root / "rollout/current.json").read_text())["directory"] == str(destination) + _checkpoint_fixture(destination, 2, pool) + branches.append(destination) + assert (root / "iter_0000002/weights.pt").read_bytes() == old_weight + # A newer but unfinished model write must not beat the last joint commit. + (branches[-1] / "iter_0000003").mkdir() + (branches[-1] / "latest_checkpointed_iteration.txt").write_text("3") + resumed = _checkpoint_args(root, root) + resolve_checkpoint(resumed) + assert resumed.load == str(branches[-1]) and resumed.ckpt_step == 2 + assert resumed.save not in map(str, branches) + assert resumed.rollout_data_dir == str(pool) + + +def test_manual_branch_and_commit_selection_exclude_abandoned_future(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + _checkpoint_fixture(root, 1, pool) + _checkpoint_fixture(root, 3, pool) + fork = _checkpoint_args(root, root, step=1) + plan_fork = resolve_checkpoint(fork) + with closing(RolloutQueueController(fork, restore_plan=plan_fork)): + _checkpoint_fixture(Path(fork.save), 2, pool) + with pytest.raises(ValueError, match="No committed"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "other", step=3)) + exact = _checkpoint_args(root / "rollout/committed_3.json", tmp_path / "manual") + resolve_checkpoint(exact) + assert exact.load == str(root) and exact.ckpt_step == 3 + branch = _checkpoint_args(fork.save, tmp_path / "branch", step=2) + resolve_checkpoint(branch) + assert branch.load == fork.save and branch.ckpt_step == 2 + with pytest.raises(ValueError, match="differs"): + resolve_checkpoint(_checkpoint_args(root / "rollout/committed_3.json", tmp_path / "bad", step=1)) + + +def test_edited_tracker_rolls_back_and_explicit_step_takes_precedence(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + for step in (0, 1, 2): + _checkpoint_fixture(root, step, pool) + (root / "latest_checkpointed_iteration.txt").write_text("0") + args = _checkpoint_args(root, root) + resolve_checkpoint(args) + assert args.ckpt_step == 0 and args.start_rollout_id == 1 + explicit = _checkpoint_args(root, root, step=1) + plan_explicit = resolve_checkpoint(explicit) + assert explicit.ckpt_step == 1 + with closing(RolloutQueueController(explicit, restore_plan=plan_explicit)): + assert (root / "latest_checkpointed_iteration.txt").read_text() == "1" + _checkpoint_fixture(Path(explicit.save), 2, pool, restore_plan=plan_explicit) + assert (root / "latest_checkpointed_iteration.txt").read_text() == "2" + # A normal restart selects the child, without treating its parent's model + # tracker (at the same logical root) as an instruction to leave the branch. + resumed = _checkpoint_args(root, root) + resolve_checkpoint(resumed) + assert resumed.ckpt_step == 2 and resumed.load == explicit.save + (root / "latest_checkpointed_iteration.txt").write_text("1") + edited = _checkpoint_args(root, root) + resolve_checkpoint(edited) + assert edited.ckpt_step == 1 and edited.load == str(root) + # Users can also edit a physical branch's tracker. + _checkpoint_fixture(Path(explicit.save), 3, pool, restore_plan=plan_explicit) + (Path(explicit.save) / "latest_checkpointed_iteration.txt").write_text("2") + physical = _checkpoint_args(explicit.save, tmp_path / "physical") + resolve_checkpoint(physical) + assert physical.ckpt_step == 2 + (root / "latest_checkpointed_iteration.txt").write_text("3\n") + through_logical_root = _checkpoint_args(root, root) + resolve_checkpoint(through_logical_root) + assert through_logical_root.ckpt_step == 2 and through_logical_root.load == explicit.save + + +def test_edited_tracker_with_missing_queue_snapshot_starts_empty(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + _checkpoint_fixture(root, 1, pool, queue=False) + _checkpoint_fixture(root, 2, pool) + (root / "latest_checkpointed_iteration.txt").write_text("1") + args = _checkpoint_args(root, root) + plan_args = resolve_checkpoint(args) + assert args.ckpt_step == 1 and (plan_args.mode == "empty") + assert args.update_weight_start_version == 2 + (root / "latest_checkpointed_iteration.txt").write_text("0") + with pytest.raises(ValueError, match="No committed"): + resolve_checkpoint(_checkpoint_args(root, root)) + + +def test_branch_without_first_commit_recovers_its_parent(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + parent, child = tmp_path / "parent", tmp_path / "child" + _checkpoint_fixture(parent, 7, tmp_path / "pool") + args = _checkpoint_args(parent, child, step=7) + plan_args = resolve_checkpoint(args) + with closing(RolloutQueueController(args, restore_plan=plan_args)): + pass # Crash before the child has produced a complete checkpoint. + again = _checkpoint_args(child, child) + resolve_checkpoint(again) + assert again.load == str(parent) and again.ckpt_step == 7 + assert Path(again.save).parent == child / "branches" + + +def test_initial_run_recovers_wal_without_queue_flags(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "run" + first = _checkpoint_args(None, root) + first.hf_checkpoint = "initial-model" + plan_first = resolve_checkpoint(first) + first.rollout_data_dir = str(root / "rollout_data") + with closing(RolloutQueueController(first, restore_plan=plan_first)): + pass + for load in (root, None): + again = _checkpoint_args(load, root) + again.hf_checkpoint = "initial-model" + plan_again = resolve_checkpoint(again) + assert (plan_again.mode == "resume") and not (plan_again.mode == "snapshot") + assert (plan_again.queue_id) == (plan_first.queue_id) + assert again.load is None and again.save == first.save + assert again.rollout_data_dir == first.rollout_data_dir + changed = _checkpoint_args(root, root) + changed.hf_checkpoint = "different-model" + with pytest.raises(ValueError, match="Initial model/input"): + resolve_checkpoint(changed) + + +def test_checkpoint_directory_lock_and_stale_selection(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "run" + _checkpoint_fixture(root, 1, tmp_path / "pool") + first, stale = (_checkpoint_args(root, root) for _ in range(2)) + plan_first = resolve_checkpoint(first) + plan_stale = resolve_checkpoint(stale) + with closing(RolloutQueueController(first, restore_plan=plan_first)): + with pytest.raises(RuntimeError, match="Another training job"): + with closing(RolloutQueueController(stale, restore_plan=plan_stale)): + pytest.fail("Concurrent job acquired the save directory") + with pytest.raises(RuntimeError, match="changed during startup"): + with closing(RolloutQueueController(stale, restore_plan=plan_stale)): + pytest.fail("Stale selection replaced the current branch") + # Both failed constructors must release their descriptors, even while their + # exceptions may still be retained by the caller. + again = _checkpoint_args(root, root) + plan_again = resolve_checkpoint(again) + with closing(RolloutQueueController(again, restore_plan=plan_again)): + pass + + +@pytest.mark.parametrize("stage", ["publication", "coordinator", "after_coordinator"]) +def test_checkpoint_lock_released_when_controller_initialization_fails(tmp_path, monkeypatch, stage): + from vime.data import queue_data_source + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "run" + args = _checkpoint_args(None, root) + plan_args = resolve_checkpoint(args) + + def fail(*args, **kwargs): + raise OSError("interrupted controller initialization") + + with monkeypatch.context() as patch: + if stage == "publication": + patch.setattr(queue_data_source, "write_report", fail) + elif stage == "coordinator": + patch.setattr(queue_data_source.Coordinator, "__init__", fail) + else: + patch.setattr(RolloutQueueController, "_start_gc", fail) + with pytest.raises(OSError, match="interrupted controller initialization") as error: + RolloutQueueController(args, restore_plan=plan_args) + # Keep the failed constructor's traceback alive: release cannot rely on GC. + assert error.value.__traceback__ is not None + again = _checkpoint_args(None, root) + plan_again = resolve_checkpoint(again) + with closing(RolloutQueueController(again, restore_plan=plan_again)): + pass + + +def test_checkpoint_lock_covers_cleanup_and_releases_on_seal_failure(tmp_path, monkeypatch): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "run" + args = _checkpoint_args(None, root) + plan_args = resolve_checkpoint(args) + controller = RolloutQueueController(args, restore_plan=plan_args) + again = _checkpoint_args(None, root) + plan_again = resolve_checkpoint(again) + + def fail_seal(): + with pytest.raises(RuntimeError, match="Another training job"): + RolloutQueueController(again, restore_plan=plan_again) + raise OSError("seal failed") + + with monkeypatch.context() as patch: + patch.setattr(controller.store, "seal", fail_seal) + with pytest.raises(OSError, match="seal failed"): + controller.close() + with closing(RolloutQueueController(again, restore_plan=plan_again)): + pass + + +def test_checkpoint_lock_released_when_controller_process_is_killed(tmp_path): + import select + + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "run" + args = _checkpoint_args(None, root) + plan_args = resolve_checkpoint(args) + process = subprocess.Popen( + [ + sys.executable, + "-c", + """ +import pickle, sys +from vime.data.queue_data_source import RolloutQueueController + +args, plan_args = pickle.load(sys.stdin.buffer) +controller = RolloutQueueController(args, restore_plan=plan_args) +print("ready", flush=True) +sys.stdin.buffer.read(1) +""", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + pickle.dump((args, plan_args), process.stdin) + process.stdin.flush() + assert select.select([process.stdout], [], [], 60)[0], "Controller did not start" + assert process.stdout.readline() == b"ready\n" + again = _checkpoint_args(None, root) + plan_again = resolve_checkpoint(again) + with pytest.raises(RuntimeError, match="Another training job"): + RolloutQueueController(again, restore_plan=plan_again) + finally: + process.kill() + process.wait(timeout=30) + process.stdin.close() + process.stdout.close() + process.stderr.close() + # No close() ran in the killed process; the kernel must release its lock. + with closing(RolloutQueueController(again, restore_plan=plan_again)): + pass + + +@pytest.mark.parametrize("has_cursor", [False, True]) +def test_missing_queue_snapshot_starts_empty_without_touching_old_model(tmp_path, has_cursor, caplog): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "legacy" + _checkpoint_fixture(root, 7, tmp_path / "pool", queue=False) + cursor = root / "rollout/global_dataset_state_dict_7.pt" + if has_cursor: + torch.save({"sample_offset": 30}, cursor) + before = (root / "iter_0000007/weights.pt").read_bytes() + args = _checkpoint_args(root, root, step=7) + plan_args = resolve_checkpoint(args) + assert (plan_args.mode == "empty") and not (plan_args.mode == "snapshot") + assert plan_args.dataset_cursor == (str(cursor) if has_cursor else None) + assert args.start_rollout_id == 8 and args.load == str(root) + assert Path(args.save).parent == root / "branches" + assert "new empty queue" in caplog.text + if not has_cursor: + assert "offset 0" in caplog.text + with closing(RolloutQueueController(args, restore_plan=plan_args)): + pass + resumed = _checkpoint_args(root, root) + plan_resumed = resolve_checkpoint(resumed) + assert (plan_resumed.mode == "empty") and resumed.ckpt_step == 7 + assert (root / "iter_0000007/weights.pt").read_bytes() == before + + +def test_missing_model_or_broken_queue_snapshot_never_falls_back(tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "legacy" + with pytest.raises(FileNotFoundError, match="model checkpoint tracker"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "absent", step=7)) + _checkpoint_fixture(root, 7, tmp_path / "pool", queue=False) + with pytest.raises(FileNotFoundError, match="model checkpoint"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "missing", step=8)) + (root / "rollout/queue_state_7.json").write_text("{}") + with pytest.raises(ValueError, match="without a complete joint commit"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "broken", step=7)) + _checkpoint_fixture(root, 9, tmp_path / "pool") + (root / "rollout/queue_state_9.json").unlink() + with pytest.raises(FileNotFoundError): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "lost", step=9)) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_score_centering.py b/tests/test_score_centering.py index a237b052f..661875bef 100644 --- a/tests/test_score_centering.py +++ b/tests/test_score_centering.py @@ -224,7 +224,7 @@ def distributed_worker(rank, world_size, port, layout, mode, disk=False): original = dict(batch) import tempfile from pathlib import Path - from vime.utils.tensor_store import DiskTensorRef + from vime.data.tensor import DiskTensorRef with tempfile.TemporaryDirectory() as directory: if disk: diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index a715f7694..ac6f63cbd 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -28,7 +28,7 @@ def no_gpu_server_imports(monkeypatch): def manager(**overrides): - from vime.rollout.batch_builder import BatchBuilder + from vime.data.batch_builder import BatchBuilder cls = BatchBuilder result = cls.__new__(cls) @@ -136,7 +136,7 @@ def test_streaming_score_centering_rejected(): @pytest.mark.parametrize("transport", ["object-store", "nixl"]) def test_dp_transport_keeps_heads_aligned(monkeypatch, transport): - from vime.rollout import batch_builder as rollout + from vime.data import batch_builder as rollout mgr = manager(rollout_data_transport=transport, global_batch_size=2) mgr.train_parallel_config = {"dp_size": 2} @@ -158,6 +158,7 @@ def put(data, **kwargs): def test_evaluation_preserves_training_score_centering(monkeypatch): from contextlib import nullcontext + from vime.rollout import vllm_rollout as rollout a = args(partial_rollout=False, group_rm=True, custom_generate_function_path=None) @@ -197,10 +198,10 @@ async def run(): def test_spilled_heads_survive_buffer_and_debug_dump_lifetimes(tmp_path): + from vime.data.tensor import DiskTensorRef from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data from vime.utils.routed_experts import cleanup_routed_experts_rollout, link_routed_experts_for_rollout from vime.utils.score_centering import spill_sampler_topk, validate_sampler_topk - from vime.utils.tensor_store import DiskTensorRef a = args(rollout_routed_experts_store_dir=str(tmp_path)) sample = samples()[0] @@ -221,8 +222,9 @@ def test_spilled_heads_survive_buffer_and_debug_dump_lifetimes(tmp_path): @pytest.mark.parametrize("disk", [False, True]) def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, disk): from megatron.core import mpu + + from vime.data.tensor import DiskTensorRef from vime.observability import train_metric_utils as metrics - from vime.utils.tensor_store import DiskTensorRef for name, value in { "get_tensor_model_parallel_rank": 0, @@ -251,7 +253,8 @@ def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, di def test_exact_top_p_transport_and_microbatch(monkeypatch, transport): import numpy as np from test_score_centering import top_p_meta - from vime.rollout import batch_builder as rollout + + from vime.data import batch_builder as rollout packed = types.ModuleType("megatron.core.packed_seq_params") packed.PackedSeqParams = object diff --git a/tests/test_straw_checkpoint_fork.py b/tests/test_straw_checkpoint_fork.py new file mode 100644 index 000000000..fbcd9ef4f --- /dev/null +++ b/tests/test_straw_checkpoint_fork.py @@ -0,0 +1,307 @@ +"""Train three async steps, restore automatic/manual branches, and replay archives. + +Uses four GPUs: one trains Qwen2.5-0.5B and three serve rollouts. Checkpoint +IDs are zero based: save 0/1/2, load 1 into a new branch, and train 2 again. +The rollout hook checks restored state before any worker can advance it. +""" + +import argparse +import hashlib +import json +import os +import tempfile +from pathlib import Path +from shlex import quote + +import vime.utils.external_utils.command_utils as U + +MODEL_NAME = "Qwen2.5-0.5B-Instruct" +MODEL_TYPE = "qwen2.5-0.5B" +NUM_GPUS = 4 + + +def prepare(): + U.exec_command("mkdir -p /root/models /root/datasets") + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def load_source_checkpoint(directory, step): + from straw.protocol import RecordSetRef + + from vime.data.transport import DiskPayloadRef + + index = json.loads((Path(directory) / "rollout" / f"queue_state_{step}.json").read_text()) + return DiskPayloadRef(RecordSetRef.from_dict(index["manifest"]), index["root"]).load() + + +def checked_rollout(args, rollout_id, data_source, evaluation=False): + """Observe the real load boundary, then run the production rollout function.""" + import ray + + from vime.data.transport import DiskPayloadRef, rollout_store + from vime.rollout.fully_async_rollout import generate_rollout_fully_async + + if data_source.restore_plan.mode == "snapshot" and rollout_id == args.start_rollout_id: + assert rollout_id == args.ckpt_step + 1 + assert data_source.restored_source.new_queue + assert not data_source.consumers, "Check the cursor before generation starts" + saved = load_source_checkpoint(args.load, args.ckpt_step) + expected = saved["reader"].load()["pending"].load() + ref = ray.get(data_source.controller.pending_snapshot.remote()) + try: + actual = DiskPayloadRef(ref, args.rollout_data_dir).load() + finally: + store, _, lock = rollout_store(args) + with lock: + store.release_publications([ref]) + # Check all cursor fields, not just offset (which wraps at epoch end). + assert actual["producer_cursor"] == expected["producer_cursor"] + assert not actual["inflight"] and not expected["inflight"] + expected_tasks = {task["task_id"]: task for task in expected["tasks"]} + actual_tasks = {task["task_id"]: task for task in actual["tasks"]} + assert actual_tasks.keys() == expected_tasks.keys() + for task_id, task in actual_tasks.items(): + original = expected_tasks[task_id] + assert task["input_ref"].manifest == original["input_ref"].manifest + assert task["priority"] == original["priority"] + assert task["metadata"] == {**original["metadata"], "source_positions": []} + + old_consumer = saved["consumers"]["fully_async"] + new_consumer = data_source._restored_consumers["fully_async"] + assert new_consumer["readers"] == old_consumer["readers"] + old_ready = old_consumer["scheduler"]["ready"] + new_ready = new_consumer["scheduler"]["ready"] + assert len(new_ready) == len(old_ready) >= args.rollout_batch_size + for (old, old_verdict), (new, new_verdict) in zip(old_ready, new_ready, strict=True): + assert old_verdict == new_verdict and old_verdict.keep + assert new.load().manifest == old.manifest, "COW must share the checkpoint payload" + assert new.receipt != old.receipt + assert new.receipt.task_id != old.receipt.task_id + assert new.source_positions == () + assert data_source.data_config["queue_id"] != saved["configuration"]["queue_id"] + report = { + "expected_cursor": expected["producer_cursor"], + "restored_cursor": actual["producer_cursor"], + "pending_groups": len(actual_tasks), + "ready_groups": len(new_ready), + "worker_count": len(new_consumer["readers"]), + } + Path(args.save).mkdir(parents=True, exist_ok=True) + Path(args.save, "restored_source.json").write_text(json.dumps(report, indent=2)) + if data_source.restore_plan.mode == "empty" and rollout_id == args.start_rollout_id: + import torch + + assert not data_source.consumers + ref = ray.get(data_source.controller.pending_snapshot.remote()) + try: + actual = DiskPayloadRef(ref, args.rollout_data_dir).load() + finally: + store, _, lock = rollout_store(args) + with lock: + store.release_publications([ref]) + expected = torch.load(data_source.restore_plan.dataset_cursor, weights_only=False) + assert actual["producer_cursor"] == expected + assert actual["tasks"] == [] and not actual["inflight"] + assert not getattr(data_source, "_restored_consumers", {}) + Path(args.save, "restored_source.json").write_text(json.dumps(actual, indent=2)) + return generate_rollout_fully_async(args, rollout_id, data_source, evaluation=evaluation) + + +def sample_values(samples): + """Compare training input without comparing branch-specific queue authority.""" + from vime.data.tensor import materialize_tensor_refs + + keys = ( + "index", + "group_index", + "prompt", + "tokens", + "response_length", + "reward", + "status", + "rollout_log_probs", + "loss_mask", + ) + values = [] + for sample in sorted(samples, key=lambda sample: sample.index): + row = materialize_tensor_refs({key: getattr(sample, key) for key in keys}) + values.append({key: value.tolist() if hasattr(value, "tolist") else value for key, value in row.items()}) + return values + + +def execute(*, model_path=None, prompt_data=None, work_dir=None, num_gpus_per_node=NUM_GPUS): + import torch + + from vime.data.archive import RolloutArchive + from vime.data.transport import load_rollout_samples + + model_path = model_path or f"/root/models/{MODEL_NAME}" + prompt_data = prompt_data or "/root/datasets/gsm8k/train.parquet" + # work_dir must be shared when the four GPUs live on different hosts. + with tempfile.TemporaryDirectory(prefix="vime_straw_fork_", dir=work_dir) as directory: + root = Path(directory) + common = ( + f"--hf-checkpoint {quote(str(model_path))} --ref-load {quote(str(model_path))} " + f"--prompt-data {quote(str(prompt_data))} " + "--input-key messages --label-key label --apply-chat-template --rollout-shuffle --rm-type math " + "--rollout-data-transport straw --rollout-queue-online-gc " + "--rollout-function-path test_straw_checkpoint_fork.checked_rollout " + "--rollout-batch-size 2 --n-samples-per-prompt 4 --global-batch-size 8 " + "--rollout-max-response-len 128 --rollout-temperature 0.8 " + "--pg-loss-type reinforce --advantage-estimator grpo --disable-grpo-std-normalization " + # Short responses can all receive zero reward. Entropy makes the + # positive gradient check independent of sampled math accuracy. + "--calculate-per-token-loss --entropy-coef 0.01 --kl-coef 0 " + "--optimizer adam --lr 1e-6 --lr-decay-style constant --weight-decay 0 " + "--tensor-model-parallel-size 1 --pipeline-model-parallel-size 1 --context-parallel-size 1 " + "--expert-model-parallel-size 1 --expert-tensor-parallel-size 1 " + "--use-dynamic-batch-size --max-tokens-per-gpu 2048 --log-probs-chunk-size 128 " + "--rollout-num-gpus-per-engine 1 --vllm-server-concurrency 8 " + "--vllm-gpu-memory-utilization 0.4 --vllm-max-cudagraph-capture-size 8 " + "--attention-dropout 0 --hidden-dropout 0 --attention-backend flash " + "--accumulate-allreduce-grads-in-fp32 --attention-softmax-in-fp32 " + f"--actor-num-nodes 1 --actor-num-gpus-per-node 1 --num-gpus-per-node {num_gpus_per_node} " + "--rollout-num-gpus 3 --save-interval 1 " + ) + parent, child = root / "parent", root / "child" + outputs = root / "outputs" + legacy = root / "legacy-model" + phases = [ + ("parent", parent, f"--num-rollout 3 --rollout-data-dir {quote(str(root / 'pool'))}", [0, 1, 2]), + # No queue lifecycle flags: use normal model checkpoint arguments. + ("child", child, f"--num-rollout 3 --load {quote(str(parent))} --ckpt-step 1", [2]), + # Same save directory, but an exact immutable marker overrides the + # current branch. Earlier child checkpoint files must survive. + ("repeat", child, f"--num-rollout 3 --load {quote(str(parent / 'rollout/committed_1.json'))}", [2]), + ("tracker", child, f"--num-rollout 3 --load {quote(str(child))}", [2]), + # Extending num-rollout changes Megatron's default LR/WD horizon; + # keep the saved optimizer schedule while testing branch selection. + ( + "resume", + child, + f"--num-rollout 4 --load {quote(str(child))} --use-checkpoint-opt-param-scheduler", + [3], + ), + ("empty", root / "empty", f"--num-rollout 3 --load {quote(str(legacy))} --ckpt-step 1", [2]), + ( + "replay_straw", + root / "replay_straw", + f"--num-rollout 1 --load-debug-rollout-data {quote(str(outputs / 'parent/debug_2.straw.json'))}", + [0], + ), + ( + "replay_pt", + root / "replay_pt", + f"--num-rollout 1 --load-debug-rollout-data {quote(str(root / 'legacy.pt'))}", + [0], + ), + ] + immutable_hashes = {} + expected_samples = None + previous_branch = None + for name, destination, options, steps in phases: + output = outputs / name + output.mkdir(parents=True) + if name == "tracker": + # Edit the logical tracker without passing --ckpt-step. + (child / "latest_checkpointed_iteration.txt").write_text("1") + if name in {"child", "repeat", "tracker", "resume"}: + source, source_step = (previous_branch, 2) if name == "resume" else (parent, 1) + saved = load_source_checkpoint(source, source_step) + ready = saved["consumers"]["fully_async"]["scheduler"]["ready"] + assert len(ready) >= 2 and all(verdict.keep for _, verdict in ready) + groups = load_rollout_samples([group for group, _ in ready[:2]]) + expected_samples = sample_values([sample for group in groups for sample in group]) + U.execute_train( + train_args=( + f"{common} {options} --save {quote(str(destination))} " + f"--save-debug-rollout-data {quote(str(output / 'debug_{rollout_id}.straw.json'))} " + f"--ci-save-grad-norm {quote(str(output / 'grad_{rollout_id}_{step_id}.pt'))} " + ), + num_gpus_per_node=num_gpus_per_node, + megatron_model_type=MODEL_TYPE, + extra_env_vars={ + "PYTHONPATH": f"{Path(__file__).resolve().parent}:{U.repo_base_dir}:/root/Megatron-LM/" + }, + ) + current = destination / "rollout/current.json" + physical = Path(json.loads(current.read_text())["directory"]) if current.exists() else destination + gradients = sorted(output.glob("grad_*.pt")) + assert len(gradients) == len(steps) + norms = [] + for path in gradients: + norm = torch.as_tensor(torch.load(path, weights_only=False)) + assert torch.isfinite(norm).all() and (norm > 0).all(), path + norms.append(norm.item()) + for step in steps: + assert (physical / f"iter_{step:07d}").is_dir() + if name not in {"replay_straw", "replay_pt"}: + marker = json.loads((physical / "rollout" / f"committed_{step}.json").read_text()) + assert marker["rollout_id"] == step and marker["resumable"] + if name == "parent": + assert json.loads(current.read_text())["tracker"].strip() == "2" + # Present a real model checkpoint with only the legacy dataset + # cursor: missing queue state must select an empty namespace. + legacy.mkdir() + (legacy / "iter_0000001").symlink_to(parent / "iter_0000001", target_is_directory=True) + (legacy / "latest_checkpointed_iteration.txt").write_text("1") + (legacy / "rollout").mkdir() + saved = load_source_checkpoint(parent, 1) + cursor = saved["reader"].load()["pending"].load()["producer_cursor"] + torch.save(cursor, legacy / "rollout/global_dataset_state_dict_1.pt") + with RolloutArchive(output / "debug_2.straw.json") as archive: + sample_key, task_key = archive.keys()[0] + assert len(archive.load_samples(sample_key=sample_key)) == 1 + assert len(archive.load_samples(task_key=task_key)) == 4 + archive.export_pt(root / "legacy.pt") + elif name in {"child", "repeat", "tracker", "resume"}: + assert physical != previous_branch + report = json.loads((physical / "restored_source.json").read_text()) + assert report["expected_cursor"] == report["restored_cursor"] + assert report["ready_groups"] >= 2 and report["worker_count"] >= 1 + if name != "resume": + assert not (physical / "iter_0000001").exists() + with RolloutArchive(output / f"debug_{steps[0]}.straw.json") as archive: + assert sample_values(archive.load_samples()) == expected_samples + if name != "resume": + with RolloutArchive(outputs / "parent/debug_2.straw.json") as old, RolloutArchive( + output / "debug_2.straw.json" + ) as new: + assert {task for _, task in old.keys()}.isdisjoint(task for _, task in new.keys()) + previous_branch = physical + print(f"{name}: restored checkpoint before generation: {report}", flush=True) + elif name == "empty": + report = json.loads((physical / "restored_source.json").read_text()) + assert report["tasks"] == [] + assert report["producer_cursor"] == torch.load( + legacy / "rollout/global_dataset_state_dict_1.pt", weights_only=False + ) + else: + with RolloutArchive(outputs / "parent/debug_2.straw.json") as old, RolloutArchive( + output / "debug_0.straw.json" + ) as new: + assert sample_values(old.load_samples()) == sample_values(new.load_samples()) + assert all( + hashlib.sha256(path.read_bytes()).hexdigest() == value for path, value in immutable_hashes.items() + ) + # current.json is intentionally mutable; every committed index and + # prior debug archive remains immutable through repeated restores. + for path in list((physical / "rollout").glob("*.json")) + list(output.glob("*.json")): + if path.name not in {"current.json", "branch.json", "fork-active.json"}: + immutable_hashes[path] = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{name}: steps {steps}, gradient norms {norms}, archive checks passed", flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-path") + parser.add_argument("--prompt-data") + parser.add_argument("--work-dir", help="Shared temporary directory for multi-host runs") + parser.add_argument("--num-gpus-per-node", type=int, default=NUM_GPUS) + options = parser.parse_args() + if not (options.model_path and options.prompt_data): + prepare() + for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): + os.environ.pop(key, None) + execute(**vars(options)) diff --git a/tests/test_straw_fully_async_recovery.py b/tests/test_straw_fully_async_recovery.py index b874f871d..f6fd82091 100644 --- a/tests/test_straw_fully_async_recovery.py +++ b/tests/test_straw_fully_async_recovery.py @@ -95,9 +95,9 @@ def _run_job(directory, phase, online_gc): from straw.protocol import Lease, RecordSetRef from straw.reporting import write_report + from vime.data.queue_data_source import QueueDataSource + from vime.data.transport import DiskPayloadRef, load_rollout_samples from vime.rollout.fully_async_rollout import generate_rollout_fully_async - from vime.rollout.queue_data_source import QueueDataSource - from vime.utils.rollout_transport import DiskPayloadRef, load_rollout_samples root = Path(directory) args = _rollout_args(root) @@ -107,7 +107,9 @@ def _run_job(directory, phase, online_gc): args.rollout_queue_max_pending = 16 args.rollout_queue_max_inflight = 4 args.rollout_queue_online_gc = online_gc - args.rollout_queue_resume = phase == "resume" + from vime.data.checkpoint import RestorePlan + + restore_plan = RestorePlan(mode="resume" if phase == "resume" else "new") args.custom_generate_function_path = "test_straw_fully_async_recovery._generate_with_interrupt" args.custom_rm_path = "test_straw_fully_async_recovery._reward_once" args.group_rm = False @@ -118,7 +120,7 @@ def _run_job(directory, phase, online_gc): for _ in range(2): cluster.add_node(num_cpus=2, num_gpus=0, object_store_memory=128 * 1024**2, include_dashboard=False) ray.init(address=cluster.address, runtime_env={"env_vars": {"PYTHONPATH": os.environ["PYTHONPATH"]}}) - source = QueueDataSource(args) + source = QueueDataSource(args, restore_plan=restore_plan) controller = source.controller if phase == "interrupt": failure = [] diff --git a/tests/test_straw_r3.py b/tests/test_straw_r3.py index e3223997d..24124581e 100644 --- a/tests/test_straw_r3.py +++ b/tests/test_straw_r3.py @@ -8,9 +8,9 @@ from straw import SharedFilesystemStore from straw.tensor import publish_tensors +from vime.data.tensor import TensorRef from vime.rollout.sample_hooks import apply_rollout_sample_hooks from vime.utils.routed_experts import RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher -from vime.utils.tensor_store import TensorRef from vime.utils.types import Sample NUM_GPUS = 0 @@ -61,7 +61,7 @@ def test_straw_publishes_replay_tensors_with_the_group_without_hook_or_spill_dir result = asyncio.run(apply_rollout_sample_hooks(args, nested, rollout_id=3)) assert result == nested assert not list(tmp_path.iterdir()) - from vime.utils.rollout_transport import pack_rollout_payload + from vime.data.transport import pack_rollout_payload durable = pack_rollout_payload(result, args, 3) samples = [sample for group in durable.load() for sample in group] @@ -121,7 +121,7 @@ def test_straw_adopts_r3_from_another_pool(tmp_path): rollout_routed_experts=ref, status=Sample.Status.COMPLETED, ) - from vime.utils.rollout_transport import pack_rollout_payload + from vime.data.transport import pack_rollout_payload sample = pack_rollout_payload(sample, args, 0).load() path.unlink() @@ -129,7 +129,7 @@ def test_straw_adopts_r3_from_another_pool(tmp_path): def test_cancelled_straw_publication_drains_before_releasing_capacity(tmp_path, monkeypatch): - from vime.utils import rollout_transport + from vime.data import transport as rollout_transport args = _args(tmp_path) args.rollout_data_transport = "straw" diff --git a/train.py b/train.py index 6befae9d0..0d6b4ab81 100644 --- a/train.py +++ b/train.py @@ -1,12 +1,13 @@ import ray +from vime.data.checkpoint import save_checkpoint from vime.observability.logging_utils import configure_logger, finish_tracking, init_tracking from vime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models from vime.utils.arguments import parse_args from vime.utils.misc import should_run_periodic_action -def train(args): +def train(args, restore_plan=None): configure_logger() release_train = args.release_train @@ -16,7 +17,7 @@ def train(args): # create the rollout manager, with vLLM engines inside. # need to initialize rollout manager first to calculate num_rollout - rollout_manager, num_rollout_per_epoch = create_rollout_manager(args, pgs["rollout"]) + rollout_manager, num_rollout_per_epoch = create_rollout_manager(args, pgs["rollout"], restore_plan=restore_plan) actor_model, critic_model = create_training_models(args, pgs, rollout_manager) @@ -73,12 +74,15 @@ def offload_train(actor_trains_this_step): if release_train or should_run_periodic_action( rollout_id, args.save_interval, num_rollout_per_epoch, args.num_rollout ): - force_sync = release_train or rollout_id == args.num_rollout - 1 - if actor_trains: - actor_model.save_model(rollout_id, force_sync=force_sync) - if args.use_critic: - critic_model.save_model(rollout_id, force_sync=force_sync) - ray.get(rollout_manager.save.remote(rollout_id)) + save_checkpoint( + args, + rollout_id, + actor_model, + critic_model, + rollout_manager, + actor_trains=actor_trains, + restore_plan=restore_plan, + ) offload_train(actor_trains) if args.offload_rollout and not release_train: @@ -99,5 +103,5 @@ def offload_train(actor_trains_this_step): if __name__ == "__main__": - args = parse_args() - train(args) + args, restore_plan = parse_args(return_restore_plan=True) + train(args, restore_plan) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index b6e624f15..be28438b6 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -12,6 +12,7 @@ from torch_memory_saver import torch_memory_saver from transformers import AutoConfig, AutoTokenizer +from vime.data.tensor import DiskTensorRef, TensorRef from vime.observability import train_data_utils, train_metric_utils from vime.observability.logging_utils import init_tracking from vime.observability.profile_utils import TrainProfiler @@ -30,7 +31,6 @@ ) from vime.utils.routed_experts import RoutedExpertsLayerRef, RoutedExpertsMicrobatch, RoutedExpertsMicrobatchPrefetcher from vime.utils.routing_replay import RoutingReplay -from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import RolloutBatch from ...utils.tensor_backper import TensorBackuper diff --git a/vime/backends/megatron_utils/checkpoint.py b/vime/backends/megatron_utils/checkpoint.py index 122e97232..90697d08d 100644 --- a/vime/backends/megatron_utils/checkpoint.py +++ b/vime/backends/megatron_utils/checkpoint.py @@ -102,13 +102,19 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, checkpointing_con ), f"{args.load=} does not exist or is an empty directory. Did you specify the wrong folder?" if _is_megatron_checkpoint(load_path): - return _load_checkpoint_megatron( + result = _load_checkpoint_megatron( ddp_model=ddp_model, optimizer=optimizer, opt_param_scheduler=opt_param_scheduler, checkpointing_context=checkpointing_context, skip_load_to_model_and_opt=False, ) + if getattr(args, "ckpt_step", None) is not None and not args.finetune and result[0] != args.ckpt_step: + raise ValueError( + f"Model loaded step {result[0]}, but checkpoint restoration requires step {args.ckpt_step}. " + "The Megatron checkpoint loader must honor --ckpt-step (including zero)." + ) + return result else: return _load_checkpoint_hf( ddp_model=ddp_model, diff --git a/vime/backends/megatron_utils/cp_utils.py b/vime/backends/megatron_utils/cp_utils.py index 7d447c365..83ebce614 100644 --- a/vime/backends/megatron_utils/cp_utils.py +++ b/vime/backends/megatron_utils/cp_utils.py @@ -5,7 +5,7 @@ import torch.nn.functional as F from megatron.core import mpu -from vime.utils.tensor_store import DiskTensorRef, TensorRef +from vime.data.tensor import DiskTensorRef, TensorRef _RoutedExpertsInput = torch.Tensor | TensorRef | DiskTensorRef diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 589c810af..2d76d45cb 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -8,6 +8,7 @@ from megatron.core import mpu from torch.utils.checkpoint import checkpoint +from vime.data.tensor import DiskTensorRef, TensorRef from vime.utils.distributed_utils import distributed_masked_whiten from vime.utils.misc import load_function from vime.utils.ppo_utils import ( @@ -27,7 +28,6 @@ importance_weights, ) from vime.utils.score_centering import get_score_centering_is_config, score_centering_correction -from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import RolloutBatch from .cp_utils import ( diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index b80d5f96b..7a54dfa51 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -115,8 +115,8 @@ def get_load_checkpoint_path_by_args(args, load_arg="load"): iteration = int(checkpoint_step.split("_")[1]) # Allow user to specify the loaded iteration. - if getattr(args, "ckpt_step", None): - iteration = args.ckpt_step + if getattr(args, "ckpt_step", None) is not None: + iteration, release = args.ckpt_step, False return get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) diff --git a/vime/data/__init__.py b/vime/data/__init__.py new file mode 100644 index 000000000..7bc880e11 --- /dev/null +++ b/vime/data/__init__.py @@ -0,0 +1,4 @@ +"""Data sources, rollout queues, tensor storage and checkpoint persistence. + +Import concrete modules as needed; importing this package does not require straw. +""" diff --git a/vime/data/archive.py b/vime/data/archive.py new file mode 100644 index 000000000..525372f50 --- /dev/null +++ b/vime/data/archive.py @@ -0,0 +1,180 @@ +"""Indexed rollout archives backed by immutable straw records. + +An archive owns its data independently of queue consumption. Keys are scoped to +one archive and identify the sample versions saved in that archive. +""" + +from __future__ import annotations + +import copy +import json +import uuid +from dataclasses import asdict +from pathlib import Path + + +class RolloutArchive: + def __init__(self, path, *, root=None): + from straw.protocol import RecordSetRef + from straw.store import SharedFilesystemStore + from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES + + from vime.data.codec import CODECS, SampleCodec + + self.path = Path(path) + self.index = json.loads(self.path.read_text()) + if self.index.get("format") != "vime.straw-debug" or self.index.get("version") != 1: + raise ValueError(f"Unsupported straw rollout archive: {path}") + self.manifest = RecordSetRef.from_dict(self.index["manifest"]) + self.store = SharedFilesystemStore( + root or self.index["root"], + self.manifest.manifest.segment.run_id, + codecs=CODECS, + max_record_bytes=MAX_TENSOR_BYTES, + max_buffer_bytes=MAX_PUBLICATION_BYTES, + ) + self.codec = SampleCodec(self.store) + self.contents = self.codec.load(self.manifest) + + @classmethod + def save(cls, path, samples, *, rollout_id, evaluation=False, args=None): + from straw.protocol import digest + from straw.reporting import write_report + from straw.store import SharedFilesystemStore + from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES + + from vime.data.codec import CODECS, SampleCodec + from vime.data.transport import DiskPayloadRef, rollout_store + + path = Path(path) + if path.exists(): + raise FileExistsError(f"Rollout archives are immutable; choose a new path: {path}") + shared = args is not None and args.rollout_data_transport == "straw" + if shared: + store, codec, lock = rollout_store(args) + else: + import threading + + store = SharedFilesystemStore( + path.parent / "straw-data", + "debug-rollout", + codecs=CODECS, + online_gc=True, + max_record_bytes=MAX_TENSOR_BYTES, + max_buffer_bytes=MAX_PUBLICATION_BYTES, + ) + codec, lock = SampleCodec(store, args=args), threading.RLock() + try: + # Bounded chunks support indexed reads without a file per sample. + # Lazy R3/SC references in the same pool share their existing bytes. + chunks, entries = [], [] + with lock: + for start in range(0, len(samples), 64): + chunk = samples[start : start + 64] + ref = codec.publish(chunk, submission_id=f"debug-chunk:{uuid.uuid4().hex}") + for offset, sample in enumerate(chunk): + context = getattr(sample, "_queue_receipt", None) or getattr(sample, "_queue_lease", None) + entries.append( + { + "sample_key": ( + f"sample:{sample.index}" + if sample.index is not None + else f"position:{start + offset}" + ), + "task_key": context["task_id"] if context else None, + "chunk": len(chunks), + "offset": offset, + } + ) + chunks.append(DiskPayloadRef(ref, str(store.backend.root))) + manifest = codec.publish( + {"chunks": chunks, "entries": entries}, submission_id=f"debug-index:{uuid.uuid4().hex}" + ) + owner = f"debug:{path.resolve()}:{digest(asdict(manifest))}" + # Ownership must commit before the externally visible index. + store.retain(owner, [manifest]) + store.release_publications([manifest]) + write_report( + path, + { + "format": "vime.straw-debug", + "version": 1, + "rollout_id": rollout_id, + "evaluation": evaluation, + "root": str(store.backend.root), + "manifest": asdict(manifest), + "storage_owner": owner, + }, + ) + finally: + if not shared: + store.close() + + def keys(self): + """Return sample/task keys in archive order, including duplicate sample IDs.""" + return [(entry["sample_key"], entry["task_key"]) for entry in self.contents["entries"]] + + def load_samples(self, *, sample_key=None, task_key=None): + """Load all samples, or the intersection of the supplied archive keys. + + Compact/custom rollouts may emit several samples with the same index; + therefore this API always returns a list. Tensor fields stay lazy. + """ + selected = [ + entry + for entry in self.contents["entries"] + if (sample_key is None or entry["sample_key"] == sample_key) + and (task_key is None or entry["task_key"] == task_key) + ] + if not selected and (sample_key is not None or task_key is not None): + raise KeyError((sample_key, task_key)) + loaded, result = {}, [] + for entry in selected: + chunk = entry["chunk"] + if chunk not in loaded: + loaded[chunk] = self.codec.load(self.contents["chunks"][chunk].manifest) + sample = copy.copy(loaded[chunk][entry["offset"]]) + # An archive is training input, not authorization to mutate its + # original queue. Keep generation provenance, strip live control. + for name in ( + "_queue_lease", + "_queue_receipt", + "_queue_source_positions", + "_queue_generation_start", + "_queue_resume_origin", + ): + sample.__dict__.pop(name, None) + result.append(sample) + return result + + def export_pt(self, path): + """Materialize a self-contained .pt dump, independent of this pool.""" + import torch + + from vime.data.tensor import materialize_tensor_refs + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + torch.save( + materialize_tensor_refs( + {"rollout_id": self.index["rollout_id"], "samples": [s.to_dict() for s in self.load_samples()]} + ), + path, + ) + + def release(self): + """Release archive retention after all its readers have finished. + + The index remains for inspection; GC may subsequently reclaim payloads + not owned by another archive, queue or checkpoint. + """ + self.store.release(self.index["storage_owner"]) + + def close(self): + self.store.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() diff --git a/vime/rollout/batch_builder.py b/vime/data/batch_builder.py similarity index 95% rename from vime/rollout/batch_builder.py rename to vime/data/batch_builder.py index de57e5707..330526870 100644 --- a/vime/rollout/batch_builder.py +++ b/vime/data/batch_builder.py @@ -6,13 +6,13 @@ import json import logging -import random from pathlib import Path -import numpy as np import ray import torch +from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.transport import DiskPayloadRef, RolloutGroupRef, TrainBatchRef, pack_rollout_payload from vime.observability.rollout_data_utils import ( tensorize_rollout_data_for_training, validate_rollout_routed_experts_for_replay, @@ -20,16 +20,15 @@ from vime.utils.data import get_source from vime.utils.dp_schedule import build_dp_schedule from vime.utils.misc import Box, load_function -from vime.utils.rollout_transport import DiskPayloadRef, RolloutGroupRef, TrainBatchRef, pack_rollout_payload -from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) class BatchBuilder: - def __init__(self, args): + def __init__(self, args, *, controller=None): self.args = args + self.controller = controller self.rollout_id = -1 self.raw_ref = None self.batch_id = None @@ -51,7 +50,7 @@ def begin(self, samples): return None from straw.protocol import Lease, RecordSetRef, digest - controller = self.args._rollout_queue_controller + controller = self.controller self.batch_id = f"batch:{self.raw_ref.receipt.commit_id}" configuration = { name: getattr(self.args, name, None) @@ -93,11 +92,7 @@ def begin(self, samples): RecordSetRef.from_dict(existing["ready_ref"]), self.args.rollout_data_dir ).load()["ranks"] return [Box(ray.put(ref)) for ref in refs] - random.setstate(plan["rng"]["python"]) - np.random.set_state(plan["rng"]["numpy"]) - torch.set_rng_state(plan["rng"]["torch"]) else: - numpy_state = np.random.get_state() positions = {self.raw_ref.receipt.position} for sample in samples: positions.update(getattr(sample, "_queue_source_positions", [])) @@ -118,11 +113,6 @@ def begin(self, samples): "digest": self.plan_digest, "raw": self.raw_ref, "input_positions": sorted(positions), - "rng": { - "python": random.getstate(), - "numpy": (numpy_state[0], numpy_state[1].tolist(), *numpy_state[2:]), - "torch": torch.get_rng_state(), - }, "producer_processing": "generation/reward/dynamic and batch filters already applied", } self._plan = pack_rollout_payload(plan, self.args, self.rollout_id) @@ -131,7 +121,7 @@ def begin(self, samples): return None def _commit_ready(self, ranks, schedule): - controller = self.args._rollout_queue_controller + controller = self.controller ready = pack_rollout_payload( { "version": 1, @@ -151,21 +141,21 @@ def training_completed(self, rollout_id): if self.batch_id is not None: if rollout_id != self.rollout_id: raise ValueError("Training completion does not match the current batch") - ray.get(self.args._rollout_queue_controller.finish_batch.remote(self.batch_id)) + ray.get(self.controller.finish_batch.remote(self.batch_id)) def save(self, rollout_id): if self.args.rollout_data_transport != "straw": return from straw.reporting import write_report - state = ray.get(self.args._rollout_queue_controller.training_state.remote()) + state = ray.get(self.controller.training_state.remote()) if state is None: return from dataclasses import asdict from straw.protocol import RecordSetRef, digest - from vime.utils.rollout_transport import rollout_store + from vime.data.transport import rollout_store path = Path(self.args.save) / "rollout" / f"builder_state_{rollout_id}.json" store, _, lock = rollout_store(self.args) @@ -184,9 +174,15 @@ def save(self, rollout_id): }, ) - def load(self, rollout_id): + def load(self, rollout_id, *, source_restore=None): if self.args.rollout_data_transport != "straw" or not self.args.load: return + if source_restore is not None and source_restore.new_queue: + # A fork is taken after training completion. Saved pending/ready + # groups were reintroduced with new receipts; old positions and + # finished batches belong exclusively to the parent queue. + self.consumer_state = self.raw_ref = self.batch_id = self._plan = None + return from straw.protocol import RecordSetRef path = Path(self.args.load) / "rollout" / f"builder_state_{rollout_id}.json" @@ -200,11 +196,11 @@ def load(self, rollout_id): state = snapshot["consumer"] self.consumer_state = DiskPayloadRef(RecordSetRef.from_dict(state["state_ref"]), self.args.rollout_data_dir) ray.get( - self.args._rollout_queue_controller.restore_training_state.remote( + self.controller.restore_training_state.remote( self.consumer_state.manifest, state["fetch_cursor"], state["processed_cursor"], - getattr(self.args, "_queue_restored_source_ref", None), + source_restore.source_ref if source_restore is not None else None, ) ) diff --git a/vime/data/checkpoint.py b/vime/data/checkpoint.py new file mode 100644 index 000000000..2e162b441 --- /dev/null +++ b/vime/data/checkpoint.py @@ -0,0 +1,370 @@ +"""Commit and validate model/source boundaries for straw checkpoint forks.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from straw.protocol import RecordSetRef + + +@dataclass(frozen=True) +class RestorePlan: + """Startup decisions, separate from CLI configuration and live queue state.""" + + mode: Literal["new", "resume", "snapshot", "empty"] = "new" + root: str | None = None + expected_current: str | None = None + branch: dict | None = None + queue_id: str | None = None + dataset_cursor: str | None = None + + +@dataclass(frozen=True) +class SourceRestore: + """Result of restoring a source, consumed by the training batch builder.""" + + new_queue: bool = False + source_ref: RecordSetRef | None = None + + +def save_checkpoint(args, rollout_id, actor_model, critic_model, rollout_manager, *, actor_trains, restore_plan=None): + """Save training and rollout state, publishing a joint boundary for straw.""" + import ray + + straw_checkpoint = ( + args.rollout_data_transport == "straw" + and actor_trains + and not args.debug_train_only + and not args.debug_rollout_only + ) + force_sync = straw_checkpoint or args.release_train or rollout_id == args.num_rollout - 1 + if straw_checkpoint and (Path(args.save) / "rollout" / f"committed_{rollout_id}.json").exists(): + raise FileExistsError("Refusing to overwrite a committed straw checkpoint") + if actor_trains: + actor_model.save_model(rollout_id, force_sync=force_sync) + if args.use_critic: + critic_model.save_model(rollout_id, force_sync=force_sync) + ray.get(rollout_manager.save.remote(rollout_id)) + if straw_checkpoint: + # Failed saves raise above; publish only after all saves return. + model_args = [actor_model.args] + if args.use_critic: + model_args.append(critic_model.args) + commit_checkpoint(args, rollout_id, model_args=model_args, restore_plan=restore_plan) + + +def commit_checkpoint(args, rollout_id, *, model_args, restore_plan=None): + """Publish the boundary after synchronous model saves and rollout snapshots. + + The driver waits for those calls; a save exception prevents this call. + Model configurations supply the paths and optimizer/RNG retention policy. + """ + from straw.reporting import write_report + + root = Path(args.save).resolve() + path = root / "rollout" / f"committed_{rollout_id}.json" + if path.exists(): + raise FileExistsError(f"Committed checkpoints are immutable: {path}") + files = {} + resumable = True + for config in model_args: + resumable &= not getattr(config, "no_save_optim", False) and not getattr(config, "no_save_rng", False) + model = Path(config.save).resolve() / f"iter_{rollout_id:07d}" + if not model.is_relative_to(root): + raise ValueError("Model checkpoint must be inside --save") + payloads = [p for p in model.rglob("*") if p.is_file()] + if not payloads: + raise ValueError(f"Missing model checkpoint payloads: {model}") + for item in payloads: + if item.is_symlink(): + raise ValueError(f"Checkpoint payload must not be a symlink: {item}") + with item.open("rb") as stream: + os.fsync(stream.fileno()) + entry = {"size": os.fstat(stream.fileno()).st_size} + if item.name.endswith(".json") or item.name == ".metadata": + entry["sha256"] = hashlib.sha256(stream.read()).hexdigest() + files[str(item.relative_to(root))] = entry + for directory in sorted( + {model, model.parent, *(p.parent for p in payloads)}, key=lambda p: len(p.parts), reverse=True + ): + fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + for name in ("queue_state", "builder_state"): + item = root / "rollout" / f"{name}_{rollout_id}.json" + if not item.exists(): + raise ValueError(f"Missing {name} checkpoint") + files[str(item.relative_to(root))] = { + "size": item.stat().st_size, + "sha256": hashlib.sha256(item.read_bytes()).hexdigest(), + } + write_report( + path, + { + "version": 1, + "rollout_id": rollout_id, + "files": files, + "resumable": bool(resumable), + # train.py syncs once before rollout 0, then after each rollout. + # This save precedes the next sync, so version = rollout_id + 1. + "weight_version": rollout_id + 1, + "rollout_data_dir": str(Path(args.rollout_data_dir).resolve()), + "run_id": getattr(args, "rollout_queue_run_id", None) or "rollout", + }, + ) + # Mirror the active branch's committed step at the logical root. Publish the + # tracker first: a crash before the pointer update still selects this valid + # commit, rather than mistaking the previous step for a user rollback. + if restore_plan is not None and restore_plan.root is not None: + logical_root = Path(restore_plan.root) + current = logical_root / "rollout/current.json" + value = json.loads(current.read_text()) + if Path(value["directory"]) != root: + raise RuntimeError("Current branch changed while saving its checkpoint") + write_report(logical_root / "latest_checkpointed_iteration.txt", rollout_id) + value["tracker"] = str(rollout_id) + write_report(current, value) + + +def _read_checkpoint(root, step): + """Validate the joint commit, never infer completion from the model tracker.""" + path = root / "rollout" / f"committed_{step}.json" + value = json.loads(path.read_text()) + if value.get("version") != 1 or value["rollout_id"] != step or not value["resumable"]: + raise ValueError("Checkpoint is incompatible or omitted optimizer/RNG state") + for name, expected in value["files"].items(): + item = (root / name).resolve() + if not item.is_relative_to(root) or item.stat().st_size != expected["size"]: + raise ValueError(f"Checkpoint component missing or changed: {name}") + if "sha256" in expected and hashlib.sha256(item.read_bytes()).hexdigest() != expected["sha256"]: + raise ValueError(f"Checkpoint component changed: {name}") + return value + + +def _read_empty_checkpoint(root, step): + """Allow absent queue state, but never downgrade a broken saved snapshot.""" + for name in ("queue_state", "builder_state"): + if (root / "rollout" / f"{name}_{step}.json").exists(): + raise ValueError("Queue snapshot exists without a complete joint commit; refusing empty-queue fallback") + model = root / f"iter_{step:07d}" + if not model.is_dir() or not any(path.is_file() for path in model.rglob("*")): + raise FileNotFoundError(f"Requested model checkpoint does not exist: {model}") + + +def _select_checkpoint(directory, step, *, allow_empty=False): + """Follow the current branch and its ancestors, excluding abandoned futures.""" + root = directory + current = root / "rollout/current.json" + if current.exists(): + root = Path(json.loads(current.read_text())["directory"]).resolve() + visited = set() + while root not in visited: + visited.add(root) + commits = list((root / "rollout").glob("committed_*.json")) + if step is None and commits: + step = max(int(path.stem.split("_")[-1]) for path in commits) + if step is not None and (root / "rollout" / f"committed_{step}.json").exists(): + return root, step, _read_checkpoint(root, step) + if step is not None and allow_empty and (root / f"iter_{step:07d}").exists(): + _read_empty_checkpoint(root, step) + return root, step, None + branch = root / "rollout/branch.json" + if not branch.exists(): + break + parent = json.loads(branch.read_text()).get("parent") + if parent is None: + break + if step is not None and step > parent["step"]: + break + step = parent["step"] if step is None else step + allow_empty = allow_empty or not parent.get("queue_snapshot", True) + root = Path(parent["directory"]).resolve() + raise ValueError(f"No committed model/source checkpoint for step {step} in {directory}'s branch history") + + +def _initial_configuration(args): + """Bind pre-checkpoint WAL recovery to the original model and input stream.""" + return { + key: getattr(args, key, None) + for key in ( + "hf_checkpoint", + "ref_load", + "prompt_data", + "input_key", + "label_key", + "metadata_key", + "tool_key", + "apply_chat_template", + "n_samples_per_prompt", + "rollout_seed", + "rollout_shuffle", + "rollout_function_path", + "custom_generate_function_path", + "custom_rm_path", + "rollout_queue_run_id", + ) + } + + +def resolve_checkpoint(args): + """Translate ordinary load/save arguments into an isolated checkpoint branch. + + Each checkpoint restore gets fresh queue authority and output files. A small + current pointer lets callers keep using the same logical save directory. + Before the first checkpoint, the original run can instead recover its WAL. + """ + mode, dataset_cursor = "new", None + if getattr(args, "rollout_data_transport", None) != "straw": + return RestorePlan() + if any(getattr(args, key, False) for key in ("debug_train_only", "debug_rollout_only", "load_debug_rollout_data")): + return RestorePlan() + if not getattr(args, "save", None): + return RestorePlan() + save_root = Path(args.save).resolve() + current_path = save_root / "rollout/current.json" + expected_current = current_path.read_text() if current_path.exists() else None + load = getattr(args, "load", None) + if load is None and expected_current is not None: + load = str(save_root) + root = Path(load).resolve() if load else None + step = getattr(args, "ckpt_step", None) + direct_commit = root is not None and root.is_file() and root.name.startswith("committed_") + if direct_commit: + selected_step = int(root.stem.split("_")[-1]) + if step is not None and step != selected_step: + raise ValueError("--ckpt-step differs from the explicit checkpoint marker") + step, root = selected_step, root.parent.parent + is_checkpoint = root is not None and ( + direct_commit + or (root / "rollout/current.json").exists() + or (root / "rollout/branch.json").exists() + or any((root / "rollout").glob("committed_*.json")) + ) + selected = None + if is_checkpoint: + current = root / "rollout/current.json" + pointer = json.loads(current.read_text()) if current.exists() else None + active = Path(pointer["directory"]).resolve() if pointer else root + if not direct_commit and step is None: + tracker = root / "latest_checkpointed_iteration.txt" + commits = list((active / "rollout").glob("committed_*.json")) + if tracker.exists(): + text = tracker.read_text() + tracked_step = int(text.strip()) + latest = max((int(path.stem.split("_")[-1]) for path in commits), default=-1) + # A changed logical tracker is an explicit user selection; an + # older physical tracker also selects a rollback. A newer + # physical tracker may be an interrupted model save and cannot + # override the joint commit. The recorded value also supports + # an inherited tracker before the branch's first commit. + logical_edit = ( + pointer is not None + and "tracker" in pointer + and text.strip() != str(pointer["tracker"]).strip() + and root != active + ) + if logical_edit or tracked_step < latest: + # An unchanged logical tracker may lag behind a child. + if logical_edit or root == active: + step = tracked_step + if step is None and root != active: + tracker = active / "latest_checkpointed_iteration.txt" + if tracker.exists() and commits: + tracked_step = int(tracker.read_text().strip()) + if tracked_step < max(int(path.stem.split("_")[-1]) for path in commits): + step = tracked_step + branch_path = active / "rollout/branch.json" + branch = json.loads(branch_path.read_text()) if branch_path.exists() else None + if ( + not direct_commit + and step is None + and branch is not None + and branch["parent"] is None + and not any((active / "rollout").glob("committed_*.json")) + ): + if root != save_root: + raise ValueError("Before the first checkpoint, restart using the original --save directory") + if branch["initial_configuration"] != _initial_configuration(args): + raise ValueError("Initial model/input configuration differs from the interrupted run") + if args.rollout_data_dir is not None and Path(args.rollout_data_dir).resolve() != Path(branch["pool"]): + raise ValueError("Interrupted run requires its original straw pool") + args.load, args.save = branch["initial_load"], str(active) + args.rollout_data_dir = branch["pool"] + return RestorePlan("resume", str(save_root), expected_current, branch, branch["queue_id"]) + selected = ( + (root, step, _read_checkpoint(root, step)) + if direct_commit + else _select_checkpoint(root, step, allow_empty=step is not None) + ) + elif root is not None and (root / "latest_checkpointed_iteration.txt").exists(): + if step is None: + step = int((root / "latest_checkpointed_iteration.txt").read_text().strip()) + _read_empty_checkpoint(root, step) + selected = root, step, None + elif root is not None and step is not None: + raise FileNotFoundError(f"No model checkpoint tracker for requested step {step}: {root}") + + if selected is not None and not (selected[0] / "latest_checkpointed_iteration.txt").exists(): + raise FileNotFoundError(f"Model checkpoint tracker is missing: {selected[0]}") + + # Never overwrite existing checkpoint files, including a partially written + # iteration without a commit marker. Repeated rollbacks use distinct paths. + occupied = ( + expected_current is not None or any(save_root.glob("iter_*")) or (save_root / "rollout/branch.json").exists() + ) + destination = ( + save_root / "branches" / uuid.uuid4().hex if occupied or (selected and selected[0] == save_root) else save_root + ) + branch = { + "version": 1, + "parent": None, + "initial_load": getattr(args, "load", None), + "initial_configuration": _initial_configuration(args), + "queue_id": f"run:{uuid.uuid4().hex}", + } + if selected is not None: + root, step, value = selected + if any(getattr(args, key, False) for key in ("finetune", "no_load_optim", "no_load_rng")): + raise ValueError("Checkpoint restoration requires model, optimizer and RNG state") + if args.start_rollout_id is not None and args.start_rollout_id != step + 1: + raise ValueError("--start-rollout-id differs from the checkpoint") + if value is not None: + if (getattr(args, "rollout_queue_run_id", None) or "rollout") != value["run_id"]: + raise ValueError("Checkpoint belongs to another straw run") + if ( + args.rollout_data_dir is not None + and Path(args.rollout_data_dir).resolve() != Path(value["rollout_data_dir"]).resolve() + ): + raise ValueError("Checkpoint restoration requires the original shared straw pool") + args.rollout_data_dir = value["rollout_data_dir"] + mode = "snapshot" + else: + mode = "empty" + cursor = root / "rollout" / f"global_dataset_state_dict_{step}.pt" + dataset_cursor = str(cursor) if cursor.exists() else None + logging.getLogger(__name__).warning( + "Model checkpoint %s step %s has no queue snapshot: starting a new empty queue; %s", + root, + step, + ( + f"restore dataset cursor from {cursor}" + if cursor.exists() + else "dataset restarts at offset 0 (not exact data replay)" + ), + ) + args.ckpt_step, args.start_rollout_id, args.load = step, step + 1, str(root) + # Also seed the counter when an older model has no queue snapshot. + args.update_weight_start_version = step + 1 + branch["parent"] = {"directory": str(root), "step": step, "queue_snapshot": value is not None} + args.save = str(destination) + return RestorePlan(mode, str(save_root), expected_current, branch, branch["queue_id"], dataset_cursor) diff --git a/vime/rollout/queue_codec.py b/vime/data/codec.py similarity index 98% rename from vime/rollout/queue_codec.py rename to vime/data/codec.py index cf248e1e1..aec3069ab 100644 --- a/vime/rollout/queue_codec.py +++ b/vime/data/codec.py @@ -3,7 +3,7 @@ The representation is tagged JSON plus immutable typed byte records. It never imports a class named by a payload and never executes pickle. Unknown Python objects fail at publication. Replay tensors are stored lazily with their owning -Sample; buffers, training batches and checkpoints share immutable records. +Sample; continuations, training batches and checkpoints share immutable records. """ from __future__ import annotations @@ -194,7 +194,7 @@ def tensor(value, *, lazy=False, kind=None, validated=False): def visit(value): nonlocal token_count - from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef + from vime.data.transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef if isinstance(value, DiskPayloadRef): dependency_index = len(dependencies) @@ -205,6 +205,7 @@ def visit(value): extra = { "index": value.index, "receipt": (dataclasses.asdict(value.receipt) if value.receipt else None), + "source_positions": value.source_positions, } elif isinstance(value, RawRolloutRef): extra = { @@ -379,7 +380,7 @@ def visit(node): if tag == "rollout-ref": from straw.protocol import CommitReceipt - from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef + from vime.data.transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef classes = { cls.__name__: cls diff --git a/vime/rollout/data_source.py b/vime/data/data_source.py similarity index 100% rename from vime/rollout/data_source.py rename to vime/data/data_source.py diff --git a/vime/rollout/queue_data_source.py b/vime/data/queue_data_source.py similarity index 67% rename from vime/rollout/queue_data_source.py rename to vime/data/queue_data_source.py index f0f62742c..fe40c3386 100644 --- a/vime/rollout/queue_data_source.py +++ b/vime/data/queue_data_source.py @@ -10,11 +10,14 @@ import asyncio import copy +import fcntl +import json import logging import threading import time import uuid from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack from dataclasses import asdict, dataclass from pathlib import Path @@ -23,10 +26,11 @@ from straw.coordinator import Coordinator from straw.errors import LeaseExpired, StaleAttempt from straw.protocol import Lease, Limits, Record, RecordSetRef, TaskSpec, encode +from straw.reporting import write_report -from vime.rollout.base_types import iter_samples -from vime.rollout.data_source import DataSource, RolloutDataSource -from vime.utils.rollout_transport import ( +from vime.data.checkpoint import RestorePlan, SourceRestore +from vime.data.data_source import DataSource, RolloutDataSource +from vime.data.transport import ( DiskPayloadRef, group_lease, pack_rollout_payload, @@ -36,57 +40,140 @@ rollout_store, unpack_rollout_payload, ) +from vime.rollout.base_types import iter_samples from vime.utils.types import Sample logger = logging.getLogger(__name__) class RolloutQueueController: - """Plain class for unit testing; the host wraps it in one concurrent Ray actor.""" + """Own a job's durable queue and checkpoint branch in one Ray actor. + + The plain class also supports local tests without starting Ray. + """ - def __init__(self, args, *, producer=None): + def __init__(self, args, *, producer=None, restore_plan=None, defer_gc=False): self.args = args - self.store, self.codec, self._writer_lock = rollout_store(args) - self._gc_error = None - if not hasattr(Coordinator, "yield_tasks"): - raise RuntimeError( - "This straw transport requires a straw-queue build with yield_tasks and priority scheduling; the original PyPI 0.1.0 wheel does not support these APIs." + self.restore_plan = restore_plan or RestorePlan() + self._checkpoint_lock = None + with ExitStack() as cleanup: + # The controller owns both the queue and its checkpoint branch. + # Hold the save-directory lock until close() (or actor exit), and + # publish the branch before any queue writes can become durable. + if self.restore_plan.root is not None: + plan = self.restore_plan + root = Path(plan.root) + (root / "rollout").mkdir(parents=True, exist_ok=True) + self._checkpoint_lock = cleanup.enter_context((root / "rollout/session.lock").open("a")) + try: + fcntl.flock(self._checkpoint_lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise RuntimeError(f"Another training job owns save directory {root}") from error + path = root / "rollout/current.json" + current = path.read_text() if path.exists() else None + if current != plan.expected_current: + raise RuntimeError("Current checkpoint branch changed during startup; restart to resolve it again") + resolve_rollout_data_dir(args) + branch = {**plan.branch, "pool": args.rollout_data_dir} + write_report(Path(args.save) / "rollout/branch.json", branch) + tracker = root / "latest_checkpointed_iteration.txt" + if parent := branch.get("parent"): + write_report(tracker, parent["step"]) + write_report( + path, + { + "version": 1, + "directory": args.save, + "tracker": tracker.read_text() if tracker.exists() else None, + }, + ) + logger.warning( + "straw checkpoint directory: %s; logical save directory: %s; restoring: %s step %s", + args.save, + root, + args.load, + getattr(args, "ckpt_step", None), + ) + self.store, self.codec, self._writer_lock = rollout_store(args) + self._gc_error = None + if not hasattr(Coordinator, "yield_tasks"): + raise RuntimeError( + "straw transport requires yield_tasks and priority scheduling. " + "Upgrade on every rollout/training node: pip install --upgrade straw-queue" + ) + fork = self.restore_plan.mode == "snapshot" + queue_options = {} + queue_id = self.restore_plan.queue_id + if queue_id is not None: + queue_options = {"queue_id": queue_id, "namespace": True} + if fork: + from straw.protocol import digest + + if not getattr(args, "save", None) or not getattr(args, "load", None): + raise ValueError("Checkpoint restore requires resolved load and save paths") + if Path(args.save).resolve() == Path(args.load).resolve(): + raise ValueError("Checkpoint restore requires an isolated output branch") + if (Path(args.save) / "rollout" / "fork-active.json").exists(): + raise ValueError("This queue branch has already started; resolve checkpoint selection again") + queue_id = queue_id or "fork:" + digest(str(Path(args.save).resolve())) + queue_options = {"queue_id": queue_id, "namespace": True} + recovering = (self.store.backend.root / "queues" / digest(queue_id) / "run.json").exists() + else: + recovering = self.restore_plan.mode == "resume" + if recovering and queue_id is not None and self.restore_plan.root: + from straw.protocol import digest + + # Initialization may have stopped after publishing the branch + # but before creating its queue. Only this namespace may start empty. + recovering = (self.store.backend.root / "queues" / digest(queue_id) / "run.json").exists() + if recovering and queue_id is None and getattr(args, "load", None): + branch = Path(args.load) / "rollout" / "fork-active.json" + if branch.exists(): + queue_options = {"queue_id": json.loads(branch.read_text())["queue_id"], "namespace": True} + self._queue = Coordinator( + self.store, + **queue_options, + exclusive_owner="job owns this non-restarting actor; prior coordinator and readers must be stopped", + recover=recovering, + lease_seconds=getattr(args, "rollout_queue_lease_seconds", 300), + limits=Limits( + pending_tasks=getattr(args, "rollout_queue_max_pending", 65536), + inflight_tasks=getattr(args, "rollout_queue_max_inflight", 65536), + ), ) - self._queue = Coordinator( - self.store, - exclusive_owner="job owns this non-restarting actor; explicit resume confirms prior owner stopped", - recover=getattr(args, "rollout_queue_resume", False), - lease_seconds=getattr(args, "rollout_queue_lease_seconds", 300), - limits=Limits( - pending_tasks=getattr(args, "rollout_queue_max_pending", 65536), - inflight_tasks=getattr(args, "rollout_queue_max_inflight", 65536), - ), - ) - self.producer = producer - self._initial_cursor = None - self._producer_lock = threading.Lock() - self._reader_tokens = {} - self._retired_readers = set() - self.branch_id = uuid.uuid4().hex - self._training_token = None - self._training_lock = threading.RLock() - self._gc_stop = threading.Event() - self._gc_thread = None - if getattr(args, "rollout_queue_resume", False): - # This flag explicitly requires the entire prior job to have stopped; - # unlike a lease timeout, that supervisor assertion ends old reads. - readers = self.queue.outstanding_reads() - for offset in range(0, len(readers), 128): - self.queue.release_task_reads(readers[offset : offset + 128]) - # Joint restore can rewind the training cursor. Reconcile its durable - # storage ownership before GC inspects restored live result roots. - if not getattr(args, "_joint_resume", None): - self._start_gc() - # Recovery invalidates leases; unfinished collections from the stopped - # manager cannot produce a new branch's batch. Accepted facts stay intact. - for task_id, task in list(self.queue.tasks.items()): - if task_id.startswith("collection:") and task["state"] == "pending": - self.queue.cancel_task(task_id, request_id=f"abandon:{self.branch_id}:{task_id}") + cleanup.callback(self._queue.close) + self.producer = producer + self._initial_cursor = None + self._producer_lock = threading.Lock() + self._reader_tokens = {} + self._retired_readers = set() + self.branch_id = uuid.uuid4().hex + self._training_token = None + self._training_lock = threading.RLock() + self._gc_stop = threading.Event() + self._gc_thread = None + self._fork_ready = not fork + self._fork_active = False + if fork and self._queue.producer_state("fork-active") is not None: + raise ValueError("This queue branch has already started; resolve checkpoint selection again") + if self.restore_plan.mode == "resume": + # Restart requires the entire prior job to have stopped. Unlike a + # lease timeout, that supervisor assertion ends old reads. + readers = self.queue.outstanding_reads() + for offset in range(0, len(readers), 128): + self.queue.release_task_reads(readers[offset : offset + 128]) + # Recovery invalidates leases; unfinished collections from the stopped + # manager cannot produce a new branch's batch. Accepted facts stay intact. + for task_id, task in list(self.queue.tasks.items()): + if task_id.startswith("collection:") and task["state"] == "pending": + self.queue.cancel_task(task_id, request_id=f"abandon:{self.branch_id}:{task_id}") + # Joint restore can rewind the training cursor. Reconcile its durable + # storage ownership before GC inspects restored live result roots. + if not fork and not defer_gc: + self._start_gc() + # Successful construction transfers ownership to close(). A failed + # constructor closes the coordinator and releases the save lock here. + cleanup.pop_all() def _check_gc_error(self): if self._gc_error is not None: @@ -123,6 +210,16 @@ def _source(self): self._check_gc_error() if self.producer is None: self.producer = RolloutDataSource(self.args) + if path := self.restore_plan.dataset_cursor: + import torch + + state = torch.load(path, map_location="cpu", weights_only=False) + for key in ("sample_offset", "epoch_id", "sample_group_index", "sample_index", "metadata"): + if key not in state: + raise ValueError(f"Dataset checkpoint is missing {key}") + setattr(self.producer, key, copy.deepcopy(state[key])) + if self.producer.dataset is not None and self.args.rollout_shuffle: + self.producer.dataset.shuffle(self.producer.epoch_id) if self.producer.dataset is not None and not len(self.producer.dataset): raise ValueError("Queue rollout dataset is empty after filtering") if self._initial_cursor is None: @@ -141,6 +238,31 @@ def _source(self): def configuration(self): with self._producer_lock: source = self._source() + dataset = { + key: getattr(self.args, key, None) + for key in ( + "input_key", + "label_key", + "metadata_key", + "tool_key", + "multimodal_keys", + "rollout_max_prompt_len", + "apply_chat_template", + "apply_chat_template_kwargs", + "hf_checkpoint", + ) + } + if getattr(self.args, "prompt_data", None): + import hashlib + + from vime.utils.data import _parse_generalized_path + + path, row_slice = _parse_generalized_path(self.args.prompt_data) + checksum = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(4 * 1024 * 1024), b""): + checksum.update(chunk) + dataset.update(sha256=checksum.hexdigest(), row_slice=str(row_slice)) return { "dataset_size": len(source), "n_samples_per_prompt": self.args.n_samples_per_prompt, @@ -148,6 +270,8 @@ def configuration(self): "shuffle": self.args.rollout_shuffle, "run_id": self.store.run_id, "branch_id": self.branch_id, + "queue_id": self.queue.queue_id, + "dataset": dataset, } def identity(self): @@ -155,14 +279,125 @@ def identity(self): return {"run_id": self.store.run_id, "branch_id": self.branch_id} def pending_snapshot(self): - """Capture shared pending work once, with explicit storage dependencies.""" - tasks = [] - for spec in self.queue.pending_tasks(task_prefix="prompt:"): - value = asdict(spec) - value["input_ref"] = DiskPayloadRef(spec.input_ref, str(self.store.backend.root)) - tasks.append(value) - with self._writer_lock: - return self.codec.publish({"tasks": tasks}, submission_id=f"pending:{uuid.uuid4().hex}") + """Capture pending inputs and the producer cursor at one refill boundary.""" + with self._producer_lock: + self._source() + producer = self.queue.producer_state("dataset") + cursor = copy.deepcopy(producer["cursor"] if producer else self._initial_cursor) + tasks = [] + for spec in self.queue.pending_tasks(task_prefix="prompt:"): + value = asdict(spec) + value["input_ref"] = DiskPayloadRef(spec.input_ref, str(self.store.backend.root)) + tasks.append(value) + inflight = self.queue.metrics()["tasks"].get("leased", 0) > 0 + with self._writer_lock: + return self.codec.publish( + {"tasks": tasks, "producer_cursor": cursor, "inflight": inflight}, + submission_id=f"pending:{uuid.uuid4().hex}", + ) + + def fork_source(self, source_ref): + """Import a paused source snapshot into a new queue, sharing payload bytes. + + Task submissions and ready-result acceptance are idempotent so an + interrupted import can finish before any generation is admitted. + Receipt positions are local to a queue: ready groups receive new ones. + """ + from straw.protocol import digest + + from vime.data.transport import RolloutGroupRef + + if self.restore_plan.mode != "snapshot": + raise ValueError("Source forks require an isolated queue") + owner = f"fork-source:{self.queue.queue_id}" + with self._producer_lock, self._training_lock, self._writer_lock: + previous = self.queue.producer_state("fork-source") + if previous: + if previous["source"] != asdict(source_ref): + raise ValueError("Fork destination already belongs to a different checkpoint") + self._fork_ready = True + self._start_gc() + return RecordSetRef.from_dict(previous["restored"]) + state = self.codec.load(source_ref) + reader = state["reader"].load() + pending = reader["pending"].load() + if "producer_cursor" not in pending or pending.get("inflight", True): + raise ValueError("Fork requires a producer cursor and a drained source checkpoint") + if set(state["consumers"]) - {"fully_async"}: + raise ValueError("Fork does not support custom consumer state yet") + # Protect the entire source graph during import, including reader + # metadata that consumers will only load when they start later. + self.store.retain(owner, [source_ref]) + self.queue.submit_tasks( + f"fork-producer:{source_ref.digest}", + [], + producer_id="dataset", + producer_state={"version": 1, "cursor": pending["producer_cursor"]}, + ) + tasks = [] + for value in pending["tasks"]: + value = dict(value) + value["input_ref"] = value["input_ref"].manifest + value["metadata"] = {**value["metadata"], "source_positions": []} + tasks.append(TaskSpec(**value)) + for start in range(0, len(tasks), 64): + self.queue.submit_tasks(f"fork-pending:{source_ref.digest}:{start}", tasks[start : start + 64]) + consumers = state["consumers"] + if "fully_async" in consumers: + ready = [] + for index, (group, verdict) in enumerate(consumers["fully_async"]["scheduler"]["ready"]): + if group is None: + ready.append((group, verdict)) + continue + task_id = f"prompt:fork-ready:{source_ref.digest}:{index}" + self.queue.submit_tasks(task_id, [TaskSpec(task_id, group.manifest)]) + status = self.queue.task_status(task_id) + if status["state"] == "completed": + receipt = self.result(Lease(**status["lease"])) + else: + page = self.queue.acquire("fork-import", 1, task_ids=[task_id]) + if not page.assignments: + raise RuntimeError(f"Cannot import ready group: {page.status}") + lease = page.assignments[0].lease + # The native protocol binds result metadata to this new + # attempt. A small wrapper shares the old payload graph. + result = self.codec.publish( + DiskPayloadRef(group.manifest, group.root), + submission_id=f"fork-result:{lease.attempt_id}", + metadata={"task_id": lease.task_id, "attempt_id": lease.attempt_id}, + ) + receipt = self.complete(lease, result) + ready.append((RolloutGroupRef(receipt.result_ref, group.root, group.index, receipt, ()), verdict)) + consumers["fully_async"]["scheduler"]["ready"] = ready + self.reader_metadata("owner", reader["metadata"]) + restored = self.codec.publish(consumers, submission_id=f"fork-consumers:{source_ref.digest}") + self.store.retain(owner, [source_ref, restored]) + self.store.release_publications([restored]) + self.queue.submit_tasks( + f"fork-complete:{digest(asdict(source_ref))}", + [], + producer_id="fork-source", + producer_state={"source": asdict(source_ref), "restored": asdict(restored)}, + ) + self._fork_ready = True + self._start_gc() + return restored + + def _activate_fork(self): + if not self._fork_ready: + raise RuntimeError("Load the fork checkpoint before admitting rollout work") + if self.restore_plan.mode == "snapshot": + from straw.reporting import write_report + + with self._producer_lock: + if not self._fork_active: + self.queue.submit_tasks( + "fork-active", [], producer_id="fork-active", producer_state={"started": True} + ) + write_report( + Path(self.args.save) / "rollout" / "fork-active.json", {"queue_id": self.queue.queue_id} + ) + self._fork_active = True def pending_count(self): return sum(spec.metadata.get("returned", False) for spec in self.queue.pending_tasks(task_prefix="prompt:")) @@ -251,6 +486,14 @@ def reader_metadata(self, reader_id, metadata=None): return self.codec.load(RecordSetRef.from_dict(state["state_ref"])).get("metadata", {}) if state else {} def take(self, reader_id, count): + """Supply queued groups, refilling an empty queue from the dataset. + + The refill path takes original prompt groups from RolloutDataSource, + writes them to straw, then enqueues their references and the new dataset + cursor. Existing tasks, including partials, are acquired first. ``count`` + counts prompt groups, each containing n_samples_per_prompt samples. + """ + self._activate_fork() if reader_id in self._retired_readers: raise StaleAttempt("Reader was retired by the scheduler") requested = count @@ -263,6 +506,8 @@ def take(self, reader_id, count): if page.status != "empty": return page source = self._source() + # Start from the last committed cursor: a failed refill may have + # advanced the in-memory source without successfully enqueueing it. state = self.queue.producer_state("dataset") for key, value in (state["cursor"] if state else self._initial_cursor).items(): setattr(source, key, copy.deepcopy(value)) @@ -274,6 +519,7 @@ def take(self, reader_id, count): groups = source.get_samples(count) group_ids = [next(iter_samples(group)).group_index for group in groups] task_ids = [f"prompt:{group_id}" for group_id in group_ids] + # Persist the sample groups; queue tasks carry references only. with self._writer_lock: refs = self.codec.publish_many( groups, @@ -299,12 +545,15 @@ def take(self, reader_id, count): "metadata", ) } + # Commit tasks and the advanced cursor together, so a failed refill + # cannot skip dataset inputs on the next attempt. self.queue.submit_tasks( f"dataset:{start}", tasks, producer_id="dataset", producer_state={"version": 1, "cursor": cursor}, ) + # Return leases and references; readers load payloads from shared storage. return self.queue.acquire(reader_id, requested, task_prefix="prompt:") def heartbeat(self, leases): @@ -383,10 +632,10 @@ def recover_pending_rollout(self): Once conversion/training has started, queue progress alone cannot identify the model/optimizer version. That recovery requires a joint checkpoint. - --rollout-queue-resume asserts that all prior readers have stopped. + The restarted job must have stopped all prior readers. """ - if not getattr(self.args, "rollout_queue_resume", False): - raise RuntimeError("Whole-job recovery requires --rollout-queue-resume") + if self.restore_plan.mode != "resume": + raise RuntimeError("Whole-job replay requires automatic WAL recovery selection") with self._producer_lock, self._training_lock: state = self._training_state() if self.queue.batches or state["processed_cursor"] or state["processed_positions"]: @@ -408,6 +657,7 @@ def recover_pending_rollout(self): return self.codec.publish(receipts, submission_id=f"recover-job:{self.branch_id}") def begin_collection(self, request_id): + self._activate_fork() task_id = f"collection:{self.branch_id}:{request_id}" self.queue.submit_tasks( task_id, @@ -572,6 +822,8 @@ def handoff_restored_source(self, source_ref): a successor joint commit allows their normal retirement. """ with self._training_lock: + if self.restore_plan.mode == "snapshot": + self.store.retain(f"fork-source:{self.queue.queue_id}", [source_ref]) state = self._training_state() if "restored_source" in state: state["restored_source"] = DiskPayloadRef(source_ref, str(self.store.backend.root)) @@ -586,7 +838,7 @@ def restore_training_state(self, state_ref, fetch_cursor, processed_cursor, reta ): raise ValueError("Consumer checkpoint cursors differ from its state") if retained_ref is not None: - from vime.utils.rollout_transport import RolloutGroupRef + from vime.data.transport import RolloutGroupRef retained, visited = set(), set() @@ -676,34 +928,36 @@ def metrics(self): return self.queue.metrics() def close(self): - self._gc_stop.set() - if self._gc_thread is not None: - self._gc_thread.join() try: - self._queue.close() + self._gc_stop.set() + if self._gc_thread is not None: + self._gc_thread.join() + try: + self._queue.close() + finally: + with self._writer_lock: + self.store.seal() + self._check_gc_error() finally: - with self._writer_lock: - self.store.seal() - self._check_gc_error() + # Keep the checkpoint branch locked through queue and writer cleanup, + # including failures. Actor termination also closes this descriptor. + if self._checkpoint_lock is not None: + self._checkpoint_lock.close() -def create_queue_controller(args): +def create_queue_controller(args, *, restore_plan=None): + """Create one job-owned actor; callers retain and explicitly share its handle.""" resolve_rollout_data_dir(args) - if getattr(args, "_rollout_queue_controller", None) is None: - args._rollout_queue_controller = ( - ray.remote(RolloutQueueController) - .options( - num_cpus=0, - max_concurrency=4, - max_restarts=0, - scheduling_strategy=NodeAffinitySchedulingStrategy( - ray.get_runtime_context().get_node_id(), soft=False - ), - ) - .remote(copy.copy(args)) + return ( + ray.remote(RolloutQueueController) + .options( + num_cpus=0, + max_concurrency=4, + max_restarts=0, + scheduling_strategy=NodeAffinitySchedulingStrategy(ray.get_runtime_context().get_node_id(), soft=False), ) - args._rollout_queue_branch = ray.get(args._rollout_queue_controller.identity.remote())["branch_id"] - return args._rollout_queue_controller + .remote(copy.copy(args), restore_plan=restore_plan) + ) @dataclass(frozen=True) @@ -712,6 +966,7 @@ class QueueReaderConfig: controller: object reader_id: str dataset_size: int + branch_id: str | None = None def open(self, args=None): return QueueReader( @@ -719,6 +974,7 @@ def open(self, args=None): self.controller, self.reader_id, self.dataset_size, + branch_id=self.branch_id, ) @@ -729,9 +985,9 @@ class QueueReader(DataSource): leases. Job-wide consumers and checkpoint files belong to QueueDataSource. """ - def __init__(self, args, controller, reader_id, dataset_size): + def __init__(self, args, controller, reader_id, dataset_size, *, branch_id=None): self.args, self.controller, self.reader_id = args, controller, reader_id - self.args._rollout_queue_controller = controller + self.branch_id = branch_id or ray.get(controller.identity.remote())["branch_id"] self.dataset_size = dataset_size # Only actively executing leases are local. Returned work lives in the # durable queue and can be acquired by any reader after this one stops. @@ -809,6 +1065,7 @@ def get_samples(self, num_samples): sample.__dict__.pop("_queue_receipt", None) sample._queue_lease = asdict(lease) sample._queue_generation_start = len(sample.tokens) + sample._queue_branch = self.branch_id sample._queue_source_positions = assignment.task.metadata.get("source_positions", []) with self._lock: self._leases[lease.task_id] = lease @@ -860,7 +1117,7 @@ def add_samples(self, groups): leases = [group_lease(group) for group in groups_to_save] receipts = ray.get(self.controller.results.remote(leases)) for group, receipt in zip(groups_to_save, receipts, strict=True): - record_generation_provenance(group, self.args) + record_generation_provenance(group) if receipt: for sample in iter_samples(group): sample.__dict__.pop("_queue_lease", None) @@ -952,7 +1209,7 @@ def load_state_dict(self, state): self.update_metadata(value["metadata"]) def materialize_samples(self, samples, *, release_files=True): - from vime.utils.tensor_store import TensorRef + from vime.data.tensor import TensorRef if isinstance(samples, list): return [self.materialize_samples(child) for child in samples] @@ -997,14 +1254,16 @@ def close(self): class QueueDataSource(QueueReader): """Job-level data source: reader creation, consumers and source checkpoints. - The manager can read through the inherited QueueReader interface; distributed - workers open separate readers against the same controller. There is no extra - sample buffer or second implementation of get_samples/add_samples here. + The manager reads through the inherited QueueReader interface; distributed + workers open separate readers against the same controller. """ - def __init__(self, args): - self._owns_controller = getattr(args, "_rollout_queue_controller", None) is None - controller = create_queue_controller(args) + def __init__(self, args, *, controller=None, restore_plan=None): + self.restore_plan = restore_plan or RestorePlan() + self.restored_source = SourceRestore(new_queue=self.restore_plan.mode == "empty") + self._owns_controller = controller is None + if controller is None: + controller = create_queue_controller(args, restore_plan=self.restore_plan) self.data_config = ray.get(controller.configuration.remote()) super().__init__(args, controller, "owner", self.data_config["dataset_size"]) self.consumers, self._restored_consumers = {}, {} @@ -1012,7 +1271,7 @@ def __init__(self, args): def reader_config(self, reader_id): if reader_id == "owner": raise ValueError("Reader ID 'owner' is reserved") - return QueueReaderConfig(self.args, self.controller, reader_id, len(self)) + return QueueReaderConfig(self.args, self.controller, reader_id, len(self), self.branch_id) def register_consumer(self, name, consumer): if name in self.consumers: @@ -1029,7 +1288,7 @@ def save(self, rollout_id): for consumer in self.consumers.values(): paused.append((consumer, consumer.pause())) state = { - "version": 1, + "version": 2, "configuration": self.data_config, "reader": self.state_dict(), "consumers": { @@ -1053,9 +1312,12 @@ def save(self, rollout_id): "root": ref.root, "manifest": asdict(ref.manifest), "storage_owner": storage_owner, + "queue_id": self.data_config["queue_id"], }, ) - if getattr(self.args, "_queue_restored_source_ref", None) is not None: + if self.data_config["queue_id"].startswith("fork:"): + write_report(path.parent / "fork-active.json", {"queue_id": self.data_config["queue_id"]}) + if self.restored_source.source_ref is not None: ray.get(self.controller.handoff_restored_source.remote(ref.manifest)) finally: for consumer, was_paused in paused: @@ -1067,12 +1329,14 @@ def load(self, rollout_id=None): from straw.protocol import RecordSetRef - if not self.args.load: - return + if not self.args.load or self.restore_plan.mode == "empty": + return self.restored_source if self.consumers: raise RuntimeError("Restore before starting rollout consumers") path = Path(self.args.load) / "rollout" / f"queue_state_{rollout_id}.json" if not path.exists(): + if self.restore_plan.mode == "snapshot": + raise FileNotFoundError(path) return value = json.loads(path.read_text()) state = DiskPayloadRef(RecordSetRef.from_dict(value["manifest"]), value["root"]).load() @@ -1085,9 +1349,25 @@ def load(self, rollout_id=None): ): if state["configuration"][key] != self.data_config[key]: raise ValueError(f"Data source checkpoint differs in {key}") - self.load_state_dict(state["reader"]) - self._restored_consumers = state["consumers"] - self.args._queue_restored_source_ref = RecordSetRef.from_dict(value["manifest"]) + if self.restore_plan.mode == "snapshot": + if state["version"] != 2: + raise ValueError("Queue fork requires a checkpoint with producer state (source version 2)") + if state["configuration"].get("dataset") != self.data_config.get("dataset"): + raise ValueError("Data source checkpoint differs in dataset identity/configuration") + if Path(value["root"]).resolve() != Path(self.args.rollout_data_dir).resolve(): + raise ValueError("Copy-on-write fork requires the original straw pool") + ref = ray.get(self.controller.fork_source.remote(RecordSetRef.from_dict(value["manifest"]))) + self._restored_consumers = DiskPayloadRef(ref, self.args.rollout_data_dir).load() + else: + if state["configuration"].get("queue_id", self.data_config["queue_id"]) != self.data_config["queue_id"]: + raise ValueError("Data source checkpoint belongs to a different queue namespace") + self.load_state_dict(state["reader"]) + self._restored_consumers = state["consumers"] + self.restored_source = SourceRestore( + new_queue=self.restore_plan.mode == "snapshot", + source_ref=RecordSetRef.from_dict(value["manifest"]), + ) + return self.restored_source def close(self): if self._closed: @@ -1098,4 +1378,3 @@ def close(self): if self._owns_controller: ray.get(self.controller.close.remote()) ray.kill(self.controller, no_restart=True) - self.args._rollout_queue_controller = None diff --git a/vime/utils/tensor_store.py b/vime/data/tensor.py similarity index 100% rename from vime/utils/tensor_store.py rename to vime/data/tensor.py diff --git a/vime/utils/rollout_transport.py b/vime/data/transport.py similarity index 89% rename from vime/utils/rollout_transport.py rename to vime/data/transport.py index 7cac59056..ca256fcc5 100644 --- a/vime/utils/rollout_transport.py +++ b/vime/data/transport.py @@ -53,7 +53,7 @@ def rollout_store(args): name="straw", ) from error - from vime.rollout.queue_codec import CODECS, SampleCodec + from vime.data.codec import CODECS, SampleCodec root = str(Path(args.rollout_data_dir).resolve()) run_id = getattr(args, "rollout_queue_run_id", None) or "rollout" @@ -95,7 +95,7 @@ def load(self, *, root=None): from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES - from vime.rollout.queue_codec import CODECS, SampleCodec + from vime.data.codec import CODECS, SampleCodec store = SharedFilesystemStore( root or self.root, @@ -111,6 +111,8 @@ def load(self, *, root=None): class RolloutGroupRef(DiskPayloadRef): index: int receipt: CommitReceipt | None = None + # A fork reuses the payload but supplies positions in its own accepted log. + source_positions: tuple[int, ...] | None = None @dataclass(frozen=True) @@ -152,6 +154,7 @@ def inherit_queue_context(source, output): "_queue_source_positions", "_queue_resume_origin", "_queue_generation_start", + "_queue_branch", "queue_policy_segments", "queue_generation_requests", ) @@ -167,12 +170,12 @@ def inherit_queue_context(source, output): return output -def record_generation_provenance(group, args): +def record_generation_provenance(group): from vime.rollout.base_types import iter_samples for sample in iter_samples(group): start = getattr(sample, "_queue_generation_start", None) - branch = getattr(args, "_rollout_queue_branch", None) + branch = getattr(sample, "_queue_branch", None) if start is not None and branch is not None and len(sample.tokens) > start: segments = list(getattr(sample, "queue_policy_segments", [])) if not segments and start: @@ -189,21 +192,20 @@ def record_generation_provenance(group, args): sample._queue_generation_start = len(sample.tokens) -def pack_rollout_group(group, args, rollout_id): +def pack_rollout_group(group, args, rollout_id, *, controller=None): if args.rollout_data_transport != "straw": return group from vime.rollout.base_types import iter_samples first = next(iter_samples(group)) lease = group_lease(group) - record_generation_provenance(group, args) + record_generation_provenance(group) metadata = {"task_id": lease.task_id, "attempt_id": lease.attempt_id} if lease else {} ref = pack_rollout_payload( group, args, rollout_id, metadata=metadata, submission_id=f"group:{lease.attempt_id}" if lease else None ) receipt = None if lease: - controller = getattr(args, "_rollout_queue_controller", None) if controller is None: raise ValueError("Queue group has a lease but no coordinator binding") receipt = ray.get(controller.complete.remote(lease, ref.manifest)) @@ -236,13 +238,13 @@ def visit(value): release_tensor_publications(store, tensors) -def discard_rollout_group(group, args, reason="dynamic filter"): +def discard_rollout_group(group, args, reason="dynamic filter", *, controller=None): lease = group_lease(group) release_rollout_publications(group, args) # Release staging while the task/accepted-result owner still protects any # previously adopted continuation, then acknowledge that reading is done. if lease: - ray.get(args._rollout_queue_controller.reject.remote(lease, reason)) + ray.get(controller.reject.remote(lease, reason)) else: from vime.rollout.base_types import iter_samples @@ -251,7 +253,7 @@ def discard_rollout_group(group, args, reason="dynamic filter"): } if positions: decision = pack_rollout_payload({"positions": sorted(positions), "reason": reason}, args, -1) - ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + ray.get(controller.record_dispositions.remote(decision.manifest)) def load_rollout_samples(value): @@ -263,6 +265,8 @@ def load_rollout_samples(value): group = unpack_rollout_payload(reference) if isinstance(reference, RolloutGroupRef) and reference.receipt: for sample in iter_samples(group): + if reference.source_positions is not None: + sample._queue_source_positions = list(reference.source_positions) sample.__dict__.pop("_queue_lease", None) sample._queue_receipt = asdict(reference.receipt) groups.append(group) @@ -283,18 +287,20 @@ def pack_rollout_payload(value, args, rollout_id, *, metadata=None, submission_i return DiskPayloadRef(ref, str(store.backend.root)) -async def publish_rollout_async(value, args, rollout_id, *, group=False): - return await run_rollout_io(args, pack_rollout_group if group else pack_rollout_payload, value, args, rollout_id) +async def publish_rollout_async(value, args, rollout_id, *, group=False, controller=None): + if group: + return await run_rollout_io(args, pack_rollout_group, value, args, rollout_id, controller=controller) + return await run_rollout_io(args, pack_rollout_payload, value, args, rollout_id) -async def run_rollout_io(args, function, *values): +async def run_rollout_io(args, function, *values, **kwargs): """Bound the executor backlog before submitting encoding/sync work.""" loop = asyncio.get_running_loop() key = (os.getpid(), loop) if key not in _async_limits: _async_limits[key] = asyncio.Semaphore(getattr(args, "rollout_io_concurrency", 4)) async with _async_limits[key]: - pending = asyncio.create_task(asyncio.to_thread(function, *values)) + pending = asyncio.create_task(asyncio.to_thread(function, *values, **kwargs)) try: return await asyncio.shield(pending) except asyncio.CancelledError: @@ -315,23 +321,22 @@ def unpack_rollout_payload(value): return value -def accept_raw_rollout(output, args, rollout_id): - """Validate accepted producers, or commit a legacy collection once. +def accept_raw_rollout(output, args, rollout_id, *, controller): + """Validate an accepted collection, or persist and accept returned Samples. A small wrapper adds task provenance to existing sealed collections without rewriting their Sample or tensor payloads. """ from straw.protocol import Lease - controller = args._rollout_queue_controller if isinstance(output, RawRolloutRef): receipt = ray.get(controller.accepted.remote(output.receipt)) if output.manifest != receipt.result_ref: raise ValueError("Raw rollout manifest differs from its accepted receipt") return output - # Legacy custom producers may borrow queue inputs and return Samples without - # publishing task results themselves. Complete those inputs before accepting - # the compatibility collection, so closing the reader cannot requeue them. + # Custom producers can return Samples without publishing task results. + # Complete their borrowed inputs before accepting the collection, so closing + # the reader cannot requeue work that has already finished. from vime.rollout.base_types import iter_samples borrowed = {} @@ -345,7 +350,7 @@ def accept_raw_rollout(output, args, rollout_id): borrowed.setdefault(lease, []).append(sample) for lease, samples in borrowed.items(): if ray.get(controller.result.remote(lease)) is None: - pack_rollout_group(samples, args, rollout_id) + pack_rollout_group(samples, args, rollout_id, controller=controller) lease = ray.get(controller.begin_collection.remote(str(rollout_id))) store, codec, lock = rollout_store(args) with lock: diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index 001f348ae..d17ef0b5d 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -5,8 +5,8 @@ import numpy as np import torch +from vime.data.tensor import DiskTensorRef, TensorRef, materialize_tensor_refs, retain_debug_tensor_refs from vime.utils.routed_experts import validate_routed_experts_value -from vime.utils.tensor_store import DiskTensorRef, TensorRef, materialize_tensor_refs, retain_debug_tensor_refs from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -111,8 +111,15 @@ def validate_rollout_id_annotated(node, depth=0): def load_debug_rollout_data(path_template, *, rollout_id: int, subsample_ratio=None) -> list[Sample]: - data = torch.load(path_template.format(rollout_id=rollout_id), weights_only=False)["samples"] - data = [Sample.from_dict(sample) for sample in data] + path = path_template.format(rollout_id=rollout_id) + if path.endswith(".straw.json"): + from vime.data.archive import RolloutArchive + + with RolloutArchive(path) as archive: + data = archive.load_samples() + else: + data = torch.load(path, weights_only=False)["samples"] + data = [Sample.from_dict(sample) for sample in data] if subsample_ratio is not None: original_num_rows = len(data) rough_subsample_num_rows = int(original_num_rows * subsample_ratio) @@ -126,7 +133,7 @@ def load_debug_rollout_data(path_template, *, rollout_id: int, subsample_ratio=N return data -def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: bool) -> None: +def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: bool, args=None) -> None: if path_template is None: return @@ -134,6 +141,13 @@ def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: logger.info(f"Save debug rollout data to {path}") path.parent.mkdir(parents=True, exist_ok=True) + if str(path).endswith(".straw.json"): + from vime.data.archive import RolloutArchive + + samples = [sample for info in data.values() for sample in info["samples"]] if evaluation else data + RolloutArchive.save(path, samples, rollout_id=rollout_id, evaluation=evaluation, args=args) + return + if evaluation: samples = [sample.to_dict() for info in data.values() for sample in info["samples"]] else: diff --git a/vime/observability/rollout_metrics.py b/vime/observability/rollout_metrics.py index 3df92b605..bda44f6d2 100644 --- a/vime/observability/rollout_metrics.py +++ b/vime/observability/rollout_metrics.py @@ -4,6 +4,7 @@ import numpy as np import torch +from vime.data.tensor import DiskTensorRef, TensorRef from vime.observability import logging_utils from vime.observability.metric_utils import ( compute_pass_rate, @@ -14,7 +15,6 @@ ) from vime.utils.misc import group_by, load_function from vime.utils.staleness import compute_staleness_metrics -from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -264,7 +264,7 @@ def log_eval_rollout_data(rollout_id, args, data, extra_metrics: dict[str, Any] return log_dict -def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_time): +def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_time, *, weight_version=None): if args.custom_rollout_log_function_path is not None: custom_log_func = load_function(args.custom_rollout_log_function_path) if custom_log_func(rollout_id, args, samples, rollout_extra_metrics, rollout_time): @@ -274,9 +274,7 @@ def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_t return log_dict = {**(rollout_extra_metrics or {})} - log_dict |= dict_add_prefix( - compute_staleness_metrics(samples, getattr(args, "_rollout_weight_version", None)), "rollout/" - ) + log_dict |= dict_add_prefix(compute_staleness_metrics(samples, weight_version), "rollout/") log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), "rollout/") log_dict |= dict_add_prefix(compute_perf_metrics_from_samples(args, samples, rollout_time), "perf/") logger.info(f"perf {rollout_id}: {log_dict}") diff --git a/vime/observability/train_data_utils.py b/vime/observability/train_data_utils.py index 8098fab95..d86dbe3e6 100644 --- a/vime/observability/train_data_utils.py +++ b/vime/observability/train_data_utils.py @@ -5,7 +5,7 @@ import torch -from vime.utils.tensor_store import DiskTensorRef, TensorRef, materialize_tensor_refs +from vime.data.tensor import DiskTensorRef, TensorRef, materialize_tensor_refs logger = logging.getLogger(__name__) diff --git a/vime/ray/actor_group.py b/vime/ray/actor_group.py index c3725d14c..d792f3fcb 100644 --- a/vime/ray/actor_group.py +++ b/vime/ray/actor_group.py @@ -148,21 +148,21 @@ def async_train(self, rollout_id, rollout_data_ref, external_data=None): for actor in self._actor_handlers ] - def save_model(self, rollout_id, force_sync=False): - """Save actor model""" - ret = ray.get([actor.save_model.remote(rollout_id, force_sync=force_sync) for actor in self._actor_handlers]) + def save_model(self, rollout_id, force_sync=False) -> None: + """Save on all ranks; force_sync also waits for asynchronous writes.""" + ray.get([actor.save_model.remote(rollout_id, force_sync=force_sync) for actor in self._actor_handlers]) if self._release_train_enabled(): self.args.load = self.args.save self.args.ckpt_step = None self.args.finetune = False self.args.no_load_optim = self.args.no_save_optim self.args.no_load_rng = False - return ret - def update_weights(self): - """Broadcast weights from rank 0 to all other ranks.""" + def update_weights(self) -> None: + """Publish actor weights; disk reload is coordinated after all ranks save.""" if not self._full_disk_weight_update_enabled(): - return ray.get([actor.update_weights.remote() for actor in self._actor_handlers]) + ray.get([actor.update_weights.remote() for actor in self._actor_handlers]) + return weight_version = self._disk_weight_version + 1 disk_weight_dir = Path(self.args.update_weight_disk_dir) / f"weight_v{weight_version:06d}" diff --git a/vime/ray/placement_group.py b/vime/ray/placement_group.py index ef88e2b11..ae15779a1 100644 --- a/vime/ray/placement_group.py +++ b/vime/ray/placement_group.py @@ -223,7 +223,7 @@ def create_training_models(args, pgs, rollout_manager, actor_cls=None): return actor_model, critic_model -def create_rollout_manager(args, pg): +def create_rollout_manager(args, pg, *, restore_plan=None): from .rollout import RolloutManager rollout_manager_options = { @@ -233,7 +233,7 @@ def create_rollout_manager(args, pg): } if getattr(args, "rollout_data_transport", "object-store") == "nixl": rollout_manager_options["enable_tensor_transport"] = True - rollout_manager = RolloutManager.options(**rollout_manager_options).remote(args, pg) + rollout_manager = RolloutManager.options(**rollout_manager_options).remote(args, pg, restore_plan=restore_plan) # calculate num_rollout from num_epoch num_rollout_per_epoch = None diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 2f92e9afe..3592a4c9c 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -6,6 +6,9 @@ import psutil import ray +from vime.data.batch_builder import BatchBuilder +from vime.data.tensor import DiskTensorRef +from vime.data.transport import accept_raw_rollout, check_rollout_storage, load_rollout_samples, seal_rollout_store from vime.observability import logging_utils from vime.observability.logging_utils import configure_logger, init_tracking from vime.observability.rollout_data_utils import ( @@ -15,20 +18,12 @@ ) from vime.observability.rollout_metrics import log_eval_rollout_data, log_rollout_data from vime.rollout.base_types import call_rollout_fn -from vime.rollout.batch_builder import BatchBuilder from vime.rollout.sample_hooks import set_current_rollout_id from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client from vime.utils.memory_utils import get_process_host_memory_gib from vime.utils.misc import load_function -from vime.utils.rollout_transport import ( - accept_raw_rollout, - check_rollout_storage, - load_rollout_samples, - seal_rollout_store, -) from vime.utils.staleness import fully_async_metrics_enabled -from vime.utils.tensor_store import DiskTensorRef from .utils import Lock, add_default_ray_env_vars @@ -42,16 +37,16 @@ class RolloutManager: """The class to run rollout and convert rollout data to training data.""" - def __init__(self, args, pg): + def __init__(self, args, pg, *, restore_plan=None): configure_logger() self.pg = pg self.args = args + self.controller = None + self._owns_controller = False + self.weight_version = None if args.rollout_data_transport == "straw": check_rollout_storage(args) - from vime.rollout.queue_data_source import create_queue_controller - - create_queue_controller(args) rollout_init_handles: list[Any] = [] if self.args.debug_train_only: @@ -63,11 +58,26 @@ def __init__(self, args, pg): self.servers, rollout_init_handles = start_rollout_servers(args, pg) data_source_cls = load_function(self.args.data_source_path) - self.data_source = data_source_cls(args) + if args.rollout_data_transport == "straw": + from vime.data.queue_data_source import QueueDataSource, QueueReader, create_queue_controller + + if data_source_cls is QueueDataSource: + self.controller = create_queue_controller(args, restore_plan=restore_plan) + self._owns_controller = True + self.data_source = data_source_cls(args, controller=self.controller, restore_plan=restore_plan) + else: + self.data_source = data_source_cls(args) + if isinstance(self.data_source, QueueReader): + self.controller = self.data_source.controller + else: + self.controller = create_queue_controller(args, restore_plan=restore_plan) + self._owns_controller = True + else: + self.data_source = data_source_cls(args) self.generate_rollout = load_function(self.args.rollout_function_path) self.eval_generate_rollout = load_function(self.args.eval_function_path) - self.batch_builder = BatchBuilder(args) + self.batch_builder = BatchBuilder(args, controller=self.controller) logger.info(f"import {self.args.rollout_function_path} as generate_rollout function.") logger.info(f"import {self.args.eval_function_path} as eval_generate_rollout function.") @@ -153,10 +163,12 @@ def dispose(self): self.cleanup_rollout_data(rollout_id) if close := getattr(self.data_source, "close", None): close() - if controller := getattr(self.args, "_rollout_queue_controller", None): + if self._owns_controller: + controller = self.controller ray.get(controller.close.remote()) ray.kill(controller, no_restart=True) - self.args._rollout_queue_controller = None + self.controller = None + self._owns_controller = False seal_rollout_store(self.args) engines = [engine for server in self.servers.values() for engine in server.all_engines if engine is not None] if engines: @@ -245,8 +257,11 @@ def generate(self, rollout_id): data, rollout_id=rollout_id, evaluation=False, + args=self.args, + ) + log_rollout_data( + rollout_id, self.args, data, metrics, time.time() - start_time, weight_version=self.weight_version ) - log_rollout_data(rollout_id, self.args, data, metrics, time.time() - start_time) if self.args.debug_rollout_only: # if debug rollout only, we don't convert samples to train data and directly return return @@ -270,20 +285,33 @@ def eval(self, rollout_id): data, rollout_id=rollout_id, evaluation=True, + args=self.args, ) log_eval_rollout_data(rollout_id, self.args, data, result.metrics) def save(self, rollout_id): - self.data_source.save(rollout_id) - self.batch_builder.save(rollout_id) + paused = [] + try: + for consumer in getattr(self.data_source, "consumers", {}).values(): + paused.append((consumer, consumer.pause())) + self.data_source.save(rollout_id) + self.batch_builder.save(rollout_id) + finally: + for consumer, was_paused in paused: + if not was_paused: + consumer.resume() def training_completed(self, rollout_id): self.batch_builder.training_completed(rollout_id) self.cleanup_rollout_data(rollout_id) def load(self, rollout_id=None): - self.data_source.load(rollout_id) - self.batch_builder.load(rollout_id) + from vime.data.checkpoint import SourceRestore + + source_restore = self.data_source.load(rollout_id) + self.batch_builder.load( + rollout_id, source_restore=source_restore if isinstance(source_restore, SourceRestore) else None + ) def offload(self): self.health_monitoring_pause() @@ -350,10 +378,10 @@ def _get_rollout_data(self, rollout_id): valid = bool(versions) and all( str(version).isascii() and str(version).isdigit() for version in versions ) - self.args._rollout_weight_version = max(map(int, versions)) if valid else None + self.weight_version = max(map(int, versions)) if valid else None data = call_rollout_fn(self.generate_rollout, self.args, rollout_id, self.data_source, evaluation=False) if self.args.rollout_data_transport == "straw": - data = accept_raw_rollout(data, self.args, rollout_id) + data = accept_raw_rollout(data, self.args, rollout_id, controller=self.controller) self.batch_builder.raw_ref = data metrics = data.metrics data = load_rollout_samples(data) diff --git a/vime/rollout/base_types.py b/vime/rollout/base_types.py index 0510a3ff2..9b995ac96 100644 --- a/vime/rollout/base_types.py +++ b/vime/rollout/base_types.py @@ -6,12 +6,12 @@ from vime.utils.types import Sample if TYPE_CHECKING: - from vime.utils.rollout_transport import DiskPayloadRef + from vime.data.transport import DiskPayloadRef @dataclass class RolloutFnTrainOutput: - # straw rollouts return a batch manifest; legacy functions may return Samples. + # Accept Sample groups or a straw manifest referencing the selected batch. samples: list[list[Sample]] | DiskPayloadRef metrics: dict[str, Any] = None @@ -23,7 +23,7 @@ class RolloutFnEvalOutput: def call_rollout_fn(fn, *args, evaluation: bool, **kwargs): - from vime.utils.rollout_transport import DiskPayloadRef, RawRolloutRef + from vime.data.transport import DiskPayloadRef, RawRolloutRef output = fn(*args, **kwargs, evaluation=evaluation) if isinstance(output, RawRolloutRef): @@ -46,10 +46,10 @@ def call_rollout_fn(fn, *args, evaluation: bool, **kwargs): raise TypeError(f"Unsupported rollout output: {type(output).__name__}") -def finalize_rollout_groups(args, rollout_id, groups, metrics=None): +def finalize_rollout_groups(args, rollout_id, groups, metrics=None, *, controller=None): """Order selected groups, run the batch hook once, and publish the batch.""" + from vime.data.transport import RolloutGroupRef, load_rollout_samples, pack_rollout_payload from vime.utils.misc import load_function - from vime.utils.rollout_transport import RolloutGroupRef, load_rollout_samples, pack_rollout_payload groups.sort( key=lambda group: (group.index if isinstance(group, RolloutGroupRef) else next(iter_samples(group)).index) or 0 @@ -79,7 +79,7 @@ def finalize_rollout_groups(args, rollout_id, groups, metrics=None): decision = pack_rollout_payload( {"positions": sorted(dropped), "reason": "rollout_sample_filter", "output": samples}, args, rollout_id ) - ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + ray.get(controller.record_dispositions.remote(decision.manifest)) elif incoming and args.rollout_data_transport != "straw": import ray @@ -88,7 +88,7 @@ def finalize_rollout_groups(args, rollout_id, groups, metrics=None): args, rollout_id, ) - ray.get(args._rollout_queue_controller.record_dispositions.remote(decision.manifest)) + ray.get(controller.record_dispositions.remote(decision.manifest)) return RolloutFnTrainOutput(samples=samples, metrics=metrics) diff --git a/vime/rollout/fully_async_distributed.py b/vime/rollout/fully_async_distributed.py index df447b21c..364b6aca2 100644 --- a/vime/rollout/fully_async_distributed.py +++ b/vime/rollout/fully_async_distributed.py @@ -13,6 +13,14 @@ from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy from straw.protocol import CommitReceipt +from vime.data.transport import ( + DiskPayloadRef, + RolloutGroupRef, + discard_rollout_group, + pack_rollout_payload, + publish_rollout_async, + unpack_rollout_payload, +) from vime.observability import logging_utils from vime.observability.rollout_data_utils import validate_rollout_id_annotated from vime.ray.utils import add_default_ray_env_vars @@ -22,14 +30,6 @@ from vime.utils.async_utils import get_async_loop from vime.utils.http_utils import get_rollout_num_engines, init_http_client from vime.utils.misc import load_function -from vime.utils.rollout_transport import ( - DiskPayloadRef, - RolloutGroupRef, - discard_rollout_group, - pack_rollout_payload, - publish_rollout_async, - unpack_rollout_payload, -) from vime.utils.types import Sample @@ -40,8 +40,9 @@ class RolloutScheduler: completed prefetch, so slow training applies backpressure to local pools. """ - def __init__(self, args, workers, capacities): + def __init__(self, args, workers, capacities, *, controller=None): self.args = args + self.controller = controller self.workers = workers self.capacities = capacities self.capacity = sum(capacities) @@ -199,7 +200,7 @@ def generate(self, rollout_id, *, prefetch): metrics["rollout/dynamic_filter/dropped_groups"] = dropped_count metrics["rollout/dynamic_filter/dropped_ratio"] = dropped_count / (len(groups) + dropped_count) # Publish a manifest of group references outside the scheduler lock. - return finalize_rollout_groups(self.args, rollout_id, groups, metrics) + return finalize_rollout_groups(self.args, rollout_id, groups, metrics, controller=self.controller) def pause(self, *, drain=True): """Stop admission; optionally wait for in-flight results to become durable.""" @@ -378,13 +379,18 @@ async def _generate_group(self, rollout_id, group=None): group, self.args, verdict.reason or "dynamic_filter", + controller=self.data_source.controller, ) return None, verdict if self.args.rollout_data_transport == "straw": - group = await publish_rollout_async(group, self.args, rollout_id, group=True) + group = await publish_rollout_async( + group, self.args, rollout_id, group=True, controller=self.data_source.controller + ) else: - if getattr(self.args, "_rollout_queue_controller", None) is not None: - accepted = await publish_rollout_async(group, self.args, rollout_id, group=True) + if getattr(self.data_source, "controller", None) is not None: + accepted = await publish_rollout_async( + group, self.args, rollout_id, group=True, controller=self.data_source.controller + ) if accepted.receipt is not None: for sample in iter_samples(group): sample.__dict__.pop("_queue_lease", None) @@ -420,13 +426,14 @@ async def _flush_rebuffers(self): self._rebuffer_task = None def state_dict(self): - state = self.data_source.state_dict(include_pending=False) - if self.args.rollout_data_transport == "straw": - state = pack_rollout_payload(state, self.args, self.rollout_id) - return state + return self.data_source.state_dict(include_pending=False) def load_state_dict(self, state): - self.data_source.load_state_dict(unpack_rollout_payload(state)) + state = unpack_rollout_payload(state) + if isinstance(state, dict) and state.get("worker_state") == 1: + # Older worker snapshots wrapped the reader with RNG state. + state = state["reader"] + self.data_source.load_state_dict(state) async def close(self): future = asyncio.run_coroutine_threadsafe(self._close(), get_async_loop().loop) @@ -443,7 +450,7 @@ async def _close(self): # closing their files or killing this process. await asyncio.get_running_loop().shutdown_default_executor() self.data_source.close() - from vime.utils.rollout_transport import seal_rollout_store + from vime.data.transport import seal_rollout_store seal_rollout_store(self.args) @@ -455,11 +462,7 @@ def _recover_worker_results(self, worker): if self.args.rollout_data_transport != "straw": return delivered = pack_rollout_payload(sorted(self.delivered), self.args, self.rollout_id) - ref = ray.get( - self.args._rollout_queue_controller.recover_reader_results.remote( - f"fully_async_{worker}", delivered.manifest - ) - ) + ref = ray.get(self.controller.recover_reader_results.remote(f"fully_async_{worker}", delivered.manifest)) for value in DiskPayloadRef(ref, self.args.rollout_data_dir).load(): receipt = CommitReceipt.from_dict(value) payload = DiskPayloadRef(receipt.result_ref, self.args.rollout_data_dir) @@ -472,8 +475,8 @@ def __init__(self, args, data_source): if args.rollout_all_samples_process_path is not None: raise ValueError("--rollout-all-samples-process-path is not supported by distributed fully-async rollout") recovered = [] - if getattr(args, "rollout_queue_resume", False) and "fully_async" not in data_source._restored_consumers: - ref = ray.get(args._rollout_queue_controller.recover_pending_rollout.remote()) + if data_source.restore_plan.mode == "resume" and "fully_async" not in data_source._restored_consumers: + ref = ray.get(data_source.controller.recover_pending_rollout.remote()) for value in DiskPayloadRef(ref, args.rollout_data_dir).load(): receipt = CommitReceipt.from_dict(value) payload = DiskPayloadRef(receipt.result_ref, args.rollout_data_dir) @@ -521,7 +524,7 @@ def __init__(self, args, data_source): for worker in workers: ray.kill(worker) raise - super().__init__(args, workers, capacities) + super().__init__(args, workers, capacities, controller=data_source.controller) self.ready.extend(recovered) self.delivered.update(group.receipt.position for group, _ in recovered) logging.getLogger(__name__).info( diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index 2a8e2fdf6..4a0926c60 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -35,13 +35,13 @@ import threading import time +from vime.data.transport import discard_rollout_group, publish_rollout_async from vime.rollout.base_types import RolloutFnTrainOutput, finalize_rollout_groups from vime.rollout.filter_hub.base_types import call_dynamic_filter from vime.rollout.vllm_rollout import GenerateState, generate_and_rm_group from vime.utils.async_utils import run from vime.utils.http_utils import get_rollout_num_engines from vime.utils.misc import load_function -from vime.utils.rollout_transport import discard_rollout_group, publish_rollout_async from vime.utils.types import Sample __all__ = [ @@ -254,11 +254,19 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout verdict = call_dynamic_filter(dynamic_filter, args, group) if verdict.keep: if args.rollout_data_transport == "straw": - group = await publish_rollout_async(group, args, rollout_id, group=True) + group = await publish_rollout_async( + group, args, rollout_id, group=True, controller=getattr(data_buffer, "controller", None) + ) collected.append(group) continue - await asyncio.to_thread(discard_rollout_group, group, args, verdict.reason or "dynamic_filter") + await asyncio.to_thread( + discard_rollout_group, + group, + args, + verdict.reason or "dynamic_filter", + controller=getattr(data_buffer, "controller", None), + ) reason = verdict.reason or "dynamic_filter" dropped_count += 1 drop_reasons[reason] = drop_reasons.get(reason, 0) + 1 @@ -292,10 +300,17 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout metrics["rollout/dynamic_filter/dropped_groups"] = dropped_count metrics["rollout/dynamic_filter/dropped_ratio"] = dropped_count / (len(collected) + dropped_count) if args.rollout_sample_filter_path is not None: - output = finalize_rollout_groups(args, rollout_id, collected, metrics) + output = finalize_rollout_groups( + args, rollout_id, collected, metrics, controller=getattr(data_buffer, "controller", None) + ) else: output = await asyncio.to_thread( - finalize_rollout_groups, args, rollout_id, collected, metrics if filters_enabled else None + finalize_rollout_groups, + args, + rollout_id, + collected, + metrics if filters_enabled else None, + controller=getattr(data_buffer, "controller", None), ) return output if filters_enabled or args.rollout_data_transport == "straw" else output.samples @@ -306,7 +321,7 @@ def generate_rollout_fully_async(args, rollout_id, data_buffer, evaluation: bool if evaluation: raise ValueError("fully-async rollout doesn't support evaluation mode") if getattr(args, "rollout_data_transport", "object-store") == "straw": - from vime.rollout.queue_data_source import QueueDataSource + from vime.data.queue_data_source import QueueDataSource if isinstance(data_buffer, QueueDataSource): from vime.rollout.fully_async_distributed import DistributedRollout diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index f54d6fb0b..446f788f5 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -15,6 +15,7 @@ from tqdm import tqdm from vime.backends.vllm_utils.server_control import abort_servers_until_idle +from vime.data.transport import discard_rollout_group, inherit_queue_context, publish_rollout_async from vime.observability.trace_utils import build_vllm_meta_trace_attrs, trace_function, trace_span from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput, finalize_rollout_groups from vime.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter, should_drop_dynamic_filter_output @@ -30,7 +31,6 @@ load_processor, load_tokenizer, ) -from vime.utils.rollout_transport import discard_rollout_group, inherit_queue_context, publish_rollout_async from vime.utils.types import Sample from .rm_hub import async_rm, batched_async_rm diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index c8c4e6c3b..17e13cbcf 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -583,7 +583,7 @@ def add_rollout_arguments(parser): help=( "Shared directory for straw rollout payloads, mounted at the same absolute path on all nodes. " "Defaults to /rollout_data. Required for straw transport when --save is unset. " - "Files are retained for buffered samples, checkpoints and debug dumps." + "Files are retained for pending samples, checkpoints and debug archives." ), ) parser.add_argument("--rollout-storage-profile", choices=["local", "juicefs"], default="local") @@ -596,11 +596,6 @@ def add_rollout_arguments(parser): default="rollout", help="Persistent queue run identity within rollout-data-dir.", ) - parser.add_argument( - "--rollout-queue-resume", - action="store_true", - help="Recover an existing run; requires the prior coordinator and its job to be stopped.", - ) parser.add_argument( "--rollout-queue-online-gc", action="store_true", @@ -685,8 +680,8 @@ def add_data_arguments(parser): default=None, help=( "The data source class. straw transport defaults to " - "vime.rollout.queue_data_source.QueueDataSource; other transports use " - "vime.rollout.data_source.RolloutDataSourceWithBuffer. Custom classes remain supported." + "vime.data.queue_data_source.QueueDataSource; other transports use " + "vime.data.data_source.RolloutDataSourceWithBuffer. Custom classes remain supported." ), ) parser.add_argument( @@ -1360,7 +1355,8 @@ def add_debug_arguments(parser): default=None, help=( "Save the rollout data to this path for debugging. " - "The file will be saved to `save_debug_rollout_data.format(rollout_id)`." + "Use a {rollout_id} template; .straw.json retains an indexed straw archive with lazy tensors, " + "other suffixes write the self-contained legacy .pt format." ), ) # --load-debug-rollout-data, --debug-rollout-only, --debug-train-only @@ -1806,7 +1802,12 @@ def _pre_parse_mode(): return temp_args -def parse_args(add_custom_arguments=None): +def parse_args(add_custom_arguments=None, *, return_restore_plan=False): + """Return configuration, optionally paired with the driver's restore plan. + + Custom argument providers and existing callers keep the Namespace contract; + queue connections and recovery progress are never attached to args. + """ # Users may call `parse_args` very early, thus we ensure logger is configured here configure_logger() @@ -1841,7 +1842,7 @@ def parse_args(add_custom_arguments=None): for key, value in vars(vllm_ns).items(): setattr(args, key, value) - vime_validate_args(args) + restore_plan = vime_validate_args(args) if not args.debug_rollout_only: megatron_validate_args(args) @@ -1849,7 +1850,7 @@ def parse_args(add_custom_arguments=None): if not args.debug_train_only: vllm_validate_args(args) - return args + return (args, restore_plan) if return_restore_plan else args def _apply_megatron_role_overrides(base_args, overrides, role): @@ -2029,6 +2030,11 @@ def vime_validate_args(args): if args.opd_teacher_load is not None: raise ValueError("--opd-teacher-load is set but --use-opd is not enabled. Please add --use-opd flag.") + # Resolve the logical checkpoint directory before the model loader's + # HuggingFace/finetune fallback can replace --load or disable optimizer load. + from vime.data.checkpoint import resolve_checkpoint + + restore_plan = resolve_checkpoint(args) load_is_megatron = ( args.load is not None and os.path.exists(args.load) @@ -2070,16 +2076,15 @@ def vime_validate_args(args): if args.data_source_path is None: args.data_source_path = ( - "vime.rollout.queue_data_source.QueueDataSource" + "vime.data.queue_data_source.QueueDataSource" if args.rollout_data_transport == "straw" - else "vime.rollout.data_source.RolloutDataSourceWithBuffer" + else "vime.data.data_source.RolloutDataSourceWithBuffer" ) if args.rollout_data_transport != "straw": - if args.data_source_path == "vime.rollout.queue_data_source.QueueDataSource": + if args.data_source_path == "vime.data.queue_data_source.QueueDataSource": raise ValueError("QueueDataSource requires --rollout-data-transport straw") - for name in ("rollout_queue_resume", "rollout_queue_online_gc"): - if getattr(args, name, False): - raise ValueError(f"--{name.replace('_', '-')} requires --rollout-data-transport straw") + if getattr(args, "rollout_queue_online_gc", False): + raise ValueError("--rollout-queue-online-gc requires --rollout-data-transport straw") for name in ( "rollout_queue_lease_seconds", "rollout_queue_max_pending", @@ -2091,7 +2096,7 @@ def vime_validate_args(args): raise ValueError(f"--{name.replace('_', '-')} must be positive") if args.rollout_data_transport == "straw": - from vime.utils.rollout_transport import resolve_rollout_data_dir + from vime.data.transport import resolve_rollout_data_dir if getattr(args, "buffer_filter_path", None) is not None: raise ValueError( @@ -2356,3 +2361,5 @@ def vime_validate_args(args): "--update-weight-mode=delta requires --update-weight-local-checkpoint-dir " "(a rollout-host-local NVMe directory)." ) + + return restore_plan diff --git a/vime/utils/data.py b/vime/utils/data.py index 91d1a2b01..b1f329b94 100644 --- a/vime/utils/data.py +++ b/vime/utils/data.py @@ -302,7 +302,7 @@ def __len__(self): def process_rollout_data(rollout_data_ref, dp_rank, dp_size): assert len(rollout_data_ref) == dp_size - from vime.utils.rollout_transport import TrainBatchRef, unpack_rollout_payload + from vime.data.transport import TrainBatchRef, unpack_rollout_payload reference = ray.get(rollout_data_ref[dp_rank].inner) if isinstance(reference, TrainBatchRef): diff --git a/vime/utils/routed_experts.py b/vime/utils/routed_experts.py index 89fe81a8d..7265f01fb 100644 --- a/vime/utils/routed_experts.py +++ b/vime/utils/routed_experts.py @@ -10,8 +10,8 @@ import torch +from vime.data.tensor import DiskTensorRef, TensorRef from vime.utils import accelerator -from vime.utils.tensor_store import DiskTensorRef, TensorRef from vime.utils.types import Sample logger = logging.getLogger(__name__) diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py index b32716281..94d4ab82c 100644 --- a/vime/utils/score_centering.py +++ b/vime/utils/score_centering.py @@ -8,8 +8,8 @@ import numpy as np import torch +from vime.data.tensor import DiskTensorRef, TensorRef from vime.utils.ppo_utils import get_pg_loss_type, importance_weights -from vime.utils.tensor_store import DiskTensorRef, TensorRef SAMPLER_TOPK_FIELDS = ("rollout_topk_token_ids", "rollout_topk_log_probs") diff --git a/vime/utils/types.py b/vime/utils/types.py index c8ff2fdd5..1d19c2793 100644 --- a/vime/utils/types.py +++ b/vime/utils/types.py @@ -6,8 +6,8 @@ import numpy as np import torch +from vime.data.tensor import DiskTensorRef, TensorRef from vime.utils.misc import decode_int32_meta_array -from vime.utils.tensor_store import DiskTensorRef, TensorRef _TOP_P_TOKEN_ID_META_KEYS = ("top_p_token_ids", "top_p_kept_token_ids") _TOP_P_TOKEN_OFFSET_META_KEYS = ("top_p_token_offsets", "top_p_kept_token_offsets") From 389da74da4a3f2ab6d6dbcfaf0d56e681ca33554 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 01:55:58 +0000 Subject: [PATCH 17/58] ci: install straw dependency for sync tests Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 965635224..553feb0c0 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -143,6 +143,7 @@ steps: -v "$$PWD:/workspace" -w /workspace \ "$${VIME_CI_IMAGE:-vllm/vime:latest}" bash -lc ' set -euo pipefail + pip install -q straw-queue --break-system-packages pip install -q -e . --no-deps --break-system-packages for test_file in \ tests/test_advantage_whiten_cp.py \ From 17c155f997538cca1bd3f5f2a304f932e0bf614f Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 03:26:09 +0000 Subject: [PATCH 18/58] test: pin vllm generation config for score centering Signed-off-by: aoshen02 --- tests/test_qwen2.5_0.5B_score_centering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_qwen2.5_0.5B_score_centering.py b/tests/test_qwen2.5_0.5B_score_centering.py index 68504affa..550e3806d 100644 --- a/tests/test_qwen2.5_0.5B_score_centering.py +++ b/tests/test_qwen2.5_0.5B_score_centering.py @@ -30,6 +30,7 @@ def execute(top_p=1.0): "--input-key messages --label-key label --apply-chat-template --rm-type math " "--num-rollout 2 --rollout-batch-size 2 --n-samples-per-prompt 4 " f"--rollout-max-response-len 256 --rollout-temperature 0.8 --rollout-top-p {top_p} --rollout-top-k -1 " + "--vllm-generation-config vllm " "--global-batch-size 8 --use-score-centering --score-centering-top-k 128 " "--rollout-data-transport straw --rollout-queue-online-gc " f"--rollout-data-dir {quote(str(Path(directory) / 'queue'))} " From 29e0517870081f9c56ec898844430c9c6d3b8ddd Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 04:56:32 +0000 Subject: [PATCH 19/58] fix: bind straw coordinator in vllm rollout Signed-off-by: aoshen02 --- vime/rollout/vllm_rollout.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 446f788f5..7cd8e2348 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -787,6 +787,7 @@ async def generate_rollout_async( """ state = GenerateState(args) + controller = getattr(getattr(data_source, "__self__", None), "controller", None) # instantiate data filters dynamic_filter = ( @@ -837,7 +838,11 @@ async def generate_rollout_async( ): if not keep_all_samples: await asyncio.to_thread( - discard_rollout_group, group, args, dynamic_filter_output.reason or "dynamic_filter" + discard_rollout_group, + group, + args, + dynamic_filter_output.reason or "dynamic_filter", + controller=controller, ) metric_gatherer.on_dynamic_filter_drop(reason=dynamic_filter_output.reason) state.remaining_batch_size -= 1 @@ -848,7 +853,7 @@ async def generate_rollout_async( if len(data) < target_data_size: sample = group[0][0] if isinstance(group[0], list) else group[0] if args.rollout_data_transport == "straw" and not keep_all_samples: - group = await publish_rollout_async(group, args, rollout_id, group=True) + group = await publish_rollout_async(group, args, rollout_id, group=True, controller=controller) data.append(group) pbar.update(args.n_samples_per_prompt) @@ -871,7 +876,10 @@ async def generate_rollout_async( process_func = load_function(args.rollout_all_samples_process_path) process_func(args, all_data, data_source) if args.rollout_data_transport == "straw": - data = [await publish_rollout_async(group, args, rollout_id, group=True) for group in data] + data = [ + await publish_rollout_async(group, args, rollout_id, group=True, controller=controller) + for group in data + ] data = await publish_rollout_async(data, args, rollout_id) output = RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()) elif args.rollout_sample_filter_path is not None: From 1ae8210daad9f1e524a605a26595dcc3bb292ee5 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 05:53:23 +0000 Subject: [PATCH 20/58] docs: record full GPU gate procedure Signed-off-by: aoshen02 --- .buildkite/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.buildkite/README.md b/.buildkite/README.md index 11876bf03..bf2a45327 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -61,6 +61,10 @@ the per-test `VIME_TEST_USE_DEEPEP` / `VIME_TEST_USE_FP8_ROLLOUT` / The block uses `blocked_state: passed`, so a build whose CPU steps are green reports a passing commit status even if nobody unblocks the GPU gate. +For sync validation, always select all six suites. The REST unblock payload +passes the selection directly, for example `{"gpu-suites": "short\nvllm-config\nmegatron\nvime-customized\nprecision\nckpt"}`; +do not wrap it in a second `fields` object. + GPU jobs run on the shared **`mithril-h100-pool`** queue, following the same pattern vllm-omni uses for it: each job is a Kubernetes pod (agent-stack-k8s `kubernetes` plugin) on an H100 SXM node, with GPUs allocated via From eaa075d7a069b2c18a3e4b9b3ac943335c14d47a Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 11:32:38 +0000 Subject: [PATCH 21/58] sync: mirror Slime #2432 Straw queue and replay changes Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 6 +- docker/version.txt | 2 +- docs/en/advanced/straw.md | 23 ++- docs/en/get_started/usage.md | 6 +- docs/zh/advanced/straw.md | 14 +- docs/zh/get_started/usage.md | 6 +- requirements.txt | 2 +- tests/test_megatron_argument_validation.py | 15 +- tests/test_published_payload_retry.py | 66 ++++++++ tests/test_queue_sample_codec.py | 9 +- ...t_qwen2.5_0.5B_debug_rollout_then_train.py | 38 +++-- tests/test_rollout_metadata_index.py | 94 +++++++++++ tests/test_rollout_straw_transport.py | 141 ++++++++++++++++ tests/test_score_centering.py | 31 +++- tests/test_score_centering_transport.py | 42 +++++ tests/test_straw_fully_async_recovery.py | 2 - vime/backends/megatron_utils/actor.py | 6 + .../update_weight_from_tensor.py | 8 + vime/data/archive.py | 63 +++++++- vime/data/batch_builder.py | 9 +- vime/data/codec.py | 65 ++++---- vime/data/queue_data_source.py | 12 +- vime/data/sample_metadata.py | 85 ++++++++++ vime/data/transport.py | 150 ++++++++++++++---- vime/observability/rollout_data_utils.py | 8 +- vime/ray/rollout.py | 42 ++++- vime/rollout/base_types.py | 5 +- vime/utils/arguments.py | 4 - vime/utils/score_centering.py | 101 ++++++++---- 29 files changed, 873 insertions(+), 182 deletions(-) create mode 100644 tests/test_published_payload_retry.py create mode 100644 tests/test_rollout_metadata_index.py create mode 100644 vime/data/sample_metadata.py diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 553feb0c0..1c9ce842b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -64,7 +64,7 @@ steps: python:3.11 bash -c ' set -euo pipefail pip install -q torch --index-url https://download.pytorch.org/whl/cpu - pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb straw-queue + pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb 'straw-queue>=0.1.2' pip install -q -e . --no-deps python tests/test_megatron_argument_validation.py python tests/test_optional_straw.py @@ -143,7 +143,7 @@ steps: -v "$$PWD:/workspace" -w /workspace \ "$${VIME_CI_IMAGE:-vllm/vime:latest}" bash -lc ' set -euo pipefail - pip install -q straw-queue --break-system-packages + pip install -q 'straw-queue>=0.1.2' --break-system-packages pip install -q -e . --no-deps --break-system-packages for test_file in \ tests/test_advantage_whiten_cp.py \ @@ -169,6 +169,8 @@ steps: tests/test_model_provider_freeze.py \ tests/test_policy_loss.py \ tests/test_ppo_kl_metric.py \ + tests/test_published_payload_retry.py \ + tests/test_rollout_metadata_index.py \ tests/test_score_centering.py \ tests/test_score_centering_transport.py \ tests/test_process_rollout_data.py \ diff --git a/docker/version.txt b/docker/version.txt index 5172efe37..bb25b0b56 100644 --- a/docker/version.txt +++ b/docker/version.txt @@ -1 +1 @@ -nightly-dev-20260928a +nightly-dev-20260930a diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md index aca841531..0a6049ede 100644 --- a/docs/en/advanced/straw.md +++ b/docs/en/advanced/straw.md @@ -112,9 +112,8 @@ window, with its own checkpoint state and durable accepted-result references. Source snapshots now store the shared pending tasks, their exact immutable input references and scheduling metadata once; worker snapshots store reader metadata. The reader snapshot format is version 3; old reader-local v1/v2 buffer snapshots -require migration and are rejected explicitly. This requires an updated -`straw-queue` build with `yield_tasks()` and persistent scheduling keys; the -original PyPI 0.1.0 wheel does not provide these APIs. +require migration and are rejected explicitly. This requires `straw-queue>=0.1.2` +with `yield_tasks()` and persistent scheduling keys. ## Shared tensors, R3 and SC @@ -162,12 +161,12 @@ segment. Tensor descriptors are restored in batches to reuse shared pack indices ## Enable it -`requirements.txt` includes `straw-queue`, so the standard Docker build and +`requirements.txt` includes `straw-queue>=0.1.2`, so the standard Docker build and normal vime installation install it automatically. For an existing environment, install it on every generation and training node, using the same version throughout the job: ```bash -pip install straw-queue +pip install 'straw-queue>=0.1.2' ``` Without straw installed, the default Ray `object-store` transport still works @@ -205,8 +204,6 @@ If `--rollout-data-dir` is omitted, straw mode uses `/rollout_data`; witho `--save`, an explicit shared directory is required. `--rollout-io-concurrency` bounds off-event-loop serialization and I/O (default 4). The adapter uses worker processes and bounded threads; straw does not create an I/O process pool. -`--rollout-queue-max-pending` and `--rollout-queue-max-inflight` bound task -admission (both default 65,536); they do not cap retained bytes or file count. During weight synchronization, distributed producers pause new admissions and allow in-flight work to finish and persist. They resume if another training @@ -226,14 +223,12 @@ writers, retained checkpoints and WAL history still consume space. GC failures stop the background loop, propagate the original cause through later coordinator operations and surface at shutdown. They do not stop work already running on remote workers. Retained data stays available for inspection. -Capacity exhaustion must be handled with -backpressure or an explicit operator decision; do not reset a live queue or +Disk exhaustion requires freeing unneeded retention or expanding storage; do not reset a live queue or unlink pack files to make room. Offline removal requires all participants to stop. Storage quotas and application retention policies remain necessary. Processed positions and filter decisions are recorded explicitly, so gaps in -acceptance order do not hold already-consumed capacity. A separate bounded -control-task allowance lets the manager commit a collection under backpressure. +acceptance order do not retain already-consumed data. ## Recovery and checkpoints @@ -310,7 +305,11 @@ An archive retains its data independently of queue consumption and GC. With straw transport it reuses existing tensors; otherwise it creates a `straw-data` pool beside the index. Each rollout has one immutable index, with samples stored in chunks. Evaluation uses `eval_`. Copying only the JSON index does -not copy payloads; train-only replay with straw needs a separate writable queue. +not copy payloads. Train-only straw replay uses the archive's writable storage +pool and run, overriding `--rollout-data-dir` and `--rollout-queue-run-id`. +Each replay creates an isolated queue without changing the source queue. Full +replay without subsampling reuses the archived Sample and tensor records; all +archives in one replay must belong to the same pool and run. `--load-debug-rollout-data-subsample` also applies to archives. ```python diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index d34d6a802..951c9c402 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -404,7 +404,9 @@ To enable distributed fully async rollout with straw, add: --rollout-data-dir /shared/run/rollout_data ``` -With straw transport, `--use-rollout-routing-replay` stores completed samples' R3 routes in straw with their owning sample group. SC tensors are also stored in the same publication when `--use-score-centering` is enabled, including SC without R3. The default object-store transport keeps these tensors in memory unless the existing disk-spill hook is configured; that hook remains available for object-store rollouts. User sample hooks run first; R3, SC and sample metadata are published together into shared pack files. Later queue publications reuse their references. Large group bundles are split within the native write budget. Aborted prefixes are persisted by the queue continuation path. `--rollout-queue-online-gc` is a separate opt-in: after training acknowledges completion, straw can reclaim sealed packs whose owners have all released them. It is disabled by default. +All nodes must mount the directory at the same absolute path and install `straw-queue>=0.1.2`. + +With straw transport, `--use-rollout-routing-replay` stores completed samples' R3 routes in straw with their owning sample group. SC tensors are also stored in the same publication when `--use-score-centering` is enabled, including SC without R3. The default object-store transport keeps these tensors in memory unless the existing disk-spill hook is configured; that hook remains available for object-store rollouts. User sample hooks run first; R3, SC and sample metadata are published together into shared pack files. Later queue publications reuse their references. Large group bundles are split by the native record-count limit. Aborted prefixes are persisted by the queue continuation path. `--rollout-queue-online-gc` is a separate opt-in: after training acknowledges completion, straw can reclaim sealed packs whose owners have all released them. It is disabled by default. One job-owned, non-restarting Ray actor coordinates task leases and a serialized dataset producer. Dataset cursor advancement and task submission are committed together. Workers receive small task references and read prompt groups directly from shared storage. This replaces the former in-memory distributed index allocator; no per-reader index ranges are abandoned on failure. Seeded shuffle and sample/group identities remain those of the existing data source. @@ -416,7 +418,7 @@ The default logical result is a complete filtered group; physical segment bounda Built-in producers return a collection manifest. Legacy custom rollouts can still return Sample lists; Manager publishes and accepts one compatibility collection. New producers may return an accepted `RawRolloutRef`; Manager validates its receipt and passes it to the same BatchBuilder without rewriting its payload. BatchBuilder preserves reward/conversion hooks and DP scheduling, records the selection plan, and publishes all rank shards before exposing `TrainBatchRef`s. All rank references identify the same batch and plan. -The vime adapter currently binds the shared root at the same absolute path on all nodes and checks visibility in both directions. The underlying store's references are relocatable. r3/sc dependencies are adopted into queue storage before references escape the producer; old rollout spill cleanup cannot remove them. Queue files are retained by default. Offline inspection/cleanup requires stopped writers, coordinator and readers; ordinary reads or batch-ready events never delete data. Debug dumps remain opt-in `torch.save` files and may contain retained queue tensor references. Evaluation follows the existing path. +The vime adapter currently binds the shared root at the same absolute path on all nodes and checks visibility in both directions. The underlying store's references are relocatable. r3/sc dependencies are adopted into queue storage before references escape the producer; old rollout spill cleanup cannot remove them. Queue files are retained by default. Offline inspection/cleanup requires stopped writers, coordinator and readers; ordinary reads or batch-ready events never delete data. Debug archives can reuse immutable Straw records; `.pt` exports remain available. Evaluation follows the existing path. Straw checkpoints commit model, optimizer/RNG, queue, builder and dataset-cursor state together. `--load` restores the latest valid joint checkpoint by default; `--ckpt-step` selects a step in the active branch history. Restore creates an isolated branch from that snapshot, leaving the source run unchanged and excluding later-produced samples. Repeated rollback follows the active branch and cannot cross a fork point. If a model checkpoint predates a straw queue snapshot, recovery starts with an empty queue and restores the saved dataset cursor when available; a damaged or incomplete joint snapshot fails rather than silently falling back. This is durable queue/data state; it does not restore GPU KV cache or generation RNG. diff --git a/docs/zh/advanced/straw.md b/docs/zh/advanced/straw.md index 50512fe87..74efbf02f 100644 --- a/docs/zh/advanced/straw.md +++ b/docs/zh/advanced/straw.md @@ -54,7 +54,7 @@ Ray 继续负责调度、RPC 和执行进程故障;vime 负责 Sample schema straw 明确拒绝 `--buffer-filter-path`;`--buffer-sort-by-staleness` 仍用于内存数据源,straw 始终采用上述顺序。reward 和样本筛选 hook 继续可用。已经接收的 group 如果显式放回,会创建独立的 delivery task,保留原 accepted 历史;消费或丢弃 delivery 时也会确认被其替代的已接收版本。fully async scheduler 仍保留有界的 ready result 窗口,其 checkpoint 状态和已接收结果引用单独持久化。 -source checkpoint 统一保存共享 pending 任务、准确的不可变输入引用和排序 metadata;worker checkpoint 只保存 reader metadata。reader 快照格式升级为 v3,旧 v1/v2 本地 buffer 快照需要迁移,加载时明确报错。此功能需要带 `yield_tasks()` 和持久化排序字段的新版 `straw-queue` 构建,原 PyPI 0.1.0 wheel 尚无这些接口。 +source checkpoint 统一保存共享 pending 任务、准确的不可变输入引用和排序 metadata;worker checkpoint 只保存 reader metadata。reader 快照格式升级为 v3,旧 v1/v2 本地 buffer 快照需要迁移,加载时明确报错。此功能需要带 `yield_tasks()` 和持久化排序字段的 `straw-queue>=0.1.2`。 ## 共享张量、R3 与 SC @@ -74,10 +74,10 @@ straw 的 `tensor.v1` 保存连续、行优先、小端的 typed bytes。Lazy re ## 启用方式 -`requirements.txt` 已包含 `straw-queue`,标准 Docker 构建和正常安装 vime 时会自动安装。已有环境可以在每个生成和训练节点执行以下命令,同一任务的所有节点使用相同版本: +`requirements.txt` 已包含 `straw-queue>=0.1.2`,标准 Docker 构建和正常安装 vime 时会自动安装。已有环境可以在每个生成和训练节点执行以下命令,同一任务的所有节点使用相同版本: ```bash -pip install straw-queue +pip install 'straw-queue>=0.1.2' ``` 未安装 straw 时,默认 Ray `object-store` 传输仍可运行,启动日志会提示安装命令。如果显式选择 `--rollout-data-transport straw`,则在启动检查阶段报错并给出同样的安装命令。 @@ -102,7 +102,7 @@ Python 导入名为 `straw`。在原有训练命令中追加以下参数,使 --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async ``` -未指定 `--rollout-data-dir` 时,straw 模式使用 `/rollout_data`;没有 `--save` 则必须显式指定共享目录。`--rollout-io-concurrency` 限制事件循环之外的序列化和 I/O 并发,默认 4。Adapter 使用 worker 进程和有界线程,straw 本身不创建 I/O 进程池。`--rollout-queue-max-pending` 与 `--rollout-queue-max-inflight` 限制任务准入,默认均为 65,536;它们不是保留字节数或文件数配额。 +未指定 `--rollout-data-dir` 时,straw 模式使用 `/rollout_data`;没有 `--save` 则必须显式指定共享目录。`--rollout-io-concurrency` 限制事件循环之外的序列化和 I/O 并发,默认 4。Adapter 使用 worker 进程和有界线程,straw 本身不创建 I/O 进程池。 权重同步期间,分布式 producer 暂停提交新任务,让在途请求完成并持久化;还有下一次训练 rollout 时恢复。权重同步本身仍是独立子系统。 @@ -112,9 +112,9 @@ Python 导入名为 `straw`。在原有训练命令中追加以下参数,使 在线 GC 默认关闭,添加 `--rollout-queue-online-gc` 后,straw 可在任务运行期间回收不再使用的封存 pack。vime 通过 straw API 提供使用完成和丢弃信号。只有 task、publication、queue、reader、checkpoint 等所有者全部释放后,pack 才能删除。仅 lease 超时不能证明 reader 已停止。一条存活记录就会保留整个 pack;活跃 writer、保留 checkpoint 和 WAL 历史仍会占用空间。 -GC 失败会停止后台循环,后续 coordinator 操作传播原始原因,关闭时也会报告错误;它不会停止已在远端 worker 中运行的工作。数据保留供排查。容量用尽时应使用背压或由操作者明确决定处理方式,不能 reset 活跃队列或直接删除 pack 腾空间。离线删除目录要求所有参与者停止;仍需配置存储配额与应用保留策略。 +GC 失败会停止后台循环,后续 coordinator 操作传播原始原因,关闭时也会报告错误;它不会停止已在远端 worker 中运行的工作。数据保留供排查。磁盘空间不足时,应释放不再需要的保留数据或扩容,不能 reset 活跃队列或直接删除 pack 腾空间。离线删除目录要求所有参与者停止;仍需配置存储配额与应用保留策略。 -已处理位置和过滤决策被显式记录,避免接收顺序中的空洞使已消费数据持续占用容量。独立且有界的 control task 配额允许 Manager 在生产背压时仍能提交 collection。 +已处理位置和过滤决策被显式记录,避免接收顺序中的空洞使已消费数据持续保留。 ## 恢复与 checkpoint @@ -158,7 +158,7 @@ GC 失败会停止后台循环,后续 coordinator 操作传播原始原因, --load-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' ``` -归档独立于队列消费和 GC 保留数据。使用 straw 传输保存时复用已有张量;否则在索引旁创建 `straw-data` 存储池。每个 rollout 一个不可变索引,样本按块打包;Evaluation 使用 `eval_`。单独复制 JSON 索引不会复制载荷;使用 straw 做只训练回放时,应选择独立的可写训练队列。`--load-debug-rollout-data-subsample` 也适用于归档。 +归档独立于队列消费和 GC 保留数据。使用 straw 传输保存时复用已有张量;否则在索引旁创建 `straw-data` 存储池。每个 rollout 一个不可变索引,样本按块打包;Evaluation 使用 `eval_`。单独复制 JSON 索引不会复制载荷。使用 straw 做只训练回放时,自动使用归档的存储池和 run,覆盖 `--rollout-data-dir` 和 `--rollout-queue-run-id`,并在该可写存储池中新建独立队列,不改变原队列。完整回放(不抽样)直接复用已有 Sample 和张量记录,不重新写入。一次回放的所有归档必须属于同一存储池和 run。`--load-debug-rollout-data-subsample` 也适用于归档。 ```python from vime.data.archive import RolloutArchive diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index ba7d7dbdd..881f3615e 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -402,7 +402,9 @@ vime 支持不同程度的自定义数据生成(rollout)。 --rollout-data-dir /shared/run/rollout_data ``` -straw 传输下,开启 `--use-rollout-routing-replay` 后会将已完成 sample 的 R3 routes 随所属 group 一起写入 straw;开启 `--use-score-centering` 时也会在同一次发布中保存 SC 张量,包括只使用 SC 的情况。默认 object-store 传输将这些张量保存在内存中,但仍保留原有的可选磁盘 spill hook。自定义 sample hook 先执行,R3、SC 与 sample 元数据一起发布到共享 pack,后续队列发布复用引用。大批 group 按底层写入预算拆分。Aborted prefix 由队列 continuation 路径持久化。`--rollout-queue-online-gc` 是独立开关,默认关闭:训练确认使用完成后,straw 可回收所有 owner 都已释放的 sealed pack。 +所有节点必须以相同绝对路径挂载该目录,并安装 `straw-queue>=0.1.2`。 + +straw 传输下,开启 `--use-rollout-routing-replay` 后会将已完成 sample 的 R3 routes 随所属 group 一起写入 straw;开启 `--use-score-centering` 时也会在同一次发布中保存 SC 张量,包括只使用 SC 的情况。默认 object-store 传输将这些张量保存在内存中,但仍保留原有的可选磁盘 spill hook。自定义 sample hook 先执行,R3、SC 与 sample 元数据一起发布到共享 pack,后续队列发布复用引用。大批 group 按 native record 数量上限拆分。Aborted prefix 由队列 continuation 路径持久化。`--rollout-queue-online-gc` 是独立开关,默认关闭:训练确认使用完成后,straw 可回收所有 owner 都已释放的 sealed pack。 作业内一个禁止自动重启的 Ray actor 管理任务 lease 和串行 dataset producer。Dataset 游标与任务提交在同一日志事务中保存,worker 通过小引用直接读取共享存储中的 prompt group,替代原来的内存索引分配器。Shuffle、group/sample 编号沿用原有数据源逻辑,故障不会丢弃 reader 预留的索引区间。 @@ -414,7 +416,7 @@ Fully async 在每个有 CPU 资源的 Ray 节点启动一个常驻生成进程 内置 producer 返回 collection manifest。旧 custom rollout 仍可返回 Sample 列表,由 Manager 兜底持久化并接受一个兼容 collection;新 producer 可以直接返回关联有效 receipt 的 `RawRolloutRef`。两者进入同一个 BatchBuilder,保留 reward/conversion hook 和 DP 调度。Builder 先保存选择计划,全部 rank shard 持久化后才返回同一 batch ID/plan 的 `TrainBatchRef`。 -Vime adapter 当前要求各节点使用同一个绝对挂载路径,并双向检查可见性;底层引用支持重新绑定 root。r3/sc 的临时文件依赖会先复制进队列,旧 spill 清理不会使已提交数据失效。默认保留队列文件;读取、batch-ready 都不删除数据。离线清理要求停止 coordinator、writer 和 reader。Debug dump 仍按需用 `torch.save` 写入,可能引用保留的队列张量;evaluation 路径保持原有行为。 +Vime adapter 当前要求各节点使用同一个绝对挂载路径,并双向检查可见性;底层引用支持重新绑定 root。r3/sc 的临时文件依赖会先复制进队列,旧 spill 清理不会使已提交数据失效。默认保留队列文件;读取、batch-ready 都不删除数据。离线清理要求停止 coordinator、writer 和 reader。Debug 归档可以复用 Straw 的不可变记录,也可导出 `.pt` 文件;evaluation 路径保持原有行为。 Straw checkpoint 会将模型、optimizer/RNG、队列、builder 和 dataset 游标作为一个整体提交。默认 `--load` 恢复最近的有效联合 checkpoint;`--ckpt-step` 可选择当前分支历史中的步骤。恢复会从快照创建隔离分支,不改动源运行,也不会带入快照之后产生的样本。多次回退沿当前分支查找,不能越过分叉点。若模型 checkpoint 没有 straw 队列快照,则从空队列恢复,并在可用时恢复保存的 dataset 游标;联合快照损坏或不完整时直接报错,不会静默降级。这会持久化队列和数据状态,但不会恢复 GPU KV cache 或生成 RNG。 diff --git a/requirements.txt b/requirements.txt index a94c2508c..fc3c4fbe1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ pyyaml qwen_vl_utils # for VLM ray[default] safetensors -straw-queue +straw-queue>=0.1.2 tensorboard transformers vllm-router>=0.1.15 diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index f50cd35f6..cb989fc69 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -219,8 +219,6 @@ def make_vime_validate_args(**overrides): rollout_data_transport="object-store", rollout_data_dir=None, rollout_queue_lease_seconds=300, - rollout_queue_max_pending=65536, - rollout_queue_max_inflight=65536, rollout_queue_segment_mib=256, rollout_io_concurrency=4, use_distributed_post=False, @@ -366,13 +364,22 @@ def test_global_dataset_flag_is_removed(monkeypatch): module.vime_validate_args(make_vime_validate_args(num_epoch=2, num_rollout=None)) -def test_queue_resume_flag_is_not_exposed(monkeypatch): +@pytest.mark.parametrize( + "flag", + [ + "--rollout-queue-resume", + "--rollout-queue-fork", + "--rollout-queue-max-pending", + "--rollout-queue-max-inflight", + ], +) +def test_removed_queue_flags_are_rejected(monkeypatch, flag): module = load_vime_arguments_module(monkeypatch) parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) args = parser.parse_args(["--rollout-batch-size", "1"]) assert not hasattr(args, "rollout_queue_resume") with pytest.raises(SystemExit): - parser.parse_args(["--rollout-batch-size", "1", "--rollout-queue-resume"]) + parser.parse_args(["--rollout-batch-size", "1", flag]) @pytest.mark.unit diff --git a/tests/test_published_payload_retry.py b/tests/test_published_payload_retry.py new file mode 100644 index 000000000..d48a93928 --- /dev/null +++ b/tests/test_published_payload_retry.py @@ -0,0 +1,66 @@ +"""Publication visibility belongs to Straw, including plain synchronous readers.""" + +import asyncio +import os +import threading +from types import SimpleNamespace + +import pytest +from straw.errors import CorruptData +from vime.data import transport + + +@pytest.mark.parametrize("loader", ["plain", "async", "disk_ref"]) +def test_result_and_shelve_payloads_recover_at_the_storage_layer(tmp_path, loader, capfd): + args = SimpleNamespace(rollout_data_transport="straw", rollout_data_dir=str(tmp_path)) + value = {"session_id": "test-result", "status": "shelved", "tokens": [1, 2, 3]} + transport.pack_rollout_payload({"prefix": True}, args, 1) + ref = transport.pack_rollout_payload(value, args, 1) + segment = ref.manifest.manifest.segment + path = tmp_path / segment.path + with path.open("r+b") as f: + f.seek(segment.offset) + header = f.read(12) + f.seek(segment.offset) + f.write(bytes(12)) + f.flush() + os.fsync(f.fileno()) + + def restore(): + with path.open("r+b") as f: + f.seek(segment.offset) + f.write(header) + f.flush() + os.fsync(f.fileno()) + + timer = threading.Timer(0.05, restore) + timer.start() + try: + if loader == "plain": + result = transport.unpack_rollout_payload(ref) + elif loader == "async": + result = asyncio.run(transport.unpack_published_payload(ref)) + else: + result = ref.load() + assert result == value + finally: + timer.join() + assert "Straw extent visibility retry" in capfd.readouterr().err + + +@pytest.mark.parametrize("message", ["Invalid segment header", "Record payload checksum mismatch"]) +def test_native_failure_is_not_retried_again_by_vime(monkeypatch, message): + ref = transport.DiskPayloadRef( + SimpleNamespace(manifest=SimpleNamespace(segment=SimpleNamespace(path="raw/test.pack", offset=123))), "/test" + ) + calls = [] + + def load(value): + calls.append(value) + raise CorruptData(message) + + monkeypatch.setattr(transport, "unpack_rollout_payload", load) + with pytest.raises(CorruptData) as error: + asyncio.run(transport.unpack_published_payload(ref)) + assert len(calls) == 1 + assert "offset=123" in error.value.__notes__[0] diff --git a/tests/test_queue_sample_codec.py b/tests/test_queue_sample_codec.py index 6ebf7289e..7303090a9 100644 --- a/tests/test_queue_sample_codec.py +++ b/tests/test_queue_sample_codec.py @@ -327,7 +327,7 @@ def test_completed_capture_is_validated_before_publication(tmp_path, invalid_fie assert not list(tmp_path.rglob("*.pack")) -def test_large_group_bundle_obeys_native_budget_and_preserves_tensor_aliases(tmp_path, monkeypatch): +def test_large_group_bundle_streams_past_scratch_budget_and_preserves_tensor_aliases(tmp_path, monkeypatch): store = SharedFilesystemStore( tmp_path, "bounded", codecs=CODECS, max_buffer_bytes=32 * 1024, max_record_bytes=16 * 1024 ) @@ -339,15 +339,14 @@ def test_large_group_bundle_obeys_native_budget_and_preserves_tensor_aliases(tmp publish = store.publish_many calls = [] - def bounded(publications, **kwargs): + def tracked(publications, **kwargs): size = sum(len(record.payload) for publication in publications for record in publication.records) - assert size <= codec.payload_budget calls.append(size) return publish(publications, **kwargs) - monkeypatch.setattr(store, "publish_many", bounded) + monkeypatch.setattr(store, "publish_many", tracked) refs = codec.publish_many(values, submission_ids=[str(i) for i in range(len(values))]) - assert len(calls) > 1 + assert max(calls) > store.max_buffer_bytes for expected, ref in zip(values, refs, strict=True): actual = codec.load(ref) assert actual["first"] is actual["same"] diff --git a/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py b/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py index 09434c660..4b13ee3e2 100644 --- a/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py +++ b/tests/test_qwen2.5_0.5B_debug_rollout_then_train.py @@ -1,11 +1,12 @@ """ -Two-phase debug test: +Debug rollout and replay test: Phase 1 – debug_rollout_only: launch vLLM, generate rollout data for 2 steps, and save them to a temp directory. Phase 2 – load_debug_rollout_data (train only): skip vLLM entirely, load the - saved rollout data, and run 2 training steps. + saved Straw archives, and run 2 training steps without copying Samples. + Phase 3 – replay exported .pt files to retain coverage of the legacy format. -Uses Qwen2.5-0.5B-Instruct (smallest supported model) with 2 GPUs. +Uses Qwen2.5-0.5B-Instruct (smallest supported model) with 8 GPUs. """ import os @@ -14,7 +15,6 @@ import vime.utils.external_utils.command_utils as U - MODEL_NAME = "Qwen2.5-0.5B-Instruct" MODEL_TYPE = "qwen2.5-0.5B" NUM_GPUS = 8 @@ -27,8 +27,8 @@ def prepare(): U.hf_download_dataset("zhuzilin/gsm8k") -def _common_args(debug_data_dir: str): - """Arguments shared by both phases.""" +def _common_args(): + """Arguments shared by generation and replay.""" ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME}/ " f"--ref-load /root/models/{MODEL_NAME}/ " @@ -92,11 +92,11 @@ def execute_rollout_only(debug_data_dir: str): ) phase1_args = ( - f"{_common_args(debug_data_dir)} " + f"{_common_args()} " f"{vllm_args} " "--debug-rollout-only " f"--rollout-data-dir {quote(os.path.join(debug_data_dir, 'rollout_queue'))} " - f"--save-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.pt " + f"--save-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.straw.json " ) print("=" * 60) @@ -110,18 +110,18 @@ def execute_rollout_only(debug_data_dir: str): ) -def execute_train_only(debug_data_dir: str): - """Phase 2: train-only, load saved rollout data.""" +def execute_train_only(debug_data_dir: str, extension: str): + """Train from a saved archive or its self-contained export.""" phase2_args = ( - f"{_common_args(debug_data_dir)} " - f"--rollout-data-dir {quote(os.path.join(debug_data_dir, 'train_queue'))} " - f"--load-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.pt " + (f"--rollout-data-dir {quote(os.path.join(debug_data_dir, 'train_queue'))} " if extension == "pt" else "") + + f"{_common_args()} " + f"--load-debug-rollout-data {debug_data_dir}/rollout_{{rollout_id}}.{extension} " "--ci-test " ) print("=" * 60) - print("Phase 2: load-debug-rollout-data (train only)") + print(f"Replay {extension}: load-debug-rollout-data (train only)") print("=" * 60) U.execute_train( @@ -135,10 +135,16 @@ def execute(): with tempfile.TemporaryDirectory(prefix="vime_debug_rollout_") as debug_data_dir: print(f"Using temp dir for rollout data: {debug_data_dir}") execute_rollout_only(debug_data_dir) - execute_train_only(debug_data_dir) + from vime.data.archive import RolloutArchive + + for rollout_id in range(NUM_ROLLOUT): + with RolloutArchive(f"{debug_data_dir}/rollout_{rollout_id}.straw.json") as archive: + archive.export_pt(f"{debug_data_dir}/rollout_{rollout_id}.pt") + execute_train_only(debug_data_dir, "straw.json") + execute_train_only(debug_data_dir, "pt") print("=" * 60) - print("Both phases completed successfully!") + print("Generation and both replay formats completed successfully!") print("=" * 60) diff --git a/tests/test_rollout_metadata_index.py b/tests/test_rollout_metadata_index.py new file mode 100644 index 000000000..ede309c95 --- /dev/null +++ b/tests/test_rollout_metadata_index.py @@ -0,0 +1,94 @@ +import json +from types import SimpleNamespace + +import pytest +import torch +from straw import SharedFilesystemStore +from straw.tensor import TensorRef + +from vime.data.archive import RolloutArchive +from vime.data.sample_metadata import describe_sample, validate_sample_metadata +from vime.utils.types import Sample + + +def sample(): + return Sample( + index=0, + group_index=0, + tokens=[9, 1, 2], + response_length=2, + loss_mask=[1, 1], + rollout_log_probs=[0.0, 0.0], + status=Sample.Status.COMPLETED, + rollout_routed_experts=torch.zeros((2, 3, 2), dtype=torch.int32), + rollout_top_p_token_ids=torch.tensor([1, 2], dtype=torch.int32), + rollout_top_p_token_offsets=torch.tensor([0, 1, 2], dtype=torch.int32), + rollout_top_p_log_probs=torch.zeros(2), + ) + + +def args(): + return SimpleNamespace( + use_rollout_routing_replay=True, use_score_centering=True, num_layers=3, moe_router_topk=2, rollout_top_p=0.95 + ) + + +def test_index_only_check_never_opens_straw_or_reads_tensors(tmp_path, monkeypatch): + path = tmp_path / "rollout.straw.json" + RolloutArchive.save(path, [sample()], rollout_id=0) + + def forbidden(*a, **kw): + raise AssertionError("Metadata check opened a data store or tensor payload") + + monkeypatch.setattr(SharedFilesystemStore, "__init__", forbidden) + monkeypatch.setattr(TensorRef, "load", forbidden) + metadata = RolloutArchive.check_metadata(path, args()) + assert len(metadata) == 1 + assert metadata[0]["response_length"] == 2 + + +@pytest.mark.parametrize("corruption", ["tokens", "mask", "routes", "offsets", "logps", "ids_dtype"]) +def test_metadata_rejects_shape_and_dtype_mismatch(corruption): + value = describe_sample(sample()) + if corruption == "tokens": + value["tokens"] = 1 + if corruption == "mask": + value["loss_mask"] = 1 + if corruption == "routes": + value["tensors"]["rollout_routed_experts"]["shape"][1] = 4 + if corruption == "offsets": + value["tensors"]["rollout_top_p_token_offsets"]["shape"] = [2] + if corruption == "logps": + value["tensors"]["rollout_top_p_log_probs"]["shape"] = [3] + if corruption == "ids_dtype": + value["tensors"]["rollout_top_p_token_ids"]["dtype"] = "float32" + with pytest.raises(ValueError): + validate_sample_metadata([value], args()) + + +def test_legacy_index_requires_explicit_sidecar_and_checks_identity(tmp_path): + path = tmp_path / "rollout.straw.json" + RolloutArchive.save(path, [sample()], rollout_id=0) + index = json.loads(path.read_text()) + metadata = index.pop("sample_metadata") + path.write_text(json.dumps(index)) + with pytest.raises(FileNotFoundError): + RolloutArchive.check_metadata(path, args()) + sidecar = path.with_suffix(path.suffix + ".metadata.json") + document = {"manifest_digest": index["manifest"]["digest"], "sample_metadata": metadata} + sidecar.write_text(json.dumps(document)) + assert len(RolloutArchive.check_metadata(path, args())) == 1 + document["manifest_digest"] = "wrong" + sidecar.write_text(json.dumps(document)) + with pytest.raises(ValueError, match="different archive"): + RolloutArchive.check_metadata(path, args()) + + +def test_metadata_counts_routed_chunks_without_materializing(): + value = sample() + value.rollout_routed_experts = [ + torch.zeros((1, 3, 2), dtype=torch.int32), + torch.zeros((2, 3, 2), dtype=torch.int32), + ] + metadata = describe_sample(value) + assert metadata["tensors"]["rollout_routed_experts"] == {"shape": [3, 3, 2], "dtype": "int32"} diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py index 38b0386d0..3e2f4cf32 100644 --- a/tests/test_rollout_straw_transport.py +++ b/tests/test_rollout_straw_transport.py @@ -628,6 +628,147 @@ def tracked(ref, **kwargs): assert len(read) == 1 +@pytest.mark.parametrize("start_rollout_id", [None, 7]) +@pytest.mark.parametrize("archive_kind", ["reference", "chunks", "standalone"]) +def test_debug_archive_replay_reuses_records_and_isolates_training( + args, tmp_path, monkeypatch, start_rollout_id, archive_kind +): + import copy + + import ray + from straw.tensor import TensorRef + + from vime.data.archive import RolloutArchive + from vime.data.batch_builder import BatchBuilder + from vime.data.codec import SampleCodec + from vime.data.transport import rollout_store + from vime.ray.rollout import RolloutManager + from vime.rollout.base_types import iter_samples + from vime.utils.data import process_rollout_data + + args.rollout_queue_run_id = "source-run" + samples = [Sample(index=i, rollout_id=i, tokens=[1, 2, 3], response_length=2, reward=0.5) for i in range(2)] + for sample in samples: + sample.rollout_routed_experts = torch.zeros((2, 1, 1), dtype=torch.int32) + # Archives must discard live source-queue authorization before replay. + sample._queue_receipt = {"position": 999, "task_id": "original"} + raw = pack_rollout_payload(samples, args, 7) + template = str(tmp_path / "rollout_{rollout_id:07d}.straw.json") + for step in (7, 8): + RolloutArchive.save( + template.format(rollout_id=step), + samples, + rollout_id=step, + args=args if archive_kind != "standalone" else None, + reference=raw if archive_kind == "reference" else None, + ) + with RolloutArchive(template.format(rollout_id=7)) as archive: + tensor_records = [s.rollout_routed_experts.record_ref for s in archive.load_samples()] + pool = archive.index["root"] + run = archive.manifest.manifest.segment.run_id + replay_args = SimpleNamespace( + **{**vars(args), "rollout_data_dir": str(tmp_path / "unused-pool"), "rollout_queue_run_id": "unused-run"}, + load_debug_rollout_data=template, + load_debug_rollout_data_subsample=None, + start_rollout_id=start_rollout_id, + custom_reward_post_process_path=None, + custom_convert_samples_to_train_data_path=None, + reward_key=None, + advantage_estimator="grpo", + rewards_normalization=False, + use_score_centering=False, + use_rollout_routing_replay=False, + num_experts=1, + global_batch_size=2, + micro_batch_size=1, + use_dynamic_batch_size=False, + balance_data=False, + balance_by_flops=False, + ) + resolve_rollout_data_dir(replay_args) + assert replay_args.rollout_data_dir == pool + assert replay_args.rollout_queue_run_id == run + monkeypatch.setattr(ray, "get", lambda value: value) + monkeypatch.setattr(ray, "put", lambda value: value) + with closing(RolloutQueueController(args)) as original: + original.begin_collection("unfinished") + source_tasks = copy.deepcopy(original.queue.tasks) + for _ in range(2): # Replaying again must start another independent queue. + with closing(RolloutQueueController(replay_args)) as replay: + assert replay.queue.queue_id != original.queue.queue_id + handle = SimpleNamespace( + **{ + name: SimpleNamespace(remote=getattr(replay, name)) + for name in ( + "begin_collection", + "complete", + "batch", + "plan_batch", + "ready_batch", + "finish_batch", + ) + } + ) + manager = object.__new__(RolloutManager.__ray_metadata__.modified_class) + manager.args, manager.controller = replay_args, handle + builder = manager.batch_builder = BatchBuilder(replay_args, controller=handle) + builder.train_parallel_config = dict( + dp_size=1, cp_size=1, vpp_size=1, microbatch_group_size_per_vp_stage=1 + ) + for step in (7, 8): + builder.rollout_id = step + with monkeypatch.context() as guard: + guard.setattr( + SampleCodec, "_prepare_sample", lambda *a: pytest.fail("republished archived Sample") + ) + guard.setattr(TensorRef, "load", lambda *a, **kw: pytest.fail("materialized archived tensor")) + store, _, _ = rollout_store(replay_args) + written = store.metrics["payload_bytes"] + loaded, metrics = manager._get_rollout_data(step) + assert metrics is None and [s.index for s in loaded] == [0, 1] + assert all(not hasattr(s, "_queue_receipt") for s in loaded) + if step == 7: + assert [s.rollout_routed_experts.record_ref for s in loaded] == tensor_records + assert [s.index for s in iter_samples(load_rollout_samples(builder.raw_ref))] == [0, 1] + assert store.metrics["payload_bytes"] - written < 10000 + assert builder.begin(loaded) is None + assert builder._positions == [step - 7] + refs = builder.split_by_dp(builder.convert(loaded)) + batch = process_rollout_data(refs, 0, 1) + assert [t.tolist() for t in batch["tokens"]] == [[1, 2, 3], [1, 2, 3]] + assert [box.inner for box in builder.begin(loaded)] == [box.inner for box in refs] + builder.training_completed(step) + assert replay.training_state()["processed_cursor"] == 2 + assert original.queue.tasks == source_tasks + assert not (tmp_path / "unused-pool").exists() + + +@pytest.mark.parametrize("different", ["pool", "run"]) +def test_debug_archive_replay_rejects_changing_storage(args, tmp_path, different): + from vime.data.archive import RolloutArchive + from vime.ray.rollout import RolloutManager + + template = str(tmp_path / "rollout_{rollout_id}.straw.json") + samples = [Sample(index=0, tokens=[1, 2], response_length=1)] + RolloutArchive.save(template.format(rollout_id=0), samples, rollout_id=0, args=args) + other = SimpleNamespace(**vars(args)) + if different == "pool": + other.rollout_data_dir = str(tmp_path / "another-pool") + else: + other.rollout_queue_run_id = "another-run" + RolloutArchive.save(template.format(rollout_id=1), samples, rollout_id=1, args=other) + args.load_debug_rollout_data = template + args.load_debug_rollout_data_subsample = None + args.start_rollout_id = 0 + resolve_rollout_data_dir(args) + manager = object.__new__(RolloutManager.__ray_metadata__.modified_class) + manager.args = args + with pytest.raises(ValueError, match="same Straw storage pool and run"): + manager._get_rollout_data(1) + assert args.rollout_data_dir == str(tmp_path) + assert args.rollout_queue_run_id == "rollout" + + def test_straw_debug_archive_uses_existing_train_only_conversion(args, tmp_path): from vime.data.batch_builder import BatchBuilder from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data diff --git a/tests/test_score_centering.py b/tests/test_score_centering.py index 661875bef..00201fe0b 100644 --- a/tests/test_score_centering.py +++ b/tests/test_score_centering.py @@ -380,7 +380,6 @@ def test_empty_sample_topk_shape(): "ids,logps", [ ([[1, 1, 2]], [[-1.0, -2.0, -3.0]]), - ([[1, 2, 3]], [[0.0, 0.0, 0.0]]), ([[1, 2, 3]], [[float("nan"), -2.0, -3.0]]), ], ) @@ -903,21 +902,33 @@ def test_top_p_resume_and_masked_environment(): assert sample.rollout_topk_token_ids is None -@pytest.mark.parametrize("corruption", ["missing", "partial", "duplicate", "offsets", "nan", "sampled"]) -def test_invalid_support_rejected(corruption): +@pytest.mark.parametrize("corruption", ["missing", "duplicate", "offsets", "nan", "sampled"]) +@pytest.mark.parametrize("disk", [False, True]) +def test_invalid_support_rejected(corruption, disk, tmp_path): from vime.utils.score_centering import validate_sampler_top_p ids, offsets, q = [1, 2], [0, 2], np.log([0.3, 0.7]) if corruption == "missing": q = None - elif corruption == "partial": - q = np.log([0.2, 0.4]) elif corruption == "duplicate": ids = [1, 1] elif corruption == "offsets": offsets = [0, 1] elif corruption == "nan": q[0] = np.nan + if disk: + from dataclasses import replace + + from straw import SharedFilesystemStore + from straw.tensor import publish_tensors + + values = {"ids": torch.tensor(ids), "offsets": torch.tensor(offsets)} + if q is not None: + values["logps"] = torch.tensor(q) + with SharedFilesystemStore(tmp_path, "invalid-top-p", codecs=("tensor.v1",)) as store: + refs = dict(zip(values, publish_tensors(store, values, submission_id="sample"), strict=True)) + refs = {key: replace(value, validated=False) for key, value in refs.items()} + ids, offsets, q = refs["ids"], refs["offsets"], refs.get("logps") with pytest.raises(ValueError): validate_sampler_top_p( ids, offsets, q, 1, tokens=[3] if corruption == "sampled" else [2], sampled_logps=[float(np.log(0.7))] @@ -926,3 +937,13 @@ def test_invalid_support_rejected(corruption): if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) + + +@pytest.mark.parametrize("mass", [0.6, 0.999898, 1.0001005, 1.5]) +def test_sampler_normalization_is_not_rechecked(mass): + from vime.utils.score_centering import validate_sampler_top_p + + logps = np.log(np.array([mass / 2, mass / 2])) + validate_sampler_top_p([0, 1], [0, 2], logps, 1) + sample = Sample(response_length=1, rollout_topk_token_ids=[[0, 1]], rollout_topk_log_probs=[logps]) + validate_sampler_topk(sample, 2) diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index ac6f63cbd..77b36917e 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -342,3 +342,45 @@ async def post(url, payload, **kwargs): if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) + + +def test_top_p_published_captures_need_only_metadata_checks(tmp_path, monkeypatch): + from test_score_centering import args + + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store + from vime.utils.score_centering import validate_sampler_top_p + + count = 4097 + a = args(rollout_top_p=0.95, rollout_data_transport="straw", rollout_data_dir=str(tmp_path)) + sample = Sample( + tokens=[9] + [4] * count, + response_length=count, + rollout_log_probs=[0.0] * count, + rollout_top_p_token_ids=torch.full((count,), 4, dtype=torch.int32), + rollout_top_p_token_offsets=torch.arange(count + 1, dtype=torch.int32), + rollout_top_p_log_probs=torch.zeros(count), + status=Sample.Status.COMPLETED, + ) + restored = pack_rollout_payload(sample, a, 0).load() + seal_rollout_store(a) + fields = (restored.rollout_top_p_token_ids, restored.rollout_top_p_token_offsets, restored.rollout_top_p_log_probs) + assert all(ref.validated for ref in fields) + + # Round-end republication must not read or revalidate immutable payloads. + def no_payload_read(*args, **kwargs): + raise AssertionError("validated top-p payload was reread during republication") + + with monkeypatch.context() as guarded: + for method in ("load", "__getitem__", "validate"): + guarded.setattr(TensorRef, method, no_payload_read) + validate_sampler_top_p(*fields, count) + republished = pack_rollout_payload({"buffer": [restored]}, a, 1) + assert republished.manifest is not None + + with monkeypatch.context() as guarded: + for method in ("load", "__getitem__", "validate"): + guarded.setattr(TensorRef, method, no_payload_read) + validate_sampler_top_p(*fields, count, tokens=[4] * count, sampled_logps=[0.0] * count) + with pytest.raises(ValueError, match="align"): + validate_sampler_top_p(*fields, count, tokens=[4], sampled_logps=[0.0]) diff --git a/tests/test_straw_fully_async_recovery.py b/tests/test_straw_fully_async_recovery.py index f6fd82091..6877b383a 100644 --- a/tests/test_straw_fully_async_recovery.py +++ b/tests/test_straw_fully_async_recovery.py @@ -104,8 +104,6 @@ def _run_job(directory, phase, online_gc): args.rollout_batch_size = 2 args.vllm_server_concurrency = 4 args.rollout_queue_segment_mib = 1 - args.rollout_queue_max_pending = 16 - args.rollout_queue_max_inflight = 4 args.rollout_queue_online_gc = online_gc from vime.data.checkpoint import RestorePlan diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index be28438b6..199e1f518 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -139,6 +139,12 @@ def init( hf_vocab = getattr(self.hf_config, "vocab_size", None) self.args.vocab_size = hf_vocab if hf_vocab is not None else self.tokenizer.vocab_size + # Model-only resumes keep the serving version aligned with the next + # rollout. Actor recreation can supply the latest version explicitly. + if not hasattr(args, "update_weight_start_version"): + args.update_weight_start_version = ( + args.start_rollout_id if args.start_rollout_id is not None else start_rollout_id + ) self.weight_updater = create_weight_updater( self.args, self.model, diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py index f72262ba6..da287c4d5 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py @@ -293,6 +293,14 @@ def _prepare_expert_weight_batch( for request in dist.batch_isend_irecv(p2p_ops) if p2p_ops else (): request.wait() + # NCCL Work.wait() only orders the current CUDA stream. A fast rank + # must not enter quantization's CUDA allocations/copies while peers + # are still issuing grouped P2P calls: those host calls can synchronize + # with the unfinished transfer and prevent its peers from launching. + # Include ranks without local transfers so every batch has one fence. + dist.barrier(group=get_gloo_group()) + torch.cuda.synchronize() + hf_named_tensors = [] for expert_param, tensor in local_params: hf_named_tensors.extend( diff --git a/vime/data/archive.py b/vime/data/archive.py index 525372f50..b75d41541 100644 --- a/vime/data/archive.py +++ b/vime/data/archive.py @@ -37,7 +37,7 @@ def __init__(self, path, *, root=None): self.contents = self.codec.load(self.manifest) @classmethod - def save(cls, path, samples, *, rollout_id, evaluation=False, args=None): + def save(cls, path, samples, *, rollout_id, evaluation=False, args=None, reference=None): from straw.protocol import digest from straw.reporting import write_report from straw.store import SharedFilesystemStore @@ -67,12 +67,30 @@ def save(cls, path, samples, *, rollout_id, evaluation=False, args=None): try: # Bounded chunks support indexed reads without a file per sample. # Lazy R3/SC references in the same pool share their existing bytes. - chunks, entries = [], [] + from vime.data.sample_metadata import describe_sample + + chunks, entries, sample_metadata = [], [], [] with lock: - for start in range(0, len(samples), 64): + if reference is not None: + if not shared or Path(reference.root).resolve() != store.backend.root: + raise ValueError("An archive reference must belong to its storage pool") + for offset, sample in enumerate(samples): + sample_metadata.append(describe_sample(sample)) + context = getattr(sample, "_queue_receipt", None) or getattr(sample, "_queue_lease", None) + entries.append( + { + "sample_key": ( + f"sample:{sample.index}" if sample.index is not None else f"position:{offset}" + ), + "task_key": context["task_id"] if context else None, + "offset": offset, + } + ) + for start in range(0, len(samples) if reference is None else 0, 64): chunk = samples[start : start + 64] ref = codec.publish(chunk, submission_id=f"debug-chunk:{uuid.uuid4().hex}") for offset, sample in enumerate(chunk): + sample_metadata.append(describe_sample(sample)) context = getattr(sample, "_queue_receipt", None) or getattr(sample, "_queue_lease", None) entries.append( { @@ -87,9 +105,12 @@ def save(cls, path, samples, *, rollout_id, evaluation=False, args=None): } ) chunks.append(DiskPayloadRef(ref, str(store.backend.root))) - manifest = codec.publish( - {"chunks": chunks, "entries": entries}, submission_id=f"debug-index:{uuid.uuid4().hex}" + contents = ( + {"raw": reference, "entries": entries} + if reference is not None + else {"chunks": chunks, "entries": entries} ) + manifest = codec.publish(contents, submission_id=f"debug-index:{uuid.uuid4().hex}") owner = f"debug:{path.resolve()}:{digest(asdict(manifest))}" # Ownership must commit before the externally visible index. store.retain(owner, [manifest]) @@ -104,12 +125,37 @@ def save(cls, path, samples, *, rollout_id, evaluation=False, args=None): "root": str(store.backend.root), "manifest": asdict(manifest), "storage_owner": owner, + "sample_metadata": {"version": 1, "samples": sample_metadata}, }, ) finally: if not shared: store.close() + @staticmethod + def check_metadata(path, args): + """Read only the small archive index, never the collection/tensor records. + + Legacy archives can have an explicitly built metadata sidecar. The + manifest digest binds it to this exact immutable collection. + """ + from vime.data.sample_metadata import validate_sample_metadata + + path = Path(path) + index = json.loads(path.read_text()) + if index.get("format") != "vime.straw-debug" or index.get("version") != 1: + raise ValueError("Unsupported rollout archive") + metadata = index.get("sample_metadata") + if metadata is None: + sidecar = json.loads(path.with_suffix(path.suffix + ".metadata.json").read_text()) + if sidecar["manifest_digest"] != index["manifest"]["digest"]: + raise ValueError("Metadata sidecar belongs to a different archive") + metadata = sidecar["sample_metadata"] + if metadata.get("version") != 1: + raise ValueError("Unsupported sample metadata version") + validate_sample_metadata(metadata["samples"], args) + return metadata["samples"] + def keys(self): """Return sample/task keys in archive order, including duplicate sample IDs.""" return [(entry["sample_key"], entry["task_key"]) for entry in self.contents["entries"]] @@ -129,8 +175,13 @@ def load_samples(self, *, sample_key=None, task_key=None): if not selected and (sample_key is not None or task_key is not None): raise KeyError((sample_key, task_key)) loaded, result = {}, [] + if "raw" in self.contents and selected: + from vime.data.transport import load_rollout_samples + from vime.rollout.base_types import iter_samples + + loaded["raw"] = list(iter_samples(load_rollout_samples(self.contents["raw"]))) for entry in selected: - chunk = entry["chunk"] + chunk = entry.get("chunk", "raw") if chunk not in loaded: loaded[chunk] = self.codec.load(self.contents["chunks"][chunk].manifest) sample = copy.copy(loaded[chunk][entry["offset"]]) diff --git a/vime/data/batch_builder.py b/vime/data/batch_builder.py index 330526870..2337eec84 100644 --- a/vime/data/batch_builder.py +++ b/vime/data/batch_builder.py @@ -12,7 +12,7 @@ import torch from vime.data.tensor import DiskTensorRef, TensorRef -from vime.data.transport import DiskPayloadRef, RolloutGroupRef, TrainBatchRef, pack_rollout_payload +from vime.data.transport import DiskPayloadRef, TrainBatchRef, pack_rollout_payload from vime.observability.rollout_data_utils import ( tensorize_rollout_data_for_training, validate_rollout_routed_experts_for_replay, @@ -102,12 +102,7 @@ def begin(self, samples): receipt = ray.get(controller.result.remote(Lease(**lease))) if receipt is not None: positions.add(receipt.position) - value = self.raw_ref - while isinstance(value, DiskPayloadRef): - value = value.load() - for group in value: - if isinstance(group, RolloutGroupRef) and group.receipt: - positions.add(group.receipt.position) + # Samples already carry group receipts/positions; do not reread raw. plan = { **identity, "digest": self.plan_digest, diff --git a/vime/data/codec.py b/vime/data/codec.py index aec3069ab..c5255cbc9 100644 --- a/vime/data/codec.py +++ b/vime/data/codec.py @@ -17,7 +17,7 @@ import torch from straw import Publication, Record from straw.errors import CorruptData, InvalidReference, UnsupportedSchema -from straw.protocol import RecordRef, RecordSetRef, decode, encode +from straw.protocol import RecordSetRef, decode, encode from straw.store import MAX_RECORDS from straw.tensor import TensorRef as QueueTensorRef from straw.tensor import tensor_record @@ -35,8 +35,6 @@ def __init__(self, store, *, args=None): raise ValueError("SampleCodec requires explicit vime.v1 and tensor.v1 store codecs") self.store = store self.args = args - # Leave room for framing and manifests within the native write budget. - self.payload_budget = store.max_buffer_bytes - min(8 * 1024**2, store.max_buffer_bytes // 8) def _prepare_sample(self, sample): """Validate completed captures before publication; partial prefixes can resume.""" @@ -109,17 +107,15 @@ def publish_many(self, values, *, submission_ids, metadata=None): return [refs[i] for i in roots] def _publish(self, publications, *, submission_id): - """Bound each native write, exposing roots only after their dependencies commit.""" + """Batch manifests; native Straw streams payloads with bounded scratch.""" refs = [] start = 0 while start < len(publications): - stop, size, count = start, 0, 0 + stop, count = start, 0 while stop < len(publications): records = publications[stop].records - added = sum(len(record.payload) for record in records) - if stop > start and (size + added > self.payload_budget or count + len(records) + 1 > MAX_RECORDS): + if stop > start and count + len(records) + 1 > MAX_RECORDS: break - size += added count += len(records) + 1 stop += 1 batch = [ @@ -197,9 +193,10 @@ def visit(value): from vime.data.transport import DiskPayloadRef, RawRolloutRef, RolloutGroupRef, TrainBatchRef if isinstance(value, DiskPayloadRef): - dependency_index = len(dependencies) - dependency_positions.setdefault(value.manifest, dependency_index) - dependencies.append(value.manifest) + if value.manifest not in dependency_positions: + dependency_positions[value.manifest] = len(dependencies) + dependencies.append(value.manifest) + dependency_index = dependency_positions[value.manifest] extra = {} if isinstance(value, RolloutGroupRef): extra = { @@ -218,6 +215,10 @@ def visit(value): "rank": value.rank, "plan_digest": value.plan_digest, } + if value.path: + extra["path"] = value.path + if value.sample_metadata is not None: + extra["sample_metadata"] = value.sample_metadata return [ "rollout-ref", type(value).__name__, @@ -239,9 +240,10 @@ def visit(value): if isinstance(value, Sample.Status): return ["status", value.value] if isinstance(value, RecordSetRef): - dependency_positions.setdefault(value, len(dependencies)) - dependencies.append(value) - return ["record-set", len(dependencies) - 1] + if value not in dependency_positions: + dependency_positions[value] = len(dependencies) + dependencies.append(value) + return ["record-set", dependency_positions[value]] identity = id(value) if identity in active: raise TypeError("Cyclic custom fields are not supported by the durable Sample codec") @@ -318,13 +320,7 @@ def visit(value): positions = {} start = 0 while start < len(blobs): - stop, size = start, 0 - while stop < len(blobs) and stop - start < MAX_RECORDS - 1: - added = len(blobs[stop].payload) - if stop > start and size + added > self.payload_budget: - break - size += added - stop += 1 + stop = min(len(blobs), start + MAX_RECORDS - 1) dependency_index = len(dependencies) dependencies.append(len(publications)) publications.append(Publication(tuple(blobs[start:stop]))) @@ -393,10 +389,10 @@ def visit(node): } if node[1] not in classes: raise UnsupportedSchema(f"Unknown rollout reference type: {node[1]}") - extra = visit(node[3]) if isinstance(node[3], list) else dict(node[3]) + extra = visit(node[3]) if extra.get("receipt"): extra["receipt"] = CommitReceipt.from_dict(extra["receipt"]) - ref = dependency(node[2]) if isinstance(node[2], int) else RecordSetRef.from_dict(node[2]) + ref = dependency(node[2]) return classes[node[1]](ref, str(self.store.backend.root), **extra) if tag == "dict": return {visit(key): visit(value) for key, value in node[1]} @@ -428,24 +424,21 @@ def visit(node): if tag == "bytes": return base64.b64decode(node[1], validate=True) if tag == "record-set": - value = dependency(node[1]) if isinstance(node[1], int) else RecordSetRef.from_dict(node[1]) + value = dependency(node[1]) reader.validate(value) return value if tag == "tensor": key = (encode(node[1]), node[4]) if key not in aliases: - if "dependency" in node[1]: - dep = node[1]["dependency"] - if dep not in tensor_sets: - tensor_sets[dep] = QueueTensorRef.from_record_set_many( - self.store, dependency(dep), reader=reader - ) - ordinal = node[1]["ordinal"] - if type(ordinal) is not int or ordinal not in tensor_sets[dep]: - raise CorruptData("Tensor ordinal does not name a tensor in the publication") - value = dataclasses.replace(tensor_sets[dep][ordinal], validated=node[6]) - else: - value = QueueTensorRef.from_record(self.store, RecordRef.from_dict(node[1]), validated=node[6]) + dep = node[1]["dependency"] + if dep not in tensor_sets: + tensor_sets[dep] = QueueTensorRef.from_record_set_many( + self.store, dependency(dep), reader=reader + ) + ordinal = node[1]["ordinal"] + if type(ordinal) is not int or ordinal not in tensor_sets[dep]: + raise CorruptData("Tensor ordinal does not name a tensor in the publication") + value = dataclasses.replace(tensor_sets[dep][ordinal], validated=node[6]) if value.shape != tuple(node[2]) or value.dtype != node[3]: raise CorruptData("Sample tensor descriptor disagrees with its storage record") value = dataclasses.replace(value, kind=node[5]) diff --git a/vime/data/queue_data_source.py b/vime/data/queue_data_source.py index fe40c3386..0d96de659 100644 --- a/vime/data/queue_data_source.py +++ b/vime/data/queue_data_source.py @@ -25,7 +25,7 @@ from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy from straw.coordinator import Coordinator from straw.errors import LeaseExpired, StaleAttempt -from straw.protocol import Lease, Limits, Record, RecordSetRef, TaskSpec, encode +from straw.protocol import Lease, Record, RecordSetRef, TaskSpec, encode from straw.reporting import write_report from vime.data.checkpoint import RestorePlan, SourceRestore @@ -55,6 +55,10 @@ class RolloutQueueController: def __init__(self, args, *, producer=None, restore_plan=None, defer_gc=False): self.args = args self.restore_plan = restore_plan or RestorePlan() + if (getattr(args, "load_debug_rollout_data", None) or "").endswith(".straw.json"): + # Replay shares immutable storage, but never the source queue's + # leases, accepted positions or training cursor. + self.restore_plan = RestorePlan(queue_id=f"debug:{uuid.uuid4().hex}") self._checkpoint_lock = None with ExitStack() as cleanup: # The controller owns both the queue and its checkpoint branch. @@ -136,10 +140,6 @@ def __init__(self, args, *, producer=None, restore_plan=None, defer_gc=False): exclusive_owner="job owns this non-restarting actor; prior coordinator and readers must be stopped", recover=recovering, lease_seconds=getattr(args, "rollout_queue_lease_seconds", 300), - limits=Limits( - pending_tasks=getattr(args, "rollout_queue_max_pending", 65536), - inflight_tasks=getattr(args, "rollout_queue_max_inflight", 65536), - ), ) cleanup.callback(self._queue.close) self.producer = producer @@ -515,7 +515,7 @@ def take(self, reader_id, count): source.dataset.shuffle(source.epoch_id) start = source.sample_group_index # Bounded refill, without unused index reservations held in readers. - count = min(max(count, 8), 64, self.queue.limits.pending_tasks) + count = min(max(count, 8), 64) groups = source.get_samples(count) group_ids = [next(iter_samples(group)).group_index for group in groups] task_ids = [f"prompt:{group_id}" for group_id in group_ids] diff --git a/vime/data/sample_metadata.py b/vime/data/sample_metadata.py new file mode 100644 index 000000000..149ea264e --- /dev/null +++ b/vime/data/sample_metadata.py @@ -0,0 +1,85 @@ +"""Shape-only rollout inspection; no tensor payloads or token sequences are read.""" + +import numpy as np + +_CAPTURE_FIELDS = ( + "rollout_routed_experts", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + "rollout_top_p_token_ids", + "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", +) + + +def describe_sample(sample): + tensors = {} + for name in _CAPTURE_FIELDS: + value = getattr(sample, name, None) + if value is None: + continue + # Captures normally arrive as TensorRefs. Raw arrays at initial + # publication have their own shape; no load/materialization is needed. + shape = getattr(value, "shape", None) + dtype = getattr(value, "dtype", None) + if name == "rollout_routed_experts" and isinstance(value, (list, tuple)) and value: + shapes = [tuple(part.shape) for part in value] + if any(part[1:] != shapes[0][1:] for part in shapes): + raise ValueError("Routed-experts chunks have different layer/expert shapes") + shape = (sum(part[0] for part in shapes), *shapes[0][1:]) + dtype = value[0].dtype + elif shape is None: + array = np.asarray(value) + shape, dtype = array.shape, array.dtype + tensors[name] = {"shape": list(shape), "dtype": str(dtype).removeprefix("torch.")} + return { + "index": sample.index.item() if isinstance(sample.index, np.generic) else sample.index, + "group_index": sample.group_index.item() if isinstance(sample.group_index, np.generic) else sample.group_index, + "status": sample.status.value, + "tokens": len(sample.tokens), + "response_length": sample.response_length, + "loss_mask": len(sample.loss_mask) if sample.loss_mask is not None else None, + "rollout_log_probs": len(sample.rollout_log_probs) if sample.rollout_log_probs is not None else None, + "tensors": tensors, + } + + +def validate_sample_metadata(metadata, args): + """Check dimensions/dtypes against this run, trusting published contents.""" + for sample in metadata: + count = sample["response_length"] + if not 0 <= count <= sample["tokens"]: + raise ValueError("Response length exceeds the token count") + for field in ("loss_mask", "rollout_log_probs"): + if sample[field] is not None and sample[field] != count: + raise ValueError(f"{field} length does not match the response length") + tensors = sample["tensors"] + routes = tensors.get("rollout_routed_experts") + if getattr(args, "use_rollout_routing_replay", False) and routes is not None: + expected = [max(0, sample["tokens"] - 1), int(args.num_layers), args.moe_router_topk] + if routes["shape"] != expected or not np.issubdtype(np.dtype(routes["dtype"]), np.integer): + raise ValueError("Routed-experts shape/dtype does not match tokens and model") + if not getattr(args, "use_score_centering", False): + continue + top_p = getattr(args, "rollout_top_p", 1.0) < 1 + prefix = "rollout_top_p" if top_p else "rollout_topk" + ids, logps = tensors.get(prefix + "_token_ids"), tensors.get(prefix + "_log_probs") + if ids is None or logps is None: + raise ValueError("Missing sampler capture metadata") + if ( + ids["shape"] != logps["shape"] + or not np.issubdtype(np.dtype(ids["dtype"]), np.integer) + or not np.issubdtype(np.dtype(logps["dtype"]), np.floating) + ): + raise ValueError("Sampler ids/logprobs shape/dtype mismatch") + if top_p: + offsets = tensors.get(prefix + "_token_offsets") + if ( + len(ids["shape"]) != 1 + or offsets is None + or offsets["shape"] != [count + 1] + or not np.issubdtype(np.dtype(offsets["dtype"]), np.integer) + ): + raise ValueError("Sampler top-p offsets/shape mismatch") + elif ids["shape"] != [count, args.score_centering_top_k]: + raise ValueError("Sampler top-k shape mismatch") diff --git a/vime/data/transport.py b/vime/data/transport.py index ca256fcc5..8d1329473 100644 --- a/vime/data/transport.py +++ b/vime/data/transport.py @@ -7,16 +7,20 @@ import os import threading import uuid -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from pathlib import Path from typing import TYPE_CHECKING import ray -from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy if TYPE_CHECKING: from straw.protocol import CommitReceipt, RecordSetRef + +def use_straw(args): + return getattr(args, "rollout_data_transport", "object-store") == "straw" + + _writers = {} _writers_lock = threading.Lock() _async_limits = {} @@ -24,16 +28,41 @@ def seal_rollout_store(args): """Seal local packs after generation/publication work has been drained.""" - if args.rollout_data_transport == "straw": + if use_straw(args): store, _, lock = rollout_store(args) with lock: store.seal() def resolve_rollout_data_dir(args): + debug_path = getattr(args, "load_debug_rollout_data", None) + if debug_path and debug_path.endswith(".straw.json"): + import glob + from string import Formatter + + from straw.protocol import RecordSetRef + + start = getattr(args, "start_rollout_id", None) + if start is None: + # Model loading may determine the starting rollout later. Read only + # an index to bind the shared pool before creating any actors. + pattern = "".join( + glob.escape(text) + ("*" if field else "") for text, field, _, _ in Formatter().parse(debug_path) + ) + paths = sorted(glob.glob(str(Path(pattern).expanduser()))) + if not paths: + raise FileNotFoundError(f"No Straw rollout archive matches {debug_path}") + path = paths[0] + else: + path = Path(debug_path.format(rollout_id=start)).expanduser() + index = json.loads(Path(path).read_text()) + if index.get("format") != "vime.straw-debug" or index.get("version") != 1: + raise ValueError(f"Unsupported straw rollout archive: {path}") + args.rollout_data_dir = index["root"] + args.rollout_queue_run_id = RecordSetRef.from_dict(index["manifest"]).manifest.segment.run_id if args.rollout_data_dir is None: if args.save is None: - raise ValueError("straw rollout transport requires --rollout-data-dir or --save on shared storage") + raise ValueError("Straw rollout transport requires --rollout-data-dir or --save on shared storage") args.rollout_data_dir = str(Path(args.save) / "rollout_data") args.rollout_data_dir = str(Path(args.rollout_data_dir).expanduser().resolve()) @@ -42,16 +71,12 @@ def rollout_store(args): """One physical writer incarnation per process/run, shared by producer calls.""" try: from straw.backend import FilesystemBackend - from straw.store import SharedFilesystemStore - from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES except ModuleNotFoundError as error: if error.name != "straw": raise - raise ModuleNotFoundError( - "straw rollout transport requires straw-queue. " - "Install it on every rollout/training node: pip install straw-queue", - name="straw", - ) from error + raise ModuleNotFoundError("Install straw with: pip install straw-queue", name="straw") from error + from straw.store import SharedFilesystemStore + from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES from vime.data.codec import CODECS, SampleCodec @@ -75,24 +100,28 @@ def rollout_store(args): max_buffer_bytes=MAX_PUBLICATION_BYTES, backend=FilesystemBackend(root, profile=profile, declaration=declaration), ) - _writers[key] = (store, SampleCodec(store, args=args), threading.RLock()) - return _writers[key] + _writers[key] = (store, threading.RLock()) + store, lock = _writers[key] + return store, SampleCodec(store, args=args), lock @dataclass(frozen=True) class DiskPayloadRef: """Bounded protocol ref plus a deployment mount hint, overridable by readers. - Only ``manifest`` is persisted by SampleCodec. Its paths are relative; root - is a local mount binding, never a dependency embedded in a durable record. + SampleCodec persists the manifest, projection path and small metadata edits. + Root is a local mount binding, never a dependency in a durable record. """ manifest: RecordSetRef root: str + # Select a completed group's generation inside its existing worker result. + # Small CA scheduling/reward edits travel alongside the immutable reference. + path: tuple = field(default=(), kw_only=True) + sample_metadata: list[dict] | None = field(default=None, kw_only=True) def load(self, *, root=None): from straw.store import SharedFilesystemStore - from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES from vime.data.codec import CODECS, SampleCodec @@ -104,7 +133,25 @@ def load(self, *, root=None): max_record_bytes=MAX_TENSOR_BYTES, max_buffer_bytes=MAX_PUBLICATION_BYTES, ) - return SampleCodec(store).load(self.manifest) + value = SampleCodec(store).load(self.manifest) + for part in self.path: + value = value[part] if isinstance(part, int) else getattr(value, part) + if self.sample_metadata is not None: + import copy + from vime.utils.types import Sample + + samples = [] + if isinstance(value, Sample): + value = [value] + for sample, metadata in zip(value, self.sample_metadata, strict=True): + sample = copy.copy(sample) + updates = dict(metadata) + if "metadata" in updates: + sample.metadata = {**(sample.metadata or {}), **updates.pop("metadata")} + sample.__dict__.update(updates) + samples.append(sample) + value = samples + return value @dataclass(frozen=True) @@ -129,6 +176,8 @@ class TrainBatchRef(DiskPayloadRef): def group_lease(group): + from straw.protocol import Lease + from vime.rollout.base_types import iter_samples samples = list(iter_samples(group)) @@ -137,8 +186,6 @@ def group_lease(group): return None if not all(value == leases[0] for value in leases): raise ValueError("A queue group must preserve one task authorization across all trajectories") - from straw.protocol import Lease - return Lease(**leases[0]) @@ -193,7 +240,7 @@ def record_generation_provenance(group): def pack_rollout_group(group, args, rollout_id, *, controller=None): - if args.rollout_data_transport != "straw": + if getattr(args, "rollout_data_transport", "object-store") != "straw": return group from vime.rollout.base_types import iter_samples @@ -214,8 +261,9 @@ def pack_rollout_group(group, args, rollout_id, *, controller=None): def release_rollout_publications(group, args): """Relinquish tensor staging before ending the group's current read lifetime.""" - if args.rollout_data_transport == "straw": + if use_straw(args): from straw.tensor import TensorRef, release_tensor_publications + from vime.rollout.base_types import iter_samples tensors = [] @@ -260,24 +308,33 @@ def load_rollout_samples(value): """Read raw collections without changing group or trajectory nesting.""" from vime.rollout.base_types import iter_samples - groups = [] - for reference in unpack_rollout_payload(value): + def read_group(reference): group = unpack_rollout_payload(reference) + if isinstance(group, list): + # CA selects several existing worker results for one prompt group. + expanded = [] + for child in group: + if isinstance(child, DiskPayloadRef): + expanded.extend(read_group(child)) + else: + expanded.append(child) + group = expanded if isinstance(reference, RolloutGroupRef) and reference.receipt: for sample in iter_samples(group): if reference.source_positions is not None: sample._queue_source_positions = list(reference.source_positions) sample.__dict__.pop("_queue_lease", None) sample._queue_receipt = asdict(reference.receipt) - groups.append(group) - return groups + return group + + return [read_group(reference) for reference in unpack_rollout_payload(value)] def pack_rollout_payload(value, args, rollout_id, *, metadata=None, submission_id=None): - if args.rollout_data_transport != "straw": + if not use_straw(args): return value store, codec, lock = rollout_store(args) - if isinstance(value, DiskPayloadRef): + if isinstance(value, DiskPayloadRef) and value.root is not None: store.validate(value.manifest) return value with lock: @@ -304,7 +361,7 @@ async def run_rollout_io(args, function, *values, **kwargs): try: return await asyncio.shield(pending) except asyncio.CancelledError: - # A cancelled coroutine cannot stop a filesystem commit in its thread. + # Repeated cancellation still cannot stop a filesystem commit. # Keep capacity and input ownership until the accepted write finishes. while not pending.done(): try: @@ -315,12 +372,39 @@ async def run_rollout_io(args, function, *values, **kwargs): raise +def release_payload_publications(args, payloads): + """Release transfer staging after readers finish and durable owners adopt it.""" + if not use_straw(args): + return + refs = {payload.manifest.digest: payload.manifest for payload in payloads if isinstance(payload, DiskPayloadRef)} + store, _, lock = rollout_store(args) + refs = list(refs.values()) + with lock: + for offset in range(0, len(refs), 128): + store.release_publications(refs[offset : offset + 128]) + + def unpack_rollout_payload(value): while isinstance(value, DiskPayloadRef): value = value.load() return value +async def unpack_published_payload(value): + """Load off the event loop; Straw handles visibility for every read path.""" + if not isinstance(value, DiskPayloadRef): + return await asyncio.to_thread(unpack_rollout_payload, value) + from straw.errors import CorruptData + + segment = value.manifest.manifest.segment + context = f"submitted manifest: root={value.root}, segment={segment.path}, offset={segment.offset}" + try: + return await asyncio.to_thread(unpack_rollout_payload, value) + except CorruptData as error: + error.add_note(context) + raise + + def accept_raw_rollout(output, args, rollout_id, *, controller): """Validate an accepted collection, or persist and accept returned Samples. @@ -355,11 +439,13 @@ def accept_raw_rollout(output, args, rollout_id, *, controller): store, codec, lock = rollout_store(args) with lock: ref = codec.publish( - output.samples, + output.sample_refs if getattr(output, "sample_refs", None) is not None else output.samples, submission_id=f"collection:{lease.attempt_id}", metadata={"task_id": lease.task_id, "attempt_id": lease.attempt_id}, ) receipt = ray.get(controller.complete.remote(lease, ref)) + if output.on_accepted is not None: + output.on_accepted() return RawRolloutRef(receipt.result_ref, str(store.backend.root), receipt, output.metrics) @@ -368,6 +454,8 @@ def _storage_probe(reference, args): def check_rollout_storage(args): + from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy + marker = uuid.uuid4().hex reference = pack_rollout_payload(marker, args, 0) probes = [ @@ -378,9 +466,11 @@ def check_rollout_storage(args): if node["Alive"] and (node["Resources"].get("CPU", 0) or node["Resources"].get("GPU", 0)) ] try: - for result in ray.get(probes, timeout=60): + results = ray.get(probes, timeout=60) + for result in results: if result.load(root=args.rollout_data_dir) != marker: raise ValueError("Rollout storage probe content differs across nodes") + release_payload_publications(args, [reference, *results]) except Exception as error: raise RuntimeError( f"All rollout/training nodes must share the run at --rollout-data-dir={args.rollout_data_dir}" diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index d17ef0b5d..81173e622 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -133,7 +133,9 @@ def load_debug_rollout_data(path_template, *, rollout_id: int, subsample_ratio=N return data -def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: bool, args=None) -> None: +def save_debug_rollout_data( + path_template, data, *, rollout_id: int, evaluation: bool, args=None, reference=None +) -> None: if path_template is None: return @@ -145,7 +147,9 @@ def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: from vime.data.archive import RolloutArchive samples = [sample for info in data.values() for sample in info["samples"]] if evaluation else data - RolloutArchive.save(path, samples, rollout_id=rollout_id, evaluation=evaluation, args=args) + RolloutArchive.save( + path, samples, rollout_id=rollout_id, evaluation=evaluation, args=args, reference=reference + ) return if evaluation: diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 3592a4c9c..2c10b3ac5 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -1,6 +1,7 @@ import itertools import logging import time +from pathlib import Path from typing import Any import psutil @@ -8,7 +9,13 @@ from vime.data.batch_builder import BatchBuilder from vime.data.tensor import DiskTensorRef -from vime.data.transport import accept_raw_rollout, check_rollout_storage, load_rollout_samples, seal_rollout_store +from vime.data.transport import ( + DiskPayloadRef, + accept_raw_rollout, + check_rollout_storage, + load_rollout_samples, + seal_rollout_store, +) from vime.observability import logging_utils from vime.observability.logging_utils import configure_logger, init_tracking from vime.observability.rollout_data_utils import ( @@ -17,7 +24,7 @@ validate_rollout_id_annotated, ) from vime.observability.rollout_metrics import log_eval_rollout_data, log_rollout_data -from vime.rollout.base_types import call_rollout_fn +from vime.rollout.base_types import RolloutFnTrainOutput, call_rollout_fn from vime.rollout.sample_hooks import set_current_rollout_id from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client @@ -258,6 +265,7 @@ def generate(self, rollout_id): rollout_id=rollout_id, evaluation=False, args=self.args, + reference=self.batch_builder.raw_ref if self.args.rollout_data_transport == "straw" else None, ) log_rollout_data( rollout_id, self.args, data, metrics, time.time() - start_time, weight_version=self.weight_version @@ -362,6 +370,29 @@ def check_weights(self, action: str): def _get_rollout_data(self, rollout_id): if self.args.load_debug_rollout_data: + if ( + self.args.rollout_data_transport == "straw" + and self.args.load_debug_rollout_data.endswith(".straw.json") + and self.args.load_debug_rollout_data_subsample is None + ): + from vime.data.archive import RolloutArchive + + path = self.args.load_debug_rollout_data.format(rollout_id=rollout_id) + with RolloutArchive(Path(path).expanduser()) as archive: + if ( + archive.store.backend.root != Path(self.args.rollout_data_dir).resolve() + or archive.manifest.manifest.segment.run_id != self.args.rollout_queue_run_id + ): + raise ValueError("Debug rollout archives must belong to the same Straw storage pool and run") + data = archive.load_samples() + refs = [archive.contents["raw"]] if "raw" in archive.contents else archive.contents["chunks"] + self.batch_builder.raw_ref = accept_raw_rollout( + RolloutFnTrainOutput(samples=data, sample_refs=refs), + self.args, + rollout_id, + controller=self.controller, + ) + return data, None data = load_debug_rollout_data( self.args.load_debug_rollout_data, rollout_id=rollout_id, @@ -381,10 +412,15 @@ def _get_rollout_data(self, rollout_id): self.weight_version = max(map(int, versions)) if valid else None data = call_rollout_fn(self.generate_rollout, self.args, rollout_id, self.data_source, evaluation=False) if self.args.rollout_data_transport == "straw": + samples = getattr(data, "samples", None) data = accept_raw_rollout(data, self.args, rollout_id, controller=self.controller) self.batch_builder.raw_ref = data metrics = data.metrics - data = load_rollout_samples(data) + data = ( + samples + if isinstance(samples, list) and all(not isinstance(group, DiskPayloadRef) for group in samples) + else load_rollout_samples(data) + ) else: metrics = data.metrics data = load_rollout_samples(data.samples) diff --git a/vime/rollout/base_types.py b/vime/rollout/base_types.py index 9b995ac96..c722b844f 100644 --- a/vime/rollout/base_types.py +++ b/vime/rollout/base_types.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from vime.utils.types import Sample @@ -14,6 +15,8 @@ class RolloutFnTrainOutput: # Accept Sample groups or a straw manifest referencing the selected batch. samples: list[list[Sample]] | DiskPayloadRef metrics: dict[str, Any] = None + on_accepted: Callable[[], None] | None = field(default=None, repr=False, compare=False) + sample_refs: list[list[DiskPayloadRef]] | None = field(default=None, repr=False, compare=False) @dataclass diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index 17e13cbcf..0713e80a4 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -602,8 +602,6 @@ def add_rollout_arguments(parser): help="Reclaim sealed straw packs after acknowledged use; retain checkpoints explicitly.", ) parser.add_argument("--rollout-queue-lease-seconds", type=float, default=300) - parser.add_argument("--rollout-queue-max-pending", type=int, default=65536) - parser.add_argument("--rollout-queue-max-inflight", type=int, default=65536) parser.add_argument( "--rollout-queue-segment-mib", type=int, @@ -2087,8 +2085,6 @@ def vime_validate_args(args): raise ValueError("--rollout-queue-online-gc requires --rollout-data-transport straw") for name in ( "rollout_queue_lease_seconds", - "rollout_queue_max_pending", - "rollout_queue_max_inflight", "rollout_queue_segment_mib", "rollout_io_concurrency", ): diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py index 94d4ab82c..256aea868 100644 --- a/vime/utils/score_centering.py +++ b/vime/utils/score_centering.py @@ -127,8 +127,8 @@ def _validate_head_arrays(chunk_ids, chunk_logps): or (np.diff(np.sort(chunk_ids, axis=-1), axis=-1) == 0).any() ): raise ValueError("Sampler top-k token ids must be distinct and nonnegative int32 integers.") - if not np.isfinite(chunk_logps).all() or (chunk_logps > 0).any() or (np.exp(chunk_logps).sum(-1) > 1.0001).any(): - raise ValueError("Sampler top-k logprobs must describe a finite, normalized probability subdistribution.") + if not np.isfinite(chunk_logps).all() or (chunk_logps > 0).any(): + raise ValueError("Sampler top-k logprobs must be finite nonpositive values.") def extract_sampler_topk(meta_info, count, k): @@ -155,10 +155,32 @@ def extract_sampler_top_p(meta_info, count): def validate_sampler_top_p(ids, offsets, logps, count, loss_mask=None, tokens=None, sampled_logps=None): if ids is None or offsets is None or logps is None: raise ValueError("Top-p score centering requires complete sampler top-p ids, offsets, and logprobs.") - # Keep large supports lazy and validate bounded ranges. The sampled token - # and loss mask can change independently of a previously validated tensor. + # Supports can be large; keep them shared and read only bounded ranges. ids = ids if isinstance(ids, TensorRef) else np.asarray(ids) logps = logps if isinstance(logps, TensorRef) else np.asarray(logps) + trusted = all(isinstance(value, TensorRef) and value.validated for value in (ids, offsets, logps)) + if trusted: + # Immutable, previously checked captures need no payload reads when + # republishing a collection or a cross-rollout buffer snapshot. + if ( + len(ids.shape) != 1 + or logps.shape != ids.shape + or offsets.shape != (count + 1,) + or not np.issubdtype(ids.dtype, np.integer) + or not np.issubdtype(offsets.dtype, np.integer) + ): + raise ValueError("Invalid top-p score-centering ids/logprobs/offsets.") + + # Published samples already established one-dimensional token fields. + # Inspect list lengths, not np.shape(list), which traverses every token. + def shape(value): + return (len(value),) if isinstance(value, (list, tuple)) else np.shape(value) + + if loss_mask is not None and shape(loss_mask) != (count,): + raise ValueError("Top-p loss mask must align with the response length.") + if tokens is not None and (shape(tokens) != (count,) or shape(sampled_logps) != (count,)): + raise ValueError("Top-p sampled tokens and logprobs must align with the response length.") + return offsets = np.asarray(offsets.load() if isinstance(offsets, TensorRef) else offsets) if ( len(ids.shape) != 1 @@ -168,32 +190,55 @@ def validate_sampler_top_p(ids, offsets, logps, count, loss_mask=None, tokens=No or not np.issubdtype(offsets.dtype, np.integer) or offsets[0] != 0 or offsets[-1] != len(ids) - or (np.diff(offsets) < 0).any() + or (offsets[1:] < offsets[:-1]).any() ): raise ValueError("Invalid top-p score-centering ids/logprobs/offsets.") - for value in (ids, logps): - if isinstance(value, TensorRef): - value.validate() - for first_row in range(0, count, 1024): - last_row = min(first_row + 1024, count) - base, stop = int(offsets[first_row]), int(offsets[last_row]) - chunk_ids, chunk_logps = np.asarray(ids[base:stop]), np.asarray(logps[base:stop]) - for row in range(first_row, last_row): - start, end = offsets[row : row + 2] - base - if loss_mask is not None and not loss_mask[row] and start == end: - continue - row_ids, row_logps = chunk_ids[start:end], chunk_logps[start:end].astype(np.float64) - _validate_head_arrays(row_ids, row_logps) - if not np.isclose(np.exp(row_logps).sum(), 1.0, rtol=1e-4, atol=1e-6): - raise ValueError( - "Top-p score centering requires the complete normalized support, not a truncated head." - ) - if tokens is not None: - selected = row_logps[row_ids == tokens[row]] - if len(selected) != 1 or not np.isclose(selected[0], sampled_logps[row], rtol=1e-4, atol=1e-5): - raise ValueError( - "Top-p sampler distribution must include the sampled token with its rollout logprob." - ) + offsets = offsets.astype(np.int64, copy=False) + mask = np.ones(count, dtype=bool) if loss_mask is None else np.asarray(loss_mask, dtype=bool) + if mask.shape != (count,): + raise ValueError("Top-p loss mask must align with the response length.") + if tokens is not None: + tokens, sampled_logps = np.asarray(tokens), np.asarray(sampled_logps) + if tokens.shape != (count,) or sampled_logps.shape != (count,): + raise ValueError("Top-p sampled tokens and logprobs must align with the response length.") + + # Per-token NumPy calls block the rollout event loop at high concurrency. + # Reduce whole ragged rows together, bounding temporary arrays by both row + # and candidate counts (a single wider support is kept intact). + first = 0 + while first < count: + stop = min( + count, + first + 4096, + max(first + 1, np.searchsorted(offsets, int(offsets[first]) + 2**20, side="right") - 1), + ) + start, end = offsets[first], offsets[stop] + lengths = np.diff(offsets[first : stop + 1]) + row_ids = np.repeat(np.arange(stop - first, dtype=np.int64), lengths) + chunk_ids = np.asarray(ids[int(start) : int(end)]) + chunk_logps = np.asarray(logps[int(start) : int(end)]).astype(np.float64) + if (chunk_ids < 0).any() or (chunk_ids > np.iinfo(np.int32).max).any(): + raise ValueError("Sampler top-p token ids must be distinct and nonnegative int32 integers.") + keys = np.sort(row_ids * 2**31 + chunk_ids.astype(np.int64, copy=False)) + if (keys[1:] == keys[:-1]).any(): + raise ValueError("Sampler top-p token ids must be distinct and nonnegative int32 integers.") + if not np.isfinite(chunk_logps).all() or (chunk_logps > 0).any(): + raise ValueError("Sampler top-p logprobs must be finite nonpositive values.") + nonempty = lengths > 0 + if (mask[first:stop] & ~nonempty).any(): + raise ValueError("Missing top-p support for a trainable token.") + active = nonempty | mask[first:stop] + if tokens is not None: + selected = chunk_ids == tokens[first:stop][row_ids] + matched_rows = row_ids[selected] + if ( + not np.array_equal(matched_rows, np.flatnonzero(active)) + or not np.isclose( + chunk_logps[selected], sampled_logps[first:stop][matched_rows], rtol=1e-4, atol=1e-5 + ).all() + ): + raise ValueError("Top-p sampler distribution must include the sampled token with its rollout logprob.") + first = stop def validate_sampler_topk(sample, k): From 3f48e09f35740a8f4831aa8a488419fdbc986fab Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Wed, 30 Sep 2026 12:08:07 +0000 Subject: [PATCH 22/58] ci: quote Straw requirement in Buildkite shell steps Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1c9ce842b..8f469652a 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -64,7 +64,7 @@ steps: python:3.11 bash -c ' set -euo pipefail pip install -q torch --index-url https://download.pytorch.org/whl/cpu - pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb 'straw-queue>=0.1.2' + pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb "straw-queue>=0.1.2" pip install -q -e . --no-deps python tests/test_megatron_argument_validation.py python tests/test_optional_straw.py @@ -143,7 +143,7 @@ steps: -v "$$PWD:/workspace" -w /workspace \ "$${VIME_CI_IMAGE:-vllm/vime:latest}" bash -lc ' set -euo pipefail - pip install -q 'straw-queue>=0.1.2' --break-system-packages + pip install -q "straw-queue>=0.1.2" --break-system-packages pip install -q -e . --no-deps --break-system-packages for test_file in \ tests/test_advantage_whiten_cp.py \ From 34b247c331afff999d91f29d03a17dfa0102ee1c Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 01:21:39 +0000 Subject: [PATCH 23/58] [Sync] Mirror Slime PipelineRL and reference top-p fix Port #2437 flush scheduling and #2434 reference log-prob replay fix. Replace request-bound TITO weight version with per-output propagation through vLLM #53199, and preserve exact top-p score-centering support. Signed-off-by: aoshen02 --- .buildkite/gpu_suites.py | 3 + .buildkite/pipeline.yml | 4 +- .../latest/vllm-pd-request-metrics.patch | 2 +- .../patch/latest/vllm-score-centering.patch | 155 ++++++++--- docker/patch/latest/vllm.patch | 263 +++++++++++++----- docs/en/developer_guide/ci.md | 7 + docs/en/get_started/usage.md | 36 +++ docs/zh/developer_guide/ci.md | 6 + docs/zh/get_started/usage.md | 32 +++ tests/pipeline_rl_test_helpers.py | 177 ++++++++++++ tests/test_megatron_argument_validation.py | 52 ++++ tests/test_pipeline_rl.py | 28 ++ tests/test_qwen2.5_0.5B_pipeline_rl.py | 120 ++++++++ tests/test_reference_top_p_replay.py | 119 ++++++++ tests/utils/test_vllm_engine.py | 39 +++ vime/backends/megatron_utils/actor.py | 4 +- .../update_weight_from_disk_delta.py | 17 +- .../update_weight_from_distributed.py | 14 +- .../update_weight_from_tensor.py | 16 +- vime/backends/vllm_utils/vllm_engine.py | 7 +- vime/ray/actor_group.py | 10 +- vime/utils/arguments.py | 52 +++- vime/utils/weight_sync.py | 13 + 23 files changed, 1030 insertions(+), 146 deletions(-) create mode 100644 tests/pipeline_rl_test_helpers.py create mode 100644 tests/test_pipeline_rl.py create mode 100644 tests/test_qwen2.5_0.5B_pipeline_rl.py create mode 100644 tests/test_reference_top_p_replay.py create mode 100644 vime/utils/weight_sync.py diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index 6a33d0ef3..2dba100c7 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -66,6 +66,9 @@ ("test_straw_checkpoint_fork.py", 4, "", {}), ("test_qwen2.5_0.5B_opd_vllm.py", 8, "", {}), ("test_qwen2.5_0.5B_fanout_short.py", 4, "", {}), + ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport nccl", {}), + ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport disk", {}), + ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport nccl --flush-cache-interval 2", {}), ("test_qwen2.5_0.5B_score_centering.py", 2, "", {}), ("test_qwen2.5_0.5B_debug_train_dump_e2e.py", 8, "", {}), ("test_qwen3_4B_external_pd.py", 6, "", {"VIME_TEST_UPDATE_MODE": "delta"}), diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 8f469652a..af7f44d81 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -168,6 +168,7 @@ steps: tests/test_layerwise_alignment.py \ tests/test_model_provider_freeze.py \ tests/test_policy_loss.py \ + tests/test_pipeline_rl.py \ tests/test_ppo_kl_metric.py \ tests/test_published_payload_retry.py \ tests/test_rollout_metadata_index.py \ @@ -177,6 +178,7 @@ steps: tests/test_qwen3_5_vl_native.py \ tests/test_qwen3_linear_attention_cu_seqlens.py \ tests/test_read_file_slicing.py \ + tests/test_reference_top_p_replay.py \ tests/test_reloadable_process_group_world.py \ tests/test_r3_spill.py \ tests/test_rollout_buffer_order.py \ @@ -238,7 +240,7 @@ steps: value: short - label: "run-ci-vllm-config — 8 GPU, 4 tests" value: vllm-config - - label: "run-ci-megatron — up to 8 GPU, 23 runs" + - label: "run-ci-megatron — up to 8 GPU, 26 runs" value: megatron - label: "run-ci-vime-customized — 1–8 GPU, 5 tests" value: vime-customized diff --git a/docker/patch/latest/vllm-pd-request-metrics.patch b/docker/patch/latest/vllm-pd-request-metrics.patch index ef7bd7add..d2813ed5d 100644 --- a/docker/patch/latest/vllm-pd-request-metrics.patch +++ b/docker/patch/latest/vllm-pd-request-metrics.patch @@ -381,7 +381,7 @@ index 6e86ac8341..5c38c0f672 100644 + per_request_metrics.speculative_decoding = spec_stats final_chunk = GenerateStreamResponse( request_id=request_id, - weight_version=weight_version, + choices=[], diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py index 18bbc10283..3996a257e7 100644 --- a/vllm/v1/core/sched/scheduler.py diff --git a/docker/patch/latest/vllm-score-centering.patch b/docker/patch/latest/vllm-score-centering.patch index 317ebfa21..d6e1e3204 100644 --- a/docker/patch/latest/vllm-score-centering.patch +++ b/docker/patch/latest/vllm-score-centering.patch @@ -116,18 +116,6 @@ index 6e86ac83417..397902ddca1 100644 ) ], ) -@@ -607,7 +615,10 @@ class ServingTokens(GenerateBaseServing): - step_top_logprobs.items() - ) - if num_output_top_logprobs is not None -- and i < max(num_output_top_logprobs, 1) -+ and ( -+ num_output_top_logprobs == -1 -+ or i < max(num_output_top_logprobs, 1) -+ ) - ], - ) - ) diff --git a/vllm/outputs.py b/vllm/outputs.py index 0507f2555df..814fa16d86b 100644 --- a/vllm/outputs.py @@ -221,10 +209,65 @@ index 57f0180edc9..3a4d262210d 100644 } if self.vllm_config.watermark_config is None: diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/output.py -index aedff5ce187..2bedf360f56 100644 --- a/vllm/v1/worker/gpu/sample/output.py +++ b/vllm/v1/worker/gpu/sample/output.py -@@ -85,6 +85,7 @@ class SamplingMaskTensors(NamedTuple): +@@ -10,6 +10,7 @@ import torch + + from vllm.triton_utils import tl, triton + from vllm.v1.outputs import LogprobsTensors, SamplingMaskLists ++from vllm.v1.worker.gpu.sample.logprob import compute_token_logprobs + + + @dataclass +@@ -36,6 +37,7 @@ def _compact_sampling_mask_kernel( + vocab_size, + max_num_kept, + BLOCK_SIZE: tl.constexpr, ++ WRITE_PACKED_MASK: tl.constexpr, + ): + """Per row: first ``max_num_kept`` finite-logit ids, the full count, the bitmask.""" + req_idx = tl.program_id(0) +@@ -59,24 +61,30 @@ def _compact_sampling_mask_kernel( + ) + count += tl.sum(keep_i32) + +- bits = tl.reshape(keep_i32, (BLOCK_SIZE // 8, 8)) << tl.arange(0, 8)[None, :] +- byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8) +- tl.store( +- packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets, +- tl.sum(bits, axis=1).to(tl.uint8), +- mask=byte_offsets < tl.cdiv(vocab_size, 8), +- ) ++ if WRITE_PACKED_MASK: ++ bits = ( ++ tl.reshape(keep_i32, (BLOCK_SIZE // 8, 8)) << tl.arange(0, 8)[None, :] ++ ) ++ byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8) ++ tl.store( ++ packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets, ++ tl.sum(bits, axis=1).to(tl.uint8), ++ mask=byte_offsets < tl.cdiv(vocab_size, 8), ++ ) + + tl.store(counts_ptr + req_idx, count) + + +-# Bounds the [num_reqs, width] int32 buffer; wider rows use the bitmask instead. + MAX_COMPACT_SUPPORT = 2048 + + + class SamplingMaskTensors(NamedTuple): + """Device-side masks pending async D2H: compact ids, plus the bitmask as +- the exact fallback for rows wider than ``max_num_kept``.""" ++ the exact fallback for rows wider than ``max_num_kept``. ++ ++ With ``logprobs``, full-vocabulary buffers preserve pure top-p support ++ without synchronizing the device. ++ """ + + # [num_requests, max_num_kept] + token_ids: torch.Tensor +@@ -85,6 +93,7 @@ class SamplingMaskTensors(NamedTuple): # [num_requests] counts: torch.Tensor vocab_size: int @@ -232,7 +275,7 @@ index aedff5ce187..2bedf360f56 100644 @classmethod def from_logits( -@@ -92,10 +93,15 @@ class SamplingMaskTensors(NamedTuple): +@@ -92,18 +101,33 @@ class SamplingMaskTensors(NamedTuple): logits: torch.Tensor, num_sampled_tokens: torch.Tensor, max_num_kept: int, @@ -244,59 +287,83 @@ index aedff5ce187..2bedf360f56 100644 + max_num_kept = ( + vocab_size + if return_logprobs -+ else min(max_num_kept, vocab_size, MAX_COMPACT_SUPPORT) ++ else min( ++ MAX_COMPACT_SUPPORT if max_num_kept == -1 else max_num_kept, ++ vocab_size, ++ MAX_COMPACT_SUPPORT, ++ ) + ) device = logits.device - token_ids = torch.empty( -@@ -119,16 +125,34 @@ class SamplingMaskTensors(NamedTuple): +- token_ids = torch.empty( +- (num_reqs, max_num_kept), dtype=torch.int32, device=device +- ) +- packed_mask = torch.empty( +- (num_reqs, (vocab_size + 7) // 8), dtype=torch.uint8, device=device +- ) ++ if return_logprobs: ++ token_ids = torch.zeros( ++ (num_reqs, max_num_kept), dtype=torch.int32, device=device ++ ) ++ packed_mask = torch.empty((num_reqs, 0), dtype=torch.uint8, device=device) ++ else: ++ token_ids = torch.empty( ++ (num_reqs, max_num_kept), dtype=torch.int32, device=device ++ ) ++ packed_mask = torch.empty( ++ (num_reqs, (vocab_size + 7) // 8), dtype=torch.uint8, device=device ++ ) + counts = torch.empty(num_reqs, dtype=torch.int32, device=device) + _compact_sampling_mask_kernel[(num_reqs,)]( + logits, +@@ -118,26 +142,45 @@ class SamplingMaskTensors(NamedTuple): + vocab_size, max_num_kept, BLOCK_SIZE=8192, ++ WRITE_PACKED_MASK=not return_logprobs, ) - return cls(token_ids, packed_mask, counts, vocab_size) + logprobs = None + if return_logprobs: -+ logprobs = torch.log_softmax(logits.float(), dim=-1) ++ logprobs = compute_token_logprobs(logits, token_ids) + return cls(token_ids, packed_mask, counts, vocab_size, logprobs) def to_cpu_nonblocking(self) -> SamplingMaskTensors: if self.token_ids.device.type == "cpu": return self -+ if self.logprobs is not None: -+ counts = self.counts.cpu() -+ width = int(counts.max()) -+ ids = self.token_ids[:, :width] -+ positions = torch.arange(width, device=ids.device)[None, :] -+ safe_ids = torch.where(positions < self.counts[:, None], ids, 0) -+ scores = self.logprobs.gather(1, safe_ids.long()) -+ return SamplingMaskTensors( -+ ids.to("cpu", non_blocking=True), -+ self.packed_mask.to("cpu", non_blocking=True), -+ counts, -+ self.vocab_size, -+ scores.to("cpu", non_blocking=True), -+ ) ++ logprobs = self.logprobs ++ if logprobs is not None: ++ logprobs = logprobs.to("cpu", non_blocking=True) return SamplingMaskTensors( self.token_ids.to("cpu", non_blocking=True), self.packed_mask.to("cpu", non_blocking=True), self.counts.to("cpu", non_blocking=True), self.vocab_size, -+ None, ++ logprobs, ) def tolists(self) -> SamplingMaskLists: -@@ -150,4 +174,10 @@ class SamplingMaskTensors(NamedTuple): - supports = [support(row) for row in range(len(counts))] - offsets = np.zeros(len(supports) + 1, dtype=np.int64) - np.cumsum([len(s) for s in supports], out=offsets[1:]) -- return SamplingMaskLists(np.concatenate(supports), offsets) -+ logprobs = None + """CSR over all requests; rows without a sampled token are empty.""" + counts = self.counts.cpu().numpy() + token_ids = self.token_ids.cpu().numpy() +- packed_mask = self.packed_mask.cpu().numpy() + width = token_ids.shape[1] + + if self.logprobs is not None: -+ scores = self.logprobs.cpu().numpy() -+ logprobs = np.concatenate( -+ [scores[row, :count] for row, count in enumerate(counts)] ++ valid = np.arange(width)[None, :] < counts[:, None] ++ offsets = np.zeros(len(counts) + 1, dtype=np.int64) ++ np.cumsum(counts, out=offsets[1:]) ++ return SamplingMaskLists( ++ token_ids[valid], ++ offsets, ++ logprobs=self.logprobs.cpu().numpy()[valid], + ) -+ return SamplingMaskLists(np.concatenate(supports), offsets, logprobs=logprobs) ++ ++ packed_mask = self.packed_mask.cpu().numpy() ++ + def support(row: int) -> np.ndarray: + if counts[row] <= width: + return token_ids[row, : counts[row]] diff --git a/vllm/v1/worker/gpu/sample/sampler.py b/vllm/v1/worker/gpu/sample/sampler.py index 802adb6f642..0deb66ec5a2 100644 --- a/vllm/v1/worker/gpu/sample/sampler.py diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index 749b7b72b..c2edb1610 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -1,86 +1,44 @@ diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index fb2b9387238..eb4000c7e29 100644 +index fb2b9387238..c05faefa4c6 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -362,6 +362,7 @@ class GenerateStreamResponse(BaseModel): - ) - choices: list[GenerateResponseStreamChoice] - usage: UsageInfo | None = Field(default=None) -+ weight_version: str | None = None +@@ -365,6 +365,7 @@ class GenerateStreamResponse(BaseModel): prompt_token_ids: list[int] | None = None mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None metrics: PerRequestMetrics | None = None -@@ -380,6 +381,7 @@ class GenerateResponse(BaseModel): - created: int | None = None - choices: list[GenerateResponseChoice] - usage: UsageInfo | None = Field(default=None) + weight_version: str | None = None - prompt_logprobs: list[dict[int, Logprob] | None] | None = None + + + class GenerateResponse(BaseModel): +@@ -384,6 +385,7 @@ class GenerateResponse(BaseModel): prompt_token_ids: list[int] | None = None mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None + metrics: PerRequestMetrics | None = None ++ weight_version: str | None = None + + kv_transfer_params: dict[str, Any] | None = Field( + default=None, diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index 59fda2dbf8c..6e86ac83417 100644 +index 59fda2dbf8c..862413c2da4 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -281,6 +281,7 @@ class ServingTokens(GenerateBaseServing): +@@ -394,6 +394,7 @@ class ServingTokens(GenerateBaseServing): + ), + mm_placeholders=request._response_mm_placeholders, + metrics=per_request_metrics, ++ weight_version=final_res.weight_version, + kv_transfer_params=final_res.kv_transfer_params, + ec_transfer_params=final_res.ec_transfer_params, ) - - assert result_generator is not None -+ weight_version = await self.engine_client.get_weight_version() - - if request.stream: - return self.serve_tokens_stream_generator( -@@ -289,10 +290,16 @@ class ServingTokens(GenerateBaseServing): - request_id, - model_name, - request_metadata, -+ weight_version, - ) - - return await self.serve_tokens_full_generator( -- request, result_generator, request_id, model_name, request_metadata -+ request, -+ result_generator, -+ request_id, -+ model_name, -+ request_metadata, -+ weight_version, - ) - - async def serve_tokens_full_generator( -@@ -302,6 +309,7 @@ class ServingTokens(GenerateBaseServing): - request_id: str, - model_name: str, - request_metadata: RequestResponseMetadata, -+ weight_version: str | None, - ) -> ErrorResponse | GenerateResponse: - created_time = int(time.time()) - final_res: RequestOutput | None = None -@@ -388,6 +396,7 @@ class ServingTokens(GenerateBaseServing): - model=model_name, - choices=choices, - usage=usage, -+ weight_version=weight_version, - prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs), - prompt_token_ids=( - final_res.prompt_token_ids if request.return_token_ids else None -@@ -426,6 +435,7 @@ class ServingTokens(GenerateBaseServing): - request_id: str, - model_name: str, - request_metadata: RequestResponseMetadata, -+ weight_version: str | None, - ) -> AsyncGenerator[str, None]: - num_prompt_tokens = 0 - num_generated_tokens: list[int] = [] -@@ -487,6 +497,7 @@ class ServingTokens(GenerateBaseServing): +@@ -487,6 +488,7 @@ class ServingTokens(GenerateBaseServing): chunk = GenerateStreamResponse( request_id=request_id, -+ weight_version=weight_version, ++ weight_version=res.weight_version, choices=[ GenerateResponseStreamChoice( index=i, -@@ -494,6 +505,11 @@ class ServingTokens(GenerateBaseServing): +@@ -494,6 +496,11 @@ class ServingTokens(GenerateBaseServing): finish_reason=finish_reason, token_ids=as_list(delta_token_ids), routed_experts=routed_experts_b64, @@ -92,19 +50,64 @@ index 59fda2dbf8c..6e86ac83417 100644 ) ], ) -@@ -538,6 +554,7 @@ class ServingTokens(GenerateBaseServing): - ) - final_chunk = GenerateStreamResponse( - request_id=request_id, -+ weight_version=weight_version, +@@ -511,7 +518,12 @@ class ServingTokens(GenerateBaseServing): + # Omit fields that are absent from token-bearing chunks. + exclude = { + name +- for name in ("prompt_token_ids", "mm_placeholders", "metrics") ++ for name in ( ++ "prompt_token_ids", ++ "mm_placeholders", ++ "metrics", ++ "weight_version", ++ ) + if getattr(chunk, name) is None + } + yield f"data: {chunk.model_dump_json(exclude=exclude)}\n\n" +@@ -541,6 +553,7 @@ class ServingTokens(GenerateBaseServing): choices=[], usage=final_usage_info, metrics=per_request_metrics, ++ weight_version=last_res.weight_version if last_res else None, + ) + yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n" + diff --git a/vllm/outputs.py b/vllm/outputs.py -index e9a08f7c462..0507f2555df 100644 +index e9a08f7c462..a5c41c99e9a 100644 --- a/vllm/outputs.py +++ b/vllm/outputs.py -@@ -191,6 +191,13 @@ class RequestOutput: +@@ -130,6 +130,7 @@ class RequestOutput: + prefix-cache writes for this request. + kv_transfer_params: The params for remote K/V transfer. + ec_transfer_params: The params for remote encoder-cache transfer. ++ weight_version: The weight version for this output. + + """ + +@@ -150,6 +151,7 @@ class RequestOutput: + *, + kv_transfer_params: dict[str, Any] | None = None, + ec_transfer_params: dict[str, Any] | None = None, ++ weight_version: str | None = None, + # Forward compatibility, code that uses args added in new release can + # still run with older versions of vLLM without breaking. + **kwargs: Any, +@@ -172,12 +174,14 @@ class RequestOutput: + self.num_cache_creation_tokens = num_cache_creation_tokens + self.kv_transfer_params = kv_transfer_params + self.ec_transfer_params = ec_transfer_params ++ self.weight_version = weight_version + + def add(self, next_output: "RequestOutput", aggregate: bool) -> None: + """Merge subsequent RequestOutput into this one""" + self.finished |= next_output.finished + self.kv_transfer_params = next_output.kv_transfer_params + self.ec_transfer_params = next_output.ec_transfer_params ++ self.weight_version = next_output.weight_version + + for next_completion in next_output.outputs: + for i, completion in enumerate(self.outputs): +@@ -191,6 +195,13 @@ class RequestOutput: if next_completion.logprobs: assert completion.logprobs is not None completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type] @@ -118,6 +121,80 @@ index e9a08f7c462..0507f2555df 100644 completion.cumulative_logprob = ( next_completion.cumulative_logprob ) +diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py +index c868d249c8a..c9f757042f7 100644 +--- a/vllm/v1/core/sched/interface.py ++++ b/vllm/v1/core/sched/interface.py +@@ -94,6 +94,7 @@ class SchedulerInterface(ABC): + self, + scheduler_output: "SchedulerOutput", + model_runner_output: "ModelRunnerOutput", ++ weight_version: str | None = None, + ) -> dict[int, "EngineCoreOutputs"]: + """Update the scheduler state based on the model runner output. + +diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py +index 18bbc102830..c81921d8725 100644 +--- a/vllm/v1/core/sched/scheduler.py ++++ b/vllm/v1/core/sched/scheduler.py +@@ -1907,6 +1907,7 @@ class Scheduler(SchedulerInterface): + self, + scheduler_output: SchedulerOutput, + model_runner_output: ModelRunnerOutput, ++ weight_version: str | None = None, + ) -> dict[int, EngineCoreOutputs]: + sampled_token_ids = model_runner_output.sampled_token_ids + logprobs = model_runner_output.logprobs +@@ -2139,6 +2140,7 @@ class Scheduler(SchedulerInterface): + EngineCoreOutput( + request_id=req_id, + new_token_ids=new_token_ids, ++ weight_version=weight_version, + finish_reason=finish_reason, + new_logprobs=new_logprobs, + new_sampling_mask=new_sampling_mask, +@@ -2189,6 +2191,7 @@ class Scheduler(SchedulerInterface): + EngineCoreOutput( + request_id=request.request_id, + new_token_ids=[], ++ weight_version=weight_version, + finish_reason=request.get_finished_reason(), + events=request.take_events(), + trace_headers=request.trace_headers, +diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py +index e5bbb96a9cf..e7a849297ed 100644 +--- a/vllm/v1/engine/__init__.py ++++ b/vllm/v1/engine/__init__.py +@@ -235,6 +235,7 @@ class EngineCoreOutput( + # Per-request spec-decode acceptance; attached only on the final output. + # Appended last so `array_like` positional serialization stays compatible. + spec_decode_metrics: RequestSpecDecodeMetrics | None = None ++ weight_version: str | None = None + + @property + def finished(self) -> bool: +diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py +index 9b27f50edc0..f0d8226e042 100644 +--- a/vllm/v1/engine/core.py ++++ b/vllm/v1/engine/core.py +@@ -655,7 +655,7 @@ class EngineCore: + # during the model execution. + self._process_aborts_queue() + engine_core_outputs = self.scheduler.update_from_output( +- scheduler_output, model_output ++ scheduler_output, model_output, self._weight_version + ) + self._attach_iteration_details(engine_core_outputs, iteration_details) + +@@ -756,7 +756,7 @@ class EngineCore: + # during the model execution. + self._process_aborts_queue() + engine_core_outputs = self.scheduler.update_from_output( +- scheduler_output, model_output ++ scheduler_output, model_output, self._weight_version + ) + self._attach_iteration_details(engine_core_outputs, iteration_details) + diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py index cb7d3d47183..09618d7eb66 100644 --- a/vllm/v1/engine/input_processor.py @@ -136,10 +213,42 @@ index cb7d3d47183..09618d7eb66 100644 self.vllm_config.reasoning_config is None or not self.vllm_config.reasoning_config.enabled diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index b4f7338048c..e739e83b1c8 100644 +index b4f7338048c..a98bf6f6e09 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -434,10 +434,16 @@ class RequestState: +@@ -304,6 +304,7 @@ class RequestState: + stop_reason: int | str | None, + kv_transfer_params: dict[str, Any] | None = None, + ec_transfer_params: dict[str, Any] | None = None, ++ weight_version: str | None = None, + ) -> RequestOutput | PoolingRequestOutput | None: + finished = finish_reason is not None + final_only = self.output_kind == RequestOutputKind.FINAL_ONLY +@@ -360,6 +361,7 @@ class RequestState: + finished, + kv_transfer_params, + ec_transfer_params, ++ weight_version, + ) + + def _new_request_output( +@@ -369,6 +371,7 @@ class RequestState: + finished: bool, + kv_transfer_params: dict[str, Any] | None = None, + ec_transfer_params: dict[str, Any] | None = None, ++ weight_version: str | None = None, + ) -> RequestOutput | PoolingRequestOutput: + # If prompt embeds were used, put placeholder prompt token ids + prompt_token_ids = self.prompt_token_ids +@@ -403,6 +406,7 @@ class RequestState: + finished=finished, + kv_transfer_params=kv_transfer_params, + ec_transfer_params=ec_transfer_params, ++ weight_version=weight_version, + num_cached_tokens=self.num_cached_tokens, + num_cache_creation_tokens=self.num_cache_creation_tokens, + metrics=self.stats, +@@ -434,10 +438,16 @@ class RequestState: logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] sampling_mask = None @@ -158,3 +267,11 @@ index b4f7338048c..e739e83b1c8 100644 # Concatenate routed experts on finish routed_experts = None +@@ -731,6 +741,7 @@ class OutputProcessor: + stop_reason, + kv_transfer_params, + ec_transfer_params, ++ engine_core_output.weight_version, + ): + if req_state.streaming_input: + request_output.finished = False diff --git a/docs/en/developer_guide/ci.md b/docs/en/developer_guide/ci.md index 9591ae637..f1ae1c20e 100644 --- a/docs/en/developer_guide/ci.md +++ b/docs/en/developer_guide/ci.md @@ -38,6 +38,13 @@ must not be updated before the change merges. The `megatron` suite includes `test_straw_checkpoint_fork.py` for checkpoint step selection, rollback branches and indexed debug archives. +`test_qwen2.5_0.5B_pipeline_rl.py` runs three GRPO steps with fully async rollout +on 4 GPUs. Its probes check that requests continue across weight updates and +that policy weights change. The matrix covers NCCL and full disk sync with +`--flush-cache-interval 0`, plus periodic refresh with NCCL and interval `2`. +`test_pipeline_rl.py` checks the schedule on CPU. These tests do not measure +learning quality or throughput gains. + ## Registering tests - Add always-on CPU tests to the appropriate command in diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index 951c9c402..4aa2469f7 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -432,6 +432,42 @@ Restored reader buffers keep an explicit storage reference independent of filter The manager still materializes the selected batch for conversion, so manager memory and shared-storage bandwidth remain limits. `--rollout-io-concurrency` bounds publication I/O submissions. straw's storage, journal and GC implementation is Rust; vime retains Python sample conversion and training integration. Measure throughput for the intended model, concurrency and shared filesystem before changing deployment defaults. +### Preserve KV across weight updates (PipelineRL) + +`--flush-cache-interval` controls the cache refresh policy at weight synchronization: + +| Value | Behavior | +| --- | --- | +| `1` (default) | Abort generation, flush KV, update weights, then resume. | +| `<= 0` | Pause generation in place, update weights, and continue unfinished requests with their existing KV. | +| `N > 1` | Fully flush every N training weight updates; preserve KV on the intervening updates. | + +The initial weight publication always flushes, including after checkpoint recovery. +For example, `2` preserves KV at serving version 2, flushes at version 3, and +preserves it again at version 4. This counts weight synchronizations, rather +than optimizer steps or rollout batches. + +Values other than `1` automatically select the fully async rollout implementation +when using the default rollout function. Custom rollout functions keep their +own scheduling. Training and rollout must use separate GPUs, without rollout +offload or `--release-train`. Stock evaluation remains available through the +standard vLLM rollout function. + +```bash +--flush-cache-interval 8 \ +--use-rollout-logprobs +``` + +This uses vLLM's `pause(mode="keep", clear_cache=false)` API. Requests spanning an +update use KV computed with older weights; rollout log probabilities reflect +the policies that generated their tokens. Periodic full refreshes abort +unfinished requests, which the fully async worker requeues. + +Shared prefixes can also retain old KV between refreshes. With `<= 0`, frequently +reused prefixes have no age bound. Disable vLLM prefix caching to prevent reuse +across requests while preserving each unfinished request's KV. Periodic refresh +bounds the lifetime of shared KV without introducing weight-version cache namespaces. + ## How to Use vLLM vime runs vLLM in server mode and talks to it over HTTP. diff --git a/docs/zh/developer_guide/ci.md b/docs/zh/developer_guide/ci.md index ed6a1ed28..83551c1d3 100644 --- a/docs/zh/developer_guide/ci.md +++ b/docs/zh/developer_guide/ci.md @@ -36,6 +36,12 @@ job。验证 Dockerfile 或 vLLM patch 修改时,通过 `VIME_CI_IMAGE` 指定 `megatron` 套件包含 `test_straw_checkpoint_fork.py`,覆盖 checkpoint 步骤选择、回退分支和带索引的 debug 归档。 +`test_qwen2.5_0.5B_pipeline_rl.py` 使用 4 张 GPU,运行 fully async rollout +和三个真实 GRPO step。探针检查同一请求跨权重更新持续生成,并确认训练改变了 +策略权重。固定矩阵覆盖 `--flush-cache-interval 0` 下的 NCCL 和 full disk +权重同步,以及 NCCL 下 interval `2` 的周期性刷新。`test_pipeline_rl.py` +在 CPU 上检查刷新周期。这些测试不衡量学习效果或吞吐增益。 + ## 注册测试 - 始终运行的 CPU 测试加入 `.buildkite/pipeline.yml` 中对应的命令。 diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 881f3615e..19be6d641 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -430,6 +430,38 @@ R3 训练只读取当前 CP/TP rank 分配到的行。续跑批量发布、加 Manager 仍会读取选中的 batch 做转换,共享存储带宽和 manager 内存仍可能成为瓶颈。`--rollout-io-concurrency` 限制队列发布的 I/O 待办数量。straw 的存储、日志和 GC 由 Rust 实现,vime 保留 Python sample 转换和训练集成。改变部署默认值前,需针对实际模型、并发和共享文件系统测量吞吐。 +### 跨权重更新保留 KV(PipelineRL) + +`--flush-cache-interval` 控制权重同步时的缓存刷新策略: + +| 值 | 行为 | +| --- | --- | +| `1`(默认) | abort 生成、flush KV、更新权重,再恢复生成。 | +| `<= 0` | 原地暂停生成,更新权重后让未完成请求沿用已有 KV 继续生成。 | +| `N > 1` | 每 N 次训练权重更新完整刷新一次,其余更新保留 KV。 | + +首次发布权重总会刷新,包括从 checkpoint 恢复时。例如 `2` 会在 serving +version 2 保留 KV、version 3 刷新、version 4 再次保留。周期按权重同步次数 +计算,而不是 optimizer step 或 rollout batch 数。 + +使用默认 rollout 函数时,非 `1` 的值会自动选择 fully async rollout 实现。 +自定义 rollout 函数保留自己的调度逻辑。训推需要使用独立 GPU,不能开启 +rollout offload 或 `--release-train`。默认评估仍使用标准 vLLM rollout 函数。 + +```bash +--flush-cache-interval 8 \ +--use-rollout-logprobs +``` + +实现使用 vLLM 已有的 `pause(mode="keep", clear_cache=false)` API。跨更新的请求 +会使用旧权重计算的 KV;rollout log probabilities 对应实际生成各 token 的策略。 +周期性完整刷新会 abort 未完成请求,fully async worker 会将其重新排队生成。 + +公共 prefix 也可能在刷新前一直复用旧 KV。设为 `<= 0` 时,高频 prefix 没有 +缓存年龄上限。可关闭 vLLM 的 prefix caching,防止跨请求复用,同时保留未完成 +请求自身的 KV。周期性刷新则可以限制公共 KV 的存活时间,暂不引入权重版本 +缓存命名空间。 + ## vLLM 使用方法 vime 以 server 模式运行 vLLM,通过 HTTP 与之通信。 diff --git a/tests/pipeline_rl_test_helpers.py b/tests/pipeline_rl_test_helpers.py new file mode 100644 index 000000000..281dc3232 --- /dev/null +++ b/tests/pipeline_rl_test_helpers.py @@ -0,0 +1,177 @@ +"""Live-request probes used by the PipelineRL GPU e2e test.""" + +import json +import os +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import requests +import torch +import torch.distributed as dist + +_probes = [] +_records = {} +_worker_urls_written = False +_initial_weights = None + + +async def reward(args, sample, **kwargs): + # The serving address is assigned inside RolloutManager, after training + # actors receive their args. Publish it from the rollout-side fixture. + global _worker_urls_written + if not _worker_urls_written: + response = requests.get(f"http://{args.vllm_router_ip}:{args.vllm_router_port}/workers", timeout=10) + response.raise_for_status() + urls = sorted({worker["url"] for worker in response.json()["workers"]}) + assert urls + path = Path(os.environ["VIME_PIPELINE_RL_PROBE_FILE"]).with_suffix(".workers.json") + tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp") + tmp.write_text(json.dumps(urls)) + tmp.replace(path) + _worker_urls_written = True + # Give each GRPO group deterministic nonuniform rewards so this short + # infrastructure test exercises a real policy gradient even on truncated + # math answers. The task's mathematical accuracy is not under test. + return float(sample.index % args.n_samples_per_prompt) + + +class GenerationProbe: + def __init__(self, url, model): + self.url = url + self.model = model + self.rid = f"pipeline-rl-probe-{uuid.uuid4().hex}" + self.engine_request_id = f"generate-tokens-{self.rid}" + self.events = [] + self.condition = threading.Condition() + self.error = None + self.done = False + self.thread = threading.Thread(target=self.run, daemon=True) + + def run(self): + try: + with requests.post( + f"{self.url}/inference/v1/generate", + json={ + "model": self.model, + "request_id": self.rid, + "token_ids": [1, 2, 3], + "sampling_params": {"temperature": 0.8, "max_tokens": 32700, "ignore_eos": True}, + "stream": True, + }, + stream=True, + timeout=(10, 300), + ) as response: + response.raise_for_status() + for line in response.iter_lines(chunk_size=4096): + if not line.startswith(b"data: ") or line == b"data: [DONE]": + continue + data = json.loads(line[6:]) + assert data["request_id"] == self.engine_request_id + for choice in data["choices"]: + with self.condition: + tokens = len(choice.get("token_ids") or []) + self.events.append( + { + "tokens": (self.events[-1]["tokens"] if self.events else 0) + tokens, + "version": str(data["weight_version"]), + "finish_reason": choice.get("finish_reason"), + "time": time.time(), + } + ) + self.condition.notify_all() + except Exception as error: + self.error = error + finally: + with self.condition: + self.done = True + self.condition.notify_all() + + def wait(self, predicate): + with self.condition: + ready = self.condition.wait_for(lambda: self.error or self.done or predicate(self.events), timeout=180) + if self.error: + raise self.error + assert ready and predicate(self.events), f"Probe failed on {self.url}: {self.events[-3:]}" + + def verify(self): + first = self.events[0] + self.wait( + lambda events: any(e["version"] != first["version"] and e["tokens"] > first["tokens"] + 16 for e in events) + ) + # Every event up to this point belongs to the same uninterrupted HTTP + # request. An abort/restart or a disk reload's implicit flush must fail. + assert all(e["finish_reason"] is None for e in self.events), self.events[-3:] + latest = self.events[-1].copy() + response = requests.get(f"{self.url}/weight_info", timeout=10) + response.raise_for_status() + assert str(response.json()["weight_version"]) == latest["version"] + # End only our synthetic probe after it has proved continuity. + response = requests.post( + f"{self.url}/abort_requests", json={"request_ids": [self.engine_request_id]}, timeout=10 + ) + response.raise_for_status() + self.thread.join(timeout=30) + assert not self.thread.is_alive() + return {"url": self.url, "rid": self.rid, "before": first, "after": latest} + + def verify_flush(self): + self.wait(lambda events: bool(events) and events[-1]["finish_reason"] is not None) + self.thread.join(timeout=30) + assert not self.thread.is_alive() + latest = self.events[-1].copy() + assert latest["finish_reason"] == "abort", self.events[-3:] + response = requests.get(f"{self.url}/weight_info", timeout=10) + response.raise_for_status() + return { + "url": self.url, + "rid": self.rid, + "before": self.events[0], + "after": latest, + "serving_version": str(response.json()["weight_version"]), + } + + +def start_probes(urls, model): + _probes.clear() + for url in urls: + probe = GenerationProbe(url, model) + _probes.append(probe) + probe.thread.start() + for probe in _probes: + probe.wait(lambda events: bool(events) and events[-1]["tokens"] >= 16) + + +def before_train_step(args, rollout_id, step_id, model, optimizer, opt_param_scheduler): + """Start a long request on every serving engine before the first update.""" + if dist.get_rank() != 0: + return + global _initial_weights + if rollout_id == 0 and step_id == 0: + name, param = next((name, param) for name, param in model[0].named_parameters() if "linear_qkv.weight" in name) + _initial_weights = (name, param.detach().cpu().clone()) + path = Path(os.environ["VIME_PIPELINE_RL_PROBE_FILE"]).with_suffix(".workers.json") + urls = json.loads(path.read_text()) + assert urls + start_probes(urls, args.hf_checkpoint) + elif rollout_id == 1 and step_id == 0: + assert _probes + name, initial = _initial_weights + current = dict(model[0].named_parameters())[name].detach().cpu() + assert not torch.equal(initial, current), "The policy weights did not change after training" + _records["weights_changed"] = True + with ThreadPoolExecutor(max_workers=len(_probes)) as pool: + _records["continuous"] = list(pool.map(lambda probe: probe.verify(), _probes)) + start_probes([probe.url for probe in _probes], args.hf_checkpoint) + elif rollout_id == 2 and step_id == 0: + assert _probes + with ThreadPoolExecutor(max_workers=len(_probes)) as pool: + _records["next_update"] = list( + pool.map( + lambda probe: probe.verify_flush() if args.flush_cache_interval == 2 else probe.verify(), _probes + ) + ) + path = Path(os.environ["VIME_PIPELINE_RL_PROBE_FILE"]) + path.write_text(json.dumps(_records, indent=2)) diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index cb989fc69..1662ce704 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -216,6 +216,7 @@ def test_update_weight_disk_dir_required_for_disk_transport(monkeypatch): def make_vime_validate_args(**overrides): values = dict( + flush_cache_interval=1, rollout_data_transport="object-store", rollout_data_dir=None, rollout_queue_lease_seconds=300, @@ -306,6 +307,57 @@ def make_vime_validate_args(**overrides): return types.SimpleNamespace(**values) +@pytest.mark.parametrize( + "rollout_path", + [ + "vime.rollout.vllm_rollout.generate_rollout", + "vime.rollout.fully_async_rollout.generate_rollout_fully_async", + "custom.rollout", + ], +) +def test_pipeline_rl_defaults_to_fully_async_with_separate_eval(monkeypatch, rollout_path): + module = load_vime_arguments_module(monkeypatch) + args = make_vime_validate_args(flush_cache_interval=0, rollout_function_path=rollout_path) + module.vime_validate_args(args) + expected = ( + "vime.rollout.fully_async_rollout.generate_rollout_fully_async" + if "vllm_rollout" in rollout_path + else rollout_path + ) + assert args.rollout_function_path == expected + assert args.eval_function_path == "vime.rollout.vllm_rollout.generate_rollout" + + +@pytest.mark.parametrize( + "options", + [{"colocate": True}, {"offload_rollout": True}, {"debug_train_only": True}, {"debug_rollout_only": True}], +) +def test_pipeline_rl_rejects_incompatible_lifecycles(monkeypatch, options): + module = load_vime_arguments_module(monkeypatch) + with pytest.raises(ValueError, match="flush-cache-interval"): + module.vime_validate_args(make_vime_validate_args(flush_cache_interval=0, **options)) + + +def test_flush_cache_interval_defaults_to_existing_behavior(monkeypatch): + module = load_vime_arguments_module(monkeypatch) + parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) + assert parser.parse_args(["--rollout-batch-size", "1"]).flush_cache_interval == 1 + assert parser.parse_args(["--rollout-batch-size", "1", "--flush-cache-interval", "0"]).flush_cache_interval == 0 + args = make_vime_validate_args(rollout_function_path="vime.rollout.vllm_rollout.generate_rollout") + module.vime_validate_args(args) + assert args.rollout_function_path == args.eval_function_path == "vime.rollout.vllm_rollout.generate_rollout" + + +@pytest.mark.parametrize("interval", [-1, -100]) +def test_negative_flush_cache_interval_disables_training_flush(monkeypatch, interval): + module = load_vime_arguments_module(monkeypatch) + args = make_vime_validate_args( + flush_cache_interval=interval, rollout_function_path="vime.rollout.vllm_rollout.generate_rollout" + ) + module.vime_validate_args(args) + assert args.rollout_function_path == "vime.rollout.fully_async_rollout.generate_rollout_fully_async" + + def test_distributed_fully_async_is_opt_in(monkeypatch): module = load_vime_arguments_module(monkeypatch) parser = module.get_vime_extra_args_provider()(argparse.ArgumentParser()) diff --git a/tests/test_pipeline_rl.py b/tests/test_pipeline_rl.py new file mode 100644 index 000000000..ad78ad252 --- /dev/null +++ b/tests/test_pipeline_rl.py @@ -0,0 +1,28 @@ +"""CPU checks for PipelineRL weight-sync cache policy.""" + +import pytest + +from vime.utils.weight_sync import should_flush_cache + +NUM_GPUS = 0 + + +@pytest.mark.parametrize( + "interval,expected", + [(-1, [1]), (-100, [1]), (0, [1]), (1, [1, 2, 3, 4, 5, 6, 7]), (2, [1, 3, 5, 7]), (3, [1, 4, 7])], +) +def test_flush_schedule_counts_training_updates_and_handles_restored_versions(interval, expected): + assert [version for version in range(1, 8) if should_flush_cache(interval, version)] == expected + assert [version for version in range(5, 8) if should_flush_cache(interval, version)] == [ + version for version in expected if version >= 5 + ] + + +@pytest.mark.parametrize("interval", [-1, 0, 1, 2, 3]) +def test_first_publication_after_resume_flushes_health_check_cache(interval): + assert should_flush_cache(interval, weight_version=6, initial_weight_version=5) + assert should_flush_cache(interval, weight_version=7, initial_weight_version=5) == should_flush_cache(interval, 7) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_qwen2.5_0.5B_pipeline_rl.py b/tests/test_qwen2.5_0.5B_pipeline_rl.py new file mode 100644 index 000000000..f25651423 --- /dev/null +++ b/tests/test_qwen2.5_0.5B_pipeline_rl.py @@ -0,0 +1,120 @@ +"""PipelineRL e2e: real training plus live sequences spanning weight updates. + +Train-step hooks start long streaming requests on every rollout engine and +check continuity after a weight update. A second set of probes verifies either +continued generation or the periodic full refresh, depending on the interval. +This catches abort pauses, KV flushes, and disk reload's implicit flush. +""" + +import argparse +import json +import os +import tempfile +from pathlib import Path +from shlex import quote + +import vime.utils.external_utils.command_utils as U + +MODEL_NAME = "Qwen2.5-0.5B-Instruct" +MODEL_TYPE = "qwen2.5-0.5B" +NUM_GPUS = 4 + + +def prepare(): + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def train_args( + directory, + *, + transport="nccl", + flush_cache_interval=0, + actor_nodes=1, + actor_gpus=1, + rollout_gpus=3, + engine_gpus=1, + model_path=None, + dataset_path=None, +): + model_path = model_path or f"/root/models/{MODEL_NAME}" + dataset_path = dataset_path or "/root/datasets/gsm8k/train.parquet" + args = ( + f"--hf-checkpoint {quote(model_path)} --ref-load {quote(model_path)} " + f"--flush-cache-interval {flush_cache_interval} --rollout-data-transport straw --rollout-queue-online-gc " + f"--rollout-data-dir {quote(str(directory / 'queue'))} " + f"--prompt-data {quote(dataset_path)} --input-key messages --label-key label --apply-chat-template " + "--custom-rm-path pipeline_rl_test_helpers.reward --num-rollout 3 --rollout-batch-size 4 --n-samples-per-prompt 4 " + "--rollout-max-response-len 256 --rollout-temperature 0.8 --global-batch-size 16 --balance-data " + "--rollout-io-concurrency 8 --vllm-gpu-memory-utilization 0.65 --vllm-max-num-seqs 16 " + "--vllm-max-model-len 32768 --vllm-max-cudagraph-capture-size 16 --vllm-generation-config vllm " + f"--actor-num-nodes {actor_nodes} --actor-num-gpus-per-node {actor_gpus} " + f"--rollout-num-gpus {rollout_gpus} --rollout-num-gpus-per-engine {engine_gpus} " + "--tensor-model-parallel-size 1 --pipeline-model-parallel-size 1 --context-parallel-size 1 " + "--expert-model-parallel-size 1 --expert-tensor-parallel-size 1 " + "--use-dynamic-batch-size --max-tokens-per-gpu 4096 " + "--advantage-estimator grpo --use-rollout-logprobs --eps-clip 0.2 " + "--optimizer adam --lr 1e-6 --lr-decay-style constant --weight-decay 0.1 " + "--adam-beta1 0.9 --adam-beta2 0.98 --attention-dropout 0.0 --hidden-dropout 0.0 " + "--accumulate-allreduce-grads-in-fp32 --attention-softmax-in-fp32 --attention-backend flash " + "--custom-megatron-before-train-step-hook-path pipeline_rl_test_helpers.before_train_step " + f"--save {quote(str(directory / 'checkpoint'))} --save-interval 3 --ci-test --ci-disable-kl-checker " + ) + if transport == "disk": + args += f"--update-weight-transport disk --update-weight-disk-dir {quote(str(directory / 'weights'))} " + return args + + +def verify(directory, expected_engines, flush_cache_interval=0): + results = json.loads((directory / "probes.json").read_text()) + assert results["weights_changed"] + records = results["continuous"] + assert len(records) == expected_engines + for record in records: + assert record["before"]["version"] == "1", record + assert record["after"]["version"] == "2", record + assert record["after"]["tokens"] > record["before"]["tokens"] + 16, record + records = results["next_update"] + assert len(records) == expected_engines + for record in records: + assert record["before"]["version"] == "2", record + if flush_cache_interval == 2: + assert record["after"]["finish_reason"] == "abort", record + assert record["serving_version"] == "3", record + else: + assert record["after"]["version"] == "3", record + assert record["after"]["tokens"] > record["before"]["tokens"] + 16, record + assert (directory / "checkpoint" / "rollout" / "committed_2.json").exists() + print(f"PipelineRL verified on {expected_engines} engines: interval={flush_cache_interval}; checkpoint committed.") + + +def execute(transport, flush_cache_interval): + with tempfile.TemporaryDirectory(prefix="vime_pipeline_rl_") as tmp: + directory = Path(tmp) + U.execute_train( + train_args=train_args(directory, transport=transport, flush_cache_interval=flush_cache_interval), + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + extra_env_vars={ + "PYTHONPATH": f"{U.repo_base_dir / 'tests'}:{U.repo_base_dir}:/root/Megatron-LM/", + "VIME_PIPELINE_RL_PROBE_FILE": str(directory / "probes.json"), + "NO_PROXY": "*", + "no_proxy": "*", + **{ + key: "" + for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "all_proxy") + }, + }, + ) + verify(directory, expected_engines=3, flush_cache_interval=flush_cache_interval) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--transport", choices=["nccl", "disk"], default="nccl") + parser.add_argument("--flush-cache-interval", type=int, choices=[-1, 0, 2], default=0) + options = parser.parse_args() + prepare() + for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): + os.environ.pop(key, None) + execute(options.transport, options.flush_cache_interval) diff --git a/tests/test_reference_top_p_replay.py b/tests/test_reference_top_p_replay.py new file mode 100644 index 000000000..5938cd25f --- /dev/null +++ b/tests/test_reference_top_p_replay.py @@ -0,0 +1,119 @@ +"""CPU regression for reference KL versus actor top-p replay.""" + +import ast +import math +from argparse import Namespace +from contextlib import nullcontext +from pathlib import Path +from types import SimpleNamespace + +import _cp_dist_helpers # noqa: F401 +import pytest +import torch + +from megatron.core import mpu +from vime.backends.megatron_utils.loss import get_log_probs_and_entropy + +NUM_GPUS = 0 + + +def _actor_methods(): + source = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils/actor.py" + actor = next( + node + for node in ast.parse(source.read_text()).body + if isinstance(node, ast.ClassDef) and node.name == "MegatronTrainRayActor" + ) + return {node.name: node for node in actor.body if isinstance(node, ast.FunctionDef)} + + +@pytest.mark.unit +def test_reference_log_probs_use_full_vocab_while_actor_replays_top_p(monkeypatch): + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) + + def fake_forward_only( + callback, args, model, data_iterator, _num_microbatches, *, store_prefix, use_rollout_top_p_replay + ): + kwargs = {} + if use_rollout_top_p_replay: + kwargs = {"top_p_token_ids": [[0, 1]], "top_p_token_offsets": [[0, 2]]} + _, result = callback( + model, + args=args, + unconcat_tokens=data_iterator, + total_lengths=[2], + response_lengths=[1], + **kwargs, + ) + return {store_prefix + key: value for key, value in result.items()} + + # Isolate the real actor method: importing the whole actor requires Ray and + # GPU Megatron modules that are not needed for this CPU probability check. + method = _actor_methods()["compute_log_prob"] + isolated = ast.Module( + body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), method], + type_ignores=[], + ) + namespace = { + "timer": lambda _name: nullcontext(), + "forward_only": fake_forward_only, + "get_log_probs_and_entropy": get_log_probs_and_entropy, + } + exec(compile(ast.fix_missing_locations(isolated), "actor.py", "exec"), namespace) + compute_log_prob = namespace["compute_log_prob"] + + args = Namespace( + rollout_temperature=1.0, + allgather_cp=False, + log_probs_chunk_size=-1, + entropy_coef=0.0, + use_rollout_entropy=False, + ) + tokens = [torch.tensor([0, 0])] + + def log_prob(probs, *, replay, prefix=""): + logits = torch.tensor([probs, probs], dtype=torch.float32).log().unsqueeze(0) + actor = SimpleNamespace(args=args, model=logits) + replay_option = {} if replay is None else {"use_rollout_top_p_replay": replay} + result = compute_log_prob(actor, tokens, [1], store_prefix=prefix, **replay_option) + return result[prefix + "log_probs"][0].item() + + actor_log_prob = log_prob([0.2, 0.2, 0.6], replay=None) + full_ref_a = log_prob([0.1, 0.2, 0.7], replay=False, prefix="ref_") + full_ref_b = log_prob([0.2, 0.4, 0.4], replay=False, prefix="ref_") + replay_ref_a = log_prob([0.1, 0.2, 0.7], replay=True, prefix="ref_") + replay_ref_b = log_prob([0.2, 0.4, 0.4], replay=True, prefix="ref_") + + assert math.exp(actor_log_prob) == pytest.approx(0.5) + assert math.exp(full_ref_a) == pytest.approx(0.1) + assert math.exp(full_ref_b) == pytest.approx(0.2) + assert math.exp(replay_ref_a) == pytest.approx(1 / 3) + assert replay_ref_b == pytest.approx(replay_ref_a) + assert actor_log_prob - full_ref_a == pytest.approx(math.log(5)) + assert actor_log_prob - replay_ref_a == pytest.approx(math.log(1.5)) + + # The training path must request full normalization for ref_log_probs. + train_actor = _actor_methods()["train_actor"] + reference_calls = [ + node + for node in ast.walk(train_actor) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "compute_log_prob" + and any( + keyword.arg == "store_prefix" and isinstance(keyword.value, ast.Constant) and keyword.value.value == "ref_" + for keyword in node.keywords + ) + ] + assert len(reference_calls) == 1 + assert any( + keyword.arg == "use_rollout_top_p_replay" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is False + for keyword in reference_calls[0].keywords + ) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index 15d8dbca5..886123794 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -829,6 +829,45 @@ def fake_post(url, *, params=None, timeout=30, json=None): @pytest.mark.unit +@pytest.mark.parametrize("flush_cache", [False, True]) +def test_pipeline_rl_pause_and_disk_reload(vllm_engine, monkeypatch, flush_cache): + calls = [] + + def post(url, *, json, params=None): + calls.append((url, json, params)) + return _MockResponse(json_data={"success": True}) + + monkeypatch.setattr(mod.requests, "post", post) + monkeypatch.setattr(vllm_engine, "set_weight_version", lambda version: None) + vllm_engine.pause_generation(mode="abort" if flush_cache else "in_place") + vllm_engine.update_weights_from_disk("/weights", weight_version="2", flush_cache=flush_cache) + vllm_engine.continue_generation() + + assert calls == [ + ( + "http://127.0.0.1:8765/pause", + {}, + {"mode": "abort" if flush_cache else "keep", "clear_cache": "false"}, + ), + ( + "http://127.0.0.1:8765/collective_rpc", + {"method": "reload_weights", "kwargs": {"weights_path": "/weights", "is_checkpoint_format": True}}, + None, + ), + ("http://127.0.0.1:8765/resume", {}, None), + ] + + +def test_pipeline_rl_worker_rank_does_not_send_control_requests(vllm_engine, monkeypatch): + vllm_engine.node_rank = 1 + monkeypatch.setattr( + mod.requests, "post", lambda *args, **kwargs: pytest.fail("Only node rank zero controls serving") + ) + vllm_engine.pause_generation() + vllm_engine.update_weights_from_disk("/weights", weight_version="2") + vllm_engine.continue_generation() + + def test_pull_weights_posts_collective_rpc(vllm_engine, monkeypatch): vllm_engine.args.update_weight_local_checkpoint_dir = "/local/checkpoint" vllm_engine.args.update_weight_disk_dir = "/shared/checkpoints" diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 199e1f518..330fd1d0b 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -388,6 +388,7 @@ def compute_log_prob( data_iterator: list[DataIterator], num_microbatches: list[int], store_prefix: str = "", + use_rollout_top_p_replay: bool = True, ) -> dict[str, list[torch.Tensor]]: with timer(f"{store_prefix}log_probs"): return forward_only( @@ -397,7 +398,7 @@ def compute_log_prob( data_iterator, num_microbatches, store_prefix=store_prefix, - use_rollout_top_p_replay=True, + use_rollout_top_p_replay=use_rollout_top_p_replay, ) def train(self, rollout_id: int, rollout_data_ref: Box, external_data=None): @@ -470,6 +471,7 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data data_iterator, num_microbatches, store_prefix="ref_", + use_rollout_top_p_replay=False, ) ) diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py index d3725dffb..6bce47ea4 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py @@ -21,6 +21,7 @@ from vime.utils import accelerator from vime.utils.disk_delta import NUM_WORKERS, checksum, make_tensor_reader, overwrite_encode from vime.utils.distributed_utils import get_gloo_group +from vime.utils.weight_sync import should_flush_cache from .update_weight_from_distributed import UpdateWeightFromDistributed @@ -172,14 +173,26 @@ def _reload_engines(self) -> None: self._post_write_hook(self.args, self._version_dir, list(self.rollout_engines)) dist.barrier(group=get_gloo_group()) if dist.get_rank() == 0: + flush_cache = should_flush_cache( + self.args.flush_cache_interval, + self.weight_version, + getattr(self.args, "update_weight_start_version", 0), + ) ray.get([engine.pull_weights.remote(self.weight_version) for engine in self.rollout_engines]) - ray.get([engine.pause_generation.remote() for engine in self.rollout_engines]) - ray.get([engine.flush_cache.remote() for engine in self.rollout_engines]) + ray.get( + [ + engine.pause_generation.remote(mode="abort" if flush_cache else "in_place") + for engine in self.rollout_engines + ] + ) + if flush_cache: + ray.get([engine.flush_cache.remote() for engine in self.rollout_engines]) ray.get( [ engine.update_weights_from_disk.remote( model_path=self.args.update_weight_local_checkpoint_dir, weight_version=str(self.weight_version), + flush_cache=flush_cache, ) for engine in self.rollout_engines ] diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_distributed.py b/vime/backends/megatron_utils/update_weight/update_weight_from_distributed.py index 6919d8d3f..13957b030 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_distributed.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_distributed.py @@ -8,6 +8,7 @@ from ray.actor import ActorHandle from vime.utils.distributed_utils import get_gloo_group +from vime.utils.weight_sync import should_flush_cache from ..dspark.export import export_dspark_model_weights from .common import HfWeightSource, VimeRayWeightSyncClient, create_nccl_trainer @@ -86,10 +87,19 @@ def pop_metrics(self) -> dict[str, float]: def update_weights(self) -> None: assert self._trainer is not None self.weight_version += 1 + flush_cache = should_flush_cache( + self.args.flush_cache_interval, self.weight_version, getattr(self.args, "update_weight_start_version", 0) + ) if dist.get_rank() == 0: - ray.get([engine.pause_generation.remote() for engine in self.rollout_engines]) - ray.get([engine.flush_cache.remote() for engine in self.rollout_engines]) + ray.get( + [ + engine.pause_generation.remote(mode="abort" if flush_cache else "in_place") + for engine in self.rollout_engines + ] + ) + if flush_cache: + ray.get([engine.flush_cache.remote() for engine in self.rollout_engines]) if self.quantization_config and self.quantization_config["quant_method"] in ["compressed-tensors"]: post_process_weights( restore_weights_before_load=True, diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py index da287c4d5..6405535fb 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py @@ -16,6 +16,7 @@ from vime.utils import accelerator from vime.utils.distributed_utils import get_gloo_group from vime.utils.types import ParamInfo +from vime.utils.weight_sync import should_flush_cache from ..dspark.export import export_dspark_model_weights from ..megatron_to_hf import convert_to_hf @@ -347,12 +348,21 @@ def _update_expert_weights( @torch.no_grad() def update_weights(self) -> None: """ - version++, flush caches, process buckets. Progress on rank 0. + version++, pause generation, optionally flush caches, process buckets. """ self.weight_version += 1 + flush_cache = should_flush_cache( + self.args.flush_cache_interval, self.weight_version, getattr(self.args, "update_weight_start_version", 0) + ) if self.rank == 0: - ray.get([engine.pause_generation.remote() for engine in self._all_rollout_engines]) - ray.get([engine.flush_cache.remote() for engine in self._all_rollout_engines]) + ray.get( + [ + engine.pause_generation.remote(mode="abort" if flush_cache else "in_place") + for engine in self._all_rollout_engines + ] + ) + if flush_cache: + ray.get([engine.flush_cache.remote() for engine in self._all_rollout_engines]) if self.quantization_config and self.quantization_config["quant_method"] in ["compressed-tensors"]: post_process_weights( restore_weights_before_load=True, diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index f35a164b0..edb00d2d2 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -412,8 +412,9 @@ def update_weights_from_disk( model_path: str, load_format: str | None = None, weight_version: str | None = None, + flush_cache: bool = True, ): - del load_format + del load_format, flush_cache if self.node_rank != 0: return response = requests.post( @@ -469,12 +470,12 @@ def update_weights_from_distributed( del weight_version return result - def pause_generation(self): + def pause_generation(self, mode: str = "abort"): if self.node_rank != 0: return response = requests.post( f"http://{self.server_host}:{self.server_port}/pause", - params={"mode": "keep", "clear_cache": "false"}, + params={"mode": "keep" if mode == "in_place" else mode, "clear_cache": "false"}, json={}, ) response.raise_for_status() diff --git a/vime/ray/actor_group.py b/vime/ray/actor_group.py index d792f3fcb..4d910f44a 100644 --- a/vime/ray/actor_group.py +++ b/vime/ray/actor_group.py @@ -8,6 +8,7 @@ from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, add_default_ray_env_vars +from vime.utils.weight_sync import should_flush_cache class RayTrainGroup: @@ -241,13 +242,18 @@ def _reload_rollout_weights_from_disk(self, disk_weight_dir, weight_version): model_path = self.args.update_weight_local_checkpoint_dir else: model_path = str(disk_weight_dir) - ray.get([engine.pause_generation.remote() for engine in engines]) - ray.get([engine.flush_cache.remote() for engine in engines]) + flush_cache = should_flush_cache( + self.args.flush_cache_interval, int(weight_version), getattr(self.args, "update_weight_start_version", 0) + ) + ray.get([engine.pause_generation.remote(mode="abort" if flush_cache else "in_place") for engine in engines]) + if flush_cache: + ray.get([engine.flush_cache.remote() for engine in engines]) ray.get( [ engine.update_weights_from_disk.remote( model_path=model_path, weight_version=weight_version, + flush_cache=flush_cache, ) for engine in engines ] diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index 0713e80a4..c9b82bef1 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -336,6 +336,17 @@ def add_rollout_arguments(parser): "Also, sometimes this will help alleviate the bug that transformers cannot find certain model." ), ) + parser.add_argument( + "--flush-cache-interval", + type=int, + default=1, + help=( + "Flush rollout KV cache every N weight syncs after the initial publication. " + "1 preserves the default abort/flush behavior; values <= 0 never flush during training. " + "Other syncs pause generation in place and preserve unfinished sequences (PipelineRL). " + "Values other than 1 select fully-async rollout by default and require separate GPUs." + ), + ) parser.add_argument( "--rollout-function-path", type=str, @@ -2239,9 +2250,6 @@ def vime_validate_args(args): args.disable_grad_buffers_cpu_backup = True args.disable_param_buffers_cpu_backup = True - if args.eval_function_path is None: - args.eval_function_path = args.rollout_function_path - if args.num_steps_per_rollout is not None: global_batch_size = args.rollout_batch_size * args.n_samples_per_prompt // args.num_steps_per_rollout if args.global_batch_size is not None: @@ -2281,22 +2289,11 @@ def vime_validate_args(args): if args.routing_replay_prefetch_microbatches < 0: raise ValueError("--routing-replay-prefetch-microbatches must be non-negative") - fully_async = "fully_async" in (getattr(args, "rollout_function_path", None) or "") disk_spill = "vime.utils.routed_experts.spill_routed_experts" in ( getattr(args, "rollout_sample_hook_path", None) or [] ) if disk_spill and not getattr(args, "rollout_routed_experts_store_dir", None): raise ValueError("vime.utils.routed_experts.spill_routed_experts requires --rollout-routed-experts-store-dir.") - if ( - not getattr(args, "debug_train_only", False) - and fully_async - and disk_spill - and not getattr(args, "keep_rollout_routed_experts_files", False) - ): - raise ValueError( - "fully-async rollout with routed-experts disk spill requires " - "--keep-rollout-routed-experts-files because in-flight samples can cross rollout boundaries." - ) if args.custom_config_path: with open(args.custom_config_path) as f: @@ -2358,4 +2355,31 @@ def vime_validate_args(args): "(a rollout-host-local NVMe directory)." ) + if args.flush_cache_interval != 1: + if args.colocate or args.offload_rollout or args.release_train: + raise ValueError( + "--flush-cache-interval values other than 1 require separate training/rollout GPUs " + "without rollout offload or release-train." + ) + if args.debug_train_only or args.debug_rollout_only or args.load_debug_rollout_data: + raise ValueError("--flush-cache-interval values other than 1 require live rollout and training.") + if args.rollout_function_path == "vime.rollout.vllm_rollout.generate_rollout": + args.rollout_function_path = "vime.rollout.fully_async_rollout.generate_rollout_fully_async" + if args.eval_function_path is None: + args.eval_function_path = "vime.rollout.vllm_rollout.generate_rollout" + + if args.eval_function_path is None: + args.eval_function_path = args.rollout_function_path + + if ( + not args.debug_train_only + and "fully_async" in args.rollout_function_path + and disk_spill + and not getattr(args, "keep_rollout_routed_experts_files", False) + ): + raise ValueError( + "fully-async rollout with routed-experts disk spill requires " + "--keep-rollout-routed-experts-files because in-flight samples can cross rollout boundaries." + ) + return restore_plan diff --git a/vime/utils/weight_sync.py b/vime/utils/weight_sync.py new file mode 100644 index 000000000..6d83bc19e --- /dev/null +++ b/vime/utils/weight_sync.py @@ -0,0 +1,13 @@ +"""Cache refresh policy shared by the weight transports.""" + + +def should_flush_cache(flush_cache_interval: int, weight_version: int, initial_weight_version: int = 0) -> bool: + """Refresh at startup and every N subsequent syncs; nonpositive preserves KV. + + The first publication flushes even when resuming with a nonzero version. + The subsequent schedule keeps its phase relative to version 1. + A full refresh aborts requests; other updates pause them in place. + """ + return weight_version == initial_weight_version + 1 or ( + flush_cache_interval > 0 and (weight_version - 1) % flush_cache_interval == 0 + ) From ad067059057ad1e647d9527f2315a64b96d40ad4 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 01:50:42 +0000 Subject: [PATCH 24/58] [CI] Add flush interval to weight updater test fixtures Signed-off-by: aoshen02 --- tests/utils/test_update_weight_from_distributed.py | 1 + tests/utils/test_update_weight_from_tensor.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/utils/test_update_weight_from_distributed.py b/tests/utils/test_update_weight_from_distributed.py index d456cc226..77df9c330 100644 --- a/tests/utils/test_update_weight_from_distributed.py +++ b/tests/utils/test_update_weight_from_distributed.py @@ -252,6 +252,7 @@ def _updater_for_transfer(update_module, *, mtp=False, dspark=False, fail=False) updater.args = types.SimpleNamespace( enable_mtp_training=mtp, dspark_enabled=dspark, + flush_cache_interval=1, vllm_speculative_config={"method": "mtp"} if mtp else {"method": "dspark"} if dspark else None, ) updater.quantization_config = None diff --git a/tests/utils/test_update_weight_from_tensor.py b/tests/utils/test_update_weight_from_tensor.py index 90c4ac7ec..5fa136c17 100644 --- a/tests/utils/test_update_weight_from_tensor.py +++ b/tests/utils/test_update_weight_from_tensor.py @@ -133,6 +133,7 @@ def _args(**overrides): "actor_num_gpus_per_node": 2, "rollout_num_gpus_per_engine": 2, "update_weight_buffer_size": 1024, + "flush_cache_interval": 1, "enable_mtp_training": False, "dspark_enabled": False, "dspark_pretrained_model": None, From 9d5c26a66d1f00a51032cc34bbd80520f6aa411b Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 02:42:18 +0000 Subject: [PATCH 25/58] [CI] Accept versionless terminal abort chunk in PipelineRL probe Signed-off-by: aoshen02 --- tests/pipeline_rl_test_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/pipeline_rl_test_helpers.py b/tests/pipeline_rl_test_helpers.py index 281dc3232..8da49f720 100644 --- a/tests/pipeline_rl_test_helpers.py +++ b/tests/pipeline_rl_test_helpers.py @@ -73,10 +73,12 @@ def run(self): for choice in data["choices"]: with self.condition: tokens = len(choice.get("token_ids") or []) + if tokens: + assert data.get("weight_version") is not None self.events.append( { "tokens": (self.events[-1]["tokens"] if self.events else 0) + tokens, - "version": str(data["weight_version"]), + "version": data.get("weight_version"), "finish_reason": choice.get("finish_reason"), "time": time.time(), } From cf5b5b5bbbc8ad8b5d538aa71c48e4f77ac5c081 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 02:58:19 +0000 Subject: [PATCH 26/58] [Sync] Mirror concurrent rollout and HTTP regression tests Signed-off-by: aoshen02 --- tests/test_rollout_sample_hooks.py | 18 +++++- tests/test_vllm_rollout.py | 90 ++++++++++++++++++++++++++++++ tests/test_vllm_server_control.py | 66 ++++++++++++++++++++++ tests/utils/test_vllm_config.py | 61 ++++++++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/tests/test_rollout_sample_hooks.py b/tests/test_rollout_sample_hooks.py index 6ee145227..d73bd3049 100644 --- a/tests/test_rollout_sample_hooks.py +++ b/tests/test_rollout_sample_hooks.py @@ -3,7 +3,7 @@ import pytest -from vime.rollout.sample_hooks import apply_rollout_sample_hooks, set_current_rollout_id +from vime.rollout.sample_hooks import apply_rollout_sample_hooks, rollout_context, set_current_rollout_id from vime.utils.types import Sample NUM_GPUS = 0 @@ -55,5 +55,21 @@ def test_rollout_sample_hooks_are_noop_when_unconfigured(): assert asyncio.run(apply_rollout_sample_hooks(args, sample)) is sample +@pytest.mark.unit +def test_concurrent_groups_keep_their_own_rollout_hook_context(): + args = types.SimpleNamespace(marker="seen", rollout_sample_hook_path=[f"{__name__}.sync_hook"]) + + async def generate(rollout_id): + with rollout_context(rollout_id): + await asyncio.sleep(0) + sample = await apply_rollout_sample_hooks(args, Sample(index=rollout_id)) + return sample.metadata["sync_hook"] + + async def exercise(): + assert await asyncio.gather(generate(10), generate(11)) == [("seen", 10), ("seen", 11)] + + asyncio.run(exercise()) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index 327e672dd..02d8536c7 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -1326,6 +1326,96 @@ async def run(): reward.assert_awaited_once_with(args, partial) +@pytest.mark.unit +@pytest.mark.parametrize("custom", [False, True]) +def test_generation_pacing_preserves_concurrency_cancellation_and_reuse(monkeypatch, custom): + async def exercise(): + args = _rollout_args(group_rm=True, custom_generate_function_path="test.generate" if custom else None) + state = _PatchedGenerateState(args) + state.semaphore = asyncio.Semaphore(128) + started = [] + first_batch, all_started, finish = asyncio.Event(), asyncio.Event(), asyncio.Event() + timers = [] + loop = asyncio.get_running_loop() + call_later = loop.call_later + + def schedule(delay, callback, *args, **kwargs): + if delay == 0.001: + timers.append(callback) + else: + return call_later(delay, callback, *args, **kwargs) + + async def generate(_args, sample, params, evaluation=False): + assert params == {"temperature": 0.5} + assert evaluation == custom + started.append(sample.index) + if len(started) == 64: + first_batch.set() + if len(started) == 127: + all_started.set() + await finish.wait() + sample.status = Sample.Status.COMPLETED + return sample + + monkeypatch.setattr(loop, "call_later", schedule) + monkeypatch.setattr(mod, "GenerateState", lambda _args: state) + monkeypatch.setattr(mod, "generate", generate) + monkeypatch.setattr(mod, "load_function", lambda _path: generate) + + def start(index): + return asyncio.create_task( + mod.generate_and_rm(args, Sample(index=index), {"temperature": 0.5}, evaluation=custom) + ) + + tasks = [start(index) for index in range(128)] + await asyncio.wait_for(first_batch.wait(), 1) + assert started == list(range(64)) + assert len(timers) == 1 + assert not any(task.done() for task in tasks) + tasks[70].cancel() + with pytest.raises(asyncio.CancelledError): + await tasks[70] + timers.pop()() + await asyncio.wait_for(all_started.wait(), 1) + assert started == [index for index in range(128) if index != 70] + assert state.active_server_generations == 127 + finish.set() + await asyncio.gather(*(task for index, task in enumerate(tasks) if index != 70)) + assert (await start(128)).status == Sample.Status.COMPLETED + assert not state.generation_pacer.pending + + asyncio.run(exercise()) + + +@pytest.mark.unit +def test_abort_while_waiting_for_generation_pacer_does_not_start_request(monkeypatch): + async def exercise(): + args = _rollout_args(group_rm=True) + state = _PatchedGenerateState(args) + queued, release = asyncio.Event(), asyncio.Event() + wait = state.generation_pacer.wait + + async def wait_for_release(): + queued.set() + await release.wait() + await wait() + + async def unexpected_generate(*args, **kwargs): + raise AssertionError("An aborted queued sample must not start generation") + + monkeypatch.setattr(state.generation_pacer, "wait", wait_for_release) + monkeypatch.setattr(mod, "GenerateState", lambda _args: state) + monkeypatch.setattr(mod, "generate", unexpected_generate) + task = asyncio.create_task(mod.generate_and_rm(args, Sample(), {})) + await queued.wait() + state.aborted = True + release.set() + assert (await task).status == Sample.Status.ABORTED + assert state.active_server_generations == 0 + + asyncio.run(exercise()) + + @pytest.mark.unit def test_multi_agent_generate_response_preserves_request_metadata(monkeypatch): from examples.multi_agent import agent_system diff --git a/tests/test_vllm_server_control.py b/tests/test_vllm_server_control.py index 5d14d2623..c6d639ba6 100644 --- a/tests/test_vllm_server_control.py +++ b/tests/test_vllm_server_control.py @@ -100,5 +100,71 @@ async def post(self, url, **kwargs): assert response.closed +@pytest.mark.unit +@pytest.mark.parametrize("concurrency", [2, 257]) +def test_http_transport_preserves_streams_timeouts_and_connection_budget(concurrency): + import httpx + + async def check(): + active = peak = accepted = 0 + tasks = set() + + async def serve(reader, writer): + nonlocal active, peak, accepted + task = asyncio.current_task() + tasks.add(task) + active += 1 + accepted += 1 + peak = max(peak, active) + try: + while True: + headers = await reader.readuntil(b"\r\n\r\n") + length = next( + ( + int(line.split(b":", 1)[1]) + for line in headers.split(b"\r\n") + if line.lower().startswith(b"content-length:") + ), + 0, + ) + body = await reader.readexactly(length) + if b"/slow " in headers: + await asyncio.sleep(0.1) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body) + await writer.drain() + except (asyncio.IncompleteReadError, ConnectionError): + pass + finally: + active -= 1 + writer.close() + await writer.wait_closed() + tasks.discard(task) + + server = await asyncio.start_server(serve, "127.0.0.1", 0, backlog=2 * concurrency) + url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}" + try: + async with httpx.AsyncClient(transport=http_utils._ShardedHTTPTransport(concurrency)) as client: + + async def request(index): + body = str(index).encode() + async with client.stream("POST", url, content=body) as response: + assert await response.aread() == body + + for _ in range(2): + await asyncio.gather(*(request(index) for index in range(concurrency + 3))) + assert peak <= concurrency + assert accepted <= concurrency + with pytest.raises(httpx.ReadTimeout): + await client.post(url + "/slow", content=b"x", timeout=0.01) + assert (await client.post(url, content=b"after-timeout")).content == b"after-timeout" + finally: + server.close() + await server.wait_closed() + await asyncio.gather(*tasks, return_exceptions=True) + assert active == 0 + + asyncio.run(check()) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/utils/test_vllm_config.py b/tests/utils/test_vllm_config.py index c943b3672..251a6f41a 100644 --- a/tests/utils/test_vllm_config.py +++ b/tests/utils/test_vllm_config.py @@ -24,6 +24,67 @@ def _write_yaml(data: dict) -> str: class TestVllmConfigUpdateWeights: + @pytest.mark.parametrize("first_model", ["regular", "pd", "epd"]) + @pytest.mark.parametrize("second_model", ["regular", "pd", "epd"]) + def test_models_starting_concurrently_use_disjoint_ports(self, monkeypatch, first_model, second_model): + from vime.backends.vllm_utils import deployment, disaggregation + from vime.backends.vllm_utils.engine_group import ServerGroup + from vime.backends.vllm_utils.vllm_config import ModelConfig, ServerGroupConfig, VllmConfig + + layouts = { + "regular": [("regular", 4)], + "pd": [("prefill", 2), ("decode", 2)], + "epd": [("encoder", 1), ("prefill", 1), ("decode", 2)], + } + config = VllmConfig( + models=[ + ModelConfig( + name=name, + update_weights=(name == "actor"), + server_groups=[ + ServerGroupConfig(worker_type=worker_type, num_gpus=num_gpus) + for worker_type, num_gpus in layouts[layout] + ], + ) + for name, layout in (("actor", first_model), ("ref", second_model)) + ] + ) + allocated = [] + + def start_engines(self, port_cursors=None): + port = (port_cursors or {}).get(0, 15000) + allocated.append(port) + engine = Mock() + engine.get_url.remote.return_value = f"http://127.0.0.1:{port}" + self.all_engines = [engine for _ in self.all_engines] + return [f"init-{port}"], {0: port + 100} + + monkeypatch.setattr(ServerGroup, "start_engines", start_engines) + monkeypatch.setattr(deployment, "resolve_vllm_config", lambda _args: config) + monkeypatch.setattr(deployment, "_start_router", lambda *_args, **_kwargs: ("127.0.0.1", 3456, None)) + monkeypatch.setattr(deployment, "get_host_info", lambda: (None, "127.0.0.1")) + monkeypatch.setattr(deployment, "find_available_port", lambda _port: 3456) + monkeypatch.setattr(deployment, "collect_pd_urls", lambda _groups: ([], [])) + monkeypatch.setattr(disaggregation.ray, "get", lambda refs: refs) + args = Namespace( + rollout_external=False, + rollout_num_gpus_per_engine=1, + num_gpus_per_node=8, + debug_train_only=False, + debug_rollout_only=False, + colocate=False, + actor_num_nodes=1, + actor_num_gpus_per_node=8, + offload_rollout=False, + hf_checkpoint="/tmp/hf", + ) + + servers, _ = deployment.start_rollout_servers(args, pg=(None, list(range(8)), list(range(8)))) + + assert list(servers) == ["actor", "ref"] + assert len(allocated) == len(layouts[first_model]) + len(layouts[second_model]) + assert len(allocated) == len(set(allocated)) + @pytest.mark.parametrize("update_weights,level", [(True, 2), (False, 1)]) @pytest.mark.parametrize("recover", [False, True]) def test_offload_preserves_frozen_weights(self, monkeypatch, update_weights, level, recover): From 8a50b8711cc13a724eee293f3e2736d477035005 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 03:55:11 +0000 Subject: [PATCH 27/58] [Sync] Preserve buffered-token weight version on abort Signed-off-by: aoshen02 --- docker/patch/latest/vllm.patch | 38 +++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index c2edb1610..8c46e32d2 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -213,10 +213,18 @@ index cb7d3d47183..09618d7eb66 100644 self.vllm_config.reasoning_config is None or not self.vllm_config.reasoning_config.enabled diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index b4f7338048c..a98bf6f6e09 100644 +index b4f7338048c..f712c9b4962 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -304,6 +304,7 @@ class RequestState: +@@ -183,6 +183,7 @@ class RequestState: + # Per-sequence spec-decode accumulator; arrives once (on finish) via + # EngineCoreOutput, then attached to this sequence's CompletionOutput. + self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None ++ self.weight_version: str | None = None + + self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None + +@@ -304,6 +305,7 @@ class RequestState: stop_reason: int | str | None, kv_transfer_params: dict[str, Any] | None = None, ec_transfer_params: dict[str, Any] | None = None, @@ -224,7 +232,7 @@ index b4f7338048c..a98bf6f6e09 100644 ) -> RequestOutput | PoolingRequestOutput | None: finished = finish_reason is not None final_only = self.output_kind == RequestOutputKind.FINAL_ONLY -@@ -360,6 +361,7 @@ class RequestState: +@@ -360,6 +362,7 @@ class RequestState: finished, kv_transfer_params, ec_transfer_params, @@ -232,7 +240,7 @@ index b4f7338048c..a98bf6f6e09 100644 ) def _new_request_output( -@@ -369,6 +371,7 @@ class RequestState: +@@ -369,6 +372,7 @@ class RequestState: finished: bool, kv_transfer_params: dict[str, Any] | None = None, ec_transfer_params: dict[str, Any] | None = None, @@ -240,7 +248,7 @@ index b4f7338048c..a98bf6f6e09 100644 ) -> RequestOutput | PoolingRequestOutput: # If prompt embeds were used, put placeholder prompt token ids prompt_token_ids = self.prompt_token_ids -@@ -403,6 +406,7 @@ class RequestState: +@@ -403,6 +407,7 @@ class RequestState: finished=finished, kv_transfer_params=kv_transfer_params, ec_transfer_params=ec_transfer_params, @@ -248,7 +256,7 @@ index b4f7338048c..a98bf6f6e09 100644 num_cached_tokens=self.num_cached_tokens, num_cache_creation_tokens=self.num_cache_creation_tokens, metrics=self.stats, -@@ -434,10 +438,16 @@ class RequestState: +@@ -434,10 +439,16 @@ class RequestState: logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] sampling_mask = None @@ -267,7 +275,23 @@ index b4f7338048c..a98bf6f6e09 100644 # Concatenate routed experts on finish routed_experts = None -@@ -731,6 +741,7 @@ class OutputProcessor: +@@ -560,6 +571,7 @@ class OutputProcessor: + stop_reason=None, + kv_transfer_params=None, + ec_transfer_params=None, ++ weight_version=req_state.weight_version, + ) + ): + req_state.queue.put(request_output) +@@ -724,6 +736,7 @@ class OutputProcessor: + req_state.logprobs_processor.update_from_output(engine_core_output) + + # 4) Create and handle RequestOutput objects. ++ req_state.weight_version = engine_core_output.weight_version + if request_output := req_state.make_request_output( + new_token_ids, + pooling_output, +@@ -731,6 +744,7 @@ class OutputProcessor: stop_reason, kv_transfer_params, ec_transfer_params, From ec87018f8412cd7cc82c6c2d40f7a5faa312dfcc Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 03:58:03 +0000 Subject: [PATCH 28/58] [Sync] Avoid zero-KL assertion for top-p actor/ref mismatch Signed-off-by: aoshen02 --- vime/backends/megatron_utils/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index 7a54dfa51..af8c56325 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -953,6 +953,7 @@ def train( if ( accumulated_step_id == 0 and not getattr(args, "use_rollout_routing_replay", False) + and args.rollout_top_p == 1.0 and "train/kl_loss" in log_dict ): assert log_dict["train/kl_loss"] < 1e-8, f"{log_dict=}" From 59a55f2643d83ec05f3875127c89a7fa5ca946cd Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 04:34:05 +0000 Subject: [PATCH 29/58] [Sync] Backport AuxOutput reset fix from vLLM #59060 Signed-off-by: aoshen02 --- docker/Dockerfile | 5 ++++- .../patch/latest/vllm-aux-output-reset.patch | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 docker/patch/latest/vllm-aux-output-reset.patch diff --git a/docker/Dockerfile b/docker/Dockerfile index 72239c8df..38161353e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -156,6 +156,7 @@ COPY docker/patch/${PATCH_VERSION}/vllm.patch /tmp/vllm.patch COPY docker/patch/${PATCH_VERSION}/vllm-score-centering.patch /tmp/vllm-score-centering.patch COPY docker/patch/${PATCH_VERSION}/vllm-pd-request-metrics.patch /tmp/vllm-pd-request-metrics.patch COPY docker/patch/${PATCH_VERSION}/vllm-inflight-queue-diagnostics.patch /tmp/vllm-inflight-queue-diagnostics.patch +COPY docker/patch/${PATCH_VERSION}/vllm-aux-output-reset.patch /tmp/vllm-aux-output-reset.patch RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirname(vllm.__file__)))')" && \ cd "$VLLM_SITE" && \ git apply -v /tmp/vllm-pull_weights.patch && \ @@ -163,10 +164,12 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn git apply -v /tmp/vllm-score-centering.patch && \ git apply -v /tmp/vllm-pd-request-metrics.patch && \ git apply -v /tmp/vllm-inflight-queue-diagnostics.patch && \ + git apply -v /tmp/vllm-aux-output-reset.patch && \ rm /tmp/vllm-pull_weights.patch /tmp/vllm.patch \ /tmp/vllm-score-centering.patch \ /tmp/vllm-pd-request-metrics.patch \ - /tmp/vllm-inflight-queue-diagnostics.patch + /tmp/vllm-inflight-queue-diagnostics.patch \ + /tmp/vllm-aux-output-reset.patch # ====================================== Install main package ============================================ diff --git a/docker/patch/latest/vllm-aux-output-reset.patch b/docker/patch/latest/vllm-aux-output-reset.patch new file mode 100644 index 000000000..77f80f6ed --- /dev/null +++ b/docker/patch/latest/vllm-aux-output-reset.patch @@ -0,0 +1,21 @@ +diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py +index e4af9071544..24ecc4534d6 100644 +--- a/vllm/v1/core/sched/scheduler.py ++++ b/vllm/v1/core/sched/scheduler.py +@@ -2781,16 +2781,6 @@ class Scheduler(SchedulerInterface): + Otherwise, this method will only reset the KV prefix cache when there + is no running requests taking KV cache. + """ +- if reset_running_requests and self.aux_output_connector is not None: +- if self._pause_state != PauseState.PAUSED_ALL: +- raise RuntimeError( +- "AuxOutput Connector only supports resetting running requests " +- "after pause(mode='keep')." +- ) +- if any(request.num_in_flight_tokens for request in self.requests.values()): +- raise RuntimeError( +- "AuxOutput Connector cannot reset while model output is in flight." +- ) + if reset_running_requests: + # For logging. + timestamp = time.monotonic() From d9782961183ddde6655541a178cadb848e9202a0 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Sun, 4 Oct 2026 04:43:52 +0000 Subject: [PATCH 30/58] [Sync] Stamp synthetic abort outputs with committed version Signed-off-by: aoshen02 --- docker/patch/latest/vllm.patch | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index 8c46e32d2..0c6682f99 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -174,7 +174,7 @@ index e5bbb96a9cf..e7a849297ed 100644 @property def finished(self) -> bool: diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py -index 9b27f50edc0..f0d8226e042 100644 +index 9b27f50edc0..3a35a40e4e1 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py @@ -655,7 +655,7 @@ class EngineCore: @@ -195,6 +195,20 @@ index 9b27f50edc0..f0d8226e042 100644 ) self._attach_iteration_details(engine_core_outputs, iteration_details) +@@ -2036,7 +2036,12 @@ class EngineCoreProc(EngineCore): + self, req_ids: list[str], client_index: int, finish_reason: FinishReason + ) -> None: + outputs = [ +- EngineCoreOutput(req_id, [], finish_reason=finish_reason) ++ EngineCoreOutput( ++ req_id, ++ [], ++ finish_reason=finish_reason, ++ weight_version=self._weight_version, ++ ) + for req_id in req_ids + ] + eco = EngineCoreOutputs(finished_requests=req_ids, outputs=outputs) diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py index cb7d3d47183..09618d7eb66 100644 --- a/vllm/v1/engine/input_processor.py From 99232ef714751c3e0448cdc1a63994da7551226b Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 6 Oct 2026 02:50:43 +0000 Subject: [PATCH 31/58] [Sync] Preserve coalesced rollout metadata and validate sampler probabilities Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 2 +- .../patch/latest/vllm-score-centering.patch | 12 +++++++ docker/patch/latest/vllm.patch | 6 +++- docs/en/get_started/usage.md | 2 +- docs/zh/get_started/usage.md | 2 +- tests/test_qwen2.5_0.5B_score_centering.py | 1 - tests/test_rollout_straw_transport.py | 6 ++-- tests/test_score_centering_transport.py | 36 +++++++++++++++++++ tests/utils/test_vllm_engine.py | 20 +++++++++++ vime/backends/vllm_utils/vllm_engine.py | 5 +++ vime/ray/rollout.py | 4 +++ vime/rollout/fully_async_rollout.py | 1 + vime/utils/http_utils.py | 14 +++++++- vime/utils/score_centering.py | 11 +----- 14 files changed, 103 insertions(+), 19 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index af7f44d81..9c3d98fc3 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -236,7 +236,7 @@ steps: multiple: true required: true options: - - label: "run-ci-short — 4 GPU, 3 tests" + - label: "run-ci-short — 4 GPU, 2 tests" value: short - label: "run-ci-vllm-config — 8 GPU, 4 tests" value: vllm-config diff --git a/docker/patch/latest/vllm-score-centering.patch b/docker/patch/latest/vllm-score-centering.patch index d6e1e3204..6a88de058 100644 --- a/docker/patch/latest/vllm-score-centering.patch +++ b/docker/patch/latest/vllm-score-centering.patch @@ -128,6 +128,18 @@ index 0507f2555df..814fa16d86b 100644 @dataclass +@@ -202,6 +203,11 @@ class RequestOutput: + completion.sampling_mask.token_ids.extend( + next_completion.sampling_mask.token_ids + ) ++ if next_completion.sampling_mask.logprobs is not None: ++ assert completion.sampling_mask.logprobs is not None ++ completion.sampling_mask.logprobs.extend( ++ next_completion.sampling_mask.logprobs ++ ) + if next_completion.spec_decode_metrics is not None: + completion.spec_decode_metrics = ( + next_completion.spec_decode_metrics diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py index 09618d7eb66..92ad049f5dc 100644 --- a/vllm/v1/engine/input_processor.py diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index 0c6682f99..fe8a9ce1c 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -107,7 +107,7 @@ index e9a08f7c462..a5c41c99e9a 100644 for next_completion in next_output.outputs: for i, completion in enumerate(self.outputs): -@@ -191,6 +195,13 @@ class RequestOutput: +@@ -191,6 +195,17 @@ class RequestOutput: if next_completion.logprobs: assert completion.logprobs is not None completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type] @@ -118,6 +118,10 @@ index e9a08f7c462..a5c41c99e9a 100644 + completion.sampling_mask.token_ids.extend( + next_completion.sampling_mask.token_ids + ) ++ if next_completion.spec_decode_metrics is not None: ++ completion.spec_decode_metrics = ( ++ next_completion.spec_decode_metrics ++ ) completion.cumulative_logprob = ( next_completion.cumulative_logprob ) diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index 4aa2469f7..86fe0c90f 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -279,7 +279,7 @@ Add the following options to an existing RL launch: - `--score-centering-top-k`: Applies only when `--rollout-top-p 1`. Number of sampler top-k token IDs and logprobs retained for each response token; defaults to 128 and must fit the model vocabulary. The head retains its full-vocabulary probability mass. The remaining sampler mass is modeled as proportional to the current trainer's tail mass. - `--use-tis`: Optional and independent of SC. Combine the two to center the weighted scores using `--tis-clip-low` and `--tis-clip`. The built-in `vime.backends.megatron_utils.loss.icepop_function` is also supported through `--custom-tis-function-path`; arbitrary custom TIS callbacks are not supported with SC. REINFORCE uses detached current-trainer/sampler weights, while PPO preserves its old-trainer/sampler weights. -**Sampling requirements:** Use a positive temperature, `0 < top_p <= 1`, `top_k=-1`, `min_p=0`, and no repetition/frequency/presence penalties or constrained decoding. Keep `VLLM_RETURN_ORIGINAL_LOGPROB` unset or false on all sampler workers when temperature differs from one or top_p is below one. Per-request temperature or top_p changes and streaming SC are unsupported. Evaluation does not request SC data and may use its own sampling settings. +**Sampling requirements:** Use a positive temperature, `0 < top_p <= 1`, `top_k=-1`, `min_p=0`, and no repetition/frequency/presence penalties or constrained decoding. All rollout engines must use `logprobs_mode=processed_logprobs`. Per-request temperature or top_p changes and streaming SC are unsupported. Evaluation does not request SC data and may use its own sampling settings. **Combining with top-p replay:** Change `--rollout-top-p 1.0` above to, for example, `--rollout-top-p 0.9`. SC automatically sums over the complete replay support and ignores `--score-centering-top-k`. Rollout returns all support IDs and their post-truncation, normalized sampler logprobs. The trainer normalizes on the same support and computes the correction `sum(stop_gradient(q * weight) * log p)`. This uses no tail approximation and excludes tokens outside the support. Full trainer logits cannot reconstruct the sampler probabilities, so the original probabilities must still be stored. Payload size varies with the support and can greatly exceed fixed top-k heads when top-p approaches one. Exactness is relative to the recorded replay support, including replay's existing rule for retaining sampled boundary tokens. diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 19be6d641..3c29dbe9b 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -282,7 +282,7 @@ PPO 相关参数: - `--score-centering-top-k`:仅在 `--rollout-top-p 1` 时生效。每个 response token 保存的 sampler top-k token ID 和 logprob 数量,默认为 128,不能超过模型词表大小。top-k 概率保留其在完整词表上的概率质量;剩余的 sampler 概率质量按当前 trainer 的尾部概率分布估计。 - `--use-tis`:可选,与 SC 独立开关。组合开启时,使用 `--tis-clip-low` 和 `--tis-clip` 对加权后的 score 做 centering。也支持通过 `--custom-tis-function-path` 选择内置的 `vime.backends.megatron_utils.loss.icepop_function`,但不支持与任意自定义 TIS 回调组合。REINFORCE 使用 detached 的当前 trainer/sampler 权重,PPO 保留原有的旧 trainer/sampler 权重。 -**采样要求:** temperature 必须为正,使用 `0 < top_p <= 1`、`top_k=-1`、`min_p=0`,不启用 repetition/frequency/presence penalty 或约束解码。temperature 不为 1 或 top_p 小于 1 时,所有 sampler worker 上的 `VLLM_RETURN_ORIGINAL_LOGPROB` 必须未设置或为 false。目前不支持逐请求修改 temperature 或 top_p,也不支持流式 SC。评估不会请求 SC 数据,可以使用独立的采样配置。 +**采样要求:** temperature 必须为正,使用 `0 < top_p <= 1`、`top_k=-1`、`min_p=0`,不启用 repetition/frequency/presence penalty 或约束解码。所有 rollout engine 必须使用 `logprobs_mode=processed_logprobs`。目前不支持逐请求修改 temperature 或 top_p,也不支持流式 SC。评估不会请求 SC 数据,可以使用独立的采样配置。 **与 top-p replay 组合:** 将上面的 `--rollout-top-p 1.0` 改为例如 `--rollout-top-p 0.9`,SC 会自动改用精确支持集求和,`--score-centering-top-k` 不再生效。rollout 返回每个 token 完整的 replay 支持集及其截断、归一化后的 sampler logprobs;trainer 在同一份支持集上归一化,并计算 `sum(stop_gradient(q * weight) * log p)` 的校正项。不使用论文的长尾近似,也不把支持集外的 token 纳入求和。训练端的完整 logits 本身无法恢复 sampler 概率,因此仍需要保存原始采样概率。传输量随每步支持集大小变化,top-p 接近 1 时可能明显大于固定的 top-k。精确性针对保存的 replay 支持集;沿用 replay 对边界 sampled token 的保留规则。 diff --git a/tests/test_qwen2.5_0.5B_score_centering.py b/tests/test_qwen2.5_0.5B_score_centering.py index 550e3806d..6186e4221 100644 --- a/tests/test_qwen2.5_0.5B_score_centering.py +++ b/tests/test_qwen2.5_0.5B_score_centering.py @@ -51,7 +51,6 @@ def execute(top_p=1.0): train_args=train_args, num_gpus_per_node=NUM_GPUS, megatron_model_type=MODEL_TYPE, - extra_env_vars={"VLLM_RETURN_ORIGINAL_LOGPROB": "0"}, ) for rollout_id in range(2): data = torch.load(Path(directory) / f"rollout_{rollout_id}.pt", weights_only=False) diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py index 3e2f4cf32..a882a815a 100644 --- a/tests/test_rollout_straw_transport.py +++ b/tests/test_rollout_straw_transport.py @@ -137,8 +137,8 @@ def test_local_test_launcher_does_not_invent_storage_paths(options, monkeypatch) commands = [] monkeypatch.setattr(command_utils, "exec_command", commands.append) monkeypatch.setattr(command_utils, "check_has_nvlink", lambda: False) - monkeypatch.setenv("SLIME_SCRIPT_EXTERNAL_RAY", "0") - monkeypatch.setenv("SLIME_SCRIPT_ENABLE_RAY_SUBMIT", "1") + monkeypatch.setenv("VIME_SCRIPT_EXTERNAL_RAY", "0") + monkeypatch.setenv("VIME_SCRIPT_ENABLE_RAY_SUBMIT", "1") command_utils.execute_train(options, num_gpus_per_node=1, megatron_model_type=None) assert commands[-1].count("--rollout-data-dir") == options.count("--rollout-data-dir") assert options in commands[-1] @@ -845,7 +845,7 @@ def test_training_commits_only_after_save_calls_return(tmp_path, monkeypatch, fa from unittest.mock import Mock # Exercise the real training loop with CPU stand-ins for the GPU services. - # Argument parsing is not used here and must not pull in SGLang on CPU CI. + # Argument parsing is not used here and must not pull in vLLM on CPU CI. monkeypatch.setitem(sys.modules, "vime.utils.arguments", SimpleNamespace(parse_args=None)) spec = importlib.util.spec_from_file_location("checkpoint_train", Path(__file__).parents[1] / "train.py") module = importlib.util.module_from_spec(spec) diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index 77b36917e..0a465e33e 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -18,6 +18,28 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("aggregate", [False, True]) +def test_sampling_mask_logprobs_survive_output_coalescing(aggregate): + outputs = pytest.importorskip("vllm.outputs") + + def output(token, support, logprobs): + completion = outputs.CompletionOutput( + index=0, + text="", + token_ids=[token], + cumulative_logprob=None, + logprobs=None, + sampling_mask=outputs.SamplingMask([support], [logprobs]), + ) + return outputs.RequestOutput("audit", None, None, None, [completion], False) + + first = output(2, [1, 2], [-1.2, -0.4]) + first.add(output(3, [3, 4], [-0.3, -1.4]), aggregate=aggregate) + mask = first.outputs[0].sampling_mask + assert mask.token_ids == ([[1, 2], [3, 4]] if aggregate else [[3, 4]]) + assert mask.logprobs == ([[-1.2, -0.4], [-0.3, -1.4]] if aggregate else [[-0.3, -1.4]]) + + @pytest.fixture(autouse=True) def no_gpu_server_imports(monkeypatch): deployment = types.ModuleType("vime.backends.vllm_utils.deployment") @@ -27,6 +49,20 @@ def no_gpu_server_imports(monkeypatch): monkeypatch.setitem(sys.modules, "vllm_router", SimpleNamespace(__version__="0.3.0")) +def test_terminal_spec_metrics_survive_output_coalescing(): + outputs = pytest.importorskip("vllm.outputs") + metrics = outputs.RequestSpecDecodeMetrics.new(2) + metrics.observe(num_draft_tokens=2, num_accepted=1) + completions = [ + outputs.CompletionOutput(index=0, text="", token_ids=[token], cumulative_logprob=None, logprobs=None) + for token in (2, 3) + ] + completions[1].spec_decode_metrics = metrics + first = outputs.RequestOutput("audit", None, None, None, [completions[0]], False) + first.add(outputs.RequestOutput("audit", None, None, None, [completions[1]], True), aggregate=True) + assert first.outputs[0].spec_decode_metrics is metrics + + def manager(**overrides): from vime.data.batch_builder import BatchBuilder diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index 886123794..e97869721 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -295,6 +295,26 @@ def test_compute_server_args_enables_sampling_mask_logprobs_for_score_centering( assert sa["return_sampling_mask_logprobs"] is True +@pytest.mark.unit +def test_score_centering_requires_processed_server_logprobs(vllm_args): + vllm_args.use_score_centering = True + vllm_args.score_centering_top_k = 3 + server_args, check_fields = mod._compute_server_args( + vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000 + ) + assert server_args["logprobs_mode"] == "processed_logprobs" + assert "logprobs_mode" in check_fields + with pytest.raises(ValueError, match="processed_logprobs"): + mod._compute_server_args( + vllm_args, + rank=0, + dist_init_addr=None, + host="127.0.0.1", + port=8000, + vllm_overrides={"logprobs_mode": "raw_logprobs"}, + ) + + @pytest.mark.unit def test_compute_server_args_applies_max_model_len_from_rollout_context(vllm_args): vllm_args.rollout_max_context_len = 8192 diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index edb00d2d2..c632c951c 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -747,6 +747,11 @@ def _compute_server_args( if "model_path" in vllm_overrides: kwargs["model"] = str(vllm_overrides["model_path"]) + if getattr(args, "use_score_centering", False): + if kwargs["logprobs_mode"] != "processed_logprobs": + raise ValueError("Score centering requires vLLM logprobs_mode=processed_logprobs.") + external_engine_need_check_fields.append("logprobs_mode") + kwargs["host"] = _wrap_ipv6(kwargs.get("host") or "127.0.0.1") # vLLM-specific: topology metadata consumed by launch_server_process / _build_subprocess_env. diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 2c10b3ac5..4678abdb9 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -298,6 +298,8 @@ def eval(self, rollout_id): log_eval_rollout_data(rollout_id, self.args, data, result.metrics) def save(self, rollout_id): + # Keep admission frozen across source and builder snapshots. Source.save + # preserves this pre-existing pause instead of resuming between files. paused = [] try: for consumer in getattr(self.data_source, "consumers", {}).values(): @@ -317,6 +319,8 @@ def load(self, rollout_id=None): from vime.data.checkpoint import SourceRestore source_restore = self.data_source.load(rollout_id) + # Custom sources keep their existing load() contract; only the built-in + # queue returns a source/builder restoration handoff. self.batch_builder.load( rollout_id, source_restore=source_restore if isinstance(source_restore, SourceRestore) else None ) diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index 4a0926c60..cf9c34282 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -300,6 +300,7 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout metrics["rollout/dynamic_filter/dropped_groups"] = dropped_count metrics["rollout/dynamic_filter/dropped_ratio"] = dropped_count / (len(collected) + dropped_count) if args.rollout_sample_filter_path is not None: + # Preserve the calling thread/context of custom batch hooks. output = finalize_rollout_groups( args, rollout_id, collected, metrics, controller=getattr(data_buffer, "controller", None) ) diff --git a/vime/utils/http_utils.py b/vime/utils/http_utils.py index 22339f435..31fb95893 100644 --- a/vime/utils/http_utils.py +++ b/vime/utils/http_utils.py @@ -146,7 +146,19 @@ def _next_actor(): class _ShardedHTTPTransport(httpx.AsyncBaseTransport): - """Limit the per-request httpcore connection scan to small pools.""" + """Reduce asyncio CPU stalls from httpcore's per-request connection scans. + + Split this client's connection budget across pools of at most 128 + connections, so each synchronous scan touches a small pool. The pools + operate concurrently: 128 is a per-pool limit, not a client-wide request + limit. Their connection limits sum to max_connections; requests assigned + to a full pool wait for a connection there. + + Allow each pool to retain its entire connection budget as idle keep-alive + connections, subject to the usual expiry. This avoids closing/reopening + most sockets after a request wave; httpx otherwise keeps at most 20 idle + connections per pool. That idle limit is separate from active concurrency. + """ def __init__(self, max_connections: int): count = max(1, (max_connections + 127) // 128) diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py index 256aea868..f9cbd50e8 100644 --- a/vime/utils/score_centering.py +++ b/vime/utils/score_centering.py @@ -1,7 +1,6 @@ """Score Centering, arXiv:2609.20807, equations (9), (12), and (14).""" import math -import os import uuid from pathlib import Path @@ -56,7 +55,7 @@ def score_centering_correction(train_head_logp, sampler_head_logp, *, mode="none def score_centering_request(args, sampling_params): - """Extra vLLM /generate fields; validate the actual request's distribution.""" + """Validate the actual vLLM token-generation request's distribution.""" if not getattr(args, "use_score_centering", False): return {} # vLLM's omitted-temperature default can differ from the trainer's. @@ -68,14 +67,6 @@ def score_centering_request(args, sampling_params): top_p = sampling_params.setdefault("top_p", getattr(args, "rollout_top_p", 1.0)) if not 0 < top_p <= 1 or top_p != getattr(args, "rollout_top_p", 1.0): raise ValueError("Score centering requires the configured rollout top_p in (0, 1] on every request.") - if (temperature != 1 or top_p < 1) and os.environ.get("VLLM_RETURN_ORIGINAL_LOGPROB", "").lower() in ( - "1", - "true", - ): - raise ValueError( - "Score centering requires temperature-scaled, post-truncation sampler logprobs; " - "unset VLLM_RETURN_ORIGINAL_LOGPROB." - ) for key, default in (("top_k", -1), ("min_p", 0.0)): if sampling_params.get(key, default) != default: raise ValueError(f"Score centering requires {key}={default}; only top-p truncation is supported.") From c5100bd2d5797f8b980a1ea4e4e7034724bf9879 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 6 Oct 2026 03:02:49 +0000 Subject: [PATCH 32/58] [Sync] Remove superseded per-file spill and mirror Straw references Signed-off-by: aoshen02 --- .buildkite/pipeline.yml | 1 - docs/en/advanced/straw.md | 467 +++++++++-------------- docs/zh/advanced/straw.md | 179 ++++----- tests/test_r3_spill.py | 134 ------- tests/test_score_centering.py | 22 +- tests/test_score_centering_transport.py | 97 +++-- vime/backends/megatron_utils/actor.py | 17 +- vime/backends/megatron_utils/cp_utils.py | 8 +- vime/backends/megatron_utils/loss.py | 12 +- vime/data/batch_builder.py | 4 +- vime/data/tensor.py | 140 ------- vime/observability/rollout_data_utils.py | 9 +- vime/observability/rollout_metrics.py | 4 +- vime/observability/train_data_utils.py | 4 +- vime/ray/rollout.py | 34 -- vime/utils/arguments.py | 32 -- vime/utils/memory_utils.py | 11 - vime/utils/routed_experts.py | 109 +----- vime/utils/score_centering.py | 47 +-- vime/utils/types.py | 30 +- 20 files changed, 384 insertions(+), 977 deletions(-) delete mode 100644 tests/test_r3_spill.py diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 9c3d98fc3..496824173 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -180,7 +180,6 @@ steps: tests/test_read_file_slicing.py \ tests/test_reference_top_p_replay.py \ tests/test_reloadable_process_group_world.py \ - tests/test_r3_spill.py \ tests/test_rollout_buffer_order.py \ tests/test_rollout_data_utils.py \ tests/test_rollout_metrics.py \ diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md index 0a6049ede..25794b260 100644 --- a/docs/en/advanced/straw.md +++ b/docs/en/advanced/straw.md @@ -1,180 +1,26 @@ # Persistent rollout with straw -[straw](https://github.com/zhuzilin/straw) provides filesystem-based durable -queues and packed tensor storage for vime. With `--rollout-data-transport straw`, -prompt tasks, partial rollouts, accepted results and training batches have -persistent identities. Multiple generation and training processes access their -payloads directly through a shared filesystem; Ray schedules actors and carries -control messages and small references. +[straw](https://github.com/zhuzilin/straw) provides filesystem-based queues and +packed tensor storage for vime. It persists prompt tasks, partial rollouts, +completed samples and training batches. Generation and training processes read +and write payloads in parallel across machines; Ray carries control messages +and references. -The current shared-filesystem target is JuiceFS. Each node must mount the same -pool at the same absolute path. Other network filesystems, including NFS, need -separate qualification of cross-client locks, visibility and durability. - -## What changes in the architecture - -| Concern | Ray object-store transport (default) | straw transport | -|---|---|---| -| Data source | `RolloutDataSourceWithBuffer` | `QueueDataSource` backed by durable prompt tasks | -| Payload exchange | Ray object references | `RecordSetRef` / tensor references into shared pack files | -| Worker assignment | In-memory producer state | Leases, attempts, durable continuations and accepted receipts | -| Rollout-to-training conversion | `BatchBuilder` applies hooks and DP partitioning | The same conversion, with a persisted selection plan and ready batch | -| Data lifetime | Process/object-store ownership | Explicit task, consumer, reader and checkpoint ownership | -| Cleanup | Object-store lifetime | Optional online GC of sealed packs with no remaining owners | - -There are two logical stages: rollout work/results, and ready training batches. -They share the same straw storage pool, so a tensor retained by either stage or -a checkpoint remains live. These stages do not require two copies of each tensor. -The vime controller coordinates both stages; straw implements the storage, -journal, task acceptance and ownership protocol in Rust. - -```{mermaid} -flowchart LR - D[Dataset producer] -->|cursor + task submission| Q[Durable prompt tasks] - Q -->|lease + input reference| W[Distributed generation workers] - W -->|durable partial continuation| Q - W -->|accepted group reference| R[Rollout results] - R --> B[BatchBuilder: selection plan and DP shards] - B -->|ready batch reference| T[Training ranks] - T -->|training completed| C[Queue controller: progress and ownership] - C --> G[straw online GC] - P[(Shared JuiceFS pack files)] -. payload reads and writes .-> W - P -. shared tensor references .-> B - P -. rank-local reads .-> T -``` - -One non-restarting Ray actor owns the coordinator for the run. It serializes -queue transitions and dataset allocation; payload writes happen in the producing -processes. This permits parallel I/O across machines while retaining one owner -for each queue journal. There is no automatic coordinator election or failover. - -## End-to-end data flow - -1. **Submit prompts.** The dataset cursor and prompt tasks commit together. - Workers claim leases and load prompt groups from the shared pool. A lost - worker does not permanently abandon a preallocated range of dataset indices. -2. **Generate and continue.** Distributed fully async generation uses one worker - per eligible Ray node. Workers generate, score and filter complete groups; - partial groups are saved as durable continuations. R3 routes and SC rows must - cover the saved token prefix. An incomplete server capture retries from the - previous consistent input. -3. **Accept results.** Payloads become durable before their references are - accepted. Receipts identify accepted groups, allowing recovery after an RPC - reply is lost. Reassigned tasks receive new attempts; stale attempts cannot - accept a replacement result. -4. **Build the training batch.** `BatchBuilder` persists the selected inputs and - conversion configuration before calling reward/conversion hooks. It publishes - all DP shards before marking one batch ready. Training readers check batch, - plan and rank identity. The manager still materializes the selected samples - for conversion, so its host memory remains a capacity consideration. -5. **Acknowledge completion.** After all rank training calls return, the manager - reports `training_completed`. This advances runtime consumption and frees - ready-batch capacity. It is distinct from a durable model/optimizer checkpoint. - -Ray remains responsible for scheduling, RPC and failures of execution processes. -vime owns Sample schemas, rewards, filters, batching and training. straw owns -bytes, references, persistence and storage lifetime. Custom rollout functions -can still return Sample lists; the manager publishes a compatibility collection. -See [customization](../get_started/customization.md). - -## Queue scheduling - -`QueueReader` is a worker's queue client: it acquires and returns groups and renews -active leases. `QueueDataSource` adds job-level reader creation, consumer lifecycle -and source checkpoints. It inherits the same sample-access methods; it does not -implement another buffer. With `--rollout-data-transport straw`, the source is -selected automatically. Explicit `--data-source-path` values should use -`vime.data.queue_data_source.QueueDataSource`. - -`QueueReader` has no local continuation buffer. `add_samples()` publishes valid -continuations, then `yield_tasks()` atomically replaces their inputs and returns -them to pending in one WAL transaction per bounded batch. The old leases end; -any worker can acquire the saved inputs with a new lease. Readers keep only -active leases and executing samples locally. Closing a reader does not rewrite -returned groups. - -The persistent order is **completed delivery → partial → fresh prompt**. Within -a stage, the oldest numeric value in a group's `weight_versions` comes first -(higher staleness), then FIFO submission/return order. Groups without numeric versions sort last -within the stage. Comparing stored versions avoids rewriting queue priorities -on every model update. This ordering does not drop stale samples automatically. -Staleness is `current serving weight version - oldest generated-token weight version`: fresh samples have staleness 0. -There is one indexed queue, not three independently managed queues. - -`--buffer-filter-path` is rejected for straw. `--buffer-sort-by-staleness` remains -an option for the in-memory data source; straw always applies the order above. -Reward and sample-selection hooks remain available. Already accepted groups -explicitly returned to the source get separate delivery tasks; accepted history -is immutable, and consuming/discarding a delivery also acknowledges its earlier -accepted versions. The fully async scheduler still keeps a bounded ready-results -window, with its own checkpoint state and durable accepted-result references. - -Source snapshots now store the shared pending tasks, their exact immutable input -references and scheduling metadata once; worker snapshots store reader metadata. -The reader snapshot format is version 3; old reader-local v1/v2 buffer snapshots -require migration and are rejected explicitly. This requires `straw-queue>=0.1.2` -with `yield_tasks()` and persistent scheduling keys. - -## Shared tensors, R3 and SC - -Each writer process reuses a pack writer. The vime adapter's default rotation -target is **256 MiB**, configured by `--rollout-queue-segment-mib`. Records and -manifests are embedded in packs; there is no file per sample or tensor. This -avoids the small-file metadata traffic that limits shared-filesystem throughput. -Queue and ownership metadata use append logs. - -Completed R3 routes and SC tensors are published with their sample group after -custom sample hooks. Later rollout/continuation/training publications reuse -immutable tensor dependencies. Updates produce new records while earlier -references remain valid: this is tensor-level sharing and copy-on-write, not -GPU shared memory or page-level copy-on-write. R3 training reads only the rows -assigned to its CP/TP rank. Large bundles are split within the native write -budget; SC also works without R3. - -Use `--rollout-data-transport straw` to persist R3/SC tensors. Object-store -transport keeps them in memory unless Vime's optional disk-spill hook is -configured. Straw transport needs no separate spill hook or cleanup step. -References from another straw storage pool -are copied into the destination pool before dependent results are accepted. -Debug dumps embed tensor contents so they remain readable after queue GC; -enabling dumps therefore adds host-memory and I/O overhead. - -## Sample encoding and integrity - -The adapter stores samples as a `vime.v1` JSON record `{version: 1, tree: ...}` -with explicit tags for containers, Sample fields, NumPy arrays, images and -rollout references. It preserves nested groups, supported dynamic fields, -multimodal inputs, R3, SC top-k and ragged top-p. Unsupported Python types and -cycles fail explicitly. The payload protocol uses no pickle and never imports -classes named by stored data. - -Tensor nodes carry shape, dtype, semantic kind, lazy/validated flags and a -`{dependency: index, ordinal: index}` pointer into the enclosing manifest's -dependencies. Physical locations belong to those manifests, so publishing -another sample or training batch can share the same immutable tensor records. -All tensor dependencies must be published before the enclosing sample is accepted. - -straw's `tensor.v1` stores contiguous, row-major, little-endian typed bytes. -Lazy readers verify metadata and the checksum of each chunk covering requested -rows; they do not verify untouched chunks. Full inspection verifies the whole -segment. Tensor descriptors are restored in batches to reuse shared pack indices. +The supported shared-filesystem target is JuiceFS. All nodes must mount the +storage pool at the same absolute path. Other network filesystems, including +NFS, require separate verification of locking, visibility and durability. ## Enable it -`requirements.txt` includes `straw-queue>=0.1.2`, so the standard Docker build and -normal vime installation install it automatically. For an existing environment, -install it on every generation and training node, using the same version throughout the job: +The standard vime installation and Docker build include `straw-queue`. To +install it in an existing environment, run this on every rollout and training +node, using the same version throughout the job: ```bash pip install 'straw-queue>=0.1.2' ``` -Without straw installed, the default Ray `object-store` transport still works -and startup logs the installation command. Selecting `--rollout-data-transport straw` -without the package fails at startup with the same command. - -The Python import name is `straw`. Add these arguments to your existing training -command, using a fresh shared directory: +Add these arguments to your training command: ```bash --rollout-data-transport straw \ @@ -184,66 +30,120 @@ command, using a fresh shared directory: --rollout-storage-declaration /shared/juicefs/deployment.json ``` -The declaration records `direct_mount: true`, `writeback: false`, -`open_cache: 0`, `readdir_cache: false`, the deployed `client_version` and a -`durability_description`. It declares verified deployment settings; it does -not discover or configure the mount. See straw's +The deployment declaration describes verified mount settings; it does not +configure the mount. It includes `direct_mount: true`, `writeback: false`, +`open_cache: 0`, `readdir_cache: false`, `client_version` and +`durability_description`. See straw's [filesystem contract](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM.md). -The default `local` storage profile represents the POSIX development contract; -using it on a shared mount does not verify that service's durability. +Use the default `local` profile for local POSIX development and testing. -Storage and execution mode are separate choices. The default transport remains -`object-store`; the default rollout entrypoint remains synchronous. To select -distributed fully async execution with straw, also add: +If `--rollout-data-dir` is omitted, a fresh run uses `/rollout_data`; without +`--save`, an explicit shared directory is required. Checkpoint recovery can infer +the pool from the saved checkpoint. + +Storage and execution mode are independent. The defaults are Ray `object-store` +transport and synchronous rollout. For distributed fully async execution, also add: ```bash --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async ``` -If `--rollout-data-dir` is omitted, straw mode uses `/rollout_data`; without -`--save`, an explicit shared directory is required. `--rollout-io-concurrency` -bounds off-event-loop serialization and I/O (default 4). The adapter uses worker -processes and bounded threads; straw does not create an I/O process pool. - -During weight synchronization, distributed producers pause new admissions and -allow in-flight work to finish and persist. They resume if another training -rollout remains. Weight synchronization itself remains a separate subsystem. -On shutdown, all generation workers share a five-minute deadline to finish -durable writes. Exceeding it reports an error rather than successful shutdown. +Fully async runs one generation process per Ray node with CPU resources and +shares the configured concurrency across them. Faster workers can supply a +training batch without waiting for every worker. Producers pause admission +during weight synchronization and persist in-flight results before resuming. + +The default object-store transport works without straw installed. Selecting +straw without the package fails at startup with the installation command. + +## Data flow and scheduling + +1. Workers request prompt groups. The data source reads the dataset and saves + both the groups and its updated cursor to the queue. +2. Workers generate and score samples. Returned partial groups are persisted + for continuation; completed, accepted groups become available for training. +3. The batch builder applies reward/conversion hooks and divides selected + samples among training ranks. After training finishes, vime acknowledges + consumption so storage can be reclaimed when no other references need it. + +Rollout and training data share the same storage pool. Large R3 and SC tensors +can be reused across these stages without duplicating their bytes. Queue tasks +use renewable leases to assign work to workers; unfinished work from a lost +worker becomes available again after lease expiry. + +Scheduling prioritizes **completed groups → partial groups → fresh prompts**. +Within each stage, groups with older generated-token weight versions come first, +with FIFO ordering for ties. Groups without a numeric version come last in that +stage. Staleness is the current serving version minus the oldest generated-token +version, so fresh samples have staleness 0. Scheduling does not automatically +discard stale samples. + +`--buffer-filter-path` is unsupported with straw. `--buffer-sort-by-staleness` +applies to the in-memory data source; straw uses the ordering above. Reward and +sample-selection hooks are available; distributed fully async does not support +`--rollout-all-samples-process-path`. See [customization](../get_started/customization.md) +for custom rollout functions and queue readers. + +## Packed storage and supported data + +Each writer appends multiple samples and tensors to pack files. There is no +file per sample or tensor, reducing small-file metadata overhead on shared +storage. Immutable tensor references let rollouts, training batches and +checkpoints share data; updates write new records while retained references +continue to identify their original contents. + +The sample codec supports nested lists, tuples and dictionaries; scalar and +byte values; NumPy arrays; PyTorch tensors; PIL images; and Sample fields, +including supported custom fields. R3 routes and SC top-k/ragged top-p data +are stored as typed tensors. Unsupported Python objects and cyclic structures +raise an error when published. Stored records and tensor reads are checksummed. + +Use `--use-rollout-routing-replay` for R3 and `--use-score-centering` for SC. +With straw transport, their tensors are persisted with the sample group. R3 +training reads only the rows assigned to the current CP/TP rank. Large replay +tensors stay lazy during batch conversion, although selected Sample metadata +and ordinary fields still occupy manager memory. + +| Option | Default | Purpose | +|---|---|---| +| `--rollout-queue-segment-mib` | `256` | Pack rotation target in MiB | +| `--rollout-io-concurrency` | `4` | Bound concurrent serialization and filesystem I/O submissions | +| `--rollout-queue-lease-seconds` | `300` | Worker lease duration; active readers renew it | -## Online GC and ownership +## Online GC -Online GC is disabled by default. Enable `--rollout-queue-online-gc` to let straw -reclaim unused sealed packs during a job. vime provides completion and discard -signals through straw APIs. A pack is deletable only after all task, publication, -queue, reader and checkpoint owners release it. A lease timeout alone does not -prove that a reader has stopped. A live record retains its entire pack; active -writers, retained checkpoints and WAL history still consume space. +Online GC is disabled by default. Enable it with `--rollout-queue-online-gc`. +vime reports when tasks and training batches are finished or discarded. straw +reclaims a sealed pack only after all tasks, readers, checkpoints and archives +have released their references. One live record keeps the whole pack alive; +active writers and journal history also consume space. -GC failures stop the background loop, propagate the original cause through -later coordinator operations and surface at shutdown. They do not stop work -already running on remote workers. Retained data stays available for inspection. -Disk exhaustion requires freeing unneeded retention or expanding storage; do not reset a live queue or -unlink pack files to make room. Offline removal requires all participants to -stop. Storage quotas and application retention policies remain necessary. +Retained checkpoints and archives protect their payloads even with GC enabled, +so rollback does not require disabling GC. Retention must also be released +when data is no longer needed; deleting an index file alone does not release +its storage ownership. Do not manually delete pack files from an active pool. +For offline removal, stop all coordinators, writers and readers first. -Processed positions and filter decisions are recorded explicitly, so gaps in -acceptance order do not retain already-consumed data. +A GC failure is reported by subsequent queue operations and at shutdown. +Disk exhaustion requires freeing unneeded retention or expanding storage; +it does not reset the queue automatically. Live-pack and journal compaction are +not implemented. ## Recovery and checkpoints Save model and rollout state together through normal training checkpoints. -They include the dataset cursor, sample/group counters, pending and partial -inputs, ready groups, and training progress. Existing straw payloads are -referenced rather than copied; retaining a checkpoint keeps its data available. +They contain the dataset cursor, sample/group counters, pending and partial +inputs, ready groups, and training progress. Payloads already in straw are +referenced rather than copied into each checkpoint. Retaining a checkpoint +keeps its referenced data available. -Stop the previous job, including remote workers, before restarting. The -save-directory lock rejects concurrent coordinators but does not stop orphaned -readers; there is no automatic coordinator failover. +Stop the entire previous job before restarting, including remote workers. +The save-directory lock rejects concurrent coordinators, but does not stop +orphaned readers. There is no automatic coordinator failover. ### Resume or select a step -Resume the latest completed checkpoint using the same logical directories: +To resume the latest completed checkpoint, use the same logical directories: ```bash --rollout-data-transport straw \ @@ -252,48 +152,65 @@ Resume the latest completed checkpoint using the same logical directories: --save-interval 1 ``` -Megatron requires a positive `--save-interval` when `--save` is set. To restore -the state saved after rollout 7, add `--ckpt-step 7`; the next rollout is 8. -Keep the dataset, model/tokenizer configuration, straw run ID, storage profile, -and fully async worker topology consistent. Training recovery requires saved -optimizer and RNG state. - -Restore creates an isolated queue that shares the checkpoint's immutable -payloads. Pending/partial inputs, ready-group order, and dataset cursor come -from that checkpoint; later samples from the source run are excluded. Further -writes do not modify the source checkpoint. If `--save` already contains a run, -outputs go to a unique `branches/` directory tracked by -`rollout/current.json`. A later restart can use the same logical `--load` and -`--save`; omitting `--load` resumes the active branch. Use a fresh path for each -immutable `.straw.json` debug archive. +Whenever `--save` is set, Megatron requires a positive `--save-interval`. +The example saves after each rollout; choose the interval for your workload. + +To restore the state saved after rollout 7, add `--ckpt-step 7`. The next rollout +is 8. Keep the dataset, model/tokenizer configuration, straw run ID, storage +profile and fully async worker topology consistent with the checkpoint. +Optimizer and training RNG state must be present for training recovery. + +Restoration creates an isolated queue sharing the checkpoint's immutable +payloads. Pending/partial inputs and ready-group order come from that checkpoint, +with the dataset cursor restored to the saved position. Later samples from the +source run are excluded. Further writes leave the source checkpoint intact. + +When `--save` already contains a run, outputs go to a unique `branches/` +directory. `rollout/current.json` tracks the active branch, so subsequent restarts +can keep using the same logical `--load` and `--save`. If `--load` is omitted and +`--save` has an active branch, it is resumed automatically. Startup logs the +resolved paths. Give each run a fresh path for immutable `.straw.json` debug archives. | Selection | Arguments | |---|---| -| Step in current branch history | `--load /shared/checkpoints/run --ckpt-step 7` | -| A specific branch | `--load /shared/checkpoints/run/branches/ --ckpt-step 7` | -| Exact checkpoint | `--load /shared/checkpoints/run/rollout/committed_7.json` | +| Step in the current branch's history | `--load /shared/checkpoints/run --ckpt-step 7` | +| A particular branch | `--load /shared/checkpoints/run/branches/ --ckpt-step 7` | +| An exact checkpoint | `--load /shared/checkpoints/run/rollout/committed_7.json` | | Separate output directory | `--save /shared/checkpoints/another-run` | -Editing `latest_checkpointed_iteration.txt` in the logical save directory or -active branch also selects an earlier step. Explicit `--ckpt-step` or an exact -commit file takes precedence. Automatic selection uses completed model-and-queue -checkpoints, follows branch ancestry only to each fork point, and ignores newer -incomplete model saves. - -If a model checkpoint has no queue snapshot, recovery starts an empty queue and -restores `rollout/global_dataset_state_dict_.pt` when present; otherwise -the dataset restarts at offset 0 with a warning. Missing models, incomplete or -corrupt snapshots, and missing payloads raise instead of silently falling back. -Before the first model checkpoint, the original run can recover persisted work -only before its first training batch is planned. Recovery uses the original -straw pool; copying the checkpoint directory alone does not copy payloads. The -boundary is a completed rollout batch, not an optimizer microstep. GPU KV -caches and generation RNG are not restored. When extending `--num-rollout`, -use `--use-checkpoint-opt-param-scheduler` to retain the saved optimizer schedule. +You can also edit `latest_checkpointed_iteration.txt` in the logical save +directory or current branch to select an earlier step. Explicit `--ckpt-step` +or an exact commit file takes precedence. Automatic selection uses completed +model-and-queue checkpoints and does not follow a newer incomplete model save. +It follows branch ancestry only up to each branch point; select an exact commit +file to load an abandoned future from another branch. + +### Missing state and recovery limits + +If the model exists but no queue snapshot was saved, recovery starts an empty +queue. When `rollout/global_dataset_state_dict_.pt` exists, its dataset +cursor is restored; otherwise the dataset starts at offset 0 with a warning. +No pending, partial or ready data is taken from another step. Missing models, +incomplete or corrupt snapshots, missing payloads and unsupported snapshot +versions raise errors instead of silently starting empty. + +Before any model checkpoint exists, restarting the original run with the same +`--save` and model/input configuration can recover persisted rollout work, but +only before the first training batch has been planned. After batch planning, +recovery requires a matching model/optimizer and rollout checkpoint. + +Recovery uses the original straw pool; copying a checkpoint directory alone +does not copy its payloads. The recovery boundary is a completed rollout +training batch, not an optimizer microstep. GPU KV caches and generation RNG +state are not restored, so newly generated tokens and random hooks may differ. + +When extending `--num-rollout`, use Megatron's +`--use-checkpoint-opt-param-scheduler` if you want to retain the saved optimizer +schedule. Queue restoration does not override optimizer settings. ## Debug archives and sample lookup -`.pt` and indexed `.straw.json` archives both work with the existing debug flags: +Both `.pt` and `.straw.json` work with the debug save/load flags: ```bash --save-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' @@ -301,15 +218,22 @@ use `--use-checkpoint-opt-param-scheduler` to retain the saved optimizer schedul --load-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' ``` -An archive retains its data independently of queue consumption and GC. With -straw transport it reuses existing tensors; otherwise it creates a `straw-data` -pool beside the index. Each rollout has one immutable index, with samples -stored in chunks. Evaluation uses `eval_`. Copying only the JSON index does -not copy payloads. Train-only straw replay uses the archive's writable storage -pool and run, overriding `--rollout-data-dir` and `--rollout-queue-run-id`. -Each replay creates an isolated queue without changing the source queue. Full -replay without subsampling reuses the archived Sample and tensor records; all -archives in one replay must belong to the same pool and run. +| Format | Contents | Storage requirement | +|---|---|---| +| `.pt` | Sample data and materialized tensors | Self-contained file | +| `.straw.json` | Immutable index with sample/task keys | Keep the referenced straw pool available | + +An indexed archive retains its data independently of queue consumption and GC. +It shares existing tensors when saved with straw transport; otherwise it creates +a `straw-data` pool alongside the index. There is one index per rollout, with +samples packed together. Evaluation uses `eval_` in place of the rollout ID. +Copying only the JSON index does not copy the payloads. For train-only replay +with straw transport automatically reuses the archive’s storage pool and run, +overriding `--rollout-data-dir` and `--rollout-queue-run-id`. Each replay creates +an isolated queue in that writable pool; the source queue is left untouched. +Full replay (without subsampling) reuses the archived Sample and tensor records +without republishing them. +All rollout archives in one replay must belong to the same pool and run. `--load-debug-rollout-data-subsample` also applies to archives. ```python @@ -317,47 +241,22 @@ from vime.data.archive import RolloutArchive from vime.observability.rollout_data_utils import load_debug_rollout_data with RolloutArchive('/shared/debug/rollout_7.straw.json') as archive: - print(archive.keys()) + print(archive.keys()) # (sample key, optional task key), in archive order samples = archive.load_samples(sample_key='sample:42') group = archive.load_samples(task_key='prompt:21') archive.export_pt('/shared/debug/rollout_7.pt') +# Convert a .pt dump to an indexed archive with an adjacent straw-data pool. samples = load_debug_rollout_data('/shared/debug/rollout_7.pt', rollout_id=7) RolloutArchive.save('/shared/debug/imported_7.straw.json', samples, rollout_id=7) ``` +Keys select data as saved in that archive. Missing keys raise `KeyError`. Lookups return lists because compact trajectories can share a sample index. -Samples without an index use their archive position; samples without queue -provenance have no task key. Closing an archive closes its reader but retains -the data. After all readers finish, call `archive.release()`; other checkpoints, -queues, and archives keep their own references. An exported `.pt` remains -readable after straw reclaims the archive payloads. - -## Validation and remaining limits - -CPU coverage includes codecs, R3/SC transport, lazy CP/TP reads, worker failures, -continuations, checkpoint views and GC ownership. straw separately checks its -Rust/Python core, bounded ownership model and native crash boundaries. Physical -multi-client filesystem checks and real GPU training complement these tests. -`tests/test_straw_fully_async_recovery.py` starts two local Ray nodes, interrupts -the entire driver/coordinator/generation process tree with SIGKILL, and starts a -new job against the same straw pool without a graceful checkpoint. It checks -accepted-result replay, unchanged token/logprob/R3/SC prefixes, new leases and -single reward evaluation, with online GC both disabled and enabled. Inference -and rewards are CPU fixtures; this test does not claim optimizer recovery. -It runs automatically in `cpu-unittest`; see [CI setup](../developer_guide/ci.md). - -Run the distributed rollout and interruption recovery tests locally with: - -```bash -PYTHONPATH=. python tests/test_distributed_rollout.py -PYTHONPATH=. python tests/test_straw_fully_async_recovery.py -``` +Samples without an index use `position:`; samples without queue +provenance have no task key. -The main deployment limits are shared-storage bandwidth, durability latency, -coordinator throughput and manager conversion memory. Live-pack compaction, -journal compaction and automatic coordinator failover are not implemented. -See [usage](../get_started/usage.md#persistent-rollout-queue-and-distributed-fully-async) -for the rollout controls and straw's -[verification guide](https://github.com/zhuzilin/straw/blob/main/docs/VERIFICATION.md) -for the storage checks and their scope. +Closing an archive closes its reader while retaining its data. After all readers +finish, open the archive and call `archive.release()` to release its retention. +Other checkpoints, queues and archives retain their own references. An exported +`.pt` remains readable after straw reclaims the payloads. diff --git a/docs/zh/advanced/straw.md b/docs/zh/advanced/straw.md index 74efbf02f..6f3c005c2 100644 --- a/docs/zh/advanced/straw.md +++ b/docs/zh/advanced/straw.md @@ -1,88 +1,18 @@ # 使用 straw 持久化 rollout -[straw](https://github.com/zhuzilin/straw) 为 vime 提供基于文件系统的持久化队列和打包张量存储。启用 `--rollout-data-transport straw` 后,prompt 任务、部分 rollout、已接收结果和训练 batch 都拥有持久化身份。多台机器上的生成与训练进程通过共享文件系统直接读写载荷;Ray 调度 actor,传递控制消息和小型引用。 +[straw](https://github.com/zhuzilin/straw) 为 vime 提供基于文件系统的队列和打包张量存储,持久化 prompt 任务、partial rollout、已完成 sample 和训练 batch。多机上的生成与训练进程可以并行读写载荷,Ray 负责传递控制消息和引用。 -当前共享文件系统目标为 JuiceFS。各节点需要在同一个绝对路径挂载同一存储池。包括 NFS 在内的其他网络文件系统,需要单独验证跨客户端锁、数据可见性和持久化语义。 - -## 架构发生了什么变化 - -| 职责 | Ray object-store 传输(默认) | straw 传输 | -|---|---|---| -| 数据源 | `RolloutDataSourceWithBuffer` | 基于持久化 prompt 任务的 `QueueDataSource` | -| 载荷交换 | Ray object reference | 指向共享 pack 的 `RecordSetRef` / 张量引用 | -| Worker 分配 | 内存中的 producer 状态 | Lease、attempt、持久化 continuation 和结果 receipt | -| Rollout 转训练数据 | `BatchBuilder` 执行 hook 和 DP 分片 | 同一转换流程,额外持久化选择计划与 ready batch | -| 数据生命周期 | 进程/object-store 所有权 | 显式 task、consumer、reader 和 checkpoint 所有权 | -| 清理 | Object-store 生命周期 | 可选在线 GC,回收没有所有者的封存 pack | - -逻辑上分成 rollout 工作与结果、ready training batch 两个阶段。它们共享同一个 straw 存储池;只要任意阶段或 checkpoint 仍引用某个张量,该张量就保持存活,不需要为两个阶段各复制一份。vime controller 协调两个阶段,straw 用 Rust 实现存储、日志、任务接收和所有权协议。 - -```{mermaid} -flowchart LR - D[数据集 producer] -->|cursor + 任务提交| Q[持久化 prompt 任务] - Q -->|lease + 输入引用| W[分布式生成 worker] - W -->|持久化部分 continuation| Q - W -->|已接收 group 引用| R[Rollout 结果] - R --> B[BatchBuilder: 选择计划与 DP 分片] - B -->|ready batch 引用| T[训练 rank] - T -->|训练完成| C[Queue controller: 进度与所有权] - C --> G[straw 在线 GC] - P[(JuiceFS 共享 pack 文件)] -. 载荷读写 .-> W - P -. 共享张量引用 .-> B - P -. 当前 rank 所需数据 .-> T -``` - -一个不自动重启的 Ray actor 持有本次运行的 coordinator,串行协调队列状态转换和数据集分配;载荷由各 producer 进程写入。因此,多机 I/O 可以并行,每个队列日志仍只有一个写入所有者。当前没有 coordinator 自动选主或故障接管。 - -## 端到端数据流 - -1. **提交 prompt。** 数据集 cursor 与 prompt 任务一起提交。Worker 领取 lease,从共享存储读取 prompt group;丢失 worker 不会永久丢弃已经预分配的数据集索引区间。 -2. **生成与续跑。** 分布式 fully async 在每个符合资源条件的 Ray 节点上启动生成 worker。Worker 生成、评分并过滤完整 group,将部分 group 保存为持久化 continuation。R3 routes 和 SC rows 必须覆盖已保存的 token 前缀;服务端 capture 不完整时,从上一份一致的输入重试。 -3. **接收结果。** 先持久化载荷,再接收引用。Receipt 标识已接收 group,因此即使 RPC 回复丢失也能恢复结果。任务重新分配会产生新 attempt,旧 attempt 无法接收替代结果。 -4. **构造训练 batch。** `BatchBuilder` 在调用 reward/conversion hook 前持久化输入选择和转换配置,发布所有 DP shard 后才将整个 batch 标记为 ready。训练 reader 检查 batch、plan 和 rank 身份。Manager 仍会将选中的 sample 物化用于转换,其主机内存仍需纳入容量规划。 -5. **确认完成。** 所有 rank 的训练调用返回后,Manager 报告 `training_completed`,推进运行时消费进度并释放 ready batch 容量。这与模型/优化器 checkpoint 持久化是两件事。 - -Ray 继续负责调度、RPC 和执行进程故障;vime 负责 Sample schema、reward、filter、batch 和训练;straw 负责字节、引用、持久化和存储生命周期。自定义 rollout 仍可返回 Sample 列表,由 Manager 发布为兼容 collection。接口见[自定义指南](../get_started/customization.md)。 - -## 队列调度 - -`QueueReader` 是 worker 的队列客户端,负责领取、归还 group 和为正在执行的 lease 续租。`QueueDataSource` 增加整个作业的 reader 创建、consumer 生命周期和 source checkpoint 管理;它继承同一套取样方法,没有另实现一层 buffer。`--rollout-data-transport straw` 会自动选择此数据源;显式指定 `--data-source-path` 时使用 `vime.data.queue_data_source.QueueDataSource`。 - -`QueueReader` 不再有本地 continuation buffer。`add_samples()` 先发布可用的续跑前缀,再通过 `yield_tasks()` 在每个有界批次的一次 WAL 事务中替换输入并归还任务。旧 lease 随之结束,任意 worker 都能用新 lease 领取这些输入。reader 本地只保留正在执行的样本和 lease,关闭时无需重写已归还的 group。 - -持久化顺序为 **已完成待交付 → partial → 新 prompt**。同类任务按 group 中最旧的数值型 `weight_versions` 排序:版本越旧,staleness 越高,越先取用;相同版本按提交/归还的 FIFO 顺序,没有有效版本的排在同类末尾。比较已有版本即可,无需每次更新模型权重都改写队列。staleness 定义为“当前 serving 权重版本 − 最旧生成权重版本”,新鲜样本为 0。排序本身不会自动丢弃过旧样本。这是在一个队列内建立索引,不是维护三个独立队列。 - -straw 明确拒绝 `--buffer-filter-path`;`--buffer-sort-by-staleness` 仍用于内存数据源,straw 始终采用上述顺序。reward 和样本筛选 hook 继续可用。已经接收的 group 如果显式放回,会创建独立的 delivery task,保留原 accepted 历史;消费或丢弃 delivery 时也会确认被其替代的已接收版本。fully async scheduler 仍保留有界的 ready result 窗口,其 checkpoint 状态和已接收结果引用单独持久化。 - -source checkpoint 统一保存共享 pending 任务、准确的不可变输入引用和排序 metadata;worker checkpoint 只保存 reader metadata。reader 快照格式升级为 v3,旧 v1/v2 本地 buffer 快照需要迁移,加载时明确报错。此功能需要带 `yield_tasks()` 和持久化排序字段的 `straw-queue>=0.1.2`。 - -## 共享张量、R3 与 SC - -每个 writer 进程复用 pack writer。vime adapter 默认按 **256 MiB** 目标大小轮换,由 `--rollout-queue-segment-mib` 配置。记录和 manifest 都嵌入 pack,**不会为每个 sample 或张量单独创建文件**,避免大量小文件带来的共享文件系统元数据开销。队列与所有权元数据使用追加日志。 - -已完成的 R3 routes 和 SC 张量,在 custom sample hook 之后随其 sample group 一起发布。后续 rollout、continuation 和训练数据发布复用不可变张量依赖;修改时写入新记录,旧引用保持有效。这是张量级共享与写时复制,不是 GPU 共享内存或内存页级写时复制。R3 训练只读取分配给当前 CP/TP rank 的行。大 bundle 会按 native 写入预算拆分;SC 也支持不启用 R3 的场景。 - -需要持久化 R3/SC 张量时,使用 `--rollout-data-transport straw`;object-store 传输默认将它们保存在内存中,也可继续使用 Vime 原有的磁盘 spill hook。straw 模式不需要独立的 spill hook 或清理步骤。导入另一个 straw 存储池的引用时,先将数据复制到目标池,再接收依赖这些数据的结果。Debug dump 直接保存张量内容,因此队列 GC 后仍可读取;启用 dump 会增加主机内存和 I/O 开销。 - -## Sample 编码与完整性校验 - -Adapter 将 sample 保存为 `vime.v1` JSON record:`{version: 1, tree: ...}`,通过显式标签编码容器、Sample 字段、NumPy 数组、图像和 rollout 引用。嵌套 group、支持的动态字段、多模态输入、R3、SC top-k 和 ragged top-p 都会保留。未知 Python 类型及循环引用明确报错;载荷协议不使用 pickle,也不会根据存储数据中的名称导入类。 - -Tensor 节点包含 shape、dtype、semantic kind、lazy/validated 标志,以及指向外层 manifest 依赖的 `{dependency: index, ordinal: index}`。物理位置保存在这些依赖 manifest 中,因此后续 sample 或训练 batch 发布可以共享相同的不可变张量记录。所有张量依赖必须先发布,才能接收所属 sample。 - -straw 的 `tensor.v1` 保存连续、行优先、小端的 typed bytes。Lazy reader 校验元数据及所读行覆盖的每个 chunk 的 checksum,不会校验未读取的 chunk;完整检查则校验整个 segment。张量描述符批量恢复,以复用共享 pack 索引。 +当前支持的共享文件系统是 JuiceFS,所有节点必须以相同绝对路径挂载存储池。包括 NFS 在内的其他网络文件系统,需要单独验证锁、可见性和持久性。 ## 启用方式 -`requirements.txt` 已包含 `straw-queue>=0.1.2`,标准 Docker 构建和正常安装 vime 时会自动安装。已有环境可以在每个生成和训练节点执行以下命令,同一任务的所有节点使用相同版本: +标准 vime 安装和 Docker 构建包含 `straw-queue`。已有环境可以在每个 rollout 和训练节点执行以下命令,同一任务使用相同版本: ```bash pip install 'straw-queue>=0.1.2' ``` -未安装 straw 时,默认 Ray `object-store` 传输仍可运行,启动日志会提示安装命令。如果显式选择 `--rollout-data-transport straw`,则在启动检查阶段报错并给出同样的安装命令。 - -Python 导入名为 `straw`。在原有训练命令中追加以下参数,使用新的共享目录: +在训练命令中添加: ```bash --rollout-data-transport straw \ @@ -92,39 +22,63 @@ Python 导入名为 `straw`。在原有训练命令中追加以下参数,使 --rollout-storage-declaration /shared/juicefs/deployment.json ``` -声明文件包含 `direct_mount: true`、`writeback: false`、`open_cache: 0`、`readdir_cache: false`、已部署的 `client_version` 和 `durability_description`。它记录经过验证的部署设置,不会自动探测或配置挂载。详见 straw 的[文件系统要求](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM_zh.md)。 +部署声明描述已经验证的挂载设置,不会修改挂载配置。其中包含 `direct_mount: true`、`writeback: false`、`open_cache: 0`、`readdir_cache: false`、`client_version` 和 `durability_description`。详见 straw 的[文件系统约定](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM.md)。本地 POSIX 开发和测试使用默认的 `local` profile。 -默认 `local` 存储 profile 对应 POSIX 开发契约;在共享挂载上选择它,并不意味着已验证该存储服务的持久性。 +新任务省略 `--rollout-data-dir` 时使用 `/rollout_data`;未设置 `--save` 时必须显式指定共享目录。从 checkpoint 恢复时,可以从 checkpoint 中读取存储池路径。 -存储方式和执行模式分别选择。默认传输仍为 `object-store`,默认 rollout 入口仍为同步模式。使用 straw 的分布式 fully async 时,额外添加: +存储方式和执行方式独立选择。默认使用 Ray `object-store` 传输和同步 rollout。分布式 fully async 还需要添加: ```bash --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async ``` -未指定 `--rollout-data-dir` 时,straw 模式使用 `/rollout_data`;没有 `--save` 则必须显式指定共享目录。`--rollout-io-concurrency` 限制事件循环之外的序列化和 I/O 并发,默认 4。Adapter 使用 worker 进程和有界线程,straw 本身不创建 I/O 进程池。 +Fully async 在每个有 CPU 资源的 Ray 节点启动一个生成进程,分配总并发。快速 worker 可以提供训练 batch,无需等待所有 worker。权重同步期间暂停接收新任务,持久化在途结果后再恢复生成。 + +未安装 straw 时,默认 object-store 传输可以运行;显式选择 straw 则在启动时报错并给出安装命令。 -权重同步期间,分布式 producer 暂停提交新任务,让在途请求完成并持久化;还有下一次训练 rollout 时恢复。权重同步本身仍是独立子系统。 +## 数据流与调度 -关闭时,所有生成 worker 共用五分钟期限完成持久化写入。超时会报告错误,不会被视为成功关闭。 +1. Worker 请求 prompt group,数据源从 dataset 读取数据,将 group 和更新后的游标一起保存到队列。 +2. Worker 生成和打分。归还的 partial group 持久化后等待续跑;已接收的完整 group 可供训练使用。 +3. Batch builder 执行 reward/conversion hook,将选中的 sample 分配给各训练 rank。训练结束后,vime 确认数据已消费;没有其他引用需要它们时即可回收存储。 -## 在线 GC 与所有权 +Rollout 和训练数据共用存储池,R3、SC 等大张量可以跨阶段复用。队列通过可续租的 lease 将任务分配给 worker;worker 失联后,未完成任务在 lease 到期后可重新领取。 -在线 GC 默认关闭,添加 `--rollout-queue-online-gc` 后,straw 可在任务运行期间回收不再使用的封存 pack。vime 通过 straw API 提供使用完成和丢弃信号。只有 task、publication、queue、reader、checkpoint 等所有者全部释放后,pack 才能删除。仅 lease 超时不能证明 reader 已停止。一条存活记录就会保留整个 pack;活跃 writer、保留 checkpoint 和 WAL 历史仍会占用空间。 +调度优先级为 **已完成 group → partial group → 新 prompt**。同类中,生成 token 的权重版本越旧越先取用;版本相同时按 FIFO 顺序,没有数值型版本的排在同类末尾。Staleness 为当前 serving 版本减去最旧生成 token 版本,因此新鲜 sample 的 staleness 为 0。调度排序不会自动丢弃过旧 sample。 -GC 失败会停止后台循环,后续 coordinator 操作传播原始原因,关闭时也会报告错误;它不会停止已在远端 worker 中运行的工作。数据保留供排查。磁盘空间不足时,应释放不再需要的保留数据或扩容,不能 reset 活跃队列或直接删除 pack 腾空间。离线删除目录要求所有参与者停止;仍需配置存储配额与应用保留策略。 +straw 不支持 `--buffer-filter-path`。`--buffer-sort-by-staleness` 适用于内存数据源,straw 使用上述排序规则。Reward 和 sample-selection hook 可用;分布式 fully async 不支持 `--rollout-all-samples-process-path`。自定义 rollout 函数和 queue reader 的用法见[自定义功能](../get_started/customization.md)。 -已处理位置和过滤决策被显式记录,避免接收顺序中的空洞使已消费数据持续保留。 +## 数据打包与支持类型 + +每个 writer 向 pack 文件追加多条 sample 和 tensor,不会为每个 sample 或 tensor 单独创建文件,从而减少共享存储上的小文件元数据开销。不可变张量引用允许 rollout、训练 batch 和 checkpoint 共享数据;修改时写入新记录,保留的引用始终指向原有内容。 + +Sample codec 支持嵌套 list、tuple、dict、标量、字节数据、NumPy 数组、PyTorch 张量、PIL 图像和 Sample 字段,包括受支持的自定义字段。R3 routes、SC top-k 和 ragged top-p 数据以 typed tensor 保存。不支持的 Python 对象和循环引用会在发布时报错。记录和张量读取都有校验和检查。 + +R3 使用 `--use-rollout-routing-replay`,SC 使用 `--use-score-centering`。选择 straw 传输后,这些张量随 sample group 一起持久化。R3 训练只读取当前 CP/TP rank 分配到的行。大 replay 张量在 batch 转换时保持 lazy,但选中的 Sample 元数据和普通字段仍会占用 manager 内存。 + +| 参数 | 默认值 | 用途 | +|---|---|---| +| `--rollout-queue-segment-mib` | `256` | Pack 轮转的目标大小,单位 MiB | +| `--rollout-io-concurrency` | `4` | 限制并发序列化与文件系统 I/O 提交 | +| `--rollout-queue-lease-seconds` | `300` | Worker 租期,活跃 reader 会续租 | + +## 在线 GC + +在线 GC 默认关闭,通过 `--rollout-queue-online-gc` 开启。vime 在任务和训练 batch 使用完成或被丢弃后通知 straw。只有所有任务、reader、checkpoint 和归档都释放引用后,straw 才会回收封存的 pack。一条存活记录就会保留整个 pack,活跃 writer 和日志历史也会占用空间。 + +保留的 checkpoint 和归档在 GC 开启时也能保护其载荷,因此回退不需要关闭 GC。数据不再需要时还应释放对应的存储保留关系;只删除索引文件不会释放所有权。不要手动删除活跃存储池中的 pack 文件。离线删除前,先停止所有 coordinator、writer 和 reader。 + +GC 失败会通过后续队列操作和关闭流程报告。容量耗尽时需要释放不再使用的保留数据或增加容量,不会自动 reset 队列。目前没有活跃 pack 和日志压缩功能。 ## 恢复与 checkpoint -正常训练 checkpoint 会同时保存模型与 rollout 状态,包括 dataset 游标、sample/group 计数、pending/partial 输入、ready group 和训练进度。已经在 straw 中的载荷通过引用保存,不会重复复制;保留 checkpoint 就会保留其依赖数据。 +正常训练 checkpoint 同时保存模型和 rollout 状态,包括 dataset 游标、sample/group 编号、pending/partial 输入、ready group 和训练进度。已经保存在 straw 中的载荷通过引用保存,不会在每次 checkpoint 时重新复制。保留 checkpoint 会保留它依赖的数据。 -重启前应停止整个旧任务,包括远端 worker。Save 目录锁会拒绝并发 coordinator,但不会停止孤立 reader;当前没有自动 coordinator 故障接管。 +重启前需要停止整个原任务,包括远端 worker。Save 目录锁会拒绝并发 coordinator,但不会终止遗留 reader。目前没有 coordinator 自动故障转移。 ### 续跑或选择 step -使用相同逻辑目录恢复最近一次完整 checkpoint: +使用相同的逻辑目录恢复最近一次完整 checkpoint: ```bash --rollout-data-transport straw \ @@ -133,24 +87,36 @@ GC 失败会停止后台循环,后续 coordinator 操作传播原始原因, --save-interval 1 ``` -设置 `--save` 时,Megatron 要求 `--save-interval` 为正数。要恢复 rollout 7 结束后的状态,添加 `--ckpt-step 7`,下一轮从 rollout 8 开始。Dataset、模型/tokenizer 配置、straw run ID、storage profile 和 fully async worker 拓扑应与 checkpoint 一致;训练恢复需要已保存 optimizer 和 RNG 状态。 +设置 `--save` 时,Megatron 要求同时指定正数的 `--save-interval`。示例每轮 rollout 保存一次,可按实际需求调整间隔。 -恢复时会创建独立队列,共享 checkpoint 中的不可变载荷。Pending/partial 输入、ready group 顺序和 dataset 游标均来自该 checkpoint;不会混入源任务之后产生的样本。后续写入不会修改源 checkpoint。若 `--save` 已包含一次运行,输出会写入唯一的 `branches/` 目录,并由 `rollout/current.json` 记录当前分支。之后可继续使用相同逻辑 `--load` 和 `--save`;省略 `--load` 时自动续跑活动分支。每次运行的不可变 `.straw.json` debug 归档应使用新路径。 +如需恢复 rollout 7 结束后的状态,添加 `--ckpt-step 7`,下一轮从 rollout 8 开始。Dataset、模型/tokenizer 配置、straw run ID、storage profile 和 fully async worker 拓扑应与 checkpoint 一致。训练恢复需要保留 optimizer 和训练 RNG 状态。 + +恢复时创建独立队列,共享 checkpoint 中的不可变载荷。Pending/partial 输入和 ready group 顺序来自该 checkpoint,dataset 游标恢复到保存的位置;不会混入原任务在此之后产生的 sample。后续写入不会修改原 checkpoint。 + +当 `--save` 已包含任务时,输出写入独立的 `branches/` 目录,由 `rollout/current.json` 记录当前分支。以后重启可以继续使用相同的逻辑 `--load` 和 `--save`。省略 `--load` 且 `--save` 存在当前分支时,也会自动续跑。启动日志会显示实际路径。不可变的 `.straw.json` debug 归档需要为每次运行指定新的输出路径。 | 选择方式 | 参数 | |---|---| | 当前分支历史中的某一步 | `--load /shared/checkpoints/run --ckpt-step 7` | | 指定分支 | `--load /shared/checkpoints/run/branches/ --ckpt-step 7` | | 指定完整 checkpoint | `--load /shared/checkpoints/run/rollout/committed_7.json` | -| 独立输出目录 | `--save /shared/checkpoints/another-run` | +| 单独的输出目录 | `--save /shared/checkpoints/another-run` | + +也可以修改逻辑 save 目录或当前分支中的 `latest_checkpointed_iteration.txt`,选择更早的 step。显式 `--ckpt-step` 或具体提交文件优先。自动选择只使用完整的模型与队列 checkpoint,不会跟随更晚但未完成的模型保存。沿父分支查找时只追溯到各分支起点;如需加载其他分支已放弃的后续历史,请指定具体提交文件。 -也可以修改逻辑 save 目录或活动分支中的 `latest_checkpointed_iteration.txt`,选择更早的 step。显式 `--ckpt-step` 或具体提交文件优先。自动选择只使用完整的模型与队列 checkpoint,沿分支祖先查找时不会越过分叉点,也不会跟随更晚但未完成的模型保存。 +### 缺失状态与恢复限制 -模型 checkpoint 没有队列快照时,会从空队列恢复;若存在 `rollout/global_dataset_state_dict_.pt`,则恢复其中的 dataset 游标,否则从 offset 0 开始并打印警告。模型缺失、快照不完整或损坏、载荷缺失时会报错,不会静默退化。第一个模型 checkpoint 尚未产生时,原任务只能在第一个训练 batch 规划前恢复已持久化工作。恢复依赖原 straw 存储池,单独复制 checkpoint 目录不会复制载荷。恢复边界是完成的 rollout batch,而不是 optimizer microstep;GPU KV cache 和生成 RNG 不会恢复。增大 `--num-rollout` 时,如需沿用保存的 optimizer schedule,可使用 `--use-checkpoint-opt-param-scheduler`。 +如果模型存在但没有保存队列快照,则从空队列开始。存在 `rollout/global_dataset_state_dict_.pt` 时恢复其中的 dataset 游标,否则从 offset 0 开始并打印警告。不会从其他 step 借用 pending、partial 或 ready 数据。模型缺失、快照不完整或损坏、载荷缺失、快照版本不受支持等情况会报错,不会静默退化成空队列。 + +尚无模型 checkpoint 时,使用相同 `--save` 和模型/输入配置重启原任务,可以恢复已持久化的 rollout 工作,但仅限于第一个训练 batch 尚未开始规划的阶段。之后恢复需要匹配的模型/optimizer 和 rollout checkpoint。 + +恢复依赖原 straw 存储池,单独复制 checkpoint 目录不会复制载荷。恢复边界是完成的 rollout 训练 batch,不是 optimizer microstep。GPU KV cache 和生成 RNG 状态不会恢复,新生成的 token 和随机 hook 结果可能不同。 + +增大 `--num-rollout` 时,如需沿用保存的 optimizer schedule,可使用 Megatron 的 `--use-checkpoint-opt-param-scheduler`。队列恢复不会覆盖 optimizer 设置。 ## Debug 归档与按 key 查询 -现有 debug 保存/加载参数同时支持 `.pt` 和带索引的 `.straw.json`: +Debug 保存和加载参数支持 `.pt` 与 `.straw.json` 两种格式: ```bash --save-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' @@ -158,35 +124,28 @@ GC 失败会停止后台循环,后续 coordinator 操作传播原始原因, --load-debug-rollout-data '/shared/debug/rollout_{rollout_id}.straw.json' ``` -归档独立于队列消费和 GC 保留数据。使用 straw 传输保存时复用已有张量;否则在索引旁创建 `straw-data` 存储池。每个 rollout 一个不可变索引,样本按块打包;Evaluation 使用 `eval_`。单独复制 JSON 索引不会复制载荷。使用 straw 做只训练回放时,自动使用归档的存储池和 run,覆盖 `--rollout-data-dir` 和 `--rollout-queue-run-id`,并在该可写存储池中新建独立队列,不改变原队列。完整回放(不抽样)直接复用已有 Sample 和张量记录,不重新写入。一次回放的所有归档必须属于同一存储池和 run。`--load-debug-rollout-data-subsample` 也适用于归档。 +| 格式 | 内容 | 存储要求 | +|---|---|---| +| `.pt` | Sample 数据和实际张量内容 | 独立文件 | +| `.straw.json` | 带 sample/task key 的不可变索引 | 需要保留所引用的 straw 存储池 | + +索引归档独立保留数据,不受队列消费和 GC 影响。使用 straw 传输保存时复用已有张量,否则在索引旁创建 `straw-data` 存储池。每个 rollout 只有一个索引,sample 打包存储。Evaluation 使用 `eval_` 替代 rollout ID。单独复制 JSON 索引不会复制载荷。使用 straw 做只训练回放时,自动使用归档的存储池和 run,覆盖 `--rollout-data-dir` 和 `--rollout-queue-run-id`,并在该可写存储池中新建独立队列,不改变原队列。完整回放(不抽样)直接复用已有 Sample 和张量记录,不重新写入。一次回放的所有归档必须属于同一存储池和 run。`--load-debug-rollout-data-subsample` 也适用于归档。 ```python from vime.data.archive import RolloutArchive from vime.observability.rollout_data_utils import load_debug_rollout_data with RolloutArchive('/shared/debug/rollout_7.straw.json') as archive: - print(archive.keys()) + print(archive.keys()) # 按归档顺序返回 (sample key, 可选 task key) samples = archive.load_samples(sample_key='sample:42') group = archive.load_samples(task_key='prompt:21') archive.export_pt('/shared/debug/rollout_7.pt') +# 将 .pt 转为索引归档,在索引旁创建 straw-data 存储池。 samples = load_debug_rollout_data('/shared/debug/rollout_7.pt', rollout_id=7) RolloutArchive.save('/shared/debug/imported_7.straw.json', samples, rollout_id=7) ``` -查询返回列表,因为 compact trajectory 可能共享 sample index。没有 index 的样本使用其归档位置;没有队列来源信息的样本没有 task key。关闭归档只会关闭 reader,数据继续保留。所有 reader 使用完后调用 `archive.release()` 释放归档保留;其他 checkpoint、队列和归档仍保留各自引用。导出的 `.pt` 在 straw 回收归档载荷后仍可读取。 - -## 验证与当前限制 - -CPU 测试覆盖 codec、R3/SC 传输、CP/TP 按行读取、worker 故障、continuation、checkpoint 视图和 GC 所有权。straw 独立验证 Rust/Python 核心、有界所有权模型和 native 崩溃边界。物理多客户端文件系统检查及真实 GPU 训练补充这些测试。 - -`tests/test_straw_fully_async_recovery.py` 启动两个本地 Ray 节点,以 SIGKILL 中断整个 driver、coordinator 和 generation 进程树,再启动新任务读取同一个 straw 存储池,不经过正常 checkpoint。测试覆盖已接收结果重放、token/logprob/R3/SC 前缀保持不变、新 lease 和 reward 只计算一次,并分别检查在线 GC 关闭与开启的情况。推理和 reward 使用 CPU fixture,不代表 optimizer 恢复验证。此测试在 `cpu-unittest` 中自动运行,安装方式见 [CI 配置](../developer_guide/ci.md)。 - -本地运行分布式 rollout 与中断恢复测试: - -```bash -PYTHONPATH=. python tests/test_distributed_rollout.py -PYTHONPATH=. python tests/test_straw_fully_async_recovery.py -``` +Key 选择的是该归档保存的数据版本,找不到时抛出 `KeyError`。Compact trajectory 可能共享 sample index,因此查询返回列表。没有 index 的 sample 使用 `position:<序号>`;没有队列来源信息的 sample 没有 task key。 -主要部署限制包括共享存储带宽、持久化延迟、coordinator 吞吐和 Manager 转换内存。目前尚无存活 pack 整理、日志压缩或 coordinator 自动接管。Rollout 参数见[使用指南](../get_started/usage.md),存储验证及其覆盖边界见 straw 的[验证文档](https://github.com/zhuzilin/straw/blob/main/docs/VERIFICATION_zh.md)。 +关闭归档只会关闭 reader,数据继续保留。所有 reader 使用完后,可以打开归档并调用 `archive.release()` 释放保留关系。其他 checkpoint、队列和归档的引用独立保留。导出的 `.pt` 在 straw 回收载荷后仍可读取。 diff --git a/tests/test_r3_spill.py b/tests/test_r3_spill.py deleted file mode 100644 index e4ce9b438..000000000 --- a/tests/test_r3_spill.py +++ /dev/null @@ -1,134 +0,0 @@ -import asyncio -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch - -from vime.data.tensor import DiskTensorRef -from vime.observability.rollout_data_utils import tensorize_rollout_data_for_training -from vime.utils.routed_experts import ( - RoutedExpertsMicrobatch, - RoutedExpertsMicrobatchPrefetcher, - cleanup_routed_experts_rollout, - spill_routed_experts, -) -from vime.utils.types import Sample - -NUM_GPUS = 0 - - -def _args(tmp_path, *, keep=False): - return SimpleNamespace( - num_layers=6, - moe_router_topk=2, - num_experts=16, - moe_layer_freq=[0, 0, 0, 1, 1, 1], - rollout_routed_experts_store_dir=str(tmp_path), - keep_rollout_routed_experts_files=keep, - ) - - -def _routes(rows=4): - routes = torch.zeros((rows, 6, 2), dtype=torch.uint8) - routes[:, 3:, 1] = 7 - return routes - - -@pytest.mark.unit -def test_spill_round_trip_preserves_disk_reference_through_tensorize(tmp_path): - routes = _routes() - sample = Sample( - index=9, - tokens=[0] * 5, - response_length=1, - loss_mask=[1], - rollout_routed_experts=routes, - status=Sample.Status.COMPLETED, - ) - - result = asyncio.run(spill_routed_experts(_args(tmp_path), sample, rollout_id=3)) - - assert isinstance(result.rollout_routed_experts, DiskTensorRef) - assert result.rollout_routed_experts.validated - assert torch.equal(result.rollout_routed_experts.load(), routes) - rollout_data = {"rollout_routed_experts": [result.rollout_routed_experts]} - tensorize_rollout_data_for_training(rollout_data) - assert rollout_data["rollout_routed_experts"][0] is result.rollout_routed_experts - - -@pytest.mark.unit -def test_aborted_sample_stays_resident_for_retry(tmp_path): - routes = _routes() - sample = Sample( - tokens=[0] * 5, - response_length=1, - loss_mask=[1], - rollout_routed_experts=[routes[:2], routes[2:]], - status=Sample.Status.ABORTED, - ) - - result = asyncio.run(spill_routed_experts(_args(tmp_path), sample, rollout_id=3)) - - assert isinstance(result.rollout_routed_experts, list) - assert torch.equal(result.materialize_rollout_routed_experts(replace=False), routes) - assert not list(tmp_path.iterdir()) - - -@pytest.mark.unit -def test_spill_relinks_cross_rollout_reference_and_cleanup_respects_keep(tmp_path): - args = _args(tmp_path) - ref = DiskTensorRef.write(_routes(), tmp_path / "rollout_00000001" / "original.safetensors", validated=True) - sample = Sample(index=1, rollout_routed_experts=ref, status=Sample.Status.COMPLETED) - - asyncio.run(spill_routed_experts(args, sample, rollout_id=2)) - - assert isinstance(sample.rollout_routed_experts, DiskTensorRef) - assert Path(sample.rollout_routed_experts.path).parent.name == "rollout_00000002" - assert torch.equal(sample.rollout_routed_experts.load(), _routes()) - cleanup_routed_experts_rollout(args, 2) - assert not (tmp_path / "rollout_00000002").exists() - - keep_args = _args(tmp_path, keep=True) - asyncio.run( - spill_routed_experts( - keep_args, Sample(index=2, rollout_routed_experts=_routes(), status=Sample.Status.COMPLETED), rollout_id=4 - ) - ) - cleanup_routed_experts_rollout(keep_args, 4) - assert (tmp_path / "rollout_00000004").exists() - - -@pytest.mark.unit -def test_lazy_prefetch_reloads_between_forward_and_backward(monkeypatch, tmp_path): - from vime.utils import routed_experts as routed_experts_module - - ref = DiskTensorRef.write(_routes(rows=3), tmp_path / "routes.safetensors", validated=True) - prefetcher = RoutedExpertsMicrobatchPrefetcher(prefetch_microbatches=1) - source = RoutedExpertsMicrobatch([ref], [torch.arange(4)], consumer_count=2, prepare_kwargs={}) - prefetcher.add(source) - loads = [] - - def load_and_prepare(): - tensor = ref.load() - loads.append(1) - return tensor - - monkeypatch.setattr(source, "_load_and_prepare", load_and_prepare) - monkeypatch.setattr(routed_experts_module.accelerator, "current_device", lambda: torch.device("cpu")) - - prefetcher.begin_pass("forward") - source.layer_to_cuda(3, "forward") - source.layer_to_cuda(4, "forward") - assert source._cpu_tensor is None - - prefetcher.begin_pass("backward") - source.layer_to_cuda(3, "backward") - source.layer_to_cuda(4, "backward") - assert source._cpu_tensor is None - prefetcher.close() - assert len(loads) == 2 - - -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_score_centering.py b/tests/test_score_centering.py index 00201fe0b..33e902d16 100644 --- a/tests/test_score_centering.py +++ b/tests/test_score_centering.py @@ -223,18 +223,24 @@ def distributed_worker(rank, world_size, port, layout, mode, disk=False): local = local.clone().requires_grad_() original = dict(batch) import tempfile - from pathlib import Path - from vime.data.tensor import DiskTensorRef with tempfile.TemporaryDirectory() as directory: if disk: - for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): - batch[key] = [ - DiskTensorRef.write( - value.int() if key.endswith("ids") else value, Path(directory) / f"{key}_{i}.safetensors" + from straw import SharedFilesystemStore + from straw.tensor import publish_tensors + + with SharedFilesystemStore(directory, "sc", codecs=("tensor.v1",)) as store: + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + batch[key] = list( + publish_tensors( + store, + { + str(i): value.int() if key.endswith("ids") else value + for i, value in enumerate(batch[key]) + }, + submission_id=key, + ) ) - for i, value in enumerate(batch[key]) - ] _check_distributed_batch( rank, layout, diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index 0a465e33e..912a28a51 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -170,6 +170,70 @@ def test_streaming_score_centering_rejected(): asyncio.run(generate_streaming(args(), Sample(), {})) +@pytest.mark.parametrize("returned_rows", [1, 2]) +def test_r3_resume_preserves_routes_and_score_centering_heads(monkeypatch, returned_rows): + import base64 + import io + + from vime.rollout import vllm_rollout as rollout + + configured = args( + hf_checkpoint="model", + vllm_router_ip="localhost", + vllm_router_port=1234, + use_rollout_routing_replay=True, + ci_test=False, + num_layers=2, + moe_router_topk=2, + ) + sample = samples()[0] + sample.status = Sample.Status.ABORTED + prefix = torch.tensor([[[1, 2], [3, 4]]], dtype=torch.int32) + sample.rollout_routed_experts = prefix.clone() + tokenizer = SimpleNamespace(decode=lambda *_args, **_kwargs: "x") + monkeypatch.setattr(rollout, "GenerateState", lambda _: SimpleNamespace(tokenizer=tokenizer, processor=None)) + monkeypatch.setattr(rollout, "_prepare_prompt_ids", lambda sample, *_: sample.tokens) + + async def post(url, payload, **kwargs): + assert payload["sampling_params"]["routed_experts_prompt_start"] == 1 + assert payload["token_ids"] == [9, 3] + capture = io.BytesIO() + np.save(capture, np.array([[[5, 6], [7, 8]]] * returned_rows, dtype=np.int32)) + head_ids, head_logprobs = meta()["score_centering_topk"] + return { + "choices": [ + { + "token_ids": [3], + "finish_reason": "stop", + "routed_experts": base64.b64encode(capture.getvalue()).decode(), + "logprobs": { + "content": [ + { + "logprob": -0.5, + "top_logprobs": [ + {"token": f"token_id:{token_id}", "logprob": float(logprob)} + for token_id, logprob in zip(head_ids[0], head_logprobs[0], strict=True) + ], + } + ] + }, + } + ] + } + + monkeypatch.setattr(rollout, "post", post) + if returned_rows == 2: + with pytest.raises(ValueError, match="element count"): + asyncio.run(rollout.generate(configured, sample, {"max_new_tokens": 8})) + assert torch.equal(sample.materialize_rollout_routed_experts(), prefix) + else: + result = asyncio.run(rollout.generate(configured, sample, {"max_new_tokens": 8})) + assert result.tokens == [9, 3, 3] + assert torch.equal(result.materialize_rollout_routed_experts()[:1], prefix) + assert result.materialize_rollout_routed_experts()[1:].flatten().tolist() == [5, 6, 7, 8] + assert result.rollout_topk_token_ids.tolist() == [[3, 1, 4]] * 2 + + @pytest.mark.parametrize("transport", ["object-store", "nixl"]) def test_dp_transport_keeps_heads_aligned(monkeypatch, transport): from vime.data import batch_builder as rollout @@ -233,33 +297,10 @@ async def run(): assert a.use_score_centering -def test_spilled_heads_survive_buffer_and_debug_dump_lifetimes(tmp_path): - from vime.data.tensor import DiskTensorRef - from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data - from vime.utils.routed_experts import cleanup_routed_experts_rollout, link_routed_experts_for_rollout - from vime.utils.score_centering import spill_sampler_topk, validate_sampler_topk - - a = args(rollout_routed_experts_store_dir=str(tmp_path)) - sample = samples()[0] - spill_sampler_topk(a, sample, 1) - assert isinstance(sample.rollout_topk_token_ids, DiskTensorRef) - link_routed_experts_for_rollout(a, sample, 2) - path = str(tmp_path / "debug.pt") - save_debug_rollout_data(path, [sample], rollout_id=2, evaluation=False) - cleanup_routed_experts_rollout(a, 1) - validate_sampler_topk(sample, 3) - cleanup_routed_experts_rollout(a, 2) - restored = load_debug_rollout_data(path, rollout_id=2)[0] - validate_sampler_topk(restored, 3) - restored.append_response_tokens(a, tokens=[3], log_probs=[-0.5], meta_info=meta()) - assert restored.rollout_topk_token_ids.tolist() == [[3, 1, 4]] * 2 - - @pytest.mark.parametrize("disk", [False, True]) def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, disk): from megatron.core import mpu - from vime.data.tensor import DiskTensorRef from vime.observability import train_metric_utils as metrics for name, value in { @@ -275,8 +316,14 @@ def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, di tensorize_rollout_data_for_training(batch) batch.update(total_lengths=[2, 2], global_batch_sizes=[2]) if disk: - for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): - batch[key] = [DiskTensorRef.write(x, tmp_path / f"{key}_{i}") for i, x in enumerate(batch[key])] + from straw import SharedFilesystemStore + from straw.tensor import publish_tensors + + with SharedFilesystemStore(tmp_path, "metrics", codecs=("tensor.v1",)) as store: + for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): + batch[key] = list( + publish_tensors(store, {str(i): x for i, x in enumerate(batch[key])}, submission_id=key) + ) metrics.log_rollout_data( 0, args(ci_test=False, log_multi_turn=False, log_passrate=False, log_correct_samples=False), batch ) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 330fd1d0b..610cc8914 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -12,7 +12,7 @@ from torch_memory_saver import torch_memory_saver from transformers import AutoConfig, AutoTokenizer -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.observability import train_data_utils, train_metric_utils from vime.observability.logging_utils import init_tracking from vime.observability.profile_utils import TrainProfiler @@ -21,7 +21,7 @@ from vime.utils import accelerator from vime.utils.data import process_rollout_data from vime.utils.distributed_utils import get_gloo_group -from vime.utils.memory_utils import clear_memory, get_process_host_memory_gib, print_memory, reset_cuda_stack_size +from vime.utils.memory_utils import clear_memory, print_memory, reset_cuda_stack_size from vime.utils.misc import Box from vime.utils.reloadable_process_group import ( destroy_process_groups, @@ -274,7 +274,7 @@ def _get_rollout_data(self, rollout_data_ref: Box) -> RolloutBatch: rollout_data[key] = [ ( value - if isinstance(value, (TensorRef, DiskTensorRef)) + if isinstance(value, TensorRef) else (value if self.args.allgather_cp else slice_log_prob_with_cp(value, total, response)).to( device="cpu", dtype=dtype ) @@ -335,7 +335,7 @@ def fill_routing_replay(self, data_iterator, num_microbatches, rollout_data): batch = iterator.get_next(["rollout_routed_experts", "tokens"]) values = batch["rollout_routed_experts"] - disk_backed = [isinstance(value, (TensorRef, DiskTensorRef)) for value in values] + disk_backed = [isinstance(value, TensorRef) for value in values] if any(disk_backed) and not all(disk_backed): raise ValueError("A routing replay microbatch cannot mix disk-backed and resident route tensors.") @@ -368,15 +368,6 @@ def fill_routing_replay(self, data_iterator, num_microbatches, rollout_data): if disk_prefetcher is not None: disk_prefetcher.start() RoutingReplay.register_lazy_resource(disk_prefetcher) - rss_gib, hwm_gib = get_process_host_memory_gib() - logger.info( - "R3 lazy replay initialized: microbatches=%d layers=%d prefetch=%d rss=%.3f GiB hwm=%.3f GiB", - len(disk_prefetcher.sources), - len(layer_ids), - disk_prefetcher.prefetch_microbatches, - rss_gib, - hwm_gib, - ) del rollout_data["rollout_routed_experts"] diff --git a/vime/backends/megatron_utils/cp_utils.py b/vime/backends/megatron_utils/cp_utils.py index 83ebce614..59186f7b1 100644 --- a/vime/backends/megatron_utils/cp_utils.py +++ b/vime/backends/megatron_utils/cp_utils.py @@ -5,9 +5,9 @@ import torch.nn.functional as F from megatron.core import mpu -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef -_RoutedExpertsInput = torch.Tensor | TensorRef | DiskTensorRef +_RoutedExpertsInput = torch.Tensor | TensorRef def get_logits_and_tokens_offset_with_cp( @@ -283,8 +283,8 @@ def _new_routed_experts_buffer( reference: _RoutedExpertsInput, ) -> torch.Tensor: kwargs = { - "device": (torch.device("cpu") if isinstance(reference, (TensorRef, DiskTensorRef)) else reference.device), - "dtype": (reference.torch_dtype if isinstance(reference, (TensorRef, DiskTensorRef)) else reference.dtype), + "device": (torch.device("cpu") if isinstance(reference, TensorRef) else reference.device), + "dtype": (reference.torch_dtype if isinstance(reference, TensorRef) else reference.dtype), } if kwargs["device"].type == "cpu": return torch.empty((rows, num_layers, topk), pin_memory=True, **kwargs) diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 2d76d45cb..8eb1e7a56 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -8,7 +8,7 @@ from megatron.core import mpu from torch.utils.checkpoint import checkpoint -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.utils.distributed_utils import distributed_masked_whiten from vime.utils.misc import load_function from vime.utils.ppo_utils import ( @@ -383,11 +383,7 @@ def _build_topp_keep_mask( # Only offsets are needed in full; ids stay lazy until selecting CP rows. top_p_token_offsets = [ - ( - t.load().tolist() - if isinstance(t, (TensorRef, DiskTensorRef)) - else t.tolist() if torch.is_tensor(t) else list(t) - ) + t.load().tolist() if isinstance(t, TensorRef) else t.tolist() if torch.is_tensor(t) else list(t) for t in top_p_token_offsets ] @@ -1014,7 +1010,7 @@ def get_score_centering_terms(args, batch, logits): batch["rollout_top_p_log_probs"], strict=True, ): - if isinstance(offsets, (TensorRef, DiskTensorRef)): + if isinstance(offsets, TensorRef): offsets = offsets.load() offsets = torch.as_tensor(offsets, device="cpu", dtype=torch.long) # Coalesce adjacent row supports, preserving zigzag/allgather CP @@ -1070,7 +1066,7 @@ def local_heads(values): if mpu.get_context_parallel_world_size() == 1 else slice_log_prob_with_cp(value, total, response) ) - if isinstance(value, (TensorRef, DiskTensorRef)) + if isinstance(value, TensorRef) else value ) for value, total, response in zip(values, total_lengths, response_lengths, strict=True) diff --git a/vime/data/batch_builder.py b/vime/data/batch_builder.py index 2337eec84..780c0ca87 100644 --- a/vime/data/batch_builder.py +++ b/vime/data/batch_builder.py @@ -11,7 +11,7 @@ import ray import torch -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.data.transport import DiskPayloadRef, TrainBatchRef, pack_rollout_payload from vime.observability.rollout_data_utils import ( tensorize_rollout_data_for_training, @@ -371,7 +371,7 @@ def convert(self, samples: list[Sample] | list[list[Sample]]): ( ( sample.rollout_routed_experts - if isinstance(sample.rollout_routed_experts, (TensorRef, DiskTensorRef)) + if isinstance(sample.rollout_routed_experts, TensorRef) else sample.materialize_rollout_routed_experts() ) if present diff --git a/vime/data/tensor.py b/vime/data/tensor.py index 3fb69e83e..9baa19ad0 100644 --- a/vime/data/tensor.py +++ b/vime/data/tensor.py @@ -1,19 +1,5 @@ """Optional straw tensor references used by rollout and training.""" -from __future__ import annotations - -import errno -import math -import os -import shutil -import uuid -from dataclasses import dataclass, replace -from pathlib import Path - -import torch -from safetensors import safe_open -from safetensors.torch import load_file, save_file - try: from straw.tensor import TensorRef except ModuleNotFoundError as error: @@ -38,129 +24,3 @@ def materialize_tensor_refs(value): if isinstance(value, tuple): return tuple(materialize_tensor_refs(item) for item in value) return value - - -def retain_debug_tensor_refs(dump_data: dict, dump_path: Path) -> None: - """Give a debug dump its own file lifetime, independent of rollout cleanup.""" - directory = dump_path.parent / (dump_path.stem + "_r3_spill") / uuid.uuid4().hex - retained = {} - for sample in dump_data.get("samples", []): - for key in ("rollout_routed_experts", "rollout_topk_token_ids", "rollout_topk_log_probs"): - ref = sample.get(key) - if isinstance(ref, DiskTensorRef): - if ref.path not in retained: - retained[ref.path] = ref.link(directory / f"{len(retained)}_{Path(ref.path).name}") - sample[key] = retained[ref.path] - - -@dataclass(frozen=True) -class DiskTensorRef: - """Small, Ray-serializable reference to a single-tensor safetensors file. - - Files are written and read with the official safetensors implementation. - """ - - path: str - shape: tuple[int, ...] - dtype: str - nbytes: int - kind: str | None = None - validated: bool = False - - def __len__(self) -> int: - return self.shape[0] - - def __getitem__(self, rows: slice) -> torch.Tensor: - """Read only selected rows, so CP ranks never materialize a full sample.""" - if not isinstance(rows, slice) or rows.step not in (None, 1): - raise TypeError("Disk tensor reads require a contiguous row slice.") - start, stop, _ = rows.indices(len(self)) - if stop <= start: - return torch.empty((0, *self.shape[1:]), dtype=self.torch_dtype) - dtype_codes = { - "uint8": "U8", - "int8": "I8", - "int16": "I16", - "int32": "I32", - "int64": "I64", - "float16": "F16", - "bfloat16": "BF16", - "float32": "F32", - "float64": "F64", - "bool": "BOOL", - } - with safe_open(self.path, framework="pt", device="cpu") as file: - value = file.get_slice("tensor") - if ( - tuple(value.get_shape()) != tuple(self.shape) - or value.get_dtype() != dtype_codes.get(self.dtype) - or math.prod(self.shape) * torch.empty((), dtype=self.torch_dtype).element_size() != self.nbytes - ): - raise OSError(f"Disk tensor metadata mismatch for {self.path}") - return value[start : max(start, stop)] - - @classmethod - def write( - cls, - tensor: torch.Tensor, - path: str | Path, - *, - kind: str | None = None, - validated: bool = False, - ) -> DiskTensorRef: - tensor = tensor.detach().cpu().contiguous() - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - save_file({"tensor": tensor}, path, metadata={"kind": kind} if kind else None) - - return cls( - path=str(path), - shape=tuple(int(dim) for dim in tensor.shape), - dtype=str(tensor.dtype).removeprefix("torch."), - nbytes=tensor.numel() * tensor.element_size(), - kind=kind, - validated=validated, - ) - - @property - def torch_dtype(self) -> torch.dtype: - dtype = getattr(torch, self.dtype, None) - if not isinstance(dtype, torch.dtype): - raise ValueError(f"Unsupported tensor dtype in disk reference: {self.dtype!r}") - return dtype - - def link(self, path: str | Path) -> DiskTensorRef: - """Give immutable tensor data another lifetime without loading it into RAM.""" - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - try: - os.link(self.path, path) - except OSError as error: - if error.errno not in (errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP): - raise - # A different filesystem or one without hard links still needs a - # durable copy. Copy bytes directly, without materializing a tensor. - shutil.copyfile(self.path, path) - return replace(self, path=str(path)) - - def load(self, *, pin_memory: bool = False) -> torch.Tensor: - path = Path(self.path) - tensor = load_file(path, device="cpu").get("tensor") - if tensor is None: - raise OSError(f"Disk tensor file {path} does not contain the 'tensor' entry.") - - actual_nbytes = tensor.numel() * tensor.element_size() - if ( - tuple(tensor.shape) != tuple(self.shape) - or tensor.dtype != self.torch_dtype - or actual_nbytes != self.nbytes - ): - raise OSError( - f"Disk tensor metadata mismatch for {path}: file has " - f"shape={tuple(tensor.shape)}, dtype={tensor.dtype}, nbytes={actual_nbytes}; " - f"reference expects shape={self.shape}, dtype={self.dtype}, nbytes={self.nbytes}." - ) - - if pin_memory and not tensor.is_pinned(): - tensor = tensor.pin_memory() - return tensor diff --git a/vime/observability/rollout_data_utils.py b/vime/observability/rollout_data_utils.py index 81173e622..ee11dc004 100644 --- a/vime/observability/rollout_data_utils.py +++ b/vime/observability/rollout_data_utils.py @@ -5,7 +5,7 @@ import numpy as np import torch -from vime.data.tensor import DiskTensorRef, TensorRef, materialize_tensor_refs, retain_debug_tensor_refs +from vime.data.tensor import TensorRef, materialize_tensor_refs from vime.utils.routed_experts import validate_routed_experts_value from vime.utils.types import Sample @@ -36,7 +36,7 @@ def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: for key, dtype in _ROLLOUT_DATA_TENSOR_DTYPES.items(): if key in rollout_data: rollout_data[key] = [ - value if isinstance(value, (TensorRef, DiskTensorRef)) else _cpu_tensor(value, dtype=dtype) + value if isinstance(value, TensorRef) else _cpu_tensor(value, dtype=dtype) for value in rollout_data[key] ] @@ -61,7 +61,7 @@ def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: def validate_rollout_routed_experts_for_replay( - routed_experts: list[torch.Tensor | TensorRef | DiskTensorRef], + routed_experts: list[torch.Tensor | TensorRef], args, expected_rows: list[int] | None = None, ) -> None: @@ -83,7 +83,7 @@ def validate_rollout_id_annotated(node, depth=0): when a compact / subagent pattern is detected. "Compact" = the rollout function wraps multiple training samples from one - rollout execution into a ``list[Sample]``. In vime's convention the + rollout execution into a ``list[Sample]``. In slime's convention the default rollout shape is ``list[list[Sample]]`` (depth-2: prompt × rollout) so its leaf ``list[Sample]`` lands at depth 1 and we skip validation, preserving backward compatibility. A compact rollout adds a third level: @@ -158,5 +158,4 @@ def save_debug_rollout_data( samples = [sample.to_dict() for sample in data] dump_data = {"rollout_id": rollout_id, "samples": samples} - retain_debug_tensor_refs(dump_data, path) torch.save(materialize_tensor_refs(dump_data), path) diff --git a/vime/observability/rollout_metrics.py b/vime/observability/rollout_metrics.py index bda44f6d2..0b63abf27 100644 --- a/vime/observability/rollout_metrics.py +++ b/vime/observability/rollout_metrics.py @@ -4,7 +4,7 @@ import numpy as np import torch -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.observability import logging_utils from vime.observability.metric_utils import ( compute_pass_rate, @@ -175,7 +175,7 @@ def _compute_top_p_kept_vocab_metrics(all_samples: list[Sample]): offsets = sample.rollout_top_p_token_offsets if offsets is None or sample.response_length == 0: continue - if isinstance(offsets, (TensorRef, DiskTensorRef)): + if isinstance(offsets, TensorRef): offsets = offsets.load() offsets = torch.as_tensor(offsets, dtype=torch.int64) if offsets.numel() == 0: diff --git a/vime/observability/train_data_utils.py b/vime/observability/train_data_utils.py index d86dbe3e6..7fcad509a 100644 --- a/vime/observability/train_data_utils.py +++ b/vime/observability/train_data_utils.py @@ -5,7 +5,7 @@ import torch -from vime.data.tensor import DiskTensorRef, TensorRef, materialize_tensor_refs +from vime.data.tensor import TensorRef, materialize_tensor_refs logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def restore_context_parallel_fields_to_cpu( full_values = [] if keep_restored else None for value, total_length, response_length in zip(values, total_lengths, response_lengths, strict=True): # Disk references and allgather-CP heads retain full response rows. - if isinstance(value, (TensorRef, DiskTensorRef)) or allgather_cp: + if isinstance(value, TensorRef) or allgather_cp: full_value = value if len(full_value) != response_length: raise ValueError(f"Full sampler head field {key!r} must have {response_length} rows.") diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 4678abdb9..4bd1e9cc8 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -4,11 +4,9 @@ from pathlib import Path from typing import Any -import psutil import ray from vime.data.batch_builder import BatchBuilder -from vime.data.tensor import DiskTensorRef from vime.data.transport import ( DiskPayloadRef, accept_raw_rollout, @@ -28,7 +26,6 @@ from vime.rollout.sample_hooks import set_current_rollout_id from vime.utils.health_monitor import RolloutHealthMonitor from vime.utils.http_utils import init_http_client -from vime.utils.memory_utils import get_process_host_memory_gib from vime.utils.misc import load_function from vime.utils.staleness import fully_async_metrics_enabled @@ -98,7 +95,6 @@ def __init__(self, args, pg, *, restore_plan=None): runtime_env={"env_vars": add_default_ray_env_vars()}, ).remote() self.rollout_id = -1 - self._active_routed_experts_rollouts: set[int] = set() self._health_monitors = [] if not self.args.debug_train_only and self.args.use_fault_tolerance: @@ -166,8 +162,6 @@ def resume_rollout_admission(self, was_paused): def dispose(self): for monitor in self._health_monitors: monitor.stop() - for rollout_id in list(self._active_routed_experts_rollouts): - self.cleanup_rollout_data(rollout_id) if close := getattr(self.data_source, "close", None): close() if self._owns_controller: @@ -182,13 +176,6 @@ def dispose(self): ray.get([engine.shutdown.remote() for engine in engines]) logging_utils.finish_tracking(self.args) - def cleanup_rollout_data(self, rollout_id: int) -> None: - from vime.utils.routed_experts import cleanup_routed_experts_rollout - - cleanup_routed_experts_rollout(self.args, rollout_id) - if active := getattr(self, "_active_routed_experts_rollouts", None): - active.discard(rollout_id) - @property def server(self) -> Any | None: """Default server (first model). For backward compatibility.""" @@ -239,26 +226,6 @@ def generate(self, rollout_id): if self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: self._try_ci_fault_injection() data, metrics = self._get_rollout_data(rollout_id=rollout_id) - disk_routes = [ - value - for sample in data - for value in (sample.rollout_routed_experts, sample.rollout_topk_token_ids, sample.rollout_topk_log_probs) - if isinstance(value, DiskTensorRef) - ] - if disk_routes: - self._active_routed_experts_rollouts.add(rollout_id) - rss_gib, hwm_gib = get_process_host_memory_gib() - logger.info( - "R3 manager spill profile: rollout_id=%d samples=%d files=%d disk_bytes=%.3f GiB " - "rss=%.3f GiB hwm=%.3f GiB host_available=%.3f GiB", - rollout_id, - len(data), - len(disk_routes), - sum(ref.nbytes for ref in disk_routes) / 1024**3, - rss_gib, - hwm_gib, - psutil.virtual_memory().available / 1024**3, - ) save_debug_rollout_data( self.args.save_debug_rollout_data, data, @@ -313,7 +280,6 @@ def save(self, rollout_id): def training_completed(self, rollout_id): self.batch_builder.training_completed(rollout_id) - self.cleanup_rollout_data(rollout_id) def load(self, rollout_id=None): from vime.data.checkpoint import SourceRestore diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index c9b82bef1..af82a657f 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -1181,27 +1181,12 @@ def add_algo_arguments(parser): default=False, help="The rollout routing replay technique from https://arxiv.org/abs/2510.11370", ) - parser.add_argument( - "--rollout-routed-experts-store-dir", - type=str, - default=None, - help=( - "Shared filesystem directory used by routed-experts sample spill hooks. " - "All rollout and training nodes must be able to access this path." - ), - ) parser.add_argument( "--routing-replay-prefetch-microbatches", type=int, default=1, help="Number of upcoming disk-backed R3 microbatches to prefetch into CPU memory.", ) - parser.add_argument( - "--keep-rollout-routed-experts-files", - action="store_true", - default=False, - help="Keep disk-backed routed-experts files after all trainers finish the rollout.", - ) parser.add_argument( "--use-opsm", action="store_true", @@ -2289,12 +2274,6 @@ def vime_validate_args(args): if args.routing_replay_prefetch_microbatches < 0: raise ValueError("--routing-replay-prefetch-microbatches must be non-negative") - disk_spill = "vime.utils.routed_experts.spill_routed_experts" in ( - getattr(args, "rollout_sample_hook_path", None) or [] - ) - if disk_spill and not getattr(args, "rollout_routed_experts_store_dir", None): - raise ValueError("vime.utils.routed_experts.spill_routed_experts requires --rollout-routed-experts-store-dir.") - if args.custom_config_path: with open(args.custom_config_path) as f: data = yaml.safe_load(f) or {} @@ -2371,15 +2350,4 @@ def vime_validate_args(args): if args.eval_function_path is None: args.eval_function_path = args.rollout_function_path - if ( - not args.debug_train_only - and "fully_async" in args.rollout_function_path - and disk_spill - and not getattr(args, "keep_rollout_routed_experts_files", False) - ): - raise ValueError( - "fully-async rollout with routed-experts disk spill requires " - "--keep-rollout-routed-experts-files because in-flight samples can cross rollout boundaries." - ) - return restore_plan diff --git a/vime/utils/memory_utils.py b/vime/utils/memory_utils.py index 1fc9f6e71..2ed637f9e 100644 --- a/vime/utils/memory_utils.py +++ b/vime/utils/memory_utils.py @@ -1,8 +1,6 @@ import ctypes import gc import logging -import resource -import sys from functools import lru_cache import psutil @@ -42,15 +40,6 @@ def reset_cuda_stack_size() -> None: logger.info("Reset CUDA stack limit after offload: %d -> 1024 bytes", previous.value) -def get_process_host_memory_gib(process: psutil.Process | None = None) -> tuple[float, float]: - """Return current and peak resident host memory in GiB.""" - process = process or psutil.Process() - rss_gib = process.memory_info().rss / 1024**3 - max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - hwm_gib = max_rss / (1024**2 if sys.platform != "darwin" else 1024**3) - return rss_gib, hwm_gib - - def clear_memory(clear_host_memory: bool = False): accelerator.synchronize() gc.collect() diff --git a/vime/utils/routed_experts.py b/vime/utils/routed_experts.py index 7265f01fb..80ab3c603 100644 --- a/vime/utils/routed_experts.py +++ b/vime/utils/routed_experts.py @@ -1,18 +1,14 @@ from __future__ import annotations import logging -import shutil import threading -import uuid from concurrent.futures import Future, ThreadPoolExecutor -from pathlib import Path from typing import Any import torch -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.utils import accelerator -from vime.utils.types import Sample logger = logging.getLogger(__name__) @@ -106,7 +102,7 @@ def validate_routed_experts_tensor( def validate_routed_experts_value( - value: torch.Tensor | TensorRef | DiskTensorRef, + value: torch.Tensor | TensorRef, args, *, sample_index: int | None, @@ -126,104 +122,12 @@ def validate_routed_experts_value( ) return - tensor = value.load() if isinstance(value, (TensorRef, DiskTensorRef)) else value + tensor = value.load() if isinstance(value, TensorRef) else value validate_routed_experts_tensor(tensor, args, sample_index=sample_index, expected_rows=expected_rows) -def link_routed_experts_for_rollout(args, sample: Sample, rollout_id: int | None) -> None: - """Retain spilled routes in a rollout directory using only file references.""" - from vime.utils.score_centering import spill_sampler_topk - - spill_sampler_topk(args, sample, rollout_id) - ref = sample.rollout_routed_experts - if not isinstance(ref, DiskTensorRef): - return - store_dir = getattr(args, "rollout_routed_experts_store_dir", None) - if not store_dir: - raise ValueError("R3 file retention requires --rollout-routed-experts-store-dir") - rollout_component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" - output_dir = Path(store_dir) / f"rollout_{rollout_component}" - if Path(ref.path).parent.resolve() == output_dir.resolve(): - if not Path(ref.path).is_file(): - raise FileNotFoundError(f"R3 disk reference no longer exists: {ref.path}") - return - sample_component = "none" if sample.index is None else str(sample.index) - output_path = output_dir / f"sample_{sample_component}_{uuid.uuid4().hex}.safetensors" - sample.rollout_routed_experts = ref.link(output_path) - - -async def spill_routed_experts( - args, - sample: Sample, - *, - rollout_id: int | None = None, - evaluation: bool = False, -) -> Sample: - """Sample hook that replaces rollout routes with a shared-filesystem reference. - - The write intentionally stays on the rollout event-loop thread. This applies - backpressure at response completion instead of allowing a large queue of - completed samples to retain their routed-expert tensors in host memory. - """ - - if evaluation: - sample.rollout_routed_experts = None - return sample - # Aborted partial-rollout samples return to the data buffer and resume in a - # later rollout, after this rollout's spill directory has been deleted, so - # their routes must stay resident and travel with the sample instead. - # Keep missing routes as None too: a waiting-queue abort can have an empty - # loss_mask, and zero placeholders would be counted as an already captured - # prefix by routed_experts_start_len on retry. - if sample.status == Sample.Status.ABORTED: - return sample - - store_dir = getattr(args, "rollout_routed_experts_store_dir", None) - if not store_dir: - raise ValueError("spill_routed_experts requires --rollout-routed-experts-store-dir") - - from vime.utils.score_centering import spill_sampler_topk - - spill_sampler_topk(args, sample, rollout_id) - if isinstance(sample.rollout_routed_experts, DiskTensorRef): - validate_routed_experts_value(sample.rollout_routed_experts, args, sample_index=sample.index) - link_routed_experts_for_rollout(args, sample, rollout_id) - return sample - - if sample.rollout_routed_experts is None: - if sample.loss_mask is None or any(sample.loss_mask): - return sample - dtype = torch.uint8 if args.num_experts <= 256 else torch.int32 - experts = torch.zeros((max(0, len(sample.tokens) - 1), args.num_layers, args.moe_router_topk), dtype=dtype) - else: - experts = sample.materialize_rollout_routed_experts(replace=False) - validate_routed_experts_tensor(experts, args, sample_index=sample.index) - - rollout_component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" - output_dir = Path(store_dir) / f"rollout_{rollout_component}" - sample_component = "none" if sample.index is None else str(sample.index) - output_path = output_dir / f"sample_{sample_component}_{uuid.uuid4().hex}.safetensors" - sample.rollout_routed_experts = DiskTensorRef.write( - experts, - output_path, - kind="rollout_routed_experts", - validated=True, - ) - return sample - - -def cleanup_routed_experts_rollout(args, rollout_id: int) -> None: - store_dir = getattr(args, "rollout_routed_experts_store_dir", None) - if not store_dir or getattr(args, "keep_rollout_routed_experts_files", False): - return - path = Path(store_dir) / f"rollout_{int(rollout_id):08d}" - if path.exists(): - shutil.rmtree(path) - logger.info("Removed routed-experts spill directory %s", path) - - def materialize_routed_experts(value: Any, *, pin_memory: bool = False) -> torch.Tensor: - if isinstance(value, (TensorRef, DiskTensorRef)): + if isinstance(value, TensorRef): return value.load(pin_memory=pin_memory) if torch.is_tensor(value): return value @@ -292,7 +196,7 @@ class RoutedExpertsMicrobatch: def __init__( self, - values: list[TensorRef | DiskTensorRef], + values: list[TensorRef], tokens: list[torch.Tensor], *, consumer_count: int, @@ -314,8 +218,7 @@ def _load_and_prepare(self) -> torch.Tensor: # Read only the CP/TP rows owned by this rank in the layout helper. tensors = [ - (value if isinstance(value, (TensorRef, DiskTensorRef)) else materialize_routed_experts(value)) - for value in self.values + (value if isinstance(value, TensorRef) else materialize_routed_experts(value)) for value in self.values ] return prepare_routed_experts_for_routing_replay( tensors, diff --git a/vime/utils/score_centering.py b/vime/utils/score_centering.py index f9cbd50e8..eb8fd99f7 100644 --- a/vime/utils/score_centering.py +++ b/vime/utils/score_centering.py @@ -1,13 +1,11 @@ """Score Centering, arXiv:2609.20807, equations (9), (12), and (14).""" import math -import uuid -from pathlib import Path import numpy as np import torch -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.utils.ppo_utils import get_pg_loss_type, importance_weights SAMPLER_TOPK_FIELDS = ("rollout_topk_token_ids", "rollout_topk_log_probs") @@ -242,7 +240,7 @@ def validate_sampler_topk(sample, k): if ids is None or logps is None: raise ValueError("Score centering requires rollout_topk_token_ids and rollout_topk_log_probs on every sample.") shape = (sample.response_length, k) - disk = [isinstance(value, (TensorRef, DiskTensorRef)) for value in (ids, logps)] + disk = [isinstance(value, TensorRef) for value in (ids, logps)] if any(disk): if not all(disk): raise ValueError("Sampler top-k ids and logprobs must both be disk references.") @@ -251,10 +249,7 @@ def validate_sampler_topk(sample, k): raise ValueError(f"Sampler top-k disk reference must have shape {shape} and dtype {dtype}.") if value.kind != key: raise ValueError(f"Unexpected sampler top-k disk reference kind: {value.kind}") - if isinstance(value, TensorRef): - value.validate() - elif not Path(value.path).is_file(): - raise FileNotFoundError(value.path) + value.validate() if ids.validated and logps.validated: return else: @@ -267,39 +262,3 @@ def validate_sampler_topk(sample, k): _validate_head_arrays( np.asarray(ids[start : start + 1024]), np.asarray(logps[start : start + 1024], dtype=np.float64) ) - - -def spill_sampler_topk(args, sample, rollout_id): - """Keep sampler heads beside routes under the existing R3 spill directory.""" - if not getattr(args, "use_score_centering", False): - return - if getattr(args, "rollout_top_p", 1.0) < 1: - return - store_dir = getattr(args, "rollout_routed_experts_store_dir", None) - if not store_dir: - if any(isinstance(getattr(sample, key), DiskTensorRef) for key in SAMPLER_TOPK_FIELDS): - raise ValueError("Sampler top-k file retention requires --rollout-routed-experts-store-dir") - return - validate_sampler_topk(sample, args.score_centering_top_k) - component = "unknown" if rollout_id is None else f"{int(rollout_id):08d}" - directory = Path(store_dir) / f"rollout_{component}" - for key, dtype in zip(SAMPLER_TOPK_FIELDS, (torch.int32, torch.float32), strict=True): - value = getattr(sample, key) - if isinstance(value, DiskTensorRef): - if Path(value.path).parent.resolve() != directory.resolve(): - setattr( - sample, key, value.link(directory / f"sample_{sample.index}_{key}_{uuid.uuid4().hex}.safetensors") - ) - continue - # Ray may return read-only arrays; writing safetensors only reads them. - array = np.asarray(value) - if not array.flags.writeable: - array = array.copy() - tensor = torch.as_tensor(array, dtype=dtype) - ref = DiskTensorRef.write( - tensor, - directory / f"sample_{sample.index}_{key}_{uuid.uuid4().hex}.safetensors", - kind=key, - validated=True, - ) - setattr(sample, key, ref) diff --git a/vime/utils/types.py b/vime/utils/types.py index 1d19c2793..bcdd71450 100644 --- a/vime/utils/types.py +++ b/vime/utils/types.py @@ -6,7 +6,7 @@ import numpy as np import torch -from vime.data.tensor import DiskTensorRef, TensorRef +from vime.data.tensor import TensorRef from vime.utils.misc import decode_int32_meta_array _TOP_P_TOKEN_ID_META_KEYS = ("top_p_token_ids", "top_p_kept_token_ids") @@ -45,9 +45,9 @@ def _merge_rollout_top_p_token_data( token_ids: torch.Tensor, offsets: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: - if isinstance(base_token_ids, (TensorRef, DiskTensorRef)): + if isinstance(base_token_ids, TensorRef): base_token_ids = base_token_ids.load() - if isinstance(base_offsets, (TensorRef, DiskTensorRef)): + if isinstance(base_offsets, TensorRef): base_offsets = base_offsets.load() base_token_ids = torch.as_tensor([] if base_token_ids is None else base_token_ids, dtype=torch.int32).reshape(-1) base_offsets = torch.as_tensor([0] if base_offsets is None else base_offsets, dtype=torch.int32).reshape(-1) @@ -68,9 +68,9 @@ def _pad_rollout_top_p_offsets( if num_tokens < 0: raise ValueError(f"num_tokens must be non-negative, got {num_tokens}.") # Masked tool tokens only extend offsets; keep the immutable ids shared. - if not isinstance(token_ids, (TensorRef, DiskTensorRef)): + if not isinstance(token_ids, TensorRef): token_ids = torch.as_tensor(token_ids, dtype=torch.int32).reshape(-1) - if isinstance(offsets, (TensorRef, DiskTensorRef)): + if isinstance(offsets, TensorRef): offsets = offsets.load() offsets = torch.as_tensor(offsets, dtype=torch.int32).reshape(-1) if offsets.numel() == 0: @@ -98,7 +98,7 @@ def _to_float_list(values) -> list[float] | None: def _numel(value) -> int: - if isinstance(value, (TensorRef, DiskTensorRef)): + if isinstance(value, TensorRef): return math.prod(value.shape) return int(torch.as_tensor(value).reshape(-1).numel()) @@ -132,14 +132,14 @@ class Sample: loss_mask: list[int] | None = None weight_versions: list[str] = field(default_factory=list) rollout_log_probs: list[float] | None = None # Log probabilities from rollout engine - rollout_topk_token_ids: np.ndarray | torch.Tensor | list[list[int]] | TensorRef | DiskTensorRef | None = None - rollout_topk_log_probs: np.ndarray | torch.Tensor | list[list[float]] | TensorRef | DiskTensorRef | None = None + rollout_topk_token_ids: np.ndarray | torch.Tensor | list[list[int]] | TensorRef | None = None + rollout_topk_log_probs: np.ndarray | torch.Tensor | list[list[float]] | TensorRef | None = None # Ragged top-p nucleus token ids replayed from rollout sampling. For response # token i, kept ids are rollout_top_p_token_ids[offsets[i]:offsets[i + 1]]. rollout_top_p_token_ids: list[int] | torch.Tensor | TensorRef | None = None rollout_top_p_token_offsets: list[int] | torch.Tensor | TensorRef | None = None rollout_top_p_log_probs: np.ndarray | torch.Tensor | list[float] | TensorRef | None = None - rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | TensorRef | DiskTensorRef | None = ( + rollout_routed_experts: list[list[int]] | list[torch.Tensor] | torch.Tensor | TensorRef | None = ( None # Routed experts from rollout engine ) remove_sample: bool = False @@ -305,7 +305,7 @@ def append_response_tokens( k = args.score_centering_top_k for key in ("rollout_topk_token_ids", "rollout_topk_log_probs"): value = getattr(self, key) - if isinstance(value, (TensorRef, DiskTensorRef)): + if isinstance(value, TensorRef): setattr(self, key, value.load().numpy()) if trainable: ids, logps = extract_sampler_topk(meta_info or {}, len(tokens), k) @@ -350,7 +350,7 @@ def append_response_tokens( torch.as_tensor(offsets), ) previous_logps = self.rollout_top_p_log_probs - if isinstance(previous_logps, (TensorRef, DiskTensorRef)): + if isinstance(previous_logps, TensorRef): previous_logps = previous_logps.load() self.rollout_top_p_log_probs = np.concatenate((np.asarray(previous_logps), logps)) # Already appended the validated replay data above. @@ -499,7 +499,7 @@ def _append_rollout_routed_experts_chunk(self, routed_experts: torch.Tensor) -> existing = self.rollout_routed_experts if existing is None: self.rollout_routed_experts = routed_experts - elif isinstance(existing, (TensorRef, DiskTensorRef)): + elif isinstance(existing, TensorRef): self.rollout_routed_experts = [existing.load(), routed_experts] elif isinstance(existing, list) and all(torch.is_tensor(item) for item in existing): existing.append(routed_experts) @@ -510,7 +510,7 @@ def get_rollout_routed_experts_length(self) -> int: routed_experts = self.rollout_routed_experts if routed_experts is None: return 0 - if isinstance(routed_experts, (TensorRef, DiskTensorRef)): + if isinstance(routed_experts, TensorRef): return int(routed_experts.shape[0]) if torch.is_tensor(routed_experts): return int(routed_experts.shape[0]) @@ -526,7 +526,7 @@ def materialize_rollout_routed_experts(self, *, replace: bool = True) -> torch.T routed_experts = self.rollout_routed_experts if routed_experts is None: return None - if isinstance(routed_experts, (TensorRef, DiskTensorRef)): + if isinstance(routed_experts, TensorRef): tensor = routed_experts.load() elif torch.is_tensor(routed_experts): tensor = routed_experts.reshape(*routed_experts.shape) @@ -558,7 +558,7 @@ def _validate_response_metadata_lengths(self): raise ValueError("rollout top-p replay must include both token ids and offsets.") offsets = self.rollout_top_p_token_offsets - if isinstance(offsets, (TensorRef, DiskTensorRef)): + if isinstance(offsets, TensorRef): offsets = offsets.load() offsets = torch.as_tensor(offsets, dtype=torch.int32).reshape(-1) if offsets.numel() != self.response_length + 1: From 9cd95d0e7fca4e6d4b42a2ca0737188cef593a08 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 6 Oct 2026 09:07:56 +0000 Subject: [PATCH 33/58] [CI] Inherit the available file descriptor limit for GPU jobs Signed-off-by: aoshen02 --- .buildkite/gpu_suites.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index 2dba100c7..13eb82234 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -128,6 +128,8 @@ def gpu_step(suite: str, test_file: str, num_gpus: int, extra_args: str, env: di 'PR="${BUILDKITE_PULL_REQUEST:-false}"', '[ "$PR" = "false" ] && PR="non-pr"', 'export GITHUB_COMMIT_NAME="${BUILDKITE_COMMIT}_${PR}"', + 'ulimit -n "$(ulimit -Hn)"', + 'echo "RLIMIT_NOFILE=$(ulimit -Sn)/$(ulimit -Hn)"', "pip install -e . --no-deps --break-system-packages", f"python tests/ci/gpu_lock_exec.py --count {num_gpus} -- " f"python tests/{test_file}{' ' + extra_args if extra_args else ''}", From 6421c01c2d0a88dbad9d1c2223e22df517cac808 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 6 Oct 2026 12:29:19 +0000 Subject: [PATCH 34/58] [Sync] Backport terminal token-stream finish reasons Signed-off-by: aoshen02 --- docker/patch/latest/vllm.patch | 10 ++++++ tests/test_score_centering_transport.py | 44 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index fe8a9ce1c..2fc6421a5 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -30,6 +30,16 @@ index 59fda2dbf8c..862413c2da4 100644 kv_transfer_params=final_res.kv_transfer_params, ec_transfer_params=final_res.ec_transfer_params, ) +@@ -464,8 +465,6 @@ class ServingTokens(GenerateBaseServing): + # Still emit a terminal empty chunk while prompt metadata + # is pending, so zero-token completions deliver it. +- if not delta_token_ids and ( +- finish_reason is None or prompt_token_ids is None +- ): ++ if not delta_token_ids and finish_reason is None: + continue + + if sampling_params.logprobs is not None: @@ -487,6 +488,7 @@ class ServingTokens(GenerateBaseServing): chunk = GenerateStreamResponse( diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index 912a28a51..324d79ddf 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -1,6 +1,7 @@ """Sampler heads survive the real rollout-manager, DP and microbatch boundaries.""" import asyncio +import json import sys import types from types import SimpleNamespace @@ -18,6 +19,49 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("finish_reason", ["abort", "length"]) +@pytest.mark.parametrize("return_token_ids", [False, True]) +@pytest.mark.parametrize("terminal_only", [False, True]) +def test_tito_stream_preserves_empty_finish_reason(finish_reason, return_token_ids, terminal_only): + serving_module = pytest.importorskip("vllm.entrypoints.scale_out.token_in_token_out.serving") + outputs = pytest.importorskip("vllm.outputs") + from vllm.sampling_params import SamplingParams + + serving = object.__new__(serving_module.ServingTokens) + serving.enable_log_outputs = False + serving.enable_prompt_tokens_details = False + serving.enable_per_request_metrics = False + serving.request_logger = None + request = SimpleNamespace( + sampling_params=SamplingParams(), + stream_options=None, + return_token_ids=return_token_ids, + _response_mm_placeholders=None, + kv_transfer_params=None, + ) + + async def results(): + chunks = [([], finish_reason)] if terminal_only else [([42], None), ([], finish_reason)] + for token_ids, reason in chunks: + completion = outputs.CompletionOutput( + index=0, text="", token_ids=token_ids, cumulative_logprob=None, logprobs=None, finish_reason=reason + ) + yield outputs.RequestOutput("probe", [1, 2, 3], None, None, [completion], reason is not None) + + async def collect(): + return [ + json.loads(chunk[6:]) + async for chunk in serving.serve_tokens_stream_generator( + request, results(), "probe", "model", SimpleNamespace() + ) + if chunk.startswith("data: {") + ] + + chunks = asyncio.run(collect()) + assert chunks[-1]["choices"][0]["finish_reason"] == finish_reason + assert chunks[-1]["choices"][0]["token_ids"] == [] + + @pytest.mark.parametrize("aggregate", [False, True]) def test_sampling_mask_logprobs_survive_output_coalescing(aggregate): outputs = pytest.importorskip("vllm.outputs") From f32a36f1d8f811260f92c1901e808c35ad13c4ea Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Tue, 6 Oct 2026 12:30:21 +0000 Subject: [PATCH 35/58] [Test] Cover terminal output with real prompt token metadata Signed-off-by: aoshen02 --- tests/test_score_centering_transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index 324d79ddf..05beb0c7b 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -46,7 +46,7 @@ async def results(): completion = outputs.CompletionOutput( index=0, text="", token_ids=token_ids, cumulative_logprob=None, logprobs=None, finish_reason=reason ) - yield outputs.RequestOutput("probe", [1, 2, 3], None, None, [completion], reason is not None) + yield outputs.RequestOutput("probe", None, [1, 2, 3], None, [completion], reason is not None) async def collect(): return [ From f15322603e291fe600f1b15a9f8ed7f27aed41d4 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 8 Oct 2026 01:13:42 +0000 Subject: [PATCH 36/58] [Sync] Mirror latest Slime and validate nightly Python/Rust adapters Mirror Slime through 2f231865 with approved native overlays and Source test registration. Pin validated43b nightly, install tested tensor-dump/tracing Python and Rust patches, and parse native integer-ID top-k responses. Preserve original Straw assertions and saving policy. Keep profiler deferred and latest unchanged. AI assistance: OpenAI Codex. Scoped validation171CPU tests, full precommit and11patch/98path installation proof pass; full remote CI and remaining candidate GPU validation pending. Signed-off-by: aoshen02 --- .buildkite/gpu_suites.py | 39 + .buildkite/pipeline.yml | 5 +- .gitignore | 4 + docker/Dockerfile | 42 +- .../patch/latest/vllm-aux-output-reset.patch | 21 - .../latest/vllm-fine-grained-tracing.patch | 626 ++++ .../patch/latest/vllm-generation-health.patch | 85 + .../vllm-inflight-queue-diagnostics.patch | 79 +- .../latest/vllm-pd-request-metrics.patch | 190 +- docker/patch/latest/vllm-pull_weights.patch | 38 +- .../patch/latest/vllm-retained-serving.patch | 16 + docker/patch/latest/vllm-rust-frontend.patch | 1872 ++++++++++ .../latest/vllm-rust-observability.patch | 1780 ++++++++++ .../patch/latest/vllm-score-centering.patch | 363 +- docker/patch/latest/vllm-tensor-dump.patch | 3130 +++++++++++++++++ docker/patch/latest/vllm.patch | 224 +- docs/en/advanced/delta-weight-sync.md | 30 +- docs/en/advanced/fault-tolerance.md | 145 +- docs/en/advanced/straw.md | 39 +- docs/en/developer_guide/ci.md | 25 + docs/en/get_started/customization.md | 10 +- docs/en/get_started/quick_start.md | 33 +- docs/en/get_started/usage.md | 50 +- docs/zh/advanced/delta-weight-sync.md | 16 +- docs/zh/advanced/fault-tolerance.md | 147 +- docs/zh/advanced/straw.md | 14 +- docs/zh/developer_guide/ci.md | 25 + docs/zh/get_started/customization.md | 7 +- docs/zh/get_started/quick_start.md | 32 +- docs/zh/get_started/usage.md | 36 +- examples/tau-bench/trainable_agents.py | 28 +- requirements.txt | 2 +- tests/rollout_health_test_helpers.py | 81 + tests/test_disk_delta_checkpoint_index.py | 114 + tests/test_disk_delta_recovery.py | 150 + tests/test_distributed_rollout.py | 75 +- tests/test_fully_async_rollout.py | 68 +- tests/test_megatron_argument_validation.py | 99 +- tests/test_optional_straw.py | 19 +- tests/test_pipeline_rl.py | 1 + tests/test_queue_sample_codec.py | 36 + tests/test_qwen2.5_0.5B_rollout_health.py | 93 + tests/test_qwen2.5_0.5B_score_centering.py | 2 +- tests/test_qwen2.5_0.5B_training_recovery.py | 417 +++ tests/test_qwen3_30B_A3B_training_recovery.py | 59 + tests/test_rollout_manager_recovery.py | 403 +++ tests/test_rollout_straw_transport.py | 243 +- tests/test_score_centering.py | 50 +- tests/test_score_centering_transport.py | 301 +- tests/test_stateless_adam.py | 70 + tests/test_training_recovery.py | 819 +++++ tests/test_vllm_rollout.py | 34 +- tests/test_vllm_server_control.py | 131 +- tests/training_recovery_test_helpers.py | 96 + tests/utils/test_vllm_engine.py | 15 +- train.py | 54 +- vime/agent/adapters/common.py | 19 +- vime/backends/megatron_utils/actor.py | 11 +- vime/backends/megatron_utils/checkpoint.py | 31 +- .../megatron_utils/hf_checkpoint_saver.py | 10 +- vime/backends/megatron_utils/initialize.py | 3 - vime/backends/megatron_utils/model.py | 15 +- .../update_weight_from_disk_delta.py | 95 +- .../update_weight_from_tensor.py | 4 +- vime/backends/vllm_utils/engine_group.py | 75 +- vime/backends/vllm_utils/server_control.py | 63 +- vime/backends/vllm_utils/vllm_engine.py | 58 +- vime/data/batch_builder.py | 223 +- vime/data/checkpoint.py | 56 +- vime/data/codec.py | 26 +- vime/data/data_source.py | 82 +- vime/data/queue_data_source.py | 172 +- vime/data/transport.py | 29 +- vime/ray/actor_group.py | 22 +- vime/ray/placement_group.py | 156 +- vime/ray/rollout.py | 377 +- vime/ray/serving.py | 334 ++ vime/ray/training_recovery.py | 450 +++ vime/rollout/fully_async_distributed.py | 42 +- vime/rollout/fully_async_rollout.py | 137 +- vime/rollout/vllm_rollout.py | 43 +- vime/rollout/vllm_streaming_rollout.py | 21 +- vime/utils/arguments.py | 63 +- vime/utils/cleanup.py | 39 + vime/utils/disk_delta.py | 39 +- vime/utils/external_utils/command_utils.py | 38 +- vime/utils/health_monitor.py | 110 +- vime_plugins/models/hf_attention.py | 35 +- 88 files changed, 13843 insertions(+), 1618 deletions(-) delete mode 100644 docker/patch/latest/vllm-aux-output-reset.patch create mode 100644 docker/patch/latest/vllm-fine-grained-tracing.patch create mode 100644 docker/patch/latest/vllm-generation-health.patch create mode 100644 docker/patch/latest/vllm-retained-serving.patch create mode 100644 docker/patch/latest/vllm-rust-frontend.patch create mode 100644 docker/patch/latest/vllm-rust-observability.patch create mode 100644 docker/patch/latest/vllm-tensor-dump.patch create mode 100644 tests/rollout_health_test_helpers.py create mode 100644 tests/test_disk_delta_checkpoint_index.py create mode 100644 tests/test_disk_delta_recovery.py create mode 100644 tests/test_qwen2.5_0.5B_rollout_health.py create mode 100644 tests/test_qwen2.5_0.5B_training_recovery.py create mode 100644 tests/test_qwen3_30B_A3B_training_recovery.py create mode 100644 tests/test_rollout_manager_recovery.py create mode 100644 tests/test_training_recovery.py create mode 100644 tests/training_recovery_test_helpers.py create mode 100644 vime/ray/serving.py create mode 100644 vime/ray/training_recovery.py create mode 100644 vime/utils/cleanup.py diff --git a/.buildkite/gpu_suites.py b/.buildkite/gpu_suites.py index 13eb82234..4f4ad369b 100644 --- a/.buildkite/gpu_suites.py +++ b/.buildkite/gpu_suites.py @@ -68,6 +68,45 @@ ("test_qwen2.5_0.5B_fanout_short.py", 4, "", {}), ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport nccl", {}), ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport disk", {}), + ("test_qwen2.5_0.5B_rollout_health.py", 4, "--crash-mode server", {}), + ("test_qwen2.5_0.5B_rollout_health.py", 4, "--crash-mode actor", {}), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode straw --failure-rollout 1 --save-interval 3 --no-fault-tolerance", + {}, + ), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode straw --failure-rollout 1 --save-interval 3 --kill-manager --no-fault-tolerance", + {}, + ), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode straw --failure-rollout 1 --role-config --kill-manager --manager-crash-phase training", + {}, + ), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode debug --failure-rollout 1 --save-interval 3 --kill-manager --no-fault-tolerance", + {}, + ), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode straw --failure-rollout 1 --save-interval 1 --weight-sync disk-delta --kill-manager", + {}, + ), + ( + "test_qwen2.5_0.5B_training_recovery.py", + 4, + "--mode straw --failure-rollout 1 --pd --wedge-engine --kill-manager", + {}, + ), + ("test_qwen3_30B_A3B_training_recovery.py", 8, "", {}), ("test_qwen2.5_0.5B_pipeline_rl.py", 4, "--transport nccl --flush-cache-interval 2", {}), ("test_qwen2.5_0.5B_score_centering.py", 2, "", {}), ("test_qwen2.5_0.5B_debug_train_dump_e2e.py", 8, "", {}), diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 496824173..99c6bd93a 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -152,6 +152,7 @@ steps: tests/test_cuda_stack_offload.py \ tests/test_deep_ep_tms_patch.py \ tests/test_distributed_rollout.py \ + tests/test_disk_delta_recovery.py \ tests/test_discounted_returns.py \ tests/test_eval_config.py \ tests/test_filter_long_prompt.py \ @@ -187,6 +188,8 @@ steps: tests/test_vllm_rollout.py \ tests/test_agent/test_sandbox_exec_and_wait.py \ tests/test_stateless_adam.py \ + tests/test_training_recovery.py \ + tests/test_rollout_manager_recovery.py \ tests/test_tau_bench_token_delta.py \ tests/test_train_data_utils.py \ tests/test_update_weight_factory.py \ @@ -239,7 +242,7 @@ steps: value: short - label: "run-ci-vllm-config — 8 GPU, 4 tests" value: vllm-config - - label: "run-ci-megatron — up to 8 GPU, 26 runs" + - label: "run-ci-megatron — up to 8 GPU, 35 runs" value: megatron - label: "run-ci-vime-customized — 1–8 GPU, 5 tests" value: vime-customized diff --git a/.gitignore b/.gitignore index c270e3a47..40dfe47ff 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,7 @@ glm/ _examples_synced/ .env .DS_Store + +# Local straw verification output and cross-project coordination +/benchmark-results/ +/STRAW_COORDINATION.md diff --git a/docker/Dockerfile b/docker/Dockerfile index 38161353e..8630df709 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,9 +1,26 @@ -ARG BASE_IMAGE=vllm/vllm-openai:nightly +ARG BASE_IMAGE=vllm/vllm-openai@sha256:f8696a5176c1fb61e025b0772505674a3e092c25597eb26117003945b5121e8d +FROM rust:1.95-bookworm AS vllm-rust-frontend +ENV CARGO_BUILD_JOBS=4 +ARG PATCH_VERSION=latest +ARG VLLM_COMMIT=43b4aaea3e40e20ef53ce2bfecfa1b72060bf2b5 +ARG VLLM_RS_BUILD_VERSION=0.31.1rc1.dev23+g43b4aaea3 +COPY docker/patch/${PATCH_VERSION}/vllm-rust-frontend.patch /tmp/vllm-rust-frontend.patch +COPY docker/patch/${PATCH_VERSION}/vllm-rust-observability.patch /tmp/vllm-rust-observability.patch +RUN git init /build && cd /build && \ + git remote add origin https://github.com/vllm-project/vllm.git && \ + git fetch --depth 1 origin ${VLLM_COMMIT} && git checkout FETCH_HEAD && \ + git apply /tmp/vllm-rust-frontend.patch && \ + git apply /tmp/vllm-rust-observability.patch +RUN cd /build && VLLM_RS_BUILD_VERSION=${VLLM_RS_BUILD_VERSION} \ + cargo build --locked --release --manifest-path rust/Cargo.toml \ + -p vllm-cmd --bin vllm-rs --features native-tls-vendored + FROM ${BASE_IMAGE} # ======================================== Arguments ============================================= ARG PATCH_VERSION=latest +ARG VLLM_COMMIT=43b4aaea3e40e20ef53ce2bfecfa1b72060bf2b5 ARG MEGATRON_COMMIT=1dcf0dafa884ad52ffb243625717a3471643e087 ARG DEEPGEMM_COMMIT=b38a77cd193cf38f670caae192310521d24343be ARG DEEPEP_COMMIT=6845ffd9d59126ec0030c13e0e155935a61e5b5a @@ -20,6 +37,7 @@ ARG FA2_MAX_JOBS=64 # ======================================== Setup ============================================= WORKDIR /root/ +RUN python3 -c 'import os, vllm; assert os.environ["VLLM_COMMIT"][:9] in vllm.__version__, (os.environ["VLLM_COMMIT"], vllm.__version__)' # ======================================== Apt dependencies ============================================= @@ -42,7 +60,8 @@ RUN apt-get update && apt-get install -y \ rm -rf /var/lib/apt/lists/* # vllm/vllm-openai base only ships python3; subsequent source builds invoke `python`. -RUN ln -sf /usr/bin/python3 /usr/local/bin/python +RUN uv pip install --system pip && ln -sf /usr/bin/python3 /usr/local/bin/python +ENV PIP_BREAK_SYSTEM_PACKAGES=1 # ====================================== Python dependencies ============================================ @@ -131,8 +150,7 @@ RUN if [ "${ENABLE_CUDA_13}" != "1" ]; then \ pip install nvidia-cudnn-cu12==9.16.0.29; \ fi -# Megatron requires NumPy 1.x, while SciPy 1.18+ requires NumPy 2.x. -RUN pip install "numpy==1.26.4" "scipy==1.17.1" +RUN pip install "scipy==1.17.1" RUN rm -rf /root/.cache/pip /root/flash-attention @@ -156,7 +174,10 @@ COPY docker/patch/${PATCH_VERSION}/vllm.patch /tmp/vllm.patch COPY docker/patch/${PATCH_VERSION}/vllm-score-centering.patch /tmp/vllm-score-centering.patch COPY docker/patch/${PATCH_VERSION}/vllm-pd-request-metrics.patch /tmp/vllm-pd-request-metrics.patch COPY docker/patch/${PATCH_VERSION}/vllm-inflight-queue-diagnostics.patch /tmp/vllm-inflight-queue-diagnostics.patch -COPY docker/patch/${PATCH_VERSION}/vllm-aux-output-reset.patch /tmp/vllm-aux-output-reset.patch +COPY docker/patch/${PATCH_VERSION}/vllm-retained-serving.patch /tmp/vllm-retained-serving.patch +COPY docker/patch/${PATCH_VERSION}/vllm-generation-health.patch /tmp/vllm-generation-health.patch +COPY docker/patch/${PATCH_VERSION}/vllm-tensor-dump.patch /tmp/vllm-tensor-dump.patch +COPY docker/patch/${PATCH_VERSION}/vllm-fine-grained-tracing.patch /tmp/vllm-fine-grained-tracing.patch RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirname(vllm.__file__)))')" && \ cd "$VLLM_SITE" && \ git apply -v /tmp/vllm-pull_weights.patch && \ @@ -164,14 +185,21 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn git apply -v /tmp/vllm-score-centering.patch && \ git apply -v /tmp/vllm-pd-request-metrics.patch && \ git apply -v /tmp/vllm-inflight-queue-diagnostics.patch && \ - git apply -v /tmp/vllm-aux-output-reset.patch && \ + git apply -v /tmp/vllm-retained-serving.patch && \ + git apply -v /tmp/vllm-generation-health.patch && \ + git apply -v /tmp/vllm-tensor-dump.patch && \ + git apply -v /tmp/vllm-fine-grained-tracing.patch && \ rm /tmp/vllm-pull_weights.patch /tmp/vllm.patch \ /tmp/vllm-score-centering.patch \ /tmp/vllm-pd-request-metrics.patch \ /tmp/vllm-inflight-queue-diagnostics.patch \ - /tmp/vllm-aux-output-reset.patch + /tmp/vllm-retained-serving.patch \ + /tmp/vllm-generation-health.patch \ + /tmp/vllm-tensor-dump.patch \ + /tmp/vllm-fine-grained-tracing.patch # ====================================== Install main package ============================================ +COPY --from=vllm-rust-frontend /build/rust/target/release/vllm-rs /usr/local/lib/python3.12/dist-packages/vllm/vllm-rs ARG VIME_REPO=https://github.com/vllm-project/vime.git ARG VIME_COMMIT=main diff --git a/docker/patch/latest/vllm-aux-output-reset.patch b/docker/patch/latest/vllm-aux-output-reset.patch deleted file mode 100644 index 77f80f6ed..000000000 --- a/docker/patch/latest/vllm-aux-output-reset.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index e4af9071544..24ecc4534d6 100644 ---- a/vllm/v1/core/sched/scheduler.py -+++ b/vllm/v1/core/sched/scheduler.py -@@ -2781,16 +2781,6 @@ class Scheduler(SchedulerInterface): - Otherwise, this method will only reset the KV prefix cache when there - is no running requests taking KV cache. - """ -- if reset_running_requests and self.aux_output_connector is not None: -- if self._pause_state != PauseState.PAUSED_ALL: -- raise RuntimeError( -- "AuxOutput Connector only supports resetting running requests " -- "after pause(mode='keep')." -- ) -- if any(request.num_in_flight_tokens for request in self.requests.values()): -- raise RuntimeError( -- "AuxOutput Connector cannot reset while model output is in flight." -- ) - if reset_running_requests: - # For logging. - timestamp = time.monotonic() diff --git a/docker/patch/latest/vllm-fine-grained-tracing.patch b/docker/patch/latest/vllm-fine-grained-tracing.patch new file mode 100644 index 000000000..6b0ab28b9 --- /dev/null +++ b/docker/patch/latest/vllm-fine-grained-tracing.patch @@ -0,0 +1,626 @@ +diff --git a/vllm/tracing/__init__.py b/vllm/tracing/__init__.py +--- a/vllm/tracing/__init__.py ++++ b/vllm/tracing/__init__.py +@@ -15,6 +15,7 @@ + is_otel_available, + manual_instrument_otel, + otel_import_error_traceback, ++ start_request_span_otel, + ) + from .utils import ( + SpanAttributes, +@@ -36,11 +37,13 @@ + "log_tracing_disabled_warning", + "contains_trace_headers", + "otel_import_error_traceback", ++ "start_request_span", + ] + + BackendAvailableFunc: TypeAlias = Callable[[], bool] + InstrumentFunc: TypeAlias = Callable[..., Any] + InstrumentManualFunc: TypeAlias = Callable[..., Any] ++StartRequestSpanFunc: TypeAlias = Callable[..., Any] + InitTracerFunc: TypeAlias = Callable[..., Any] + InitWorkerTracerFunc: TypeAlias = Callable[..., Any] + _REGISTERED_TRACING_BACKENDS: dict[ +@@ -51,6 +54,7 @@ + InitWorkerTracerFunc, + InstrumentFunc, + InstrumentManualFunc, ++ StartRequestSpanFunc, + ], + ] = { + "otel": ( +@@ -59,6 +63,7 @@ + init_otel_worker_tracer, + instrument_otel, + manual_instrument_otel, ++ start_request_span_otel, + ), + } + +@@ -68,7 +73,7 @@ + otlp_traces_endpoint: str, + extra_attributes: dict[str, str] | None = None, + ): +- is_available, init_tracer_fn, _, _, _ = _REGISTERED_TRACING_BACKENDS["otel"] ++ is_available, init_tracer_fn, _, _, _, _ = _REGISTERED_TRACING_BACKENDS["otel"] + if is_available(): + return init_tracer_fn( + instrumenting_module_name, otlp_traces_endpoint, extra_attributes +@@ -80,7 +85,9 @@ + process_kind: str, + process_name: str, + ): +- is_available, _, init_worker_tracer_fn, _, _ = _REGISTERED_TRACING_BACKENDS["otel"] ++ is_available, _, init_worker_tracer_fn, _, _, _ = _REGISTERED_TRACING_BACKENDS[ ++ "otel" ++ ] + if is_available(): + return init_worker_tracer_fn( + instrumenting_module_name, process_kind, process_name +@@ -104,7 +111,7 @@ + ) + + # Dispatch to OTel (and potentially others later) +- is_available, _, _, otel_instrument, _ = _REGISTERED_TRACING_BACKENDS["otel"] ++ is_available, _, _, otel_instrument, _, _ = _REGISTERED_TRACING_BACKENDS["otel"] + if is_available(): + return otel_instrument( + func=obj, +@@ -135,7 +142,9 @@ + kind: Optional SpanKind (e.g., SpanKind.SERVER). + + """ +- is_available, _, _, _, manual_instrument_fn = _REGISTERED_TRACING_BACKENDS["otel"] ++ is_available, _, _, _, manual_instrument_fn, _ = _REGISTERED_TRACING_BACKENDS[ ++ "otel" ++ ] + if is_available(): + return manual_instrument_fn( + span_name, start_time, end_time, attributes, context, kind +@@ -144,12 +153,35 @@ + return None + + ++def start_request_span( ++ span_name: str, ++ start_time: int, ++ attributes: dict[str, Any] | None = None, ++ context: Any = None, ++ kind: Any = None, ++) -> tuple[Any, dict[str, str] | None]: ++ """Start a span and return it along with injected W3C trace headers. ++ ++ Args: ++ span_name: Name of the span to create. ++ start_time: Start time in nanoseconds since epoch. ++ attributes: Optional dict of span attributes. ++ context: Optional trace context (e.g., from extract_trace_context). ++ kind: Optional SpanKind (e.g., SpanKind.SERVER). ++ """ ++ is_available, _, _, _, _, start_span_fn = _REGISTERED_TRACING_BACKENDS["otel"] ++ if is_available(): ++ return start_span_fn(span_name, start_time, attributes, context, kind) ++ else: ++ return None, None ++ ++ + def is_tracing_available() -> bool: + """Returns True if any tracing backend (OTel, Profiler, etc.) is available. + Use this to guard expensive tracing logic in the main code. + """ + check_available = [ + is_available +- for is_available, _, _, _, _ in _REGISTERED_TRACING_BACKENDS.values() ++ for is_available, _, _, _, _, _ in _REGISTERED_TRACING_BACKENDS.values() + ] + return any(check_available) +diff --git a/vllm/tracing/otel.py b/vllm/tracing/otel.py +--- a/vllm/tracing/otel.py ++++ b/vllm/tracing/otel.py +@@ -7,7 +7,7 @@ + import os + import traceback + from collections.abc import Mapping +-from contextlib import contextmanager ++from contextlib import contextmanager, suppress + from typing import Any + + from vllm.logger import init_logger +@@ -52,9 +52,17 @@ + Resource = None # type: ignore + SpanKind = Any # type: ignore + ++_GLOBAL_TRACER_PROVIDER: Any = None ++ + + def is_otel_available() -> bool: + return _IS_OTEL_AVAILABLE ++ ++ ++def _get_tracer(name: str = __name__) -> Tracer: ++ if _GLOBAL_TRACER_PROVIDER is not None: ++ return _GLOBAL_TRACER_PROVIDER.get_tracer(name) ++ return trace.get_tracer(name) + + + def init_otel_tracer( +@@ -74,6 +82,7 @@ + os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = otlp_traces_endpoint + + resource_attrs = {} ++ resource_attrs["service.name"] = instrumenting_module_name + resource_attrs["vllm.instrumenting_module_name"] = instrumenting_module_name + resource_attrs["vllm.process_id"] = str(os.getpid()) + if extra_attributes: +@@ -83,7 +92,10 @@ + trace_provider = TracerProvider(resource=resource) + span_exporter = get_span_exporter(otlp_traces_endpoint) + trace_provider.add_span_processor(BatchSpanProcessor(span_exporter)) +- set_tracer_provider(trace_provider) ++ with suppress(Exception): ++ set_tracer_provider(trace_provider) ++ global _GLOBAL_TRACER_PROVIDER ++ _GLOBAL_TRACER_PROVIDER = trace_provider + + atexit.register(trace_provider.shutdown) + +@@ -91,12 +103,17 @@ + return tracer + + +-def get_span_exporter(endpoint): ++def get_span_exporter(endpoint: str): ++ # Normalize endpoint: strip grpc:// prefix if present for OTLPGrpcExporter ++ clean_endpoint = endpoint ++ if clean_endpoint.startswith("grpc://"): ++ clean_endpoint = clean_endpoint[len("grpc://") :] ++ + protocol = os.environ.get(OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "grpc") + if protocol == "grpc": +- exporter = OTLPGrpcExporter(endpoint=endpoint, insecure=True) ++ exporter = OTLPGrpcExporter(endpoint=clean_endpoint, insecure=True) + elif protocol == "http/protobuf": +- exporter = OTLPHttpExporter(endpoint=endpoint) ++ exporter = OTLPHttpExporter(endpoint=clean_endpoint) + else: + raise ValueError(f"Unsupported OTLP protocol '{protocol}' is configured") + return exporter +@@ -146,7 +163,7 @@ + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): +- tracer = trace.get_tracer(module_name) ++ tracer = _get_tracer(module_name) + ctx = _get_smart_context() + with ( + tracer.start_as_current_span( +@@ -161,7 +178,7 @@ + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): +- tracer = trace.get_tracer(module_name) ++ tracer = _get_tracer(module_name) + ctx = _get_smart_context() + with ( + tracer.start_as_current_span( +@@ -189,7 +206,7 @@ + if not _IS_OTEL_AVAILABLE: + return + +- tracer = trace.get_tracer(__name__) ++ tracer = _get_tracer(__name__) + # Use provided context, or fall back to smart context detection + ctx = context if context is not None else _get_smart_context() + +@@ -208,6 +225,46 @@ + span.end(end_time=end_time) + else: + span.end() ++ ++ ++def start_request_span_otel( ++ span_name: str, ++ start_time: int, ++ attributes: dict[str, Any] | None = None, ++ context: Context | None = None, ++ kind: Any = None, ++) -> tuple[Any, dict[str, str] | None]: ++ """Start an OpenTelemetry span and inject its context into a carrier dict. ++ ++ Returns: ++ (span, trace_headers): The active span object and a dict of W3C ++ trace headers (e.g. {'traceparent': ...}) representing this span's ++ context for propagation to downstream workers/subsystems. ++ """ ++ if not _IS_OTEL_AVAILABLE: ++ return None, None ++ ++ tracer = _get_tracer(__name__) ++ ctx = context if context is not None else _get_smart_context() ++ ++ span_kwargs: dict[str, Any] = { ++ "name": span_name, ++ "context": ctx, ++ "start_time": start_time, ++ } ++ if kind is not None: ++ span_kwargs["kind"] = kind ++ ++ span = tracer.start_span(**span_kwargs) ++ if attributes: ++ span.set_attributes(attributes) ++ ++ # Inject the new span's context into W3C trace headers ++ carrier: dict[str, str] = {} ++ span_ctx = trace.set_span_in_context(span) ++ TraceContextTextMapPropagator().inject(carrier, context=span_ctx) ++ ++ return span, carrier + + + def _get_smart_context() -> Context | None: +diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py +--- a/vllm/v1/core/sched/scheduler.py ++++ b/vllm/v1/core/sched/scheduler.py +@@ -1307,6 +1307,7 @@ + # into the WAITING_FOR_REMOTE_KV state. + request.start_remote_kv_wait() + request.status = RequestStatus.WAITING_FOR_REMOTE_KVS ++ request.async_kv_load_start_time_ns = time.time_ns() + # Set num_computed_tokens even though KVs are not yet loaded. + # request.num_computed_tokens will not be used anywhere until + # the request finished the KV transfer. +@@ -1337,9 +1338,15 @@ + EngineCoreEventType.SCHEDULED, scheduled_timestamp + ) + if request.status == RequestStatus.WAITING: ++ request.trace_end_queuing() ++ if request.num_computed_tokens > 0: ++ request.decode_start_time_ns = time.time_ns() ++ else: ++ request.prefill_start_time_ns = time.time_ns() + scheduled_new_reqs.append(request) + elif request.status == RequestStatus.PREEMPTED: + scheduled_resumed_reqs.append(request) ++ request.prefill_start_time_ns = time.time_ns() + else: + raise RuntimeError(f"Invalid request status: {request.status}") + +@@ -1583,6 +1590,8 @@ + ) + if self.aux_output_connector is not None: + self.aux_output_connector.request_finished(request) ++ # Close active prefill or decode spans ++ request.trace_preempt() + self._free_request_blocks(request) + self.encoder_cache_manager.free(request) + self._inflight_prefills.discard(request) +@@ -2103,6 +2112,11 @@ + generated_token_ids = ( + sampled_token_ids[req_index] if sampled_token_ids else [] + ) ++ ++ if generated_token_ids: ++ request.trace_end_prefill() ++ if request.decode_start_time_ns is None: ++ request.decode_start_time_ns = time.time_ns() + + scheduled_spec_token_ids = ( + scheduler_output.scheduled_spec_decode_tokens.get(req_id) +@@ -2764,6 +2778,7 @@ + + if self.aux_output_connector is not None: + self.aux_output_connector.request_finished(request) ++ request.trace_cleanup() + self._inflight_prefills.discard(request) + self._set_kv_fetch_stage(request, None) + connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request) +@@ -3221,6 +3236,7 @@ + self._update_waiting_for_remote_kv(request) + request.stop_remote_kv_wait() + request.kv_queue_started_at["post_receive"] = time.monotonic() ++ request.trace_end_kv_transfer() + if request.num_preemptions: + request.status = RequestStatus.PREEMPTED + else: +diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py +--- a/vllm/v1/engine/output_processor.py ++++ b/vllm/v1/engine/output_processor.py +@@ -27,6 +27,8 @@ + SpanKind, + extract_trace_context, + instrument_manual, ++ is_tracing_available, ++ start_request_span, + ) + from vllm.utils import length_from_prompt_token_ids_or_embeds + from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason +@@ -202,6 +204,9 @@ + deque() if stream_input else None + ) + ++ # Tracing span ++ self.trace_span: Any | None = None ++ + def apply_streaming_update(self, update: StreamingUpdate) -> None: + # Apply the update to the request state. + self.streaming_input = not update.final +@@ -574,6 +579,8 @@ + for request_id in internal_req_ids: + req_state = self.request_states.pop(request_id, None) + if req_state is not None: ++ if req_state.trace_span is not None: ++ req_state.trace_span.end() + self.lora_states.request_finished(request_id, req_state.lora_name) + request_ids_to_abort.append(request_id) + # Produce final abort output. +@@ -624,9 +631,23 @@ + parent_req=parent_req, + request_index=request_index, + queue=queue, +- log_stats=self.log_stats, ++ log_stats=self.log_stats or self.tracing_enabled, + stream_interval=self.stream_interval, + ) ++ ++ if self.tracing_enabled and is_tracing_available(): ++ arrival_time_ns = int(request.arrival_time * 1e9) ++ trace_context = extract_trace_context(request.trace_headers) ++ span, updated_headers = start_request_span( ++ span_name="llm_request", ++ start_time=arrival_time_ns, ++ context=trace_context, ++ kind=SpanKind.SERVER, ++ ) ++ req_state.trace_span = span ++ if updated_headers: ++ request.trace_headers = updated_headers ++ + self.request_states[request_id] = req_state + if parent_req: + self.parent_requests[parent_req.request_id] = parent_req +@@ -854,39 +875,53 @@ + req_state: RequestState, + iteration_stats: IterationStats | None, + ) -> None: +- assert req_state.stats is not None +- assert iteration_stats is not None +- +- metrics = req_state.stats +- arrival_time_ns = int(metrics.arrival_time * 1e9) + trace_context = extract_trace_context(engine_core_output.trace_headers) + prompt_length = length_from_prompt_token_ids_or_embeds( + req_state.prompt_token_ids, req_state.prompt_embeds + ) + +- # Calculate timing metrics +- e2e_time = iteration_stats.iteration_timestamp - metrics.arrival_time +- queued_time = metrics.scheduled_ts - metrics.queued_ts +- prefill_time = metrics.first_token_ts - metrics.scheduled_ts +- decode_time = metrics.last_token_ts - metrics.first_token_ts +- inference_time = metrics.last_token_ts - metrics.scheduled_ts ++ arrival_time_ns = ( ++ int(req_state.stats.arrival_time * 1e9) ++ if req_state.stats is not None ++ else 0 ++ ) ++ completion_tokens = ( ++ req_state.stats.num_generation_tokens if req_state.stats is not None else 0 ++ ) + + # Build attributes dict + attributes: dict[str, Any] = { +- SpanAttributes.GEN_AI_LATENCY_TIME_TO_FIRST_TOKEN: ( +- metrics.first_token_latency +- ), +- SpanAttributes.GEN_AI_LATENCY_E2E: e2e_time, +- SpanAttributes.GEN_AI_LATENCY_TIME_IN_QUEUE: queued_time, + SpanAttributes.GEN_AI_USAGE_PROMPT_TOKENS: prompt_length, +- SpanAttributes.GEN_AI_USAGE_COMPLETION_TOKENS: ( +- metrics.num_generation_tokens +- ), +- SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_PREFILL: prefill_time, +- SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_DECODE: decode_time, +- SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_INFERENCE: inference_time, ++ SpanAttributes.GEN_AI_USAGE_COMPLETION_TOKENS: completion_tokens, + SpanAttributes.GEN_AI_REQUEST_ID: req_state.external_req_id, + } ++ ++ # Calculate timing metrics if stats available ++ if req_state.stats is not None and iteration_stats is not None: ++ metrics = req_state.stats ++ e2e_time = iteration_stats.iteration_timestamp - metrics.arrival_time ++ queued_time = metrics.scheduled_ts - metrics.queued_ts ++ prefill_time = metrics.first_token_ts - metrics.scheduled_ts ++ decode_time = metrics.last_token_ts - metrics.first_token_ts ++ inference_time = metrics.last_token_ts - metrics.scheduled_ts ++ ++ attributes.update( ++ { ++ SpanAttributes.GEN_AI_LATENCY_TIME_TO_FIRST_TOKEN: ( ++ metrics.first_token_latency ++ ), ++ SpanAttributes.GEN_AI_LATENCY_E2E: e2e_time, ++ SpanAttributes.GEN_AI_LATENCY_TIME_IN_QUEUE: queued_time, ++ SpanAttributes.GEN_AI_USAGE_COMPLETION_TOKENS: ( ++ metrics.num_generation_tokens ++ ), ++ SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_PREFILL: (prefill_time), ++ SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_DECODE: (decode_time), ++ SpanAttributes.GEN_AI_LATENCY_TIME_IN_MODEL_INFERENCE: ( ++ inference_time ++ ), ++ } ++ ) + + # Add optional request parameters + if req_state.top_p: +@@ -902,13 +937,17 @@ + if req_state.n: + attributes[SpanAttributes.GEN_AI_REQUEST_N] = req_state.n + +- instrument_manual( +- span_name="llm_request", +- start_time=arrival_time_ns, +- attributes=attributes, +- context=trace_context, +- kind=SpanKind.SERVER, +- ) ++ if req_state.trace_span is not None: ++ req_state.trace_span.set_attributes(attributes) ++ req_state.trace_span.end() ++ else: ++ instrument_manual( ++ span_name="llm_request", ++ start_time=arrival_time_ns, ++ attributes=attributes, ++ context=trace_context, ++ kind=SpanKind.SERVER, ++ ) + + def _update_stats_from_output( + self, +diff --git a/vllm/v1/engine/utils.py b/vllm/v1/engine/utils.py +--- a/vllm/v1/engine/utils.py ++++ b/vllm/v1/engine/utils.py +@@ -1151,6 +1151,8 @@ + addresses: EngineZmqAddresses, + ) -> Iterator[CoreEngineLaunch]: + """Launch engine and DP coordinator processes as needed.""" ++ if endpoint := vllm_config.observability_config.otlp_traces_endpoint: ++ os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = endpoint + parallel_config = vllm_config.parallel_config + dp_size = parallel_config.data_parallel_size + local_engine_count = parallel_config.data_parallel_size_local +diff --git a/vllm/v1/request.py b/vllm/v1/request.py +--- a/vllm/v1/request.py ++++ b/vllm/v1/request.py +@@ -111,6 +111,12 @@ + # E/P/D: Connector-specific encoder-cache transfer parameters. + self.ec_transfer_params: dict[str, Any] | None = None + ++ # Distributed Tracing: Explicit state transition timing fields. ++ self.arrival_time_ns: int | None = int(self.arrival_time * 1e9) ++ self.async_kv_load_start_time_ns: int | None = None ++ self.prefill_start_time_ns: int | None = None ++ self.decode_start_time_ns: int | None = None ++ + if pooling_params is not None: + # Pooling models. + self.max_tokens = 1 +@@ -381,6 +387,113 @@ + return self.request_id < other.request_id + return id(self) < id(other) + ++ def emit_span( ++ self, ++ span_name: str, ++ start_time: int, ++ end_time: int, ++ attributes: dict[str, Any] | None = None, ++ ) -> None: ++ """Helper to manually emit an OTel trace span linked to this request's ++ context.""" ++ if not self.trace_headers: ++ return ++ from vllm.tracing import extract_trace_context, instrument_manual ++ ++ trace_context = ( ++ extract_trace_context(self.trace_headers) if self.trace_headers else None ++ ) ++ ++ merged_attributes = {"request_id": self.request_id} ++ if attributes: ++ merged_attributes.update(attributes) ++ ++ instrument_manual( ++ span_name=span_name, ++ start_time=start_time, ++ end_time=end_time, ++ context=trace_context, ++ attributes=merged_attributes, ++ ) ++ ++ def trace_end_queuing(self) -> None: ++ """Transition request out of the queue. Closes vllm.request.queue span.""" ++ if self.arrival_time_ns is None: ++ return ++ ++ now = time.time_ns() ++ self.emit_span("vllm.request.queue", self.arrival_time_ns, now) ++ self.arrival_time_ns = None # prevent double queue spans ++ ++ def trace_end_kv_transfer(self, aborted: bool = False) -> None: ++ """Transition request out of KV transfer. Closes ++ vllm.request.wait_remote_kv span. ++ """ ++ if self.async_kv_load_start_time_ns is None: ++ return ++ ++ now = time.time_ns() ++ attributes = {"aborted": True} if aborted else None ++ self.emit_span( ++ "vllm.request.wait_remote_kv", ++ self.async_kv_load_start_time_ns, ++ now, ++ attributes, ++ ) ++ self.async_kv_load_start_time_ns = None ++ ++ def trace_end_prefill(self, aborted: bool = False) -> None: ++ """Transition request out of prefill. Closes vllm.request.prefill span.""" ++ if self.prefill_start_time_ns is None: ++ return ++ ++ now = time.time_ns() ++ attributes = {"aborted": True} if aborted else None ++ self.emit_span( ++ "vllm.request.prefill", self.prefill_start_time_ns, now, attributes ++ ) ++ self.prefill_start_time_ns = None ++ ++ def trace_end_decode(self, aborted: bool = False) -> None: ++ """Transition request out of decode. Closes vllm.request.decode span.""" ++ if self.decode_start_time_ns is None: ++ return ++ ++ now = time.time_ns() ++ attributes = {"aborted": True} if aborted else None ++ self.emit_span( ++ "vllm.request.decode", self.decode_start_time_ns, now, attributes ++ ) ++ self.decode_start_time_ns = None ++ ++ def trace_preempt(self) -> None: ++ """Transition request to PREEMPTED. Closes active prefill or decode spans.""" ++ now = time.time_ns() ++ attributes = {"preempted": True} ++ ++ if self.prefill_start_time_ns is not None: ++ self.emit_span( ++ "vllm.request.prefill", self.prefill_start_time_ns, now, attributes ++ ) ++ self.prefill_start_time_ns = None ++ ++ if self.decode_start_time_ns is not None: ++ self.emit_span( ++ "vllm.request.decode", self.decode_start_time_ns, now, attributes ++ ) ++ self.decode_start_time_ns = None ++ ++ def trace_cleanup(self) -> None: ++ """Catch-all transition to clean up any lingering active spans when ++ request is freed.""" ++ is_aborted = ( ++ self.status == RequestStatus.FINISHED_ABORTED ++ or self.status == RequestStatus.FINISHED_ERROR ++ ) ++ self.trace_end_kv_transfer(aborted=is_aborted) ++ self.trace_end_prefill(aborted=is_aborted) ++ self.trace_end_decode(aborted=is_aborted) ++ + + class RequestStatus(enum.IntEnum): + """Status of a request.""" diff --git a/docker/patch/latest/vllm-generation-health.patch b/docker/patch/latest/vllm-generation-health.patch new file mode 100644 index 000000000..a90290e6f --- /dev/null +++ b/docker/patch/latest/vllm-generation-health.patch @@ -0,0 +1,85 @@ +--- a/vllm/engine/protocol.py ++++ b/vllm/engine/protocol.py +@@ -162,6 +162,11 @@ + ... + + @abstractmethod ++ async def check_health_generate(self, timeout: float = 20.0) -> None: ++ """Require a scheduler or inference response, not just a live frontend.""" ++ ... ++ ++ @abstractmethod + async def start_profile( + self, + profile_prefix: str | None = None, +--- a/vllm/entrypoints/serve/instrumentator/health.py ++++ b/vllm/entrypoints/serve/instrumentator/health.py +@@ -7,7 +7,7 @@ + + from vllm.engine.protocol import EngineClient + from vllm.logger import init_logger +-from vllm.v1.engine.exceptions import EngineDeadError ++from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError + + logger = init_logger(__name__) + +@@ -31,3 +31,16 @@ + return Response(status_code=200) + except EngineDeadError: + return Response(status_code=503) ++ ++ ++@router.get("/health_generate", response_class=Response) ++async def health_generate(raw_request: Request) -> Response: ++ """Check inference progress while keeping the lightweight health route intact.""" ++ client = engine_client(raw_request) ++ if client is None: ++ return Response(status_code=200) ++ try: ++ await client.check_health_generate() ++ return Response(status_code=200) ++ except (EngineDeadError, EngineGenerateError, TimeoutError): ++ return Response(status_code=503) +--- a/vllm/v1/engine/async_llm.py ++++ b/vllm/v1/engine/async_llm.py +@@ -47,6 +47,7 @@ + from vllm.tracing import init_tracer + from vllm.transformers_utils.config import maybe_register_config_serialize_by_value + from vllm.usage.usage_lib import UsageContext ++from vllm.utils import random_uuid + from vllm.utils.async_utils import cancel_task_threadsafe + from vllm.utils.collection_utils import as_list + from vllm.v1.engine import EngineCoreRequest, PauseMode +@@ -1089,6 +1090,32 @@ + if self.errored: + raise self.dead_error + ++ async def check_health_generate(self, timeout: float = 20.0) -> None: ++ """Require a scheduler reply when busy, or an inference result when idle.""" ++ async with asyncio.timeout(timeout): ++ await self.check_health() ++ loads = await self.get_inflight_queue_diagnostics(0) ++ if any( ++ load.get("has_pending_work", False) ++ or any(queue["num_requests"] for queue in load["queues"]) ++ for load in loads ++ ): ++ return ++ request_id = f"healthcheck-{random_uuid()}" ++ prompt = TokensPrompt(prompt_token_ids=[0]) ++ generator = ( ++ self.generate( ++ prompt, SamplingParams(max_tokens=1, temperature=1, top_k=1), request_id ++ ) ++ if self.model_config.runner_type == "generate" ++ else self.encode(prompt, PoolingParams(), request_id) ++ ) ++ try: ++ await anext(generator) ++ finally: ++ await self.abort(request_id) ++ await generator.aclose() ++ + async def start_profile( + self, + profile_prefix: str | None = None, diff --git a/docker/patch/latest/vllm-inflight-queue-diagnostics.patch b/docker/patch/latest/vllm-inflight-queue-diagnostics.patch index 12717f850..013589204 100644 --- a/docker/patch/latest/vllm-inflight-queue-diagnostics.patch +++ b/docker/patch/latest/vllm-inflight-queue-diagnostics.patch @@ -1,8 +1,6 @@ -diff --git a/vllm/engine/protocol.py b/vllm/engine/protocol.py -index 74849a85ce..470f81b774 100644 --- a/vllm/engine/protocol.py +++ b/vllm/engine/protocol.py -@@ -311,3 +311,7 @@ class EngineClient(ABC): +@@ -321,3 +321,7 @@ async def get_weight_version(self) -> str: """Return the latest committed weight version.""" raise NotImplementedError @@ -10,11 +8,9 @@ index 74849a85ce..470f81b774 100644 + async def get_inflight_queue_diagnostics(self, limit: int) -> list[dict[str, Any]]: + """Return bounded snapshots of in-flight request queues.""" + raise NotImplementedError -diff --git a/vllm/entrypoints/serve/instrumentator/basic.py b/vllm/entrypoints/serve/instrumentator/basic.py -index 64201db3f3..16f1d7bdac 100644 --- a/vllm/entrypoints/serve/instrumentator/basic.py +++ b/vllm/entrypoints/serve/instrumentator/basic.py -@@ -28,7 +28,9 @@ def engine_client(request: Request) -> EngineClient: +@@ -28,7 +28,9 @@ @router.get("/load") @@ -25,7 +21,7 @@ index 64201db3f3..16f1d7bdac 100644 # This endpoint returns the current server load metrics. # It tracks requests utilizing the GPU from the following routes: # - /v1/responses -@@ -47,7 +49,12 @@ async def get_server_load_metrics(request: Request): +@@ -47,7 +49,12 @@ # - /rerank # - /v1/rerank # - /v2/rerank @@ -39,8 +35,6 @@ index 64201db3f3..16f1d7bdac 100644 @router.get("/version") -diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py -index c868d249c8..4acaf60415 100644 --- a/vllm/v1/core/sched/interface.py +++ b/vllm/v1/core/sched/interface.py @@ -3,7 +3,7 @@ @@ -52,7 +46,7 @@ index c868d249c8..4acaf60415 100644 from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry -@@ -243,6 +243,11 @@ class SchedulerInterface(ABC): +@@ -243,6 +244,11 @@ """Returns (num_running_reqs, num_waiting_reqs).""" raise NotImplementedError @@ -64,13 +58,11 @@ index c868d249c8..4acaf60415 100644 def get_kv_cache_usage(self) -> float: """Returns the fraction of the KV cache currently in use (0.0-1.0).""" return 0.0 -diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index 3996a257e7..82bf206c02 100644 --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py -@@ -2491,6 +2491,40 @@ class Scheduler(SchedulerInterface): +@@ -2613,6 +2613,45 @@ """Returns (num_running_reqs, num_waiting_reqs).""" - return len(self.running), len(self.waiting) + len(self.skipped_waiting) + return len(self.running), len(self.waiting) + len(self.kv_holding_waiting) + def get_inflight_queue_diagnostics(self, limit: int) -> dict[str, Any]: + """Return a bounded snapshot of in-flight request queues.""" @@ -81,7 +73,11 @@ index 3996a257e7..82bf206c02 100644 + for name, requests in ( + ("running", self.running), + ("waiting", self.waiting), -+ ("skipped_waiting", self.skipped_waiting), ++ ("kv_holding_waiting", self.kv_holding_waiting), ++ ( ++ "finished_pending_cleanup", ++ [request for request in self.requests.values() if request.is_finished()], ++ ), + ): + entries = [] + for request in requests: @@ -103,28 +99,25 @@ index 3996a257e7..82bf206c02 100644 + + return { + "data_parallel_rank": self.parallel_config.data_parallel_index, ++ "has_pending_work": self.has_requests(), + "queues": queues, + } + def get_kv_cache_usage(self) -> float: """Returns the fraction of the KV cache currently in use (0.0-1.0).""" return self.kv_cache_manager.usage -diff --git a/vllm/v1/engine/async_llm.py b/vllm/v1/engine/async_llm.py -index 7d71761990..6c485201c7 100644 --- a/vllm/v1/engine/async_llm.py +++ b/vllm/v1/engine/async_llm.py -@@ -1297,3 +1297,6 @@ class AsyncLLM(EngineClient): +@@ -1358,3 +1358,6 @@ def get_kv_event_sources(self) -> dict[int, KVEventsConfig]: return self.engine_core.get_kv_event_sources() + + async def get_inflight_queue_diagnostics(self, limit: int) -> list[dict[str, Any]]: + return await self.engine_core.get_inflight_queue_diagnostics_async(limit) -diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py -index 9b27f50edc..f546277ea6 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py -@@ -1046,6 +1046,9 @@ class EngineCore: +@@ -1057,6 +1057,9 @@ """Return the latest committed weight version.""" return self._weight_version @@ -134,11 +127,9 @@ index 9b27f50edc..f546277ea6 100644 def preprocess_add_request(self, request: EngineCoreRequest) -> tuple[Request, int]: """Preprocess the request. -diff --git a/vllm/v1/engine/core_client.py b/vllm/v1/engine/core_client.py -index 6ad05e1f8c..97814cc28e 100644 --- a/vllm/v1/engine/core_client.py +++ b/vllm/v1/engine/core_client.py -@@ -216,6 +216,9 @@ class EngineCoreClient(ABC): +@@ -222,6 +222,9 @@ def get_weight_version(self) -> str: raise NotImplementedError @@ -148,29 +139,29 @@ index 6ad05e1f8c..97814cc28e 100644 async def execute_dummy_batch_async(self) -> None: raise NotImplementedError -@@ -225,6 +228,11 @@ class EngineCoreClient(ABC): - async def get_weight_version_async(self) -> str: +@@ -229,6 +232,11 @@ raise NotImplementedError + async def get_weight_version_async(self) -> str: ++ raise NotImplementedError ++ + async def get_inflight_queue_diagnostics_async( + self, limit: int + ) -> list[dict[str, Any]]: -+ raise NotImplementedError -+ - def abort_requests(self, request_ids: list[str]) -> None: raise NotImplementedError -@@ -426,6 +434,9 @@ class InprocClient(EngineCoreClient): + def abort_requests(self, request_ids: list[str]) -> None: +@@ -452,6 +460,9 @@ + def get_weight_version(self) -> str: return self.engine_core.get_weight_version() - ++ + def get_inflight_queue_diagnostics(self, limit: int) -> list[dict[str, Any]]: + return [self.engine_core.get_inflight_queue_diagnostics(limit)] -+ + def add_lora(self, lora_request: LoRARequest) -> bool: return self.engine_core.add_lora(lora_request) - -@@ -1062,6 +1073,9 @@ class SyncMPClient(MPClient): +@@ -1101,6 +1112,9 @@ def get_weight_version(self) -> str: return self.call_utility("get_weight_version") @@ -180,22 +171,22 @@ index 6ad05e1f8c..97814cc28e 100644 def collective_rpc( self, method: str | Callable[..., _R], -@@ -1309,6 +1323,12 @@ class AsyncMPClient(MPClient): - async def get_weight_version_async(self) -> str: - return await self.call_utility_async("get_weight_version") +@@ -1376,6 +1390,12 @@ + async def get_weight_version_async(self) -> str: + return await self.call_utility_consensus_async("get_weight_version") ++ + async def get_inflight_queue_diagnostics_async( + self, limit: int + ) -> list[dict[str, Any]]: + result = await self.call_utility_async("get_inflight_queue_diagnostics", limit) + return [result] -+ + async def add_lora_async(self, lora_request: LoRARequest) -> bool: return await self.call_utility_async("add_lora", lora_request) - -@@ -1644,6 +1664,18 @@ class DPLBAsyncMPClient(DPAsyncMPClient): - ) - )[0] +@@ -1713,6 +1733,18 @@ + # Only the result from the first engine is returned. + return (await self.call_utility_all_async(method, *args))[0] + async def get_inflight_queue_diagnostics_async( + self, limit: int @@ -212,11 +203,9 @@ index 6ad05e1f8c..97814cc28e 100644 @staticmethod async def process_engine_outputs( self: "DPLBAsyncMPClient", outputs: EngineCoreOutputs -diff --git a/vllm/v1/engine/llm_engine.py b/vllm/v1/engine/llm_engine.py -index c925ba90e8..6a3c5ffdfb 100644 --- a/vllm/v1/engine/llm_engine.py +++ b/vllm/v1/engine/llm_engine.py -@@ -451,6 +451,9 @@ class LLMEngine: +@@ -497,6 +497,9 @@ """Return the latest committed weight version.""" return self.engine_core.get_weight_version() diff --git a/docker/patch/latest/vllm-pd-request-metrics.patch b/docker/patch/latest/vllm-pd-request-metrics.patch index d2813ed5d..8bcff3fa7 100644 --- a/docker/patch/latest/vllm-pd-request-metrics.patch +++ b/docker/patch/latest/vllm-pd-request-metrics.patch @@ -1,22 +1,18 @@ -diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/base.py b/vllm/distributed/kv_transfer/kv_connector/v1/base.py -index 072018c039..f2feef64cb 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/base.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/base.py -@@ -167,6 +167,9 @@ class KVConnectorWorkerMetadata(ABC): +@@ -168,6 +168,9 @@ + will be aggregated using the `aggregate` method below, before being passed to the Scheduler KVConnector. """ - ++ + def get_request_metrics(self) -> dict[str, dict[str, float]]: + return {} -+ + @abstractmethod def aggregate( - self, other: "KVConnectorWorkerMetadata" -diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py -index 0f13a265df..9b34d898ea 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py -@@ -43,6 +43,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( +@@ -43,6 +43,7 @@ NixlAgentMetadata, NixlConnectorMetadata, NixlHandshakePayload, @@ -24,7 +20,7 @@ index 0f13a265df..9b34d898ea 100644 ReqId, ReqMeta, TransferHandle, -@@ -868,6 +869,8 @@ class NixlBaseConnectorWorker: +@@ -913,6 +914,8 @@ self.consumer_notification_counts_by_req = defaultdict[ReqId, int](int) self.expected_consumer_notifications_by_req: dict[ReqId, int] = {} self.xfer_stats = NixlKVConnectorStats() @@ -33,7 +29,7 @@ index 0f13a265df..9b34d898ea 100644 self._physical_blocks_per_logical_kv_block = 1 self._sync_block_size_with_kernel() -@@ -1318,6 +1321,7 @@ class NixlBaseConnectorWorker: +@@ -1367,6 +1370,7 @@ ): # Do NIXL handshake in background and add to _ready_requests when done. assert meta.remote is not None @@ -41,7 +37,7 @@ index 0f13a265df..9b34d898ea 100644 fut = self._ensure_handshake( remote_engine_id, meta.remote.host, -@@ -1335,6 +1339,7 @@ class NixlBaseConnectorWorker: +@@ -1384,6 +1388,7 @@ def request_ready(f: Future[Any], entry=(req_id, meta)): try: f.result() @@ -49,7 +45,7 @@ index 0f13a265df..9b34d898ea 100644 self._ready_requests.put(entry) except Exception as e: self._log_failure( -@@ -3033,6 +3038,13 @@ class NixlBaseConnectorWorker: +@@ -3140,6 +3145,13 @@ if xfer_state == "DONE": res = self.nixl_wrapper.get_xfer_telemetry(handle) self.xfer_stats.record_transfer(res) @@ -63,7 +59,7 @@ index 0f13a265df..9b34d898ea 100644 self.nixl_wrapper.release_xfer_handle(handle) elif xfer_state == "PROC": in_progress.append(handle) -@@ -3056,6 +3068,10 @@ class NixlBaseConnectorWorker: +@@ -3163,6 +3175,10 @@ in_progress.append(handle) if not in_progress: @@ -74,18 +70,16 @@ index 0f13a265df..9b34d898ea 100644 done_req_ids.add(req_id) del transfers[req_id] else: -@@ -3612,4 +3628,6 @@ class NixlBaseConnectorWorker: +@@ -3831,4 +3847,6 @@ for handle in handles: self.nixl_wrapper.release_xfer_handle(handle) self._recving_transfers.clear() + self.pending_request_metrics.clear() + self.request_metrics.requests.clear() self._finish_shutdown() -diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py -index ac929f7fcd..dc5e847fdf 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py -@@ -37,6 +37,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( +@@ -37,6 +37,7 @@ ) from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( NixlConnectorMetadata, @@ -93,7 +87,7 @@ index ac929f7fcd..dc5e847fdf 100644 ) from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import ( NixlPullConnectorScheduler, -@@ -258,6 +259,12 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA): +@@ -264,6 +265,12 @@ assert self.connector_worker is not None return self.connector_worker.get_block_ids_with_load_errors() @@ -106,11 +100,9 @@ index ac929f7fcd..dc5e847fdf 100644 def get_kv_connector_stats(self) -> KVConnectorStats | None: if self.connector_worker is None: return None -diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py -index ca6f30d0cf..914bd79489 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py -@@ -10,6 +10,7 @@ from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds, EngineId +@@ -10,6 +10,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import ( KVConnectorHandshakeMetadata, KVConnectorMetadata, @@ -118,10 +110,11 @@ index ca6f30d0cf..914bd79489 100644 ) from vllm.logger import init_logger -@@ -18,6 +19,22 @@ logger = init_logger(__name__) +@@ -17,6 +18,22 @@ + TransferHandle = int ReqId = str - ++ + +@dataclass +class NixlRequestMetrics(KVConnectorWorkerMetadata): @@ -137,11 +130,10 @@ index ca6f30d0cf..914bd79489 100644 + target[name] = target.get(name, 0) + value + return self + -+ + GET_META_MSG = b"get_meta_msg" - # Push-mode (WRITE-based) registration notification. -@@ -255,6 +272,7 @@ class ReqMeta: +@@ -258,6 +275,7 @@ # Worker-only, per-region physical pages to zero after a successful pull. # None selects group-based completion; empty lists mean no zeroing. region_blocks_to_zero: BlockIds | None = None @@ -149,11 +141,9 @@ index ca6f30d0cf..914bd79489 100644 class NixlConnectorMetadata(KVConnectorMetadata): -diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py -index 6539b7e57e..887912f94b 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py -@@ -143,6 +143,9 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker): +@@ -135,6 +135,9 @@ def _read_blocks_for_req(self, req_id: str, meta: ReqMeta): assert meta.remote is not None and self.transfer_topo is not None @@ -161,13 +151,11 @@ index 6539b7e57e..887912f94b 100644 + "kv_handshake_wait_worker_time_ms" + ] = meta.handshake_wait_time * 1000 engine_id = meta.remote.engine_id - # Update last activity from this remote. Mind that cleanup is done on main - # thread (this one), so we don't race on this structure. -diff --git a/vllm/entrypoints/generate/base/protocol.py b/vllm/entrypoints/generate/base/protocol.py -index 91d2e30a47..cbd34e8298 100644 + if engine_id in self._invalid_remote_engines: + self._handle_failed_transfer( --- a/vllm/entrypoints/generate/base/protocol.py +++ b/vllm/entrypoints/generate/base/protocol.py -@@ -79,6 +79,52 @@ class PerRequestMetrics(OpenAIBaseModel): +@@ -114,6 +114,52 @@ tokens_per_second: float | None = None # Experimental, subject to change. speculative_decoding: SpeculativeDecodingMetrics | None = None @@ -220,11 +208,9 @@ index 91d2e30a47..cbd34e8298 100644 class RequestResponseMetadata(BaseModel): -diff --git a/vllm/entrypoints/generate/base/serving.py b/vllm/entrypoints/generate/base/serving.py -index 5142346df6..213962bd55 100644 --- a/vllm/entrypoints/generate/base/serving.py +++ b/vllm/entrypoints/generate/base/serving.py -@@ -50,6 +50,7 @@ PRIORITY_HEADER = "X-Vllm-Priority" +@@ -51,6 +51,7 @@ def build_per_request_timing_metrics( metrics: RequestStateStats | None, num_generation_tokens: int, @@ -232,7 +218,7 @@ index 5142346df6..213962bd55 100644 ) -> PerRequestMetrics: """Build per-request timing metrics from ``RequestStateStats``. -@@ -94,11 +95,29 @@ def build_per_request_timing_metrics( +@@ -95,11 +96,29 @@ tokens_per_second = num_generation_tokens / inference_time_ms * 1000 return PerRequestMetrics( @@ -262,11 +248,9 @@ index 5142346df6..213962bd55 100644 ) -diff --git a/vllm/entrypoints/scale_out/factories.py b/vllm/entrypoints/scale_out/factories.py -index bd48c709dd..dfe4d09911 100644 --- a/vllm/entrypoints/scale_out/factories.py +++ b/vllm/entrypoints/scale_out/factories.py -@@ -57,6 +57,7 @@ def init_scale_out_state( +@@ -57,6 +57,7 @@ request_logger=request_logger, return_tokens_as_token_ids=args.return_tokens_as_token_ids, enable_prompt_tokens_details=args.enable_prompt_tokens_details, @@ -274,32 +258,17 @@ index bd48c709dd..dfe4d09911 100644 enable_log_outputs=args.enable_log_outputs, force_no_detokenize=args.tokens_only, ) -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index eb4000c7e2..50d376f747 100644 ---- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -+++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -397,8 +397,6 @@ class GenerateResponse(BaseModel): - "ECTransfer parameters used for encoder-cache disaggregated serving." - ), - ) -- -- - class DerenderChatRequest(BaseModel): - """Request for the /v1/chat/completions/derender endpoint (non-streaming). - -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index 6e86ac8341..5c38c0f672 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -19,6 +19,7 @@ from vllm.entrypoints.generate.base.protocol import ( +@@ -21,6 +21,7 @@ from vllm.entrypoints.generate.base.serving import ( GenerateBaseServing, build_spec_decoding_metrics, + build_per_request_timing_metrics, clamp_prompt_logprobs, + format_token_id_placeholder, ) - from vllm.entrypoints.openai.chat_completion.protocol import ( -@@ -76,6 +77,7 @@ class ServingTokens(GenerateBaseServing): +@@ -124,6 +125,7 @@ force_no_detokenize: bool = False, return_tokens_as_token_ids: bool = False, enable_prompt_tokens_details: bool = False, @@ -307,15 +276,15 @@ index 6e86ac8341..5c38c0f672 100644 enable_log_outputs: bool = False, ): super().__init__( -@@ -86,6 +88,7 @@ class ServingTokens(GenerateBaseServing): +@@ -134,6 +136,7 @@ ) self.online_renderer = online_renderer self.enable_prompt_tokens_details = enable_prompt_tokens_details + self.enable_per_request_metrics = enable_per_request_metrics self.enable_log_outputs = enable_log_outputs self.force_no_detokenize = force_no_detokenize - if force_no_detokenize: -@@ -385,11 +388,20 @@ class ServingTokens(GenerateBaseServing): + self.has_tokenizer = ( +@@ -486,11 +489,20 @@ request_metadata.final_usage_info = usage @@ -335,14 +304,14 @@ index 6e86ac8341..5c38c0f672 100644 - per_request_metrics = PerRequestMetrics(speculative_decoding=spec_stats) + per_request_metrics = per_request_metrics or PerRequestMetrics() + per_request_metrics.speculative_decoding = spec_stats - response = GenerateResponse( + response_fields: dict[str, Any] = dict( request_id=request_id, created=created_time, -@@ -407,6 +419,18 @@ class ServingTokens(GenerateBaseServing): - ec_transfer_params=final_res.ec_transfer_params, - ) +@@ -517,6 +529,18 @@ + else: + response = GenerateTokensResponse(choices=tokens_choices, **response_fields) -+ if per_request_metrics is not None and response.kv_transfer_params is not None: ++ if self.enable_per_request_metrics and response.kv_transfer_params is not None: + response.kv_transfer_params["prefill_metrics"] = { + "queue_time_ms": per_request_metrics.queue_time_ms, + "time_to_first_token_ms": per_request_metrics.time_to_first_token_ms, @@ -356,8 +325,8 @@ index 6e86ac8341..5c38c0f672 100644 + # Log complete response if output logging is enabled if self.enable_log_outputs and self.request_logger: - for choice in choices: -@@ -545,13 +569,20 @@ class ServingTokens(GenerateBaseServing): + for choice in response.choices: +@@ -690,13 +714,20 @@ ) if include_usage: @@ -379,14 +348,12 @@ index 6e86ac8341..5c38c0f672 100644 - ) + per_request_metrics = per_request_metrics or PerRequestMetrics() + per_request_metrics.speculative_decoding = spec_stats - final_chunk = GenerateStreamResponse( - request_id=request_id, - choices=[], -diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index 18bbc10283..3996a257e7 100644 + final_chunk: GenerateTokensStreamResponse | GenerateTextStreamResponse + if text_mode: + final_chunk = GenerateTextStreamResponse( --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py -@@ -1198,6 +1198,18 @@ class Scheduler(SchedulerInterface): +@@ -1219,6 +1219,18 @@ # Replayed tokens are already counted in the adopted hit; a # chunk that ends inside the replayed range needs no new slots. num_tokens_past_hit = max(num_new_tokens - num_replay_tokens, 0) @@ -405,29 +372,29 @@ index 18bbc10283..3996a257e7 100644 new_blocks = self.kv_cache_manager.allocate_slots( request, num_tokens_past_hit, -@@ -1221,6 +1233,13 @@ class Scheduler(SchedulerInterface): +@@ -1246,6 +1258,13 @@ + if request.has_encoder_inputs: self.encoder_cache_manager.free(request) break - ++ + if request.kv_allocation_started_at is not None: + request.kv_transfer_metrics["kv_allocation_wait_time_ms"] = ( + request.kv_transfer_metrics.get("kv_allocation_wait_time_ms", 0) + + (time.monotonic() - request.kv_allocation_started_at) * 1000 + ) + request.kv_allocation_started_at = None -+ + # KVTransfer: the connector uses this info to determine # if a load is needed. Note that - # This information is used to determine if a load is -@@ -1251,6 +1270,7 @@ class Scheduler(SchedulerInterface): +@@ -1286,6 +1305,7 @@ if load_kv_async: # If loading async, allocate memory and put request # into the WAITING_FOR_REMOTE_KV state. + request.start_remote_kv_wait() request.status = RequestStatus.WAITING_FOR_REMOTE_KVS - step_skipped_waiting.prepend_request(request) # Set num_computed_tokens even though KVs are not yet loaded. -@@ -2036,6 +2056,7 @@ class Scheduler(SchedulerInterface): + # request.num_computed_tokens will not be used anywhere until +@@ -2136,6 +2156,7 @@ pooler_output = pooler_outputs[req_index] if pooler_outputs else None kv_transfer_params = None ec_transfer_params = None @@ -435,7 +402,7 @@ index 18bbc10283..3996a257e7 100644 prefill_stats = None status_before_stop = request.status -@@ -2107,6 +2128,8 @@ class Scheduler(SchedulerInterface): +@@ -2216,6 +2237,8 @@ kv_transfer_params["remote_prefill_cached_tokens"] = ( prefill_stats.num_cached_tokens ) @@ -444,7 +411,7 @@ index 18bbc10283..3996a257e7 100644 if status_before_stop == RequestStatus.RUNNING: stopped_running_reqs.add(request) -@@ -2154,6 +2177,8 @@ class Scheduler(SchedulerInterface): +@@ -2258,6 +2281,8 @@ ), kv_transfer_params=kv_transfer_params, ec_transfer_params=ec_transfer_params, @@ -453,36 +420,37 @@ index 18bbc10283..3996a257e7 100644 trace_headers=request.trace_headers, routed_experts=routed_experts, num_nans_in_logits=request.num_nans_in_logits, -@@ -2494,6 +2519,7 @@ class Scheduler(SchedulerInterface): +@@ -2616,6 +2641,7 @@ self.num_spec_tokens ) if self.connector is not None: + request.kv_queue_started_at["initial"] = time.monotonic() self.connector.on_new_request(request) - if self.log_stats: - request.record_event(EngineCoreEventType.QUEUED) -@@ -2567,6 +2593,8 @@ class Scheduler(SchedulerInterface): + kv_transfer_params = request.kv_transfer_params + if kv_transfer_params and kv_transfer_params.get("do_remote_prefill"): +@@ -2694,6 +2720,8 @@ + self, request: Request, delay_free_blocks: bool = False ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: assert request.is_finished() - -+ request.stop_remote_kv_wait() + ++ request.stop_remote_kv_wait() + if self.aux_output_connector is not None: self.aux_output_connector.request_finished(request) - self._inflight_prefills.discard(request) -@@ -3022,6 +3050,8 @@ class Scheduler(SchedulerInterface): - if request.request_id not in self.finished_recving_kv_req_ids: +@@ -3152,6 +3180,8 @@ return False + # Promote request to waiting or preempted. self._update_waiting_for_remote_kv(request) + request.stop_remote_kv_wait() + request.kv_queue_started_at["post_receive"] = time.monotonic() if request.num_preemptions: request.status = RequestStatus.PREEMPTED else: -@@ -3059,6 +3089,16 @@ class Scheduler(SchedulerInterface): +@@ -3186,6 +3216,16 @@ + """ if self.connector is not None: self.connector.update_connector_output(kv_connector_output) - ++ + metadata = kv_connector_output.kv_connector_worker_meta + if metadata is not None: + for request_id, metrics in metadata.get_request_metrics().items(): @@ -492,28 +460,12 @@ index 18bbc10283..3996a257e7 100644 + request.kv_transfer_metrics[name] = ( + request.kv_transfer_metrics.get(name, 0) + value + ) -+ + # KV Connector:: update recv and send status from last step. for req_id in kv_connector_output.finished_recving or (): - logger.debug("Finished recving KV transfer for request %s", req_id) -diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py -index e5bbb96a9c..516918aa9b 100644 ---- a/vllm/v1/engine/__init__.py -+++ b/vllm/v1/engine/__init__.py -@@ -231,6 +231,8 @@ class EngineCoreOutput( - mm_cache_miss_hashes: list[str] | None = None - - new_sampling_mask: SamplingMaskLists | None = None -+ remote_kv_wait_time: float | None = None -+ kv_transfer_metrics: dict[str, float] | None = None - - # Per-request spec-decode acceptance; attached only on the final output. - # Appended last so `array_like` positional serialization stays compatible. -diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index e739e83b1c..465c7f3e8f 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -690,6 +690,17 @@ class OutputProcessor: +@@ -713,6 +713,17 @@ stop_reason = engine_core_output.stop_reason kv_transfer_params = engine_core_output.kv_transfer_params ec_transfer_params = engine_core_output.ec_transfer_params @@ -531,11 +483,9 @@ index e739e83b1c..465c7f3e8f 100644 if engine_core_output.routed_experts is not None: req_state.routed_experts_chunks.append( engine_core_output.routed_experts -diff --git a/vllm/v1/metrics/stats.py b/vllm/v1/metrics/stats.py -index 66088c7954..45afed4ff1 100644 --- a/vllm/v1/metrics/stats.py +++ b/vllm/v1/metrics/stats.py -@@ -234,6 +234,9 @@ class RequestStateStats: +@@ -274,6 +274,9 @@ # first token latency first_token_latency: float = 0.0 @@ -545,11 +495,9 @@ index 66088c7954..45afed4ff1 100644 # Track if this request is corrupted (NaNs in logits) is_corrupted: bool = False -diff --git a/vllm/v1/request.py b/vllm/v1/request.py -index 4b1b9ecc2e..4dcf2c5ae9 100644 --- a/vllm/v1/request.py +++ b/vllm/v1/request.py -@@ -103,6 +103,11 @@ class Request: +@@ -103,6 +103,11 @@ # P/D: Connector-specific KV transfer parameters. self.kv_transfer_params: dict[str, Any] | None = None @@ -561,7 +509,7 @@ index 4b1b9ecc2e..4dcf2c5ae9 100644 # E/P/D: Connector-specific encoder-cache transfer parameters. self.ec_transfer_params: dict[str, Any] | None = None -@@ -341,6 +346,16 @@ class Request: +@@ -341,6 +346,16 @@ ) -> None: self.events.append(EngineCoreEvent.new_event(event_type, timestamp)) diff --git a/docker/patch/latest/vllm-pull_weights.patch b/docker/patch/latest/vllm-pull_weights.patch index 4c44b9f2e..4782fcadd 100644 --- a/docker/patch/latest/vllm-pull_weights.patch +++ b/docker/patch/latest/vllm-pull_weights.patch @@ -1,8 +1,7 @@ diff --git a/vllm/config/model.py b/vllm/config/model.py -index 7b938bc8e5..8f7fb67152 100644 --- a/vllm/config/model.py +++ b/vllm/config/model.py -@@ -341,6 +341,9 @@ class ModelConfig: +@@ -367,6 +367,9 @@ used with `--generation-config auto`, the override parameters will be merged with the default config from the model. If used with `--generation-config vllm`, only the override parameters are used.""" @@ -13,10 +12,9 @@ index 7b938bc8e5..8f7fb67152 100644 """Enable sleep mode for the engine (only cuda and hip platforms are supported).""" diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py -index 0a5472bce7..7369989bb7 100644 --- a/vllm/engine/arg_utils.py +++ b/vllm/engine/arg_utils.py -@@ -740,6 +740,9 @@ class EngineArgs: +@@ -769,6 +769,9 @@ reasoning_config: ReasoningConfig = get_field(VllmConfig, "reasoning_config") generation_config: str = ModelConfig.generation_config @@ -24,33 +22,32 @@ index 0a5472bce7..7369989bb7 100644 + ModelConfig.custom_pull_weights_pre_read_hook + ) enable_sleep_mode: bool = ModelConfig.enable_sleep_mode + sleep_mode_offload_cudagraph: bool = ModelConfig.sleep_mode_offload_cudagraph sleep_preserve_parameter_names: list[str] = get_field( - ModelConfig, "sleep_preserve_parameter_names" -@@ -975,6 +978,10 @@ class EngineArgs: +@@ -1004,6 +1007,10 @@ + ) model_group.add_argument( "--override-generation-config", **model_kwargs["override_generation_config"] - ) ++ ) + model_group.add_argument( + "--custom-pull-weights-pre-read-hook", + **model_kwargs["custom_pull_weights_pre_read_hook"], -+ ) + ) model_group.add_argument( "--enable-sleep-mode", **model_kwargs["enable_sleep_mode"] - ) -@@ -1910,6 +1917,7 @@ class EngineArgs: +@@ -1988,6 +1995,7 @@ pooler_config=self.pooler_config, generation_config=self.generation_config, override_generation_config=self.override_generation_config, + custom_pull_weights_pre_read_hook=self.custom_pull_weights_pre_read_hook, enable_sleep_mode=self.enable_sleep_mode, + sleep_mode_offload_cudagraph=self.sleep_mode_offload_cudagraph, sleep_preserve_parameter_names=self.sleep_preserve_parameter_names, - enable_cumem_allocator=self.enable_cumem_allocator, diff --git a/vllm/utils/local_checkpoint.py b/vllm/utils/local_checkpoint.py new file mode 100644 -index 0000000000..42be58d249 --- /dev/null +++ b/vllm/utils/local_checkpoint.py -@@ -0,0 +1,319 @@ +@@ -0,0 +1,329 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Maintain a host-local checkpoint from full and delta weight versions.""" @@ -220,8 +217,18 @@ index 0000000000..42be58d249 + + +def _tensor_locations(checkpoint_dir: str) -> dict[str, tuple[str, int, int]]: ++ """Map each tensor to (file, byte offset, nbytes), honoring the HF shard index.""" ++ index_path = os.path.join(checkpoint_dir, "model.safetensors.index.json") ++ if os.path.isfile(index_path): ++ with open(index_path) as index_file: ++ weight_map = json.load(index_file)["weight_map"] ++ # Match the model loader: unreferenced files may contain stale copies of ++ # the same tensors. Open indexed shards directly so missing files fail. ++ paths = [os.path.join(checkpoint_dir, name) for name in sorted(set(weight_map.values()))] ++ else: ++ paths = glob.glob(os.path.join(checkpoint_dir, "*.safetensors")) + locations = {} -+ for path in glob.glob(os.path.join(checkpoint_dir, "*.safetensors")): ++ for path in paths: + with open(path, "rb") as tensor_file: + (header_length,) = struct.unpack(" None: ++ """Release a trainer connection without shutting down inference.""" ++ if self._weight_update_active: ++ raise RuntimeError("Cannot release a trainer connection during a weight update") ++ if self.weight_transfer_engine is not None: ++ self.weight_transfer_engine.shutdown() ++ + def start_weight_update(self) -> None: + """Start a new weight update session. + diff --git a/docker/patch/latest/vllm-rust-frontend.patch b/docker/patch/latest/vllm-rust-frontend.patch new file mode 100644 index 000000000..ca7759225 --- /dev/null +++ b/docker/patch/latest/vllm-rust-frontend.patch @@ -0,0 +1,1872 @@ +diff --git a/rust/proto/inference.proto b/rust/proto/inference.proto +index 2bc35bcb64a..7b792b901bc 100644 +--- a/rust/proto/inference.proto ++++ b/rust/proto/inference.proto +@@ -177,6 +177,12 @@ message SequenceOutput { + optional FinishInfo finish_info = 8; + // Only present in final output when sampling-mask capture is enabled. + repeated TokenIds sampling_mask = 10; ++ // Aligned with sampling_mask when paired logprobs are enabled. ++ repeated SamplingMaskLogprobs sampling_mask_logprobs = 11; ++} ++ ++message SamplingMaskLogprobs { ++ repeated float values = 1; + } + + // Prompt info, returned in the first response +diff --git a/rust/src/cmd/src/cli.rs b/rust/src/cmd/src/cli.rs +index 1269e0b8afa..22b7c12af92 100644 +--- a/rust/src/cmd/src/cli.rs ++++ b/rust/src/cmd/src/cli.rs +@@ -330,6 +330,10 @@ pub struct SharedRuntimeArgs { + #[serde(default)] + pub enable_log_requests: bool, + ++ #[arg(long, default_missing_value = "true", num_args = 0..=1)] ++ #[serde(default)] ++ pub enable_per_request_metrics: bool, ++ + /// Include prompt_tokens_details in usage when cached prompt tokens are + /// present. + #[arg( +@@ -624,6 +628,7 @@ impl SharedRuntimeArgs { + fn api_server_options(&self) -> ApiServerOptions { + ApiServerOptions { + enable_log_requests: self.enable_log_requests, ++ enable_per_request_metrics: self.enable_per_request_metrics, + enable_prompt_tokens_details: self.enable_prompt_tokens_details, + enable_request_id_headers: self.enable_request_id_headers, + enable_scale_out: self.enable_scale_out, +diff --git a/rust/src/cmd/src/cli/tests.rs b/rust/src/cmd/src/cli/tests.rs +index 944febc36c1..d5ffa9e4bd4 100644 +--- a/rust/src/cmd/src/cli/tests.rs ++++ b/rust/src/cmd/src/cli/tests.rs +@@ -197,6 +197,7 @@ fn serve_args_forward_python_flags_with_separator() { + lora_modules: [], + chat_template_content_format: Auto, + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +@@ -1038,6 +1039,7 @@ fn frontend_args_accept_json() { + lora_modules: [], + chat_template_content_format: Auto, + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +@@ -1710,6 +1712,7 @@ fn serve_args_accept_handshake_aliases() { + lora_modules: [], + chat_template_content_format: Auto, + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +@@ -1868,6 +1871,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() { + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +@@ -1963,6 +1967,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() { + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +@@ -2102,6 +2107,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, ++ enable_per_request_metrics: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + enable_scale_out: false, +diff --git a/rust/src/cmd/src/cli/unsupported.rs b/rust/src/cmd/src/cli/unsupported.rs +index f65f766e847..9d0bdcf7ece 100644 +--- a/rust/src/cmd/src/cli/unsupported.rs ++++ b/rust/src/cmd/src/cli/unsupported.rs +@@ -480,15 +480,6 @@ pub struct ServerUnsupportedArgs { + #[arg(long)] + pub max_log_len: Option, + +- /// If set to True, include per-request timing metrics in API responses. +- #[arg( +- long, +- visible_alias = "no-enable-per-request-metrics", +- default_missing_value = "true", +- num_args = 0..=1 +- )] +- pub enable_per_request_metrics: Option, +- + /// If set to True, enable tracking server_load_metrics in the app state. + #[arg( + long, +diff --git a/rust/src/engine-core-client/src/protocol/logprobs/array.rs b/rust/src/engine-core-client/src/protocol/logprobs/array.rs +index 919ef80f46a..b4aa011d6f8 100644 +--- a/rust/src/engine-core-client/src/protocol/logprobs/array.rs ++++ b/rust/src/engine-core-client/src/protocol/logprobs/array.rs +@@ -78,6 +78,22 @@ pub(crate) fn decode_array1_u32( + Ok(data) + } + ++pub(crate) fn decode_array1_f32( ++ value: WireNdArray, ++ field: &str, ++ frames: &[Bytes], ++) -> Result> { ++ let (shape, bytes, _, endianness) = ++ decode_array_metadata(value, field, frames, &[TensorDtype::F32])?; ++ if shape.len() != 1 { ++ return Err(decode_error( ++ field, ++ &format!("expected rank-1 array, got rank {}", shape.len()), ++ )); ++ } ++ decode_f32_vec(&bytes, endianness, field) ++} ++ + pub(super) fn decode_array2_f32( + value: WireNdArray, + field: &str, +diff --git a/rust/src/engine-core-client/src/protocol/output.rs b/rust/src/engine-core-client/src/protocol/output.rs +index c33062eeb3c..a8fd165e420 100644 +--- a/rust/src/engine-core-client/src/protocol/output.rs ++++ b/rust/src/engine-core-client/src/protocol/output.rs +@@ -138,6 +138,12 @@ pub struct EngineCoreOutput { + /// the first output of a request that asked for them. + #[serde(default)] + pub prompt_token_id_logprobs: Option, ++ #[serde(default)] ++ pub weight_version: Option, ++ #[serde(default)] ++ pub remote_kv_wait_time: Option, ++ #[serde(default)] ++ pub kv_transfer_metrics: Option, + } + + /// Raw per-sequence speculative-decoding accumulator. +@@ -542,6 +548,9 @@ mod tests { + new_sampling_mask: None, + spec_decode_metrics: None, + prompt_token_id_logprobs: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, + }, + ], + scheduler_stats: None, +diff --git a/rust/src/engine-core-client/src/protocol/sampling_mask.rs b/rust/src/engine-core-client/src/protocol/sampling_mask.rs +index 7f5c4592a7d..a1eede0f28d 100644 +--- a/rust/src/engine-core-client/src/protocol/sampling_mask.rs ++++ b/rust/src/engine-core-client/src/protocol/sampling_mask.rs +@@ -9,13 +9,30 @@ use serde::{Deserialize, Deserializer, Serialize}; + use serde_tuple::{Deserialize_tuple, Serialize_tuple}; + + use crate::error::{Error, Result, bail_ext_value_decode}; +-use crate::protocol::logprobs::array::decode_array1_u32; ++use crate::protocol::logprobs::array::{decode_array1_f32, decode_array1_u32}; + use crate::protocol::tensor::WireNdArray; + +-/// Decoded sampling-mask support sets, one row per generated token position. +-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] ++/// Decoded sampling support and optional aligned logprobs, one row per token. ++#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] + pub struct SamplingMask { + pub rows: Vec>, ++ pub logprobs: Option>>, ++} ++ ++impl SamplingMask { ++ pub fn append(&mut self, mut other: Self) { ++ self.rows.append(&mut other.rows); ++ if let Some(mut scores) = other.logprobs { ++ self.logprobs.get_or_insert_default().append(&mut scores); ++ } ++ } ++ ++ pub fn truncate(&mut self, len: usize) { ++ self.rows.truncate(len); ++ if let Some(scores) = &mut self.logprobs { ++ scores.truncate(len); ++ } ++ } + } + + /// Python `SamplingMaskLists` tuple before ndarray raw views are resolved. +@@ -26,6 +43,8 @@ pub struct WireSamplingMask { + offsets: Option, + #[serde(default)] + cu_num_generated_tokens: Option>, ++ #[serde(default)] ++ logprobs: Option, + } + + /// Sampling-mask field while it transitions from Python wire data to rows. +@@ -95,11 +114,21 @@ impl WireSamplingMask { + value.rows.len() + )); + }; ++ if let Some(scores) = value.logprobs.as_ref() ++ && (scores.len() != 1 || scores[0].len() != row.len()) ++ { ++ return Err("sampling-mask logprobs must align with token IDs".into()); ++ } + let token_ids = row.iter().map(|&token_id| i64::from(token_id)).collect::>(); + Ok(Self { + token_ids: WireNdArray::from_i64(vec![token_ids.len()], token_ids)?, + offsets: None, + cu_num_generated_tokens: None, ++ logprobs: value ++ .logprobs ++ .as_ref() ++ .map(|rows| WireNdArray::from_f32(vec![row.len()], rows[0].clone())) ++ .transpose()?, + }) + } + +@@ -112,9 +141,17 @@ impl WireSamplingMask { + + let token_ids = + decode_array1_u32(self.token_ids, &format!("{field_prefix}.token_ids"), frames)?; ++ let logprobs = self ++ .logprobs ++ .map(|value| decode_array1_f32(value, &format!("{field_prefix}.logprobs"), frames)) ++ .transpose()?; ++ if logprobs.as_ref().is_some_and(|values| values.len() != token_ids.len()) { ++ bail_ext_value_decode!("{field_prefix}.logprobs: length must match token_ids"); ++ } + let Some(offsets) = self.offsets else { + return Ok(SamplingMask { + rows: vec![token_ids], ++ logprobs: logprobs.map(|values| vec![values]), + }); + }; + let offsets = decode_array1_u32(offsets, &format!("{field_prefix}.offsets"), frames)?; +@@ -132,14 +169,21 @@ impl WireSamplingMask { + ); + } + +- let rows = offsets +- .windows(2) +- .map(|pair| token_ids[pair[0] as usize..pair[1] as usize].to_vec()) +- .collect(); +- Ok(SamplingMask { rows }) ++ Ok(SamplingMask { ++ rows: split_rows(&token_ids, &offsets), ++ logprobs: logprobs.map(|values| split_rows(&values, &offsets)), ++ }) + } + } + ++/// Split CSR values into per-request rows using validated offsets. ++fn split_rows(values: &[T], offsets: &[u32]) -> Vec> { ++ offsets ++ .windows(2) ++ .map(|pair| values[pair[0] as usize..pair[1] as usize].to_vec()) ++ .collect() ++} ++ + #[cfg(test)] + mod tests { + use super::*; +@@ -156,6 +200,7 @@ mod tests { + token_ids: WireNdArray::from_i64(vec![3], vec![2, 12, 16]).unwrap(), + offsets: None, + cu_num_generated_tokens: None, ++ logprobs: None, + }; + let encoded = encode_msgpack(&wire).unwrap(); + let decoded: MaybeWireSamplingMask = decode_msgpack(&encoded).unwrap(); +@@ -164,12 +209,24 @@ mod tests { + assert_eq!(decoded.into_direct().unwrap().rows, vec![vec![2, 12, 16]]); + } + ++ #[test] ++ fn decodes_legacy_mask_without_logprobs() { ++ let token_ids = WireNdArray::from_i64(vec![2], vec![2, 12]).unwrap(); ++ let encoded = ++ encode_msgpack(&(token_ids, None::, None::>)).unwrap(); ++ let decoded: MaybeWireSamplingMask = decode_msgpack(&encoded).unwrap(); ++ let mask = decoded.resolve(&[], "new_sampling_mask").unwrap(); ++ assert_eq!(mask.rows, vec![vec![2, 12]]); ++ assert_eq!(mask.logprobs, None); ++ } ++ + #[test] + fn decodes_csr_rows_and_rejects_bad_terminal_offset() { + let wire = WireSamplingMask { + token_ids: WireNdArray::from_i64(vec![4], vec![2, 12, 16, 18]).unwrap(), + offsets: Some(WireNdArray::from_i64(vec![3], vec![0, 2, 4]).unwrap()), + cu_num_generated_tokens: None, ++ logprobs: None, + }; + let decoded = MaybeWireSamplingMask::Wire(Box::new(wire)) + .resolve(&[], "new_sampling_mask") +@@ -183,6 +240,7 @@ mod tests { + token_ids: WireNdArray::from_i64(vec![2], vec![2, 12]).unwrap(), + offsets: Some(WireNdArray::from_i64(vec![2], vec![0, 1]).unwrap()), + cu_num_generated_tokens: None, ++ logprobs: None, + }; + let error = MaybeWireSamplingMask::Wire(Box::new(malformed)) + .resolve(&[], "new_sampling_mask") +@@ -207,6 +265,11 @@ mod tests { + data: WireArrayData::AuxIndex(2), + }), + cu_num_generated_tokens: None, ++ logprobs: Some(WireNdArray { ++ dtype: NumpyDtype::little(TensorDtype::F32), ++ shape: vec![4], ++ data: WireArrayData::AuxIndex(3), ++ }), + }))), + ..Default::default() + }; +@@ -220,11 +283,16 @@ mod tests { + let token_ids = + [2_i32, 12, 16, 18].into_iter().flat_map(i32::to_le_bytes).collect::>(); + let offsets = [0_i64, 2, 4].into_iter().flat_map(i64::to_le_bytes).collect::>(); ++ let logprobs = [-0.1_f32, -0.2, -0.3, -0.4] ++ .into_iter() ++ .flat_map(f32::to_le_bytes) ++ .collect::>(); + + let decoded = decode_engine_core_outputs(&[ + Bytes::from(primary), + Bytes::from(token_ids), + Bytes::from(offsets), ++ Bytes::from(logprobs), + ]) + .unwrap(); + let mask = decoded.as_request_batch().unwrap().outputs[0] +@@ -233,5 +301,9 @@ mod tests { + .unwrap(); + + assert_eq!(mask.rows, vec![vec![2, 12], vec![16, 18]]); ++ assert_eq!( ++ mask.logprobs, ++ Some(vec![vec![-0.1, -0.2], vec![-0.3, -0.4]]) ++ ); + } + } +diff --git a/rust/src/engine-core-client/src/tests/client.rs b/rust/src/engine-core-client/src/tests/client.rs +index 0ac81764a9e..69c441ab2d1 100644 +--- a/rust/src/engine-core-client/src/tests/client.rs ++++ b/rust/src/engine-core-client/src/tests/client.rs +@@ -2858,6 +2858,11 @@ fn python_msgpack_fixtures_match_rust_encoding() { + new_sampling_mask: None, + spec_decode_metrics: None, + prompt_token_id_logprobs: None, ++ weight_version: Some( ++ "step-7", ++ ), ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, + }, + ], + scheduler_stats: None, +diff --git a/rust/src/engine-core-client/src/tests/python_compat.py b/rust/src/engine-core-client/src/tests/python_compat.py +index bd8d40dc32f..0b3d096d45b 100755 +--- a/rust/src/engine-core-client/src/tests/python_compat.py ++++ b/rust/src/engine-core-client/src/tests/python_compat.py +@@ -117,6 +117,9 @@ class EngineCoreOutput( + new_sampling_mask: object | None = None + spec_decode_metrics: object | None = None + prompt_token_id_logprobs: object | None = None ++ weight_version: str | None = None ++ remote_kv_wait_time: float | None = None ++ kv_transfer_metrics: dict[str, float] | None = None + + + class ExtendedEngineCoreOutput(EngineCoreOutput): +@@ -237,6 +240,7 @@ outputs = EngineCoreOutputs( + request_id="req-1", + new_token_ids=[7, 8], + finish_reason=FinishReason.LENGTH, ++ weight_version="step-7", + ) + ], + finished_requests={"req-1"}, +diff --git a/rust/src/llm/src/lib.rs b/rust/src/llm/src/lib.rs +index 015624b409c..98e956795c2 100644 +--- a/rust/src/llm/src/lib.rs ++++ b/rust/src/llm/src/lib.rs +@@ -14,7 +14,7 @@ mod request_metrics; + pub use error::{Error, Result}; + pub use output::{ + CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStream, +- GenerateOutputStreamExt, GeneratePromptInfo, TokenUsage, ++ GenerateOutputStreamExt, GeneratePromptInfo, RequestTimingStats, TokenUsage, + }; + pub use request::GenerateRequest; + pub use request_metrics::current_unix_timestamp_secs; +diff --git a/rust/src/llm/src/output.rs b/rust/src/llm/src/output.rs +index 4b6d0335399..5608f92dd95 100644 +--- a/rust/src/llm/src/output.rs ++++ b/rust/src/llm/src/output.rs +@@ -33,6 +33,15 @@ pub struct TokenUsage { + pub cached_token_count: usize, + } + ++#[derive(Debug, Clone, Copy, Default, PartialEq)] ++pub struct RequestTimingStats { ++ pub queued_ts: f64, ++ pub scheduled_ts: f64, ++ pub first_token_ts: f64, ++ pub last_token_ts: f64, ++ pub num_generation_tokens: u32, ++} ++ + /// Final raw token output plus terminal stream metadata. + #[derive(Debug, Clone, PartialEq)] + pub struct CollectedGenerateOutput { +@@ -53,6 +62,10 @@ pub struct CollectedGenerateOutput { + pub sampling_mask: Option, + /// Per-request speculative-decoding metrics from the terminal output. + pub spec_decode_metrics: Option, ++ pub weight_version: Option, ++ pub remote_kv_wait_time: Option, ++ pub kv_transfer_metrics: Option, ++ pub request_timings: Option, + } + + /// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for +@@ -170,6 +183,10 @@ pub struct GenerateOutput { + pub sampling_mask: Option, + /// Per-request speculative-decoding metrics, present on terminal outputs. + pub spec_decode_metrics: Option, ++ pub weight_version: Option, ++ pub remote_kv_wait_time: Option, ++ pub kv_transfer_metrics: Option, ++ pub request_timings: Option, + } + + impl GenerateOutput { +@@ -220,6 +237,10 @@ impl GenerateOutput { + ec_transfer_params: None, + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + } + } + } +@@ -315,6 +336,7 @@ impl Stream for GenerateOutputStream { + if let Some(finish_reason) = finish_reason.as_ref() { + self.request_metrics.record_finished(received_at, finish_reason.clone()); + } ++ let request_timings = finish_reason.as_ref().map(|_| self.request_metrics.snapshot()); + + let output = GenerateOutput { + request_id: raw.request_id, +@@ -327,6 +349,10 @@ impl Stream for GenerateOutputStream { + ec_transfer_params: raw.ec_transfer_params, + sampling_mask, + spec_decode_metrics: raw.spec_decode_metrics, ++ weight_version: raw.weight_version, ++ remote_kv_wait_time: raw.remote_kv_wait_time, ++ kv_transfer_metrics: raw.kv_transfer_metrics, ++ request_timings, + }; + + Poll::Ready(Some(Ok(output))) +@@ -400,8 +426,8 @@ impl> + Send> T { + existing.logprobs = Some(step_logprobs); + } + } +- if let Some(mut mask) = sampling_mask { +- existing.sampling_mask.get_or_insert_default().rows.append(&mut mask.rows); ++ if let Some(mask) = sampling_mask { ++ existing.sampling_mask.get_or_insert_default().append(mask); + } + } else { + collected = Some(CollectedGenerateOutput { +@@ -421,6 +447,10 @@ impl> + Send> T { + ec_transfer_params: None, + sampling_mask, + spec_decode_metrics: None, ++ weight_version: output.weight_version.clone(), ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }); + } + +@@ -435,6 +465,10 @@ impl> + Send> T { + collected.kv_transfer_params = output.kv_transfer_params; + collected.ec_transfer_params = output.ec_transfer_params; + collected.spec_decode_metrics = output.spec_decode_metrics; ++ collected.weight_version = output.weight_version; ++ collected.remote_kv_wait_time = output.remote_kv_wait_time; ++ collected.kv_transfer_metrics = output.kv_transfer_metrics; ++ collected.request_timings = output.request_timings; + if let Some(mask) = collected.sampling_mask.as_ref() + && mask.rows.len() != collected.token_ids.len() + { +diff --git a/rust/src/llm/src/request_metrics.rs b/rust/src/llm/src/request_metrics.rs +index 98f9cadd1a3..99556009851 100644 +--- a/rust/src/llm/src/request_metrics.rs ++++ b/rust/src/llm/src/request_metrics.rs +@@ -12,7 +12,7 @@ use vllm_metrics::{ + U64Counter, + }; + +-use crate::FinishReason; ++use crate::{FinishReason, RequestTimingStats}; + + const PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE: &str = "local_compute"; + const PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT: &str = "local_cache_hit"; +@@ -169,6 +169,16 @@ impl RequestMetricsTracker { + } + } + ++ pub(crate) fn snapshot(&self) -> RequestTimingStats { ++ RequestTimingStats { ++ queued_ts: self.queued_ts, ++ scheduled_ts: self.scheduled_ts, ++ first_token_ts: self.first_token_ts, ++ last_token_ts: self.last_token_ts, ++ num_generation_tokens: self.num_generation_tokens, ++ } ++ } ++ + /// Emit the terminal request metrics once a finished output has been + /// observed. + /// +diff --git a/rust/src/llm/tests/generate.rs b/rust/src/llm/tests/generate.rs +index c596c89ba3b..00645a177fa 100644 +--- a/rust/src/llm/tests/generate.rs ++++ b/rust/src/llm/tests/generate.rs +@@ -339,6 +339,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { + new_sampling_mask: Some(MaybeWireSamplingMask::Direct( + SamplingMask { + rows: vec![vec![1, 33, 99]], ++ logprobs: Some(vec![vec![-0.1, -0.2, -0.3]]), + }, + )), + ..request_output_with_logprobs( +@@ -353,6 +354,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { + new_sampling_mask: Some(MaybeWireSamplingMask::Direct( + SamplingMask { + rows: vec![vec![2, 44, 88]], ++ logprobs: Some(vec![vec![-0.4, -0.5, -0.6]]), + }, + )), + ..request_output_with_logprobs_and_kv( +@@ -401,6 +403,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { + collected.sampling_mask, + Some(SamplingMask { + rows: vec![vec![1, 33, 99], vec![2, 44, 88]], ++ logprobs: Some(vec![vec![-0.1, -0.2, -0.3], vec![-0.4, -0.5, -0.6]]), + }) + ); + } +@@ -422,8 +425,13 @@ async fn collect_output_rejects_partial_sampling_mask() { + ec_transfer_params: None, + sampling_mask: Some(SamplingMask { + rows: vec![vec![1, 33, 99]], ++ ..Default::default() + }), + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }; + + let error = futures::stream::iter([Ok(output)]).collect_output().await.unwrap_err(); +diff --git a/rust/src/server/src/config.rs b/rust/src/server/src/config.rs +index 8e51f1440b3..bfd2ebc410f 100644 +--- a/rust/src/server/src/config.rs ++++ b/rust/src/server/src/config.rs +@@ -53,6 +53,7 @@ pub enum CoordinatorMode { + pub struct ApiServerOptions { + /// Log a summary line for each completed request. + pub enable_log_requests: bool, ++ pub enable_per_request_metrics: bool, + /// When `true`, include prompt token cache details in response usage. + pub enable_prompt_tokens_details: bool, + /// When `true`, set `X-Request-Id` on every HTTP response. +diff --git a/rust/src/server/src/grpc/convert.rs b/rust/src/server/src/grpc/convert.rs +index d5dd1ae64df..2531e22813f 100644 +--- a/rust/src/server/src/grpc/convert.rs ++++ b/rust/src/server/src/grpc/convert.rs +@@ -350,6 +350,17 @@ pub fn to_sequence_output( + .and_then(|finished| finished.sampling_mask.as_ref()) + .map(|mask| mask.rows.iter().map(|row| pb::TokenIds { ids: row.clone() }).collect()) + .unwrap_or_default(); ++ let sampling_mask_logprobs = finished ++ .and_then(|finished| finished.sampling_mask.as_ref()) ++ .and_then(|mask| mask.logprobs.as_ref()) ++ .map(|rows| { ++ rows.iter() ++ .map(|row| pb::SamplingMaskLogprobs { ++ values: row.clone(), ++ }) ++ .collect() ++ }) ++ .unwrap_or_default(); + + Ok(pb::SequenceOutput { + index: 0, // TODO: multi-sequence (n > 1) not supported +@@ -369,6 +380,7 @@ pub fn to_sequence_output( + candidate_tokens: candidates, + finish_info, + sampling_mask, ++ sampling_mask_logprobs, + }) + } + +@@ -879,6 +891,7 @@ mod tests { + let mut fin = finished(FinishReason::Length); + fin.sampling_mask = Some(SamplingMask { + rows: vec![vec![1, 10], vec![2, 20]], ++ logprobs: Some(vec![vec![-0.1, -2.0], vec![-0.2, -1.8]]), + }); + + let terminal = +@@ -894,7 +907,19 @@ mod tests { + pb::TokenIds { ids: vec![2, 20] } + ] + ); ++ assert_eq!( ++ terminal.sampling_mask_logprobs, ++ vec![ ++ pb::SamplingMaskLogprobs { ++ values: vec![-0.1, -2.0], ++ }, ++ pb::SamplingMaskLogprobs { ++ values: vec![-0.2, -1.8], ++ }, ++ ] ++ ); + assert!(intermediate.sampling_mask.is_empty()); ++ assert!(intermediate.sampling_mask_logprobs.is_empty()); + } + + #[test] +diff --git a/rust/src/server/src/routes.rs b/rust/src/server/src/routes.rs +index 28f35573249..922e95f8f57 100644 +--- a/rust/src/server/src/routes.rs ++++ b/rust/src/server/src/routes.rs +@@ -90,6 +90,7 @@ fn build_router_with_options( + let mut router = Router::new() + // Health & monitoring + .route("/health", get(health::health)) ++ .route("/health_generate", get(health::health_generate)) + .route("/metrics", get(metrics::scrape)) + .route("/load", get(load::load)) + .route("/version", get(version::version)) +diff --git a/rust/src/server/src/routes/health.rs b/rust/src/server/src/routes/health.rs +index 743c0a83f4e..794a76eb905 100644 +--- a/rust/src/server/src/routes/health.rs ++++ b/rust/src/server/src/routes/health.rs +@@ -2,12 +2,31 @@ + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + + use std::sync::Arc; ++use std::time::{SystemTime, UNIX_EPOCH}; + + use axum::extract::State; + use axum::http::StatusCode; ++use futures::StreamExt; ++use serde::Deserialize; ++use tokio::time::{Duration, timeout}; ++use uuid::Uuid; ++use vllm_engine_core_client::protocol::request::EngineCoreRequest; ++use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; + + use crate::state::AppState; + ++#[derive(Deserialize)] ++struct QueueLoad { ++ num_requests: usize, ++} ++ ++#[derive(Deserialize)] ++struct EngineLoad { ++ queues: Vec, ++ #[serde(default)] ++ has_pending_work: bool, ++} ++ + pub async fn health(State(state): State>) -> StatusCode { + if state.chat.engine_core_client().is_healthy() { + StatusCode::OK +@@ -15,3 +34,47 @@ pub async fn health(State(state): State>) -> StatusCode { + StatusCode::SERVICE_UNAVAILABLE + } + } ++ ++pub async fn health_generate(State(state): State>) -> StatusCode { ++ let client = state.chat.engine_core_client(); ++ if !client.is_healthy() { ++ return StatusCode::SERVICE_UNAVAILABLE; ++ } ++ let probe = async { ++ let loads = client ++ .call_utility::("get_inflight_queue_diagnostics", (0,)) ++ .await ++ .map_err(|_| ())?; ++ if loads.iter().any(|load| { ++ load.has_pending_work || load.queues.iter().any(|queue| queue.num_requests > 0) ++ }) { ++ return Ok(()); ++ } ++ let mut stream = client ++ .call(EngineCoreRequest { ++ request_id: format!("healthcheck-{}", Uuid::new_v4()), ++ prompt_token_ids: Some(vec![0]), ++ sampling_params: Some(EngineCoreSamplingParams { ++ max_tokens: 1, ++ temperature: 1.0, ++ top_k: 1, ++ ..Default::default() ++ }), ++ arrival_time: SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .expect("system clock before Unix epoch") ++ .as_secs_f64(), ++ ..Default::default() ++ }) ++ .await ++ .map_err(|_| ())?; ++ match stream.next().await { ++ Some(Ok(_)) => Ok(()), ++ _ => Err(()), ++ } ++ }; ++ match timeout(Duration::from_secs(20), probe).await { ++ Ok(Ok(())) if client.is_healthy() => StatusCode::OK, ++ _ => StatusCode::SERVICE_UNAVAILABLE, ++ } ++} +diff --git a/rust/src/server/src/routes/inference/generate.rs b/rust/src/server/src/routes/inference/generate.rs +index 99d7aec1b77..5dd54d453e4 100644 +--- a/rust/src/server/src/routes/inference/generate.rs ++++ b/rust/src/server/src/routes/inference/generate.rs +@@ -26,7 +26,8 @@ use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs}; + use vllm_engine_core_client::protocol::multimodal::MmFeatureSpec; + use vllm_engine_core_client::protocol::tensor::WireNdArray; + use vllm_llm::{ +- CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, TokenUsage, ++ CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, ++ RequestTimingStats, TokenUsage, + }; + + use self::convert::{ResponseOptions, prepare_generate_request}; +@@ -34,7 +35,7 @@ use self::types::{ + GenerateLogProb, GenerateLogProbs, GenerateLogProbsContent, GenerateLogprob, GenerateResponse, + GenerateResponseChoice, GenerateResponseStreamChoice, GenerateStreamResponse, + MultiModalPlaceholders, PerRequestMetrics, PlaceholderRangeInfo, SpeculativeDecodingMetrics, +- StreamingSpeculativeDecodingMetrics, ++ StreamingPerRequestMetrics, StreamingSpeculativeDecodingMetrics, + }; + pub(crate) use self::types::{GenerateRequest, GenerateSamplingParams}; + pub(crate) use self::validate::validate_request_compat; +@@ -81,6 +82,11 @@ pub async fn generate( + .then(|| extract_mm_placeholders(mm_features.as_deref())) + .flatten(); + ++ let prefill_metrics = body ++ .kv_transfer_params ++ .as_ref() ++ .and_then(|params| params.get("prefill_metrics")) ++ .cloned(); + let prepared = + match prepare_generate_request(body, &lora_resolution, request_context, mm_features) { + Ok(prepared) => prepared, +@@ -115,6 +121,7 @@ pub async fn generate( + api_server_options, + prepared.options, + mm_placeholders, ++ prefill_metrics, + ); + let sse_stream = generate_sse_stream(chunk_stream).instrument(request_span); + +@@ -138,6 +145,7 @@ pub async fn generate( + api_server_options, + prepared.options, + mm_placeholders, ++ prefill_metrics, + ) { + Ok(response) => response, + Err(error) => return error.into_response(), +@@ -152,6 +160,7 @@ async fn generate_chunk_stream( + request_id: String, + ApiServerOptions { + enable_log_requests, ++ enable_per_request_metrics, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, +@@ -164,6 +173,7 @@ async fn generate_chunk_stream( + return_token_ids, + }: ResponseOptions, + mut mm_placeholders: Option, ++ prefill_metrics: Option, + mut y: TryYielder, + ) -> Result<(), ApiError> { + pin_mut!(stream); +@@ -171,10 +181,22 @@ async fn generate_chunk_stream( + let mut prompt_token_ids = None; + let mut usage = TokenUsage::default(); + let mut spec_decode_metrics = None; ++ let mut weight_version = None; ++ let mut kv_transfer_metrics = serde_json::Map::new(); + + while let Some(next) = stream.next().await { + match next { + Ok(output) => { ++ if output.finish_reason.is_some() { ++ kv_transfer_metrics = build_per_request_metrics( ++ enable_per_request_metrics, ++ output.request_timings, ++ output.remote_kv_wait_time, ++ output.kv_transfer_metrics.as_ref(), ++ prefill_metrics.as_ref(), ++ ); ++ } ++ weight_version = output.weight_version.clone(); + if let Some(metrics) = output.spec_decode_metrics { + spec_decode_metrics = Some(SpeculativeDecodingMetrics::from(metrics)); + } +@@ -226,6 +248,9 @@ async fn generate_chunk_stream( + None + }; + ++ let (sampling_mask, sampling_mask_logprobs) = output ++ .sampling_mask ++ .map_or((None, None), |mask| (Some(mask.rows), mask.logprobs)); + let prompt_token_ids = prompt_token_ids.take(); + y.yield_ok(GenerateStreamResponse { + request_id: request_id.clone(), +@@ -234,13 +259,15 @@ async fn generate_chunk_stream( + logprobs, + finish_reason: finish_reason.map(|reason| reason.as_str().to_string()), + token_ids, +- sampling_mask: output.sampling_mask.map(|mask| mask.rows), ++ sampling_mask, ++ sampling_mask_logprobs, + }], + usage: include_continuous_usage + .then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)), + mm_placeholders: prompt_token_ids.as_ref().and_then(|_| mm_placeholders.take()), + prompt_token_ids, + metrics: None, ++ weight_version: output.weight_version, + }) + .await; + } +@@ -261,11 +288,12 @@ async fn generate_chunk_stream( + usage: Some(Usage::from_token_usage(usage, enable_prompt_tokens_details)), + prompt_token_ids: None, + mm_placeholders: None, +- metrics: spec_decode_metrics.map(|speculative_decoding| PerRequestMetrics { +- speculative_decoding: StreamingSpeculativeDecodingMetrics::from( +- speculative_decoding, +- ), +- }), ++ metrics: PerRequestMetrics::new( ++ spec_decode_metrics.map(StreamingSpeculativeDecodingMetrics::from), ++ kv_transfer_metrics, ++ ) ++ .map(StreamingPerRequestMetrics::from), ++ weight_version, + }) + .await; + } +@@ -278,6 +306,7 @@ fn collect_generate( + request_id: String, + ApiServerOptions { + enable_log_requests, ++ enable_per_request_metrics, + .. + }: ApiServerOptions, + ResponseOptions { +@@ -290,6 +319,7 @@ fn collect_generate( + return_token_ids, + }: ResponseOptions, + mm_placeholders: Option, ++ prefill_metrics: Option, + ) -> Result { + let logprobs = if let Some(requested) = output_logprobs { + let logprobs = collected.logprobs.as_ref().ok_or_else(|| { +@@ -318,6 +348,9 @@ fn collect_generate( + None + }; + let finish_reason = collected.finish_reason.as_str().to_string(); ++ let (sampling_mask, sampling_mask_logprobs) = collected ++ .sampling_mask ++ .map_or((None, None), |mask| (Some(mask.rows), mask.logprobs)); + + if enable_log_requests { + info!( +@@ -328,6 +361,35 @@ fn collect_generate( + ); + } + ++ let kv_transfer_metrics = build_per_request_metrics( ++ enable_per_request_metrics, ++ collected.request_timings, ++ collected.remote_kv_wait_time, ++ collected.kv_transfer_metrics.as_ref(), ++ prefill_metrics.as_ref(), ++ ); ++ let speculative_decoding = collected.spec_decode_metrics.map(SpeculativeDecodingMetrics::from); ++ let mut kv_transfer_params = collected.kv_transfer_params; ++ if enable_per_request_metrics { ++ if let Some(serde_json::Value::Object(params)) = kv_transfer_params.as_mut() { ++ let prefill = [ ++ "queue_time_ms", ++ "time_to_first_token_ms", ++ "kv_allocation_wait_time_ms", ++ "kv_initial_queue_wait_time_ms", ++ ] ++ .into_iter() ++ .map(|name| { ++ ( ++ name.to_string(), ++ kv_transfer_metrics.get(name).cloned().unwrap_or(serde_json::Value::Null), ++ ) ++ }) ++ .collect(); ++ params.insert("prefill_metrics".into(), serde_json::Value::Object(prefill)); ++ } ++ } ++ + Ok(GenerateResponse { + request_id, + choices: vec![GenerateResponseChoice { +@@ -335,7 +397,8 @@ fn collect_generate( + logprobs, + finish_reason: Some(finish_reason), + token_ids: collected.token_ids, +- sampling_mask: collected.sampling_mask.map(|mask| mask.rows), ++ sampling_mask, ++ sampling_mask_logprobs, + }], + prompt_logprobs, + prompt_token_id_logprobs: collected +@@ -345,14 +408,74 @@ fn collect_generate( + .transpose()?, + prompt_token_ids: return_token_ids.then_some(collected.prompt_token_ids), + mm_placeholders: return_token_ids.then_some(mm_placeholders).flatten(), +- kv_transfer_params: collected.kv_transfer_params, ++ kv_transfer_params, + ec_transfer_params: collected.ec_transfer_params, +- metrics: collected.spec_decode_metrics.map(|metrics| PerRequestMetrics { +- speculative_decoding: SpeculativeDecodingMetrics::from(metrics), +- }), ++ metrics: PerRequestMetrics::new(speculative_decoding, kv_transfer_metrics), ++ weight_version: collected.weight_version, + }) + } + ++fn build_per_request_metrics( ++ enabled: bool, ++ timings: Option, ++ remote_kv_wait_time: Option, ++ metrics: Option<&serde_json::Value>, ++ prefill_metrics: Option<&serde_json::Value>, ++) -> serde_json::Map { ++ if !enabled { ++ return serde_json::Map::new(); ++ } ++ let mut metrics = metrics.and_then(serde_json::Value::as_object).cloned().unwrap_or_default(); ++ if let Some(timings) = timings { ++ let duration = |start, end| (start > 0.0 && end > 0.0).then_some((end - start) * 1000.0); ++ let generation_time = duration(timings.first_token_ts, timings.last_token_ts); ++ for (name, value) in [ ++ ( ++ "time_to_first_token_ms", ++ duration(timings.scheduled_ts, timings.first_token_ts), ++ ), ++ ("generation_time_ms", generation_time), ++ ( ++ "queue_time_ms", ++ duration(timings.queued_ts, timings.scheduled_ts), ++ ), ++ ( ++ "mean_itl_ms", ++ generation_time ++ .filter(|_| timings.num_generation_tokens > 1) ++ .map(|elapsed| elapsed / (timings.num_generation_tokens - 1) as f64), ++ ), ++ ( ++ "tokens_per_second", ++ duration(timings.scheduled_ts, timings.last_token_ts) ++ .filter(|elapsed| *elapsed > 0.0) ++ .map(|elapsed| timings.num_generation_tokens as f64 * 1000.0 / elapsed), ++ ), ++ ] { ++ metrics.insert(name.into(), serde_json::json!(value)); ++ } ++ } ++ if let Some(wait) = remote_kv_wait_time { ++ metrics.insert( ++ "remote_kv_wait_time_ms".into(), ++ serde_json::json!(wait * 1000.0), ++ ); ++ } ++ if let Some(prefill) = prefill_metrics { ++ for name in [ ++ "queue_time_ms", ++ "time_to_first_token_ms", ++ "kv_allocation_wait_time_ms", ++ "kv_initial_queue_wait_time_ms", ++ ] { ++ if let Some(value) = prefill.get(name) { ++ metrics.insert(format!("prefill_{name}"), value.clone()); ++ } ++ } ++ } ++ metrics ++} ++ + fn extract_mm_placeholders(features: Option<&[MmFeatureSpec]>) -> Option { + let features = features?; + if features.is_empty() { +@@ -629,6 +752,10 @@ mod tests { + ec_transfer_params: None, + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }), + Ok(GenerateOutput { + request_id: String::new(), +@@ -645,6 +772,10 @@ mod tests { + ec_transfer_params: None, + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: Some("step-2".to_string()), ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }), + ]); + +@@ -669,12 +800,15 @@ mod tests { + ..Default::default() + }, + Some(mm_placeholders.clone()), ++ None, + ) + .try_collect() + .await + .expect("collect chunks"); + + assert_eq!(chunks.len(), 2); ++ assert_eq!(chunks[0].weight_version.as_deref(), Some("step-2")); ++ assert_eq!(chunks[1].weight_version.as_deref(), Some("step-2")); + assert_eq!(chunks[0].prompt_token_ids, Some(vec![11, 22])); + assert_eq!(chunks[0].mm_placeholders, Some(mm_placeholders)); + assert!(chunks[1].prompt_token_ids.is_none()); +@@ -709,6 +843,142 @@ mod tests { + ); + } + ++ #[tokio::test] ++ async fn generate_chunk_stream_tracks_weight_updates() { ++ let mut first = stream_output(Some(&[11]), vec![33], None); ++ first.weight_version = Some("step-1".to_string()); ++ let mut second = stream_output(None, vec![44], Some(FinishReason::Length)); ++ second.weight_version = Some("step-2".to_string()); ++ ++ let chunks = collect_chunks(vec![first, second], false, None).await; ++ ++ assert_eq!(chunks[0].weight_version.as_deref(), Some("step-1")); ++ assert_eq!(chunks[1].weight_version.as_deref(), Some("step-2")); ++ } ++ ++ #[tokio::test] ++ async fn generate_chunk_stream_returns_terminal_pd_metrics() { ++ let mut terminal = stream_output(Some(&[11]), vec![33], Some(FinishReason::Length)); ++ terminal.remote_kv_wait_time = Some(0.125); ++ terminal.kv_transfer_metrics = Some(serde_json::json!({"kv_transfer_bytes": 64})); ++ for enabled in [false, true] { ++ let chunks: Vec = generate_chunk_stream( ++ futures::stream::iter([Ok(terminal.clone())]), ++ "pd-1".to_string(), ++ ApiServerOptions { ++ enable_per_request_metrics: enabled, ++ ..Default::default() ++ }, ++ ResponseOptions { ++ include_usage: true, ++ ..Default::default() ++ }, ++ None, ++ Some(serde_json::json!({"queue_time_ms": 2.5})), ++ ) ++ .try_collect() ++ .await ++ .expect("collect chunks"); ++ assert_eq!(chunks.len(), 2); ++ assert!(chunks[0].metrics.is_none()); ++ if enabled { ++ let json = serde_json::to_value(&chunks[1]).expect("serialize chunk"); ++ assert_eq!( ++ json["metrics"], ++ serde_json::json!({ ++ "remote_kv_wait_time_ms": 125.0, ++ "kv_transfer_bytes": 64, ++ "prefill_queue_time_ms": 2.5 ++ }) ++ ); ++ } else { ++ assert!(chunks[1].metrics.is_none()); ++ } ++ let collected = futures::stream::iter([Ok(terminal.clone())]) ++ .collect_output() ++ .await ++ .expect("collect output"); ++ let response = collect_generate( ++ collected, ++ "pd-1".to_string(), ++ ApiServerOptions { ++ enable_per_request_metrics: enabled, ++ ..Default::default() ++ }, ++ ResponseOptions::default(), ++ None, ++ Some(serde_json::json!({"queue_time_ms": 2.5})), ++ ) ++ .expect("collect response"); ++ if enabled { ++ assert_eq!( ++ serde_json::to_value(&response).expect("serialize response")["metrics"], ++ serde_json::json!({ ++ "speculative_decoding": null, ++ "remote_kv_wait_time_ms": 125.0, ++ "kv_transfer_bytes": 64, ++ "prefill_queue_time_ms": 2.5, ++ "time_to_first_token_ms": null, ++ "generation_time_ms": null, ++ "queue_time_ms": null, ++ "mean_itl_ms": null, ++ "tokens_per_second": null, ++ "kv_initial_queue_wait_time_ms": null, ++ "kv_post_receive_queue_wait_time_ms": null, ++ "kv_allocation_wait_time_ms": null, ++ "kv_handshake_wait_worker_time_ms": null, ++ "kv_transfer_worker_time_ms": null, ++ "kv_transfer_post_worker_time_ms": null, ++ "prefill_time_to_first_token_ms": null, ++ "prefill_kv_allocation_wait_time_ms": null, ++ "prefill_kv_initial_queue_wait_time_ms": null, ++ }), ++ ); ++ } else { ++ assert!(response.metrics.is_none()); ++ } ++ } ++ } ++ ++ #[tokio::test] ++ async fn collect_generate_carries_own_prefill_timings() { ++ let mut terminal = stream_output(Some(&[11]), vec![33], Some(FinishReason::Length)); ++ terminal.request_timings = Some(RequestTimingStats { ++ queued_ts: 1.0, ++ scheduled_ts: 3.0, ++ first_token_ts: 4.0, ++ last_token_ts: 4.0, ++ num_generation_tokens: 1, ++ }); ++ terminal.kv_transfer_metrics = Some( ++ serde_json::json!({"kv_allocation_wait_time_ms": 7.0, "kv_initial_queue_wait_time_ms": 8.0}), ++ ); ++ terminal.kv_transfer_params = Some(serde_json::json!({"do_remote_decode": true})); ++ let collected = futures::stream::iter([Ok(terminal)]) ++ .collect_output() ++ .await ++ .expect("collect output"); ++ let response = collect_generate( ++ collected, ++ "prefill-1".to_string(), ++ ApiServerOptions { ++ enable_per_request_metrics: true, ++ ..Default::default() ++ }, ++ ResponseOptions::default(), ++ None, ++ None, ++ ) ++ .expect("collect response"); ++ assert_eq!( ++ response.kv_transfer_params.expect("transfer params")["prefill_metrics"], ++ serde_json::json!({ ++ "queue_time_ms": 2000.0, "time_to_first_token_ms": 1000.0, ++ "kv_allocation_wait_time_ms": 7.0, "kv_initial_queue_wait_time_ms": 8.0, ++ }) ++ ); ++ } ++ + fn stream_output( + prompt_token_ids: Option<&[u32]>, + token_ids: Vec, +@@ -729,6 +999,10 @@ mod tests { + ec_transfer_params: None, + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + } + } + +@@ -746,6 +1020,7 @@ mod tests { + ..Default::default() + }, + mm_placeholders, ++ None, + ) + .try_collect() + .await +@@ -753,15 +1028,16 @@ mod tests { + } + + #[tokio::test] +- async fn generate_chunk_stream_returns_sampling_mask() { ++ async fn generate_chunk_stream_returns_aligned_sampling_mask_logprobs() { + let mut output = stream_output(Some(&[11]), vec![33], Some(FinishReason::Length)); + output.sampling_mask = Some(SamplingMask { + rows: vec![vec![33, 44]], ++ logprobs: Some(vec![vec![-0.1, -0.2]]), + }); +- + let chunks = collect_chunks(vec![output], false, None).await; +- +- assert_eq!(chunks[0].choices[0].sampling_mask, Some(vec![vec![33, 44]])); ++ let choice = &chunks[0].choices[0]; ++ assert_eq!(choice.sampling_mask, Some(vec![vec![33, 44]])); ++ assert_eq!(choice.sampling_mask_logprobs, Some(vec![vec![-0.1, -0.2]])); + } + + #[tokio::test] +@@ -829,6 +1105,7 @@ mod tests { + ..Default::default() + }, + None, ++ None, + ) + .try_collect() + .await +@@ -839,11 +1116,12 @@ mod tests { + assert!(chunks[1].choices.is_empty()); + assert_eq!( + chunks[1].metrics, +- Some(PerRequestMetrics { +- speculative_decoding: StreamingSpeculativeDecodingMetrics::from( ++ Some(StreamingPerRequestMetrics::from(PerRequestMetrics { ++ speculative_decoding: Some(StreamingSpeculativeDecodingMetrics::from( + SpeculativeDecodingMetrics::from(metrics.clone()) +- ) +- }) ++ )), ++ kv_transfer_metrics: serde_json::Map::new(), ++ })) + ); + let json = serde_json::to_value(&chunks[1]).expect("serialize chunk"); + let stats = &json["metrics"]["speculative_decoding"]; +@@ -901,8 +1179,13 @@ mod tests { + prompt_token_ids: vec![10, 20], + sampling_mask: Some(SamplingMask { + rows: vec![vec![30, 40]], ++ logprobs: Some(vec![vec![-0.1, -0.2]]), + }), + spec_decode_metrics: None, ++ weight_version: Some("step-2".to_string()), ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }; + + let response = collect_generate( +@@ -917,9 +1200,11 @@ mod tests { + length: 1, + }], + )])), ++ None, + ) + .expect("response"); + ++ assert_eq!(response.weight_version.as_deref(), Some("step-2")); + assert!(response.prompt_token_ids.is_none()); + assert!(response.mm_placeholders.is_none()); + assert_eq!(response.choices[0].sampling_mask, Some(vec![vec![30, 40]])); +@@ -957,6 +1242,10 @@ mod tests { + prompt_token_ids: vec![10], + sampling_mask: None, + spec_decode_metrics: Some(metrics.clone()), ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }; + + let response = collect_generate( +@@ -965,14 +1254,16 @@ mod tests { + ApiServerOptions::default(), + ResponseOptions::default(), + None, ++ None, + ) + .expect("response"); + + assert_eq!( + response.metrics, +- Some(PerRequestMetrics { +- speculative_decoding: SpeculativeDecodingMetrics::from(metrics) +- }) ++ PerRequestMetrics::new( ++ Some(SpeculativeDecodingMetrics::from(metrics)), ++ serde_json::Map::new() ++ ) + ); + let json = serde_json::to_value(&response).expect("serialize response"); + let stats = &json["metrics"]["speculative_decoding"]; +@@ -1049,6 +1340,10 @@ mod tests { + prompt_token_ids: vec![10, 20], + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + }; + + let response = collect_generate( +@@ -1060,6 +1355,7 @@ mod tests { + ..Default::default() + }, + None, ++ None, + ) + .expect("response"); + +@@ -1084,6 +1380,10 @@ mod tests { + ec_transfer_params: None, + sampling_mask: None, + spec_decode_metrics: None, ++ weight_version: None, ++ remote_kv_wait_time: None, ++ kv_transfer_metrics: None, ++ request_timings: None, + prompt_token_ids, + }; + +@@ -1096,6 +1396,7 @@ mod tests { + ..Default::default() + }, + None, ++ None, + ) + .expect("single-token prompt without payload maps to [None]"); + let prompt_logprobs = response.prompt_logprobs.expect("prompt logprobs present"); +@@ -1111,6 +1412,7 @@ mod tests { + ..Default::default() + }, + None, ++ None, + ) + .expect_err("multi-token prompt without payload is an engine failure"); + } +diff --git a/rust/src/server/src/routes/inference/generate/types.rs b/rust/src/server/src/routes/inference/generate/types.rs +index bd277ef34a0..4642b2a15b2 100644 +--- a/rust/src/server/src/routes/inference/generate/types.rs ++++ b/rust/src/server/src/routes/inference/generate/types.rs +@@ -65,6 +65,7 @@ pub(super) struct GenerateResponseChoice { + pub finish_reason: Option, + pub token_ids: Vec, + pub sampling_mask: Option>>, ++ pub sampling_mask_logprobs: Option>>, + } + + /// Mirrors the Python vLLM `GenerateResponseStreamChoice` class. +@@ -76,6 +77,7 @@ pub(super) struct GenerateResponseStreamChoice { + pub finish_reason: Option, + pub token_ids: Vec, + pub sampling_mask: Option>>, ++ pub sampling_mask_logprobs: Option>>, + } + + /// Mirrors the Python vLLM `GenerateStreamResponse` class. +@@ -87,7 +89,8 @@ pub(super) struct GenerateStreamResponse { + pub usage: Option, + pub prompt_token_ids: Option>, + pub mm_placeholders: Option, +- pub metrics: Option>, ++ pub metrics: Option>, ++ pub weight_version: Option, + } + + /// Mirrors the Python vLLM `GenerateResponse` class. +@@ -103,11 +106,68 @@ pub(super) struct GenerateResponse { + pub kv_transfer_params: Option, + pub ec_transfer_params: Option, + pub metrics: Option>, ++ pub weight_version: Option, + } + + #[derive(Debug, Clone, PartialEq, Serialize)] + pub(super) struct PerRequestMetrics { +- pub speculative_decoding: T, ++ pub speculative_decoding: Option, ++ #[serde(flatten)] ++ pub kv_transfer_metrics: Map, ++} ++ ++impl PerRequestMetrics { ++ pub fn new( ++ speculative_decoding: Option, ++ mut kv_transfer_metrics: Map, ++ ) -> Option { ++ if speculative_decoding.is_none() && kv_transfer_metrics.is_empty() { ++ return None; ++ } ++ for name in [ ++ "time_to_first_token_ms", ++ "generation_time_ms", ++ "queue_time_ms", ++ "mean_itl_ms", ++ "tokens_per_second", ++ "remote_kv_wait_time_ms", ++ "kv_initial_queue_wait_time_ms", ++ "kv_post_receive_queue_wait_time_ms", ++ "kv_allocation_wait_time_ms", ++ "kv_handshake_wait_worker_time_ms", ++ "kv_transfer_worker_time_ms", ++ "kv_transfer_post_worker_time_ms", ++ "kv_transfer_bytes", ++ "prefill_queue_time_ms", ++ "prefill_time_to_first_token_ms", ++ "prefill_kv_allocation_wait_time_ms", ++ "prefill_kv_initial_queue_wait_time_ms", ++ ] { ++ kv_transfer_metrics.entry(name).or_insert(Value::Null); ++ } ++ Some(Self { ++ speculative_decoding, ++ kv_transfer_metrics, ++ }) ++ } ++} ++ ++#[serde_with::skip_serializing_none] ++#[derive(Debug, Clone, PartialEq, Serialize)] ++pub(super) struct StreamingPerRequestMetrics { ++ pub speculative_decoding: Option, ++ #[serde(flatten)] ++ pub kv_transfer_metrics: Map, ++} ++ ++impl From> for StreamingPerRequestMetrics { ++ fn from(mut metrics: PerRequestMetrics) -> Self { ++ metrics.kv_transfer_metrics.retain(|_, value| !value.is_null()); ++ Self { ++ speculative_decoding: metrics.speculative_decoding, ++ kv_transfer_metrics: metrics.kv_transfer_metrics, ++ } ++ } + } + + /// Mirrors the Python vLLM `SpeculativeDecodingMetrics` class. +diff --git a/rust/src/server/src/routes/load.rs b/rust/src/server/src/routes/load.rs +index de1bd2ee988..087c4be512b 100644 +--- a/rust/src/server/src/routes/load.rs ++++ b/rust/src/server/src/routes/load.rs +@@ -4,18 +4,54 @@ + use std::sync::Arc; + + use axum::Json; +-use axum::extract::State; +-use serde::Serialize; ++use axum::extract::{Query, State}; ++use serde::{Deserialize, Serialize}; ++use serde_json::Value; + ++use crate::error::ApiError; + use crate::state::AppState; ++use crate::utils::utility_call_error; ++ ++#[derive(Deserialize)] ++pub(crate) struct ServerLoadQuery { ++ #[serde(default)] ++ include_inflight: bool, ++ #[serde(default = "default_inflight_limit")] ++ inflight_limit: i32, ++} ++ ++fn default_inflight_limit() -> i32 { ++ 100 ++} + + #[derive(Serialize)] + pub(crate) struct ServerLoadResponse { + server_load: u64, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ inflight: Option>, + } + +-pub async fn load(State(state): State>) -> Json { +- Json(ServerLoadResponse { ++pub async fn load( ++ State(state): State>, ++ Query(query): Query, ++) -> Result, ApiError> { ++ let inflight = if query.include_inflight { ++ Some( ++ state ++ .chat ++ .engine_core_client() ++ .call_utility( ++ "get_inflight_queue_diagnostics", ++ (query.inflight_limit.clamp(0, 1000),), ++ ) ++ .await ++ .map_err(|error| utility_call_error("get_inflight_queue_diagnostics", error))?, ++ ) ++ } else { ++ None ++ }; ++ Ok(Json(ServerLoadResponse { + server_load: state.server_load(), +- }) ++ inflight, ++ })) + } +diff --git a/rust/src/server/src/routes/openai/utils/types.rs b/rust/src/server/src/routes/openai/utils/types.rs +index e2187bae673..9994eed71f5 100644 +--- a/rust/src/server/src/routes/openai/utils/types.rs ++++ b/rust/src/server/src/routes/openai/utils/types.rs +@@ -22,22 +22,7 @@ pub fn default_true() -> bool { + true + } + +-/// Deserialize an OpenAI request `top_k` while preserving explicit disable. +-/// +-/// Null remains `None` so model generation defaults apply. Explicit `-1` and +-/// `0` become `Some(0)` so the request overrides those defaults and disables +-/// top-k sampling. Positive limits are preserved. +-pub fn deserialize_request_top_k<'de, D>(deserializer: D) -> Result, D::Error> +-where +- D: Deserializer<'de>, +-{ +- match Option::::deserialize(deserializer)? { +- None => Ok(None), +- Some(value) => vllm_text::normalize_top_k(value) +- .map(|value| Some(value.unwrap_or(0))) +- .map_err(serde::de::Error::custom), +- } +-} ++pub use vllm_text::deserialize_request_top_k; + + /// Effort level for reasoning models. + /// +diff --git a/rust/src/server/src/routes/render.rs b/rust/src/server/src/routes/render.rs +index 2a6f70ec4b6..6236cd90707 100644 +--- a/rust/src/server/src/routes/render.rs ++++ b/rust/src/server/src/routes/render.rs +@@ -29,6 +29,7 @@ use crate::utils::{resolve_request_context, unix_timestamp}; + pub(crate) fn build_router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) ++ .route("/health_generate", get(health)) + .route("/ping", get(health).post(health)) + .route("/v1/models", get(list_models)) + .route("/v1/chat/completions/render", post(render_chat)) +diff --git a/rust/src/server/src/routes/tests.rs b/rust/src/server/src/routes/tests.rs +index 0ed2ea4d459..13df4d9e98d 100644 +--- a/rust/src/server/src/routes/tests.rs ++++ b/rust/src/server/src/routes/tests.rs +@@ -3330,6 +3330,104 @@ async fn health_endpoint_returns_ok_with_empty_body_when_client_is_healthy() { + engine_task.await.expect("mock engine task"); + } + ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++#[serial] ++async fn generation_health_requires_scheduler_reply_and_probes_only_idle_engines() { ++ for (queued, pending_work) in [(0, false), (2, false), (0, true)] { ++ let (mut app, engine_task) = test_admin_app_with_engine_script(move |dealer, push| { ++ boxed_test_future(async move { ++ let utility = recv_engine_message(dealer).await; ++ assert_eq!(utility[0].as_ref(), &[0x03]); ++ let payload = decode_value(&utility[1]).expect("decode utility"); ++ let fields = payload.as_array().expect("utility fields"); ++ assert_eq!(fields[2], Value::from("get_inflight_queue_diagnostics")); ++ assert_eq!(fields[3], Value::Array(vec![Value::from(0)])); ++ send_outputs( ++ push, ++ utility_outputs( ++ fields[1].as_u64().unwrap(), ++ utility_result_value( ++ json!({"queues": [{"num_requests": queued}], "data_parallel_rank": 0, ++ "has_pending_work": pending_work}), ++ ), ++ ), ++ ) ++ .await; ++ if queued == 0 && !pending_work { ++ let add = recv_engine_message(dealer).await; ++ assert_eq!(add[0].as_ref(), &[0x00]); ++ let request: EngineCoreRequest = ++ rmp_serde::from_slice(&add[1]).expect("decode probe"); ++ assert!(request.request_id.starts_with("healthcheck-")); ++ assert_eq!(request.prompt_token_ids, Some(vec![0])); ++ let sampling = request.sampling_params.expect("probe sampling"); ++ assert_eq!(sampling.max_tokens, 1); ++ assert_eq!(sampling.temperature, 1.0); ++ assert_eq!(sampling.top_k, 1); ++ send_outputs( ++ push, ++ engine_outputs_for_request( ++ &request.request_id, ++ vec![(vec![42], Some(EngineCoreFinishReason::Length))], ++ ), ++ ) ++ .await; ++ } else { ++ assert!( ++ tokio::time::timeout( ++ Duration::from_millis(50), ++ recv_engine_message(dealer) ++ ) ++ .await ++ .is_err() ++ ); ++ } ++ }) ++ }) ++ .await; ++ let response = app ++ .call(Request::builder().uri("/health_generate").body(Body::empty()).unwrap()) ++ .await ++ .unwrap(); ++ assert_eq!(response.status(), StatusCode::OK); ++ assert!(to_bytes(response.into_body(), usize::MAX).await.unwrap().is_empty()); ++ engine_task.await.expect("mock engine task"); ++ } ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++#[serial] ++async fn load_diagnostics_forwards_limit_and_preserves_scheduler_rank() { ++ let (mut app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { ++ boxed_test_future(async move { ++ let utility = recv_engine_message(dealer).await; ++ let payload = decode_value(&utility[1]).expect("decode utility"); ++ let fields = payload.as_array().expect("utility fields"); ++ assert_eq!(fields[2], Value::from("get_inflight_queue_diagnostics")); ++ assert_eq!(fields[3], Value::Array(vec![Value::from(1)])); ++ send_outputs(push, utility_outputs(fields[1].as_u64().unwrap(), utility_result_value( ++ json!({"queues": [{"name": "running", "num_requests": 2, "requests": ["a"]}], "data_parallel_rank": 7}) ++ ))).await; ++ }) ++ }).await; ++ let response = app ++ .call( ++ Request::builder() ++ .uri("/load?include_inflight=true&inflight_limit=1") ++ .body(Body::empty()) ++ .unwrap(), ++ ) ++ .await ++ .unwrap(); ++ assert_eq!(response.status(), StatusCode::OK); ++ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); ++ let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); ++ assert_eq!(value["server_load"], 0); ++ assert_eq!(value["inflight"][0]["data_parallel_rank"], 7); ++ assert_eq!(value["inflight"][0]["queues"][0]["num_requests"], 2); ++ engine_task.await.expect("mock engine task"); ++} ++ + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn health_endpoint_returns_503_after_engine_core_dead_sentinel() { +diff --git a/rust/src/server/src/utils.rs b/rust/src/server/src/utils.rs +index c3a8449617a..6a6ba16e822 100644 +--- a/rust/src/server/src/utils.rs ++++ b/rust/src/server/src/utils.rs +@@ -43,7 +43,7 @@ where + S: Stream> + Send + 'static, + E: Into, + { +- let sse = Sse::new(stream); ++ let sse = Sse::new(Box::pin(stream)); + match keep_alive_interval { + Some(interval) => sse + .keep_alive(KeepAlive::new().interval(interval).text("keep-alive")) +diff --git a/rust/src/text/src/lib.rs b/rust/src/text/src/lib.rs +index a6aed592b79..3c5113fe6a9 100644 +--- a/rust/src/text/src/lib.rs ++++ b/rust/src/text/src/lib.rs +@@ -22,7 +22,9 @@ pub use output::{ + DecodedText, DecodedTextEvent, DecodedTokenLogprob, Finished, SampledDelta, TextDecodeOptions, + TextOutputStreamExt, TokenAnchor, TokenAttribution, + }; +-pub use request::{Prompt, SamplingParams, TextRequest, normalize_top_k}; ++pub use request::{ ++ Prompt, SamplingParams, TextRequest, deserialize_request_top_k, normalize_top_k, ++}; + use trait_set::trait_set; + use vllm_engine_core_client::EngineCoreClient; + pub use vllm_llm::FinishReason; +diff --git a/rust/src/text/src/output/decoded.rs b/rust/src/text/src/output/decoded.rs +index d3dc4226564..764b31dcc84 100644 +--- a/rust/src/text/src/output/decoded.rs ++++ b/rust/src/text/src/output/decoded.rs +@@ -226,13 +226,13 @@ pub async fn decoded_text_event_stream( + logprobs.positions.truncate(num_tokens); + } + if let Some(mask) = &mut new_sampling_mask { +- mask.rows.truncate(num_tokens); ++ mask.truncate(num_tokens); + } + } + + output_token_count += new_token_ids.len(); +- if let Some(mut mask) = new_sampling_mask { +- sampling_mask.get_or_insert_default().rows.append(&mut mask.rows); ++ if let Some(mask) = new_sampling_mask { ++ sampling_mask.get_or_insert_default().append(mask); + } + + let decoded_logprobs = new_logprobs +@@ -413,11 +413,13 @@ mod tests { + let mut first = GenerateOutput::for_test(Some(prompt), vec![b'a' as u32], None); + first.sampling_mask = Some(SamplingMask { + rows: vec![vec![1, b'a' as u32]], ++ logprobs: Some(vec![vec![-0.1, -0.2]]), + }); + let mut terminal = + GenerateOutput::for_test(None, vec![b'b' as u32], Some(FinishReason::Length)); + terminal.sampling_mask = Some(SamplingMask { + rows: vec![vec![2, b'b' as u32]], ++ logprobs: Some(vec![vec![-0.3, -0.4]]), + }); + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new()); + +@@ -436,6 +438,7 @@ mod tests { + collected.sampling_mask, + Some(SamplingMask { + rows: vec![vec![1, b'a' as u32], vec![2, b'b' as u32]], ++ logprobs: Some(vec![vec![-0.1, -0.2], vec![-0.3, -0.4]]), + }) + ); + } +@@ -450,6 +453,7 @@ mod tests { + ); + terminal.sampling_mask = Some(SamplingMask { + rows: vec![vec![1, b'a' as u32]], ++ ..Default::default() + }); + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new()); + +@@ -690,6 +694,7 @@ mod tests { + }); + raw.sampling_mask = Some(SamplingMask { + rows: token_ids.iter().map(|&token_id| vec![token_id, token_id + 100]).collect(), ++ ..Default::default() + }); + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new()); + +@@ -717,6 +722,7 @@ mod tests { + vec![b'c' as u32, b'c' as u32 + 100], + vec![b'x' as u32, b'x' as u32 + 100], + ], ++ ..Default::default() + }) + ); + assert_eq!(output.usage.output_token_count, 4); +diff --git a/rust/src/text/src/request.rs b/rust/src/text/src/request.rs +index c37e21060ea..908295707dd 100644 +--- a/rust/src/text/src/request.rs ++++ b/rust/src/text/src/request.rs +@@ -4,7 +4,7 @@ + use std::collections::HashMap; + + use enum_as_inner::EnumAsInner; +-use serde::{Deserialize, Serialize}; ++use serde::{Deserialize, Deserializer, Serialize}; + use serde_json::Value; + use vllm_engine_core_client::protocol::kv_hints::KvHintsEnvelope; + use vllm_engine_core_client::protocol::lora::LoraRequest; +@@ -35,6 +35,25 @@ pub fn normalize_top_k(value: i64) -> std::result::Result, String> { + } + } + ++/// Deserialize a request `top_k` while preserving explicit disable. ++/// ++/// Null remains `None` so model generation defaults apply. Explicit `-1` and ++/// `0` become `Some(0)` so the request overrides those defaults and disables ++/// top-k sampling. Positive limits are preserved. ++pub fn deserialize_request_top_k<'de, D>( ++ deserializer: D, ++) -> std::result::Result, D::Error> ++where ++ D: Deserializer<'de>, ++{ ++ match Option::::deserialize(deserializer)? { ++ None => Ok(None), ++ Some(value) => normalize_top_k(value) ++ .map(|value| Some(value.unwrap_or(0))) ++ .map_err(serde::de::Error::custom), ++ } ++} ++ + /// One raw text-generation prompt. + /// + /// This supports either ordinary text that still needs tokenization or +@@ -75,6 +94,7 @@ pub struct SamplingParams { + /// Cumulative probability threshold for nucleus sampling. + pub top_p: Option, + /// Maximum number of top tokens to consider. `Some(0)` means all tokens. ++ #[serde(deserialize_with = "deserialize_request_top_k")] + pub top_k: Option, + /// Random seed used by the sampler when present. + pub seed: Option, +@@ -298,6 +318,27 @@ impl TextRequest { + mod tests { + use super::*; + ++ #[test] ++ fn sampling_params_preserve_explicit_top_k_disable() { ++ for (value, expected) in [ ++ (serde_json::Value::Null, None), ++ (serde_json::json!(-1), Some(0)), ++ (serde_json::json!(0), Some(0)), ++ (serde_json::json!(32), Some(32)), ++ ] { ++ let params: SamplingParams = ++ serde_json::from_value(serde_json::json!({"top_k": value})) ++ .expect("parse sampling params"); ++ assert_eq!(params.top_k, expected); ++ } ++ for value in [-2i64, u32::MAX as i64 + 1] { ++ assert!( ++ serde_json::from_value::(serde_json::json!({"top_k": value})) ++ .is_err() ++ ); ++ } ++ } ++ + #[test] + fn validate_rejects_empty_stop_string_at_shared_chokepoint() { + let mut request = TextRequest::for_test(); diff --git a/docker/patch/latest/vllm-rust-observability.patch b/docker/patch/latest/vllm-rust-observability.patch new file mode 100644 index 000000000..cc7e6cfff --- /dev/null +++ b/docker/patch/latest/vllm-rust-observability.patch @@ -0,0 +1,1780 @@ +diff --git a/rust/Cargo.lock b/rust/Cargo.lock +--- a/rust/Cargo.lock ++++ b/rust/Cargo.lock +@@ -2905,6 +2905,66 @@ + "openssl-src", + "pkg-config", + "vcpkg", ++] ++ ++[[package]] ++name = "opentelemetry" ++version = "0.32.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" ++dependencies = [ ++ "futures-core", ++ "futures-sink", ++ "js-sys", ++ "pin-project-lite", ++ "thiserror 2.0.18", ++ "tracing", ++] ++ ++[[package]] ++name = "opentelemetry-otlp" ++version = "0.32.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" ++dependencies = [ ++ "http", ++ "opentelemetry", ++ "opentelemetry-proto", ++ "opentelemetry_sdk", ++ "prost", ++ "thiserror 2.0.18", ++ "tokio", ++ "tonic", ++ "tonic-types", ++] ++ ++[[package]] ++name = "opentelemetry-proto" ++version = "0.32.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" ++dependencies = [ ++ "opentelemetry", ++ "opentelemetry_sdk", ++ "prost", ++ "tonic", ++ "tonic-prost", ++] ++ ++[[package]] ++name = "opentelemetry_sdk" ++version = "0.32.1" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" ++dependencies = [ ++ "futures-channel", ++ "futures-executor", ++ "futures-util", ++ "opentelemetry", ++ "percent-encoding", ++ "portable-atomic", ++ "rand 0.9.5", ++ "thiserror 2.0.18", + ] + + [[package]] +@@ -5189,6 +5249,17 @@ + "syn 2.0.117", + "tempfile", + "tonic-build", ++] ++ ++[[package]] ++name = "tonic-types" ++version = "0.14.5" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399" ++dependencies = [ ++ "prost", ++ "prost-types", ++ "tonic", + ] + + [[package]] +@@ -5831,6 +5902,8 @@ + "enum-as-inner", + "expect-test", + "futures", ++ "opentelemetry", ++ "opentelemetry_sdk", + "parking_lot", + "rmp-serde", + "serde", +@@ -5947,6 +6020,10 @@ + "libc", + "llm-multimodal", + "openssl", ++ "opentelemetry", ++ "opentelemetry-otlp", ++ "opentelemetry-proto", ++ "opentelemetry_sdk", + "prost", + "prost-types", + "rmp-serde", +diff --git a/rust/Cargo.toml b/rust/Cargo.toml +--- a/rust/Cargo.toml ++++ b/rust/Cargo.toml +@@ -70,6 +70,10 @@ + openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false } + openai-protocol = "1.6.0" + openssl = "0.10" ++opentelemetry = "0.32.0" ++opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["grpc-tonic", "trace"] } ++opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["gen-tonic", "trace"] } ++opentelemetry_sdk = "0.32.1" + parking_lot = "0.12.5" + parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4", "brotli"] } + paste = "1.0.15" +diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs +--- a/rust/src/chat/src/lib.rs ++++ b/rust/src/chat/src/lib.rs +@@ -201,6 +201,7 @@ + add_special_tokens: request.add_special_tokens, + data_parallel_rank: request.data_parallel_rank, + session_id: request.session_id, ++ trace_headers: request.trace_headers, + kv_hints: None, + reasoning_parser_kwargs: ReasoningParserKwargs { + chat_template_kwargs, +diff --git a/rust/src/chat/src/request.rs b/rust/src/chat/src/request.rs +--- a/rust/src/chat/src/request.rs ++++ b/rust/src/chat/src/request.rs +@@ -1,7 +1,7 @@ + // SPDX-License-Identifier: Apache-2.0 + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +-use std::collections::{HashMap, HashSet}; ++use std::collections::{BTreeMap, HashMap, HashSet}; + + use llm_multimodal::ImageDetail; + use serde::{Deserialize, Serialize}; +@@ -593,6 +593,8 @@ + /// Stable session identity shared by related requests. + #[serde(default)] + pub session_id: Option, ++ #[serde(default)] ++ pub trace_headers: Option>, + /// LoRA adapter selected for this request. + #[serde(default)] + pub lora_request: Option, +@@ -616,6 +618,7 @@ + add_special_tokens: false, + data_parallel_rank: None, + session_id: None, ++ trace_headers: None, + lora_request: None, + } + } +diff --git a/rust/src/cmd/src/cli.rs b/rust/src/cmd/src/cli.rs +--- a/rust/src/cmd/src/cli.rs ++++ b/rust/src/cmd/src/cli.rs +@@ -31,8 +31,8 @@ + use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args}; + use vllm_server::{ + ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig, +- DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, LoraModulePath, ParserSelection, RenderConfig, +- RendererSelection, ++ DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, LoraModulePath, ObservabilityConfig, ++ ParserSelection, RenderConfig, RendererSelection, + }; + use vllm_text::backend::hf::HfOverrides; + +@@ -435,6 +435,18 @@ + #[serde(default)] + pub profiler_config: Option, + ++ /// Target URL to which OpenTelemetry traces will be sent. Enables the Rust ++ /// frontend trace exporter; also forwarded to the managed Python engine. ++ #[arg(long)] ++ #[serde(default)] ++ pub otlp_traces_endpoint: Option, ++ ++ /// Modules to collect detailed traces for, forwarded verbatim to the engine. ++ /// Requires `--otlp-traces-endpoint`. ++ #[arg(long)] ++ #[serde(default)] ++ pub collect_detailed_traces: Option, ++ + /// Unsupported Python vLLM frontend arguments recognized but not yet + /// implemented in Rust. + #[educe(Debug(ignore))] +@@ -519,6 +531,7 @@ + let cors = self.cors_config(); + let tls = self.ssl.tls_config(); + let profiler = self.profiler(); ++ let observability = self.observability_config(); + + Config { + transport_mode: TransportMode::Bootstrapped { +@@ -561,6 +574,7 @@ + manages_engine: false, + keep_alive_timeout, + profiler, ++ observability, + } + } + +@@ -584,6 +598,7 @@ + let cors = self.cors_config(); + let tls = self.ssl.tls_config(); + let profiler = self.profiler(); ++ let observability = self.observability_config(); + + Config { + transport_mode: TransportMode::HandshakeOwner { +@@ -622,6 +637,7 @@ + manages_engine, + keep_alive_timeout, + profiler, ++ observability, + } + } + +@@ -643,6 +659,14 @@ + allow_methods: self.allowed_methods.0.clone(), + allow_headers: self.allowed_headers.0.clone(), + allow_credentials: self.allow_credentials, ++ } ++ } ++ /// Build the observability config. Public so the managed `serve` path can ++ /// validate it before spawning the Python engine. ++ pub fn observability_config(&self) -> ObservabilityConfig { ++ ObservabilityConfig { ++ otlp_traces_endpoint: self.otlp_traces_endpoint.clone(), ++ collect_detailed_traces: self.collect_detailed_traces.clone(), + } + } + } +@@ -839,6 +863,8 @@ + self.runtime.revision.clone(), + self.runtime.max_logprobs, + profiler_config, ++ self.runtime.otlp_traces_endpoint.clone(), ++ self.runtime.collect_detailed_traces.clone(), + reasoning_parser.as_deref(), + self.runtime.language_model_only, + self.runtime.disable_log_stats, +diff --git a/rust/src/cmd/src/cli/tests.rs b/rust/src/cmd/src/cli/tests.rs +--- a/rust/src/cmd/src/cli/tests.rs ++++ b/rust/src/cmd/src/cli/tests.rs +@@ -228,6 +228,8 @@ + ssl_ciphers: None, + }, + profiler_config: None, ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, + }, + managed_engine: ManagedEngineArgs { + python: "../vllm/.venv/bin/python", +@@ -1070,6 +1072,8 @@ + ssl_ciphers: None, + }, + profiler_config: None, ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, + }, + }, + ), +@@ -1743,6 +1747,8 @@ + ssl_ciphers: None, + }, + profiler_config: None, ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, + }, + managed_engine: ManagedEngineArgs { + python: "python3", +@@ -1896,6 +1902,10 @@ + manages_engine: true, + keep_alive_timeout: 5s, + profiler: None, ++ observability: ObservabilityConfig { ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, ++ }, + } + "#]] + .assert_debug_eq(&Config { +@@ -1992,6 +2002,10 @@ + manages_engine: true, + keep_alive_timeout: 5s, + profiler: None, ++ observability: ObservabilityConfig { ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, ++ }, + } + "#]] + .assert_debug_eq(&config); +@@ -2132,6 +2146,10 @@ + manages_engine: false, + keep_alive_timeout: 5s, + profiler: None, ++ observability: ObservabilityConfig { ++ otlp_traces_endpoint: None, ++ collect_detailed_traces: None, ++ }, + } + "#]] + .assert_debug_eq(&config); +@@ -2312,3 +2330,106 @@ + let engine = args.to_managed_engine_config(1234); + assert!(engine.python_args.windows(2).any(|args| args == ["--revision", "release"])); + } ++ ++#[test] ++fn serve_args_accept_otlp_flags_into_config() { ++ let cli = Cli::try_parse_from([ ++ "vllm-rs", ++ "serve", ++ "Qwen/Qwen3-0.6B", ++ "--otlp-traces-endpoint", ++ "localhost:4317", ++ "--collect-detailed-traces", ++ "all", ++ ]) ++ .unwrap(); ++ ++ let Command::Serve(args) = cli.command else { ++ panic!("expected serve args"); ++ }; ++ let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); ++ assert_eq!( ++ config.observability.otlp_traces_endpoint.as_deref(), ++ Some("localhost:4317") ++ ); ++ assert_eq!( ++ config.observability.collect_detailed_traces.as_deref(), ++ Some("all") ++ ); ++} ++ ++#[test] ++fn frontend_args_json_accepts_graduated_otlp_endpoint() { ++ // Previously rejected as an unsupported field; now a real config knob. ++ let cli = Cli::try_parse_from([ ++ "vllm-rs", ++ "frontend", ++ "--listen-fd", ++ "3", ++ "--input-address", ++ "ipc:///tmp/input.sock", ++ "--output-address", ++ "ipc:///tmp/output.sock", ++ "--args-json", ++ r#"{"model_tag":"Qwen/Qwen3-0.6B","otlp_traces_endpoint":"localhost:4317"}"#, ++ ]) ++ .unwrap(); ++ ++ let Command::Frontend(args) = cli.command else { ++ panic!("expected frontend args"); ++ }; ++ let config = args.into_config(); ++ assert_eq!( ++ config.observability.otlp_traces_endpoint.as_deref(), ++ Some("localhost:4317") ++ ); ++} ++ ++#[test] ++fn serve_args_reject_detailed_traces_without_endpoint() { ++ let cli = Cli::try_parse_from([ ++ "vllm-rs", ++ "serve", ++ "Qwen/Qwen3-0.6B", ++ "--collect-detailed-traces", ++ "all", ++ ]) ++ .unwrap(); ++ ++ let Command::Serve(args) = cli.command else { ++ panic!("expected serve args"); ++ }; ++ let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); ++ let err = config.validate().unwrap_err().to_string(); ++ assert!(err.contains("--collect-detailed-traces requires"), "{err}"); ++} ++ ++#[test] ++fn serve_args_forward_otlp_flags_to_managed_engine() { ++ let cli = Cli::try_parse_from([ ++ "vllm-rs", ++ "serve", ++ "Qwen/Qwen3-0.6B", ++ "--otlp-traces-endpoint", ++ "localhost:4317", ++ "--collect-detailed-traces", ++ "all", ++ ]) ++ .unwrap(); ++ ++ let Command::Serve(args) = cli.command else { ++ panic!("expected serve args"); ++ }; ++ let config = args.to_managed_engine_config(5555); ++ expect![[r#" ++ [ ++ "--otlp-traces-endpoint", ++ "localhost:4317", ++ "--collect-detailed-traces", ++ "all", ++ "--reasoning-parser", ++ "qwen3", ++ ] ++ "#]] ++ .assert_debug_eq(&config.python_args); ++} +diff --git a/rust/src/cmd/src/cli/unsupported.rs b/rust/src/cmd/src/cli/unsupported.rs +--- a/rust/src/cmd/src/cli/unsupported.rs ++++ b/rust/src/cmd/src/cli/unsupported.rs +@@ -351,14 +351,6 @@ + #[arg(long)] + pub default_mm_loras: Option, + +- /// Target URL to which OpenTelemetry traces will be sent. +- #[arg(long)] +- pub otlp_traces_endpoint: Option, +- +- /// It makes sense to set this only if `--otlp-traces-endpoint` is set. +- #[arg(long)] +- pub collect_detailed_traces: Option, +- + /// The interval (or buffer size) for streaming in terms of token length. + #[arg(long)] + pub stream_interval: Option, +diff --git a/rust/src/cmd/src/main.rs b/rust/src/cmd/src/main.rs +--- a/rust/src/cmd/src/main.rs ++++ b/rust/src/cmd/src/main.rs +@@ -121,6 +121,10 @@ + vllm_bench::run(bench_args).await + } + Command::Serve(args) => { ++ args.runtime ++ .observability_config() ++ .validate() ++ .context("invalid observability configuration")?; + let handshake_port = args.managed_engine.resolve_handshake_port()?; + + if args.managed_engine.data_parallel_size_local == Some(0) { +diff --git a/rust/src/llm/Cargo.toml b/rust/src/llm/Cargo.toml +--- a/rust/src/llm/Cargo.toml ++++ b/rust/src/llm/Cargo.toml +@@ -12,6 +12,7 @@ + enum-as-inner.workspace = true + futures.workspace = true + parking_lot.workspace = true ++opentelemetry.workspace = true + serde.workspace = true + serde_json.workspace = true + thiserror.workspace = true +@@ -23,6 +24,7 @@ + vllm-metrics.workspace = true + + [dev-dependencies] ++opentelemetry_sdk.workspace = true + anyhow.workspace = true + bytes.workspace = true + clap.workspace = true +diff --git a/rust/src/llm/src/lib.rs b/rust/src/llm/src/lib.rs +--- a/rust/src/llm/src/lib.rs ++++ b/rust/src/llm/src/lib.rs +@@ -10,6 +10,7 @@ + mod output; + mod request; + mod request_metrics; ++mod request_trace; + + pub use error::{Error, Result}; + pub use output::{ +@@ -36,6 +37,7 @@ + randomize_request_id: bool, + stats_logger: Option, + inflight: InflightRequests, ++ tracing_enabled: bool, + } + + impl Llm { +@@ -47,6 +49,7 @@ + randomize_request_id: true, + stats_logger: None, + inflight: InflightRequests::new(), ++ tracing_enabled: false, + } + } + +@@ -71,6 +74,11 @@ + self + } + ++ pub fn with_tracing(mut self, enabled: bool) -> Self { ++ self.tracing_enabled = enabled; ++ self ++ } ++ + /// Expose the underlying engine-core client for low-level utility/admin + /// calls. + pub fn engine_core_client(&self) -> &EngineCoreClient { +@@ -80,7 +88,11 @@ + /// Submit one tokenized generate request and return a per-request output + /// stream. + pub async fn generate(&self, req: GenerateRequest) -> Result { +- let prepared = req.prepare(self.randomize_request_id)?; ++ let mut prepared = req.prepare(self.randomize_request_id)?; ++ let request_trace = self ++ .tracing_enabled ++ .then(|| request_trace::RequestTrace::start(&mut prepared.engine_request)) ++ .flatten(); + let prompt_token_ids = prepared.prompt_token_ids().into(); + let external_request_id = prepared + .engine_request +@@ -115,6 +127,7 @@ + stream, + request_metrics, + guard, ++ request_trace, + )) + } + +diff --git a/rust/src/llm/src/output.rs b/rust/src/llm/src/output.rs +--- a/rust/src/llm/src/output.rs ++++ b/rust/src/llm/src/output.rs +@@ -255,6 +255,7 @@ + pending_prompt_info: Option, + raw_stream: EngineCoreOutputStream, + request_metrics: RequestMetricsTracker, ++ request_trace: Option, + /// Removes this request's external→internal tracking edge on drop. Held for + /// its `Drop` side effect only; never read directly. + _request_guard: RequestGuard, +@@ -268,6 +269,7 @@ + raw_stream: EngineCoreOutputStream, + request_metrics: RequestMetricsTracker, + request_guard: RequestGuard, ++ request_trace: Option, + ) -> Self { + Self { + pending_prompt_info: Some(GeneratePromptInfo { +@@ -277,6 +279,7 @@ + }), + raw_stream, + request_metrics, ++ request_trace, + _request_guard: request_guard, + } + } +@@ -334,7 +337,12 @@ + + let finish_reason = finish_reason_from_engine(raw.finish_reason, raw.stop_reason); + if let Some(finish_reason) = finish_reason.as_ref() { +- self.request_metrics.record_finished(received_at, finish_reason.clone()); ++ let state = self.as_mut().get_mut(); ++ state.request_metrics.record_finished( ++ received_at, ++ finish_reason.clone(), ++ state.request_trace.as_mut(), ++ ); + } + let request_timings = finish_reason.as_ref().map(|_| self.request_metrics.snapshot()); + +@@ -381,9 +389,11 @@ + AbortCause::DroppedStream => FinishReason::Abort, + AbortCause::StopStringMatched => FinishReason::Stop(None), + }; +- +- self.request_metrics +- .record_finished(current_unix_timestamp_secs(), finish_reason); ++ self.request_metrics.record_finished( ++ current_unix_timestamp_secs(), ++ finish_reason, ++ self.request_trace.as_mut(), ++ ); + } + } + +diff --git a/rust/src/llm/src/request_metrics.rs b/rust/src/llm/src/request_metrics.rs +--- a/rust/src/llm/src/request_metrics.rs ++++ b/rust/src/llm/src/request_metrics.rs +@@ -2,6 +2,8 @@ + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + + use std::time::{SystemTime, UNIX_EPOCH}; ++ ++use opentelemetry::KeyValue; + + use vllm_engine_core_client::protocol::output::{ + EngineCoreEvent, EngineCoreEventType, EngineCoreOutput, +@@ -184,7 +186,12 @@ + /// + /// Original Python finished-request stats: + /// +- pub(crate) fn record_finished(&self, received_at: f64, finish_reason: FinishReason) { ++ pub(crate) fn record_finished( ++ &self, ++ received_at: f64, ++ finish_reason: FinishReason, ++ trace: Option<&mut crate::request_trace::RequestTrace>, ++ ) { + let prefill_kv_computed_tokens = + self.prompt_len.saturating_sub(self.latest_num_cached_tokens); + let e2e_latency_seconds = received_at - self.arrival_time; +@@ -196,6 +203,7 @@ + 0.0 + }; + ++ let aborted = matches!(finish_reason, FinishReason::Abort); + self.record_request_success(finish_reason); + + self.handles.request_prompt_tokens.observe(self.prompt_len as f64); +@@ -217,6 +225,22 @@ + self.handles + .request_time_per_output_token_seconds + .observe(time_per_output_token_seconds); ++ if let Some(trace) = trace.as_ref() { ++ trace.set_attributes([ ++ KeyValue::new("gen_ai.usage.prompt_tokens", i64::from(self.prompt_len)), ++ KeyValue::new( ++ "gen_ai.usage.completion_tokens", ++ i64::from(self.num_generation_tokens), ++ ), ++ KeyValue::new("gen_ai.request.n", i64::from(self.n_param)), ++ KeyValue::new( ++ "gen_ai.latency.time_to_first_token", ++ self.first_token_latency, ++ ), ++ KeyValue::new("gen_ai.latency.e2e", e2e_latency_seconds), ++ KeyValue::new("gen_ai.latency.time_in_model_decode", decode_time_seconds), ++ ]); ++ } + + if let Some(events) = &self.handles.events { + let queue_time_seconds = diff_or_zero(self.scheduled_ts, self.queued_ts); +@@ -227,6 +251,19 @@ + events.request_queue_time_seconds.observe(queue_time_seconds); + events.request_prefill_time_seconds.observe(prefill_time_seconds); + events.request_inference_time_seconds.observe(inference_time_seconds); ++ if let Some(trace) = trace.as_ref() { ++ trace.set_attributes([ ++ KeyValue::new("gen_ai.latency.time_in_queue", queue_time_seconds), ++ KeyValue::new("gen_ai.latency.time_in_model_prefill", prefill_time_seconds), ++ KeyValue::new( ++ "gen_ai.latency.time_in_model_inference", ++ inference_time_seconds, ++ ), ++ ]); ++ } ++ } ++ if let Some(trace) = trace { ++ trace.finish(aborted); + } + } + +diff --git a/rust/src/llm/src/request_trace.rs b/rust/src/llm/src/request_trace.rs +new file mode 100644 +--- /dev/null ++++ b/rust/src/llm/src/request_trace.rs +@@ -0,0 +1,215 @@ ++use std::collections::HashMap; ++use std::time::{Duration, UNIX_EPOCH}; ++ ++use opentelemetry::trace::{SpanKind, TraceContextExt, Tracer}; ++use opentelemetry::{Context, KeyValue, global}; ++use vllm_engine_core_client::protocol::request::EngineCoreRequest; ++ ++pub(crate) struct RequestTrace { ++ context: Context, ++ finished: bool, ++} ++ ++impl RequestTrace { ++ pub(crate) fn start(request: &mut EngineCoreRequest) -> Option { ++ let carrier: HashMap = request ++ .trace_headers ++ .iter() ++ .flat_map(|headers| headers.iter()) ++ .map(|(name, value)| (name.clone(), value.clone())) ++ .collect(); ++ let parent = global::get_text_map_propagator(|propagator| propagator.extract(&carrier)); ++ let tracer = global::tracer("vllm.llm_engine"); ++ let start_time = ++ UNIX_EPOCH.checked_add(Duration::try_from_secs_f64(request.arrival_time).ok()?)?; ++ let span = tracer ++ .span_builder("llm_request") ++ .with_kind(SpanKind::Server) ++ .with_start_time(start_time) ++ .with_attributes([KeyValue::new( ++ "gen_ai.request.id", ++ request.external_req_id.as_ref().unwrap_or(&request.request_id).clone(), ++ )]) ++ .start_with_context(&tracer, &parent); ++ let context = parent.with_span(span); ++ if !context.span().span_context().is_valid() { ++ return None; ++ } ++ if let Some(parameters) = request.sampling_params.as_ref() { ++ if parameters.temperature != 0.0 { ++ context.span().set_attribute(KeyValue::new( ++ "gen_ai.request.temperature", ++ f64::from(parameters.temperature), ++ )); ++ } ++ if parameters.top_p != 0.0 { ++ context.span().set_attribute(KeyValue::new( ++ "gen_ai.request.top_p", ++ f64::from(parameters.top_p), ++ )); ++ } ++ if parameters.max_tokens != 0 { ++ context.span().set_attribute(KeyValue::new( ++ "gen_ai.request.max_tokens", ++ i64::from(parameters.max_tokens), ++ )); ++ } ++ } ++ let mut propagated = HashMap::new(); ++ global::get_text_map_propagator(|propagator| { ++ propagator.inject_context(&context, &mut propagated); ++ }); ++ request.trace_headers = Some(propagated.into_iter().collect()); ++ Some(Self { ++ context, ++ finished: false, ++ }) ++ } ++ ++ pub(crate) fn finish(&mut self, aborted: bool) { ++ if !self.finished { ++ if aborted { ++ self.context.span().set_attribute(KeyValue::new("aborted", true)); ++ } ++ self.context.span().end(); ++ self.finished = true; ++ } ++ } ++ ++ pub(crate) fn set_attributes(&self, attributes: impl IntoIterator) { ++ self.context.span().set_attributes(attributes); ++ } ++} ++ ++impl Drop for RequestTrace { ++ fn drop(&mut self) { ++ self.finish(true); ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use std::sync::{Arc, Mutex}; ++ ++ use opentelemetry::Value; ++ use opentelemetry_sdk::error::OTelSdkResult; ++ use opentelemetry_sdk::propagation::TraceContextPropagator; ++ use opentelemetry_sdk::trace::{SdkTracerProvider, SpanData, SpanExporter}; ++ use vllm_engine_core_client::protocol::output::{ ++ EngineCoreEvent, EngineCoreEventType, EngineCoreOutput, ++ }; ++ use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; ++ ++ use super::*; ++ use crate::FinishReason; ++ use crate::request_metrics::RequestMetricsTracker; ++ ++ #[derive(Clone, Debug, Default)] ++ struct CaptureExporter(Arc>>); ++ ++ impl SpanExporter for CaptureExporter { ++ fn export( ++ &self, ++ batch: Vec, ++ ) -> impl std::future::Future + Send { ++ self.0.lock().unwrap().extend(batch); ++ async { Ok(()) } ++ } ++ } ++ ++ #[test] ++ fn root_preserves_parent_reports_metrics_and_ends_once_on_finish_or_drop() { ++ let exporter = CaptureExporter::default(); ++ let provider = SdkTracerProvider::builder().with_simple_exporter(exporter.clone()).build(); ++ global::set_tracer_provider(provider.clone()); ++ global::set_text_map_propagator(TraceContextPropagator::new()); ++ let mut request = EngineCoreRequest { ++ request_id: "internal".into(), ++ external_req_id: Some("external".into()), ++ arrival_time: 1000.0, ++ trace_headers: Some( ++ [( ++ "traceparent".into(), ++ "00-33333333333333333333333333333333-1111111111111111-01".into(), ++ )] ++ .into(), ++ ), ++ sampling_params: Some(EngineCoreSamplingParams { ++ max_tokens: 16, ++ temperature: 0.0, ++ top_p: 0.95, ++ ..EngineCoreSamplingParams::for_test() ++ }), ++ ..EngineCoreRequest::default() ++ }; ++ let mut trace = RequestTrace::start(&mut request).unwrap(); ++ assert!( ++ request.trace_headers.as_ref().unwrap()["traceparent"] ++ .starts_with("00-33333333333333333333333333333333-") ++ ); ++ assert_ne!( ++ request.trace_headers.as_ref().unwrap()["traceparent"], ++ "00-33333333333333333333333333333333-1111111111111111-01" ++ ); ++ let mut metrics = ++ RequestMetricsTracker::new("model".into(), 0, 1000.0, 3, Some(16), 1, true); ++ metrics.observe_output( ++ 1002.0, ++ 1002.0, ++ &EngineCoreOutput { ++ new_token_ids: vec![1, 2], ++ events: Some(vec![ ++ EngineCoreEvent { ++ r#type: EngineCoreEventType::Queued, ++ timestamp: 1000.0, ++ }, ++ EngineCoreEvent { ++ r#type: EngineCoreEventType::Scheduled, ++ timestamp: 1001.0, ++ }, ++ ]), ++ ..EngineCoreOutput::default() ++ }, ++ ); ++ metrics.record_finished(1003.0, FinishReason::Length, Some(&mut trace)); ++ drop(trace); ++ drop(RequestTrace::start(&mut request).unwrap()); ++ provider.force_flush().unwrap(); ++ let captured = exporter.0.lock().unwrap(); ++ assert_eq!(captured.len(), 2); ++ assert_eq!(captured[0].name, "llm_request"); ++ assert_eq!( ++ captured[0].span_context.trace_id().to_string(), ++ "33333333333333333333333333333333" ++ ); ++ assert_eq!(captured[0].parent_span_id.to_string(), "1111111111111111"); ++ let attributes: HashMap<_, _> = captured[0] ++ .attributes ++ .iter() ++ .map(|entry| (entry.key.as_str(), &entry.value)) ++ .collect(); ++ assert_eq!( ++ attributes["gen_ai.request.id"], ++ &Value::String("external".into()) ++ ); ++ assert_eq!(attributes["gen_ai.usage.prompt_tokens"], &Value::I64(3)); ++ assert_eq!(attributes["gen_ai.usage.completion_tokens"], &Value::I64(2)); ++ assert_eq!(attributes["gen_ai.request.max_tokens"], &Value::I64(16)); ++ assert_eq!(attributes["gen_ai.latency.e2e"], &Value::F64(3.0)); ++ assert_eq!(attributes["gen_ai.latency.time_in_queue"], &Value::F64(1.0)); ++ assert_eq!( ++ attributes["gen_ai.latency.time_in_model_prefill"], ++ &Value::F64(1.0) ++ ); ++ assert!(!attributes.contains_key("gen_ai.request.temperature")); ++ assert!(!attributes.contains_key("aborted")); ++ assert!( ++ captured[1] ++ .attributes ++ .iter() ++ .any(|entry| entry.key.as_str() == "aborted" && entry.value == Value::Bool(true)) ++ ); ++ drop(captured); ++ provider.shutdown().unwrap(); ++ } ++} +diff --git a/rust/src/managed-engine/src/cli.rs b/rust/src/managed-engine/src/cli.rs +--- a/rust/src/managed-engine/src/cli.rs ++++ b/rust/src/managed-engine/src/cli.rs +@@ -87,6 +87,8 @@ + revision: Option, + max_logprobs: Option, + profiler_config: Option, ++ otlp_traces_endpoint: Option, ++ collect_detailed_traces: Option, + reasoning_parser: Option<&str>, + language_model_only: bool, + disable_log_stats: bool, +@@ -112,6 +114,14 @@ + if let Some(profiler_config) = profiler_config { + python_args.push("--profiler-config".to_string()); + python_args.push(profiler_config); ++ } ++ if let Some(otlp_traces_endpoint) = otlp_traces_endpoint { ++ python_args.push("--otlp-traces-endpoint".to_string()); ++ python_args.push(otlp_traces_endpoint); ++ } ++ if let Some(collect_detailed_traces) = collect_detailed_traces { ++ python_args.push("--collect-detailed-traces".to_string()); ++ python_args.push(collect_detailed_traces); + } + if let Some(reasoning_parser) = reasoning_parser { + python_args.push("--reasoning-parser".to_string()); +diff --git a/rust/src/server/Cargo.toml b/rust/src/server/Cargo.toml +--- a/rust/src/server/Cargo.toml ++++ b/rust/src/server/Cargo.toml +@@ -20,6 +20,9 @@ + libc.workspace = true + llm-multimodal.workspace = true + openssl.workspace = true ++opentelemetry.workspace = true ++opentelemetry-otlp.workspace = true ++opentelemetry_sdk.workspace = true + prost.workspace = true + prost-types.workspace = true + rmp-serde.workspace = true +@@ -62,9 +65,12 @@ + clap.workspace = true + expect-test.workspace = true + prost.workspace = true ++opentelemetry-proto.workspace = true ++rmp-serde.workspace = true + serial_test.workspace = true + tempfile.workspace = true + tokio = { workspace = true, features = ["test-util"] } ++tokio-stream = { workspace = true, features = ["net"] } + tower.workspace = true + vllm-engine-core-client = { workspace = true, features = ["test-util"] } + vllm-tokenizer = { workspace = true, features = ["test-utils"] } +diff --git a/rust/src/server/examples/external_engine_openai_qwen.rs b/rust/src/server/examples/external_engine_openai_qwen.rs +--- a/rust/src/server/examples/external_engine_openai_qwen.rs ++++ b/rust/src/server/examples/external_engine_openai_qwen.rs +@@ -90,6 +90,7 @@ + manages_engine: false, + keep_alive_timeout: Duration::from_secs(5), + profiler: None, ++ observability: Default::default(), + }; + + let bind_address = format!("127.0.0.1:{port}"); +diff --git a/rust/src/server/src/config.rs b/rust/src/server/src/config.rs +--- a/rust/src/server/src/config.rs ++++ b/rust/src/server/src/config.rs +@@ -205,6 +205,31 @@ + } + serde_json::from_str(value) + .map_err(|e| format!("expected `name=path` or a JSON object: {e}")) ++ } ++} ++ ++/// OpenTelemetry / observability settings, mirroring Python's ++/// `ObservabilityConfig`. A `None` endpoint disables tracing. ++#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] ++pub struct ObservabilityConfig { ++ /// Target endpoint for the OTLP trace exporter. When `None`, no trace ++ /// provider is initialized. ++ pub otlp_traces_endpoint: Option, ++ /// Opaque `--collect-detailed-traces` value, forwarded verbatim to the ++ /// engine (the frontend does not interpret it). Requires an endpoint. ++ pub collect_detailed_traces: Option, ++} ++ ++impl ObservabilityConfig { ++ /// Validate the tracing configuration, failing closed. ++ pub fn validate(&self) -> Result<()> { ++ if self.collect_detailed_traces.is_some() && self.otlp_traces_endpoint.is_none() { ++ bail!("--collect-detailed-traces requires --otlp-traces-endpoint to be set"); ++ } ++ if self.otlp_traces_endpoint.is_some() { ++ crate::otel::validate_protocol_env()?; ++ } ++ Ok(()) + } + } + +@@ -286,6 +311,8 @@ + /// Profiler mode that registers `/start_profile` and `/stop_profile` + /// routes when present. + pub profiler: Option, ++ /// OpenTelemetry / observability settings (OTLP trace export). ++ pub observability: ObservabilityConfig, + } + + impl Config { +@@ -297,6 +324,7 @@ + if let Some(tls) = &self.tls { + tls.validate()?; + } ++ self.observability.validate()?; + if let Some(max_logprobs) = self.max_logprobs + && max_logprobs < -1 + { +diff --git a/rust/src/server/src/grpc/convert.rs b/rust/src/server/src/grpc/convert.rs +--- a/rust/src/server/src/grpc/convert.rs ++++ b/rust/src/server/src/grpc/convert.rs +@@ -136,6 +136,7 @@ + add_special_tokens: true, + data_parallel_rank: None, + session_id, ++ trace_headers: None, + kv_hints, + reasoning_parser_kwargs: Default::default(), + reasoning_ended: None, +diff --git a/rust/src/server/src/grpc/inference.rs b/rust/src/server/src/grpc/inference.rs +--- a/rust/src/server/src/grpc/inference.rs ++++ b/rust/src/server/src/grpc/inference.rs +@@ -1,6 +1,7 @@ + // SPDX-License-Identifier: Apache-2.0 + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + ++use std::collections::BTreeMap; + use std::pin::Pin; + use std::sync::Arc; + use std::time::Instant; +@@ -23,6 +24,23 @@ + pub(crate) type InferenceGrpcService = InferenceServer; + + const DATA_PARALLEL_RANK_METADATA_KEY: &str = "x-data-parallel-rank"; ++ ++fn trace_headers_from_metadata( ++ request: &Request, ++) -> Option> { ++ let headers: BTreeMap<_, _> = ["traceparent", "tracestate"] ++ .into_iter() ++ .filter_map(|name| { ++ request ++ .metadata() ++ .get(name)? ++ .to_str() ++ .ok() ++ .map(|value| (name.to_owned(), value.to_owned())) ++ }) ++ .collect(); ++ (!headers.is_empty()).then_some(headers) ++} + + /// gRPC inference service backed by the shared application state. + pub struct InferenceServiceImpl { +@@ -223,13 +241,15 @@ + request: Request, + ) -> Result, Status> { + let data_parallel_rank = data_parallel_rank_from_metadata(&request)?; ++ let trace_headers = trace_headers_from_metadata(&request); + let proto_req = request.into_inner(); + let response_opts = ResponseOpts::from_proto(proto_req.response.as_ref()); + let PreparedGrpcRequest { +- text_request, ++ mut text_request, + request_span, + started_at, + } = self.prepare_request(proto_req, data_parallel_rank, false, "Generate").await?; ++ text_request.trace_headers = trace_headers; + + let stream = self + .state +@@ -285,15 +305,17 @@ + request: Request, + ) -> Result, Status> { + let data_parallel_rank = data_parallel_rank_from_metadata(&request)?; ++ let trace_headers = trace_headers_from_metadata(&request); + let proto_req = request.into_inner(); + let response_opts = ResponseOpts::from_proto(proto_req.response.as_ref()); + let PreparedGrpcRequest { +- text_request, ++ mut text_request, + request_span, + started_at, + } = self + .prepare_request(proto_req, data_parallel_rank, true, "GenerateStream") + .await?; ++ text_request.trace_headers = trace_headers; + + let stream = self + .state +diff --git a/rust/src/server/src/lib.rs b/rust/src/server/src/lib.rs +--- a/rust/src/server/src/lib.rs ++++ b/rust/src/server/src/lib.rs +@@ -9,6 +9,7 @@ + mod listener; + mod lora; + mod middleware; ++mod otel; + mod render; + mod routes; + mod runtime; +@@ -29,7 +30,7 @@ + use axum::http::Request; + pub use config::{ + ApiServerOptions, Config, CoordinatorMode, CorsConfig, DEFAULT_KEEP_ALIVE_TIMEOUT, +- HttpListenerMode, LoraModulePath, TlsConfig, ++ HttpListenerMode, LoraModulePath, ObservabilityConfig, TlsConfig, + }; + use hyper::body::Incoming; + use hyper::server::conn::http1; +@@ -123,7 +124,9 @@ + .await + .context("failed to connect to engine core")?; + +- let llm = Llm::new(client).with_log_stats(!config.disable_log_stats); ++ let llm = Llm::new(client) ++ .with_log_stats(!config.disable_log_stats) ++ .with_tracing(config.observability.otlp_traces_endpoint.is_some()); + let text = TextLlm::new(llm, text_backend).with_max_logprobs(config.max_logprobs); + + let chat = ChatLlm::new(text, chat_backend) +@@ -260,6 +263,9 @@ + None + }; + ++ // Init after all fallible setup, so an early return has no provider to leak. ++ let tracer_provider = otel::init_tracer_provider(&config.observability)?; ++ + let scheme = if tls_config.is_some() { + "https" + } else { +@@ -384,13 +390,21 @@ + }; + + let (http_res, grpc_res) = tokio::join!(http_fut, grpc_fut); +- http_res.and(grpc_res)?; ++ let serve_result = http_res.and(grpc_res); + + let shutdown_deadline = shutdown_deadline + .get() + .copied() + .unwrap_or_else(|| Instant::now() + config.shutdown_timeout); +- state.shutdown(shutdown_deadline).await ++ let state_result = state.shutdown(shutdown_deadline).await; ++ ++ // Flush on every exit path (clean or error); `shutdown_provider` bounds the ++ // budget by the shutdown deadline. ++ if let Some(provider) = tracer_provider { ++ otel::shutdown_provider(provider, shutdown_deadline).await; ++ } ++ ++ serve_result.and(state_result) + } + + /// Per-connection timeouts applied while serving HTTP/HTTPS. +diff --git a/rust/src/server/src/middleware/auth.rs b/rust/src/server/src/middleware/auth.rs +--- a/rust/src/server/src/middleware/auth.rs ++++ b/rust/src/server/src/middleware/auth.rs +@@ -13,7 +13,7 @@ + + use crate::state::{ApiKeyHash, AppState, hash_api_key}; + +-const GUARDED_PREFIXES: &[&str] = &["/v1", "/v2", "/inference"]; ++const GUARDED_PREFIXES: &[&str] = &["/v1", "/v2", "/inference", "/dumper"]; + + /// Authenticate guarded HTTP routes with an OpenAI-compatible bearer token. + /// +diff --git a/rust/src/server/src/otel.rs b/rust/src/server/src/otel.rs +new file mode 100644 +--- /dev/null ++++ b/rust/src/server/src/otel.rs +@@ -0,0 +1,247 @@ ++// SPDX-License-Identifier: Apache-2.0 ++// SPDX-FileCopyrightText: Copyright contributors to the vLLM project ++ ++//! OpenTelemetry trace-exporter bootstrap: a gRPC OTLP exporter (insecure by ++//! default) feeding a batch span processor on a `TracerProvider` tagged with the ++//! `vllm.*` resource attributes. No span is emitted here; this module only stands ++//! up the provider and flushes it on shutdown. ++ ++use std::borrow::Cow; ++use std::time::Duration; ++ ++use anyhow::{Context, Result, bail}; ++use opentelemetry::KeyValue; ++use opentelemetry_otlp::{SpanExporter, WithExportConfig}; ++use opentelemetry_sdk::Resource; ++use opentelemetry_sdk::trace::SdkTracerProvider; ++use tokio::time::Instant; ++use tracing::{info, warn}; ++ ++use crate::config::ObservabilityConfig; ++ ++/// Env var selecting the OTLP transport. ++const OTEL_TRACES_PROTOCOL_ENV: &str = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"; ++ ++/// Instrumenting-module name set on every span's resource. ++const INSTRUMENTING_MODULE_NAME: &str = "vllm.llm_engine"; ++ ++/// Minimum flush budget, so a span buffered right before shutdown survives even ++/// when `shutdown_timeout` is 0 (the default). ++const MIN_FLUSH: Duration = Duration::from_secs(3); ++ ++/// Validate the OTLP transport selected by the environment, failing closed. ++pub(crate) fn validate_protocol_env() -> Result<()> { ++ validate_protocol(std::env::var(OTEL_TRACES_PROTOCOL_ENV).ok().as_deref()) ++} ++ ++/// Validate an OTLP transport value, failing closed. Only gRPC is wired; ++/// `http/protobuf` is recognized but unimplemented, anything else rejected. ++fn validate_protocol(protocol: Option<&str>) -> Result<()> { ++ match protocol { ++ None | Some("grpc") => Ok(()), ++ Some("http/protobuf") => bail!( ++ "OTLP protocol 'http/protobuf' is not yet supported in the Rust frontend; \ ++ use 'grpc' (the default) via OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" ++ ), ++ Some(other) => bail!("unsupported OTLP protocol '{other}' is configured"), ++ } ++} ++ ++/// Default a scheme-less endpoint to plaintext `http://`, so a bare `host:port` ++/// is accepted. ++fn normalize_endpoint(endpoint: &str) -> Cow<'_, str> { ++ if let Some(address) = endpoint.strip_prefix("grpc://") { ++ Cow::Owned(format!("http://{address}")) ++ } else if endpoint.contains("://") { ++ Cow::Borrowed(endpoint) ++ } else { ++ Cow::Owned(format!("http://{endpoint}")) ++ } ++} ++ ++/// Build the OTLP trace provider and register it globally; returns the flush ++/// handle, or `None` when no endpoint is set. Protocol is assumed already validated. ++pub(crate) fn init_tracer_provider(obs: &ObservabilityConfig) -> Result> { ++ let Some(endpoint) = obs.otlp_traces_endpoint.as_deref() else { ++ return Ok(None); ++ }; ++ ++ let endpoint_url = normalize_endpoint(endpoint); ++ let exporter = SpanExporter::builder() ++ .with_tonic() ++ .with_endpoint(endpoint_url.as_ref()) ++ .build() ++ .context("failed to build OTLP gRPC span exporter")?; ++ ++ let resource = Resource::builder() ++ .with_attributes([ ++ KeyValue::new("vllm.instrumenting_module_name", INSTRUMENTING_MODULE_NAME), ++ KeyValue::new("vllm.process_id", std::process::id().to_string()), ++ ]) ++ .build(); ++ ++ let provider = SdkTracerProvider::builder() ++ .with_resource(resource) ++ .with_batch_exporter(exporter) ++ .build(); ++ ++ opentelemetry::global::set_tracer_provider(provider.clone()); ++ opentelemetry::global::set_text_map_propagator( ++ opentelemetry_sdk::propagation::TraceContextPropagator::new(), ++ ); ++ info!(endpoint, "OpenTelemetry trace exporter initialized (gRPC)"); ++ Ok(Some(provider)) ++} ++ ++/// Flush and shut down the provider before `deadline` (floored by `MIN_FLUSH`), so ++/// a span buffered right before shutdown still ships. The calls block, so they run ++/// on a blocking task. ++pub(crate) async fn shutdown_provider(provider: SdkTracerProvider, deadline: Instant) { ++ let budget = deadline.saturating_duration_since(Instant::now()).max(MIN_FLUSH); ++ let flush = tokio::task::spawn_blocking(move || { ++ if let Err(error) = provider.force_flush() { ++ warn!(%error, "failed to flush OpenTelemetry spans on shutdown"); ++ } ++ if let Err(error) = provider.shutdown() { ++ warn!(%error, "failed to shut down OpenTelemetry provider"); ++ } ++ }); ++ if tokio::time::timeout(budget, flush).await.is_err() { ++ warn!( ++ ?budget, ++ "OpenTelemetry flush did not finish within the shutdown budget" ++ ); ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::{ObservabilityConfig, init_tracer_provider, normalize_endpoint, validate_protocol}; ++ ++ #[test] ++ fn no_provider_when_tracing_disabled() { ++ assert!(init_tracer_provider(&ObservabilityConfig::default()).unwrap().is_none()); ++ } ++ ++ #[test] ++ fn endpoint_gets_a_scheme_only_when_missing() { ++ assert_eq!( ++ normalize_endpoint("localhost:4317"), ++ "http://localhost:4317" ++ ); ++ assert_eq!(normalize_endpoint("http://x:4317"), "http://x:4317"); ++ assert_eq!(normalize_endpoint("https://x:4317"), "https://x:4317"); ++ assert_eq!(normalize_endpoint("grpc://x:4317"), "http://x:4317"); ++ } ++ ++ #[test] ++ fn protocol_defaults_and_grpc_are_accepted() { ++ validate_protocol(None).unwrap(); ++ validate_protocol(Some("grpc")).unwrap(); ++ } ++ ++ #[test] ++ fn http_protobuf_fails_closed_with_guidance() { ++ let err = validate_protocol(Some("http/protobuf")).unwrap_err().to_string(); ++ assert!(err.contains("not yet supported"), "{err}"); ++ } ++ ++ #[test] ++ fn unknown_protocol_is_rejected() { ++ let err = validate_protocol(Some("carrier-pigeon")).unwrap_err().to_string(); ++ assert!(err.contains("unsupported OTLP protocol"), "{err}"); ++ } ++} ++ ++#[cfg(test)] ++mod lifecycle_tests { ++ use std::time::Duration; ++ ++ use opentelemetry::global; ++ use opentelemetry::trace::Tracer as _; ++ use opentelemetry_proto::tonic::collector::trace::v1::trace_service_server::{ ++ TraceService, TraceServiceServer, ++ }; ++ use opentelemetry_proto::tonic::collector::trace::v1::{ ++ ExportTraceServiceRequest, ExportTraceServiceResponse, ++ }; ++ use tokio::sync::mpsc; ++ use tokio::time::Instant; ++ use tokio_stream::wrappers::TcpListenerStream; ++ use tonic::{Request, Response, Status}; ++ ++ use super::{ObservabilityConfig, init_tracer_provider, shutdown_provider}; ++ ++ /// In-process OTLP gRPC collector that forwards each export over a channel for ++ /// assertions. ++ struct FakeCollector { ++ exports: mpsc::UnboundedSender, ++ } ++ ++ #[tonic::async_trait] ++ impl TraceService for FakeCollector { ++ async fn export( ++ &self, ++ request: Request, ++ ) -> Result, Status> { ++ let _ = self.exports.send(request.into_inner()); ++ Ok(Response::new(ExportTraceServiceResponse::default())) ++ } ++ } ++ ++ /// The shutdown flush delivers a buffered span with the `vllm.*` resource attrs. ++ #[tokio::test(flavor = "multi_thread")] ++ #[serial_test::serial] ++ async fn shutdown_flush_delivers_a_buffered_span_to_the_collector() { ++ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); ++ let addr = listener.local_addr().unwrap(); ++ let (tx, mut rx) = mpsc::unbounded_channel(); ++ let server = tokio::spawn(async move { ++ tonic::transport::Server::builder() ++ .add_service(TraceServiceServer::new(FakeCollector { exports: tx })) ++ .serve_with_incoming(TcpListenerStream::new(listener)) ++ .await ++ }); ++ ++ let obs = ObservabilityConfig { ++ otlp_traces_endpoint: Some(format!("http://{addr}")), ++ collect_detailed_traces: None, ++ }; ++ let provider = init_tracer_provider(&obs).unwrap().expect("provider initialized"); ++ ++ // Emit and end a span but do not flush it; the shutdown flush must deliver it. ++ global::tracer("test").start("llm_request"); ++ ++ shutdown_provider(provider, Instant::now() + Duration::from_secs(10)).await; ++ ++ let export = tokio::time::timeout(Duration::from_secs(10), rx.recv()) ++ .await ++ .expect("collector received an export within the budget") ++ .expect("export payload present"); ++ let resource = export.resource_spans[0].resource.as_ref().expect("resource present"); ++ let keys: Vec<&str> = resource.attributes.iter().map(|kv| kv.key.as_str()).collect(); ++ assert!(keys.contains(&"vllm.instrumenting_module_name"), "{keys:?}"); ++ assert!(keys.contains(&"vllm.process_id"), "{keys:?}"); ++ ++ server.abort(); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ #[serial_test::serial] ++ async fn shutdown_flush_returns_within_budget_when_endpoint_is_unreachable() { ++ let obs = ObservabilityConfig { ++ // Reserved port 1: connection is refused. ++ otlp_traces_endpoint: Some("http://127.0.0.1:1".to_string()), ++ collect_detailed_traces: None, ++ }; ++ let provider = init_tracer_provider(&obs).unwrap().expect("provider initialized"); ++ global::tracer("test").start("llm_request"); ++ ++ let start = Instant::now(); ++ shutdown_provider(provider, start + Duration::from_secs(2)).await; ++ assert!( ++ start.elapsed() < Duration::from_secs(9), ++ "flush must not hang" ++ ); ++ } ++} +diff --git a/rust/src/server/src/routes.rs b/rust/src/server/src/routes.rs +--- a/rust/src/server/src/routes.rs ++++ b/rust/src/server/src/routes.rs +@@ -16,6 +16,7 @@ + pub(super) mod render; + mod server_info; + mod sleep; ++mod tensor_dump; + mod tokenize; + mod version; + mod weight_checker; +@@ -56,6 +57,7 @@ + server_dev_mode_enabled(), + runtime_lora_updating_enabled(), + scale_out_endpoints_enabled, ++ std::env::var("DUMPER_SERVER_PORT").ok().as_deref() == Some("reuse"), + ) + } + +@@ -70,7 +72,13 @@ + dev_mode_enabled: bool, + runtime_lora_updating_enabled: bool, + ) -> Router { +- build_router_with_options(state, dev_mode_enabled, runtime_lora_updating_enabled, true) ++ build_router_with_options( ++ state, ++ dev_mode_enabled, ++ runtime_lora_updating_enabled, ++ true, ++ false, ++ ) + } + + #[cfg(test)] +@@ -78,7 +86,7 @@ + state: Arc, + scale_out_endpoints_enabled: bool, + ) -> Router { +- build_router_with_options(state, false, false, scale_out_endpoints_enabled) ++ build_router_with_options(state, false, false, scale_out_endpoints_enabled, false) + } + + fn build_router_with_options( +@@ -86,6 +94,7 @@ + dev_mode_enabled: bool, + runtime_lora_updating_enabled: bool, + scale_out_endpoints_enabled: bool, ++ dumper_enabled: bool, + ) -> Router { + let mut router = Router::new() + // Health & monitoring +@@ -103,6 +112,10 @@ + .route("/tokenize", post(tokenize::tokenize)) + .route("/detokenize", post(tokenize::detokenize)); + ++ if dumper_enabled { ++ router = router.route("/dumper/{action}", post(tensor_dump::control)); ++ } ++ + if scale_out_endpoints_enabled { + router = router.route("/inference/v1/generate", post(inference::generate)); + } else { +diff --git a/rust/src/server/src/routes/inference/generate/convert.rs b/rust/src/server/src/routes/inference/generate/convert.rs +--- a/rust/src/server/src/routes/inference/generate/convert.rs ++++ b/rust/src/server/src/routes/inference/generate/convert.rs +@@ -84,6 +84,7 @@ + add_special_tokens: false, + data_parallel_rank: ctx.data_parallel_rank, + session_id: ctx.session_id, ++ trace_headers: ctx.trace_headers, + kv_hints: None, + reasoning_parser_kwargs: Default::default(), + reasoning_ended: None, +diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs +--- a/rust/src/server/src/routes/openai/chat_completions/convert.rs ++++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs +@@ -201,6 +201,7 @@ + add_special_tokens: request.add_special_tokens, + data_parallel_rank: ctx.data_parallel_rank, + session_id, ++ trace_headers: ctx.trace_headers, + lora_request: lora_resolution.lora_request.clone(), + }; + +diff --git a/rust/src/server/src/routes/openai/completions/convert.rs b/rust/src/server/src/routes/openai/completions/convert.rs +--- a/rust/src/server/src/routes/openai/completions/convert.rs ++++ b/rust/src/server/src/routes/openai/completions/convert.rs +@@ -157,6 +157,7 @@ + add_special_tokens: request.add_special_tokens, + data_parallel_rank: ctx.data_parallel_rank, + session_id, ++ trace_headers: ctx.trace_headers, + kv_hints: None, + reasoning_parser_kwargs: Default::default(), + reasoning_ended: None, +diff --git a/rust/src/server/src/routes/tensor_dump.rs b/rust/src/server/src/routes/tensor_dump.rs +new file mode 100644 +--- /dev/null ++++ b/rust/src/server/src/routes/tensor_dump.rs +@@ -0,0 +1,44 @@ ++use std::collections::BTreeMap; ++use std::sync::Arc; ++ ++use axum::Json; ++use axum::extract::{Path, State}; ++use rmpv::Value as MsgpackValue; ++use serde::Deserialize; ++use serde_json::{Map, Value}; ++ ++use crate::error::ApiError; ++use crate::state::AppState; ++use crate::utils::utility_call_error; ++ ++#[derive(Deserialize)] ++#[serde(rename_all = "snake_case")] ++pub(crate) enum Action { ++ Configure, ++ Reset, ++ GetState, ++} ++ ++pub async fn control( ++ Path(action): Path, ++ State(state): State>, ++ body: Option>>>, ++) -> Result>, ApiError> { ++ let action = match action { ++ Action::Configure => "configure", ++ Action::Reset => "reset", ++ Action::GetState => "get_state", ++ }; ++ let body = Value::Object(body.and_then(|Json(body)| body).unwrap_or_default()).to_string(); ++ let results = state ++ .engine_core_client() ++ .collective_rpc( ++ "dumper_control", ++ None, ++ (action, body), ++ BTreeMap::::new(), ++ ) ++ .await ++ .map_err(|error| utility_call_error("dumper_control", error))?; ++ Ok(Json(results)) ++} +diff --git a/rust/src/server/src/routes/tests.rs b/rust/src/server/src/routes/tests.rs +--- a/rust/src/server/src/routes/tests.rs ++++ b/rust/src/server/src/routes/tests.rs +@@ -5913,6 +5913,120 @@ + }] + }) + ); ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++#[serial] ++async fn dumper_control_routes_forward_without_dev_mode_and_return_worker_lists() { ++ let (state, engine_task) = ++ test_admin_state_with_ready_and_engine_script(default_ready_response(), |dealer, push| { ++ boxed_test_future(async move { ++ for (action, expected_body) in [ ++ ("configure", json!({"enable": true})), ++ ("reset", json!({})), ++ ("get_state", json!({})), ++ ] { ++ let utility = recv_engine_message(dealer).await; ++ let payload = decode_value(&utility[1]).unwrap(); ++ let request = payload.as_array().unwrap(); ++ assert_eq!(request[2], Value::from("collective_rpc")); ++ let arguments = request[3].as_array().unwrap(); ++ assert_eq!(arguments[0], Value::from("dumper_control")); ++ assert_eq!(arguments[1], Value::Nil); ++ let positional = arguments[2].as_array().unwrap(); ++ assert_eq!(positional[0], Value::from(action)); ++ assert_eq!( ++ serde_json::from_str::(positional[1].as_str().unwrap()) ++ .unwrap(), ++ expected_body ++ ); ++ assert_eq!(arguments[3], Value::Map(vec![])); ++ send_outputs( ++ push, ++ utility_outputs( ++ request[1].as_u64().unwrap(), ++ UtilityResultEnvelope::without_type_info(Value::Array(vec![ ++ Value::Map(vec![(Value::from("action"), Value::from(action))]), ++ ])), ++ ), ++ ) ++ .await; ++ } ++ }) ++ }) ++ .await; ++ let mut disabled = super::build_router_with_options(state.clone(), false, false, false, false); ++ let response = disabled ++ .call( ++ Request::builder() ++ .method("POST") ++ .uri("/dumper/get_state") ++ .body(Body::empty()) ++ .unwrap(), ++ ) ++ .await ++ .unwrap(); ++ assert_eq!(response.status(), StatusCode::NOT_FOUND); ++ let mut enabled = super::build_router_with_options(state, false, false, false, true); ++ for (action, body) in [ ++ ("configure", Some(r#"{"enable":true}"#)), ++ ("reset", None), ++ ("get_state", Some("null")), ++ ] { ++ let request = Request::builder().method("POST").uri(format!("/dumper/{action}")); ++ let request = if let Some(body) = body { ++ request ++ .header("content-type", "application/json") ++ .body(Body::from(body)) ++ .unwrap() ++ } else { ++ request.body(Body::empty()).unwrap() ++ }; ++ let response = enabled.call(request).await.unwrap(); ++ assert_eq!(response.status(), StatusCode::OK); ++ let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); ++ assert_eq!( ++ serde_json::from_slice::(&bytes).unwrap(), ++ json!([{"action": action}]) ++ ); ++ } ++ engine_task.await.unwrap(); ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++#[serial] ++async fn dumper_control_routes_require_configured_api_keys() { ++ let (chat, _engine_task) = test_models_with_engine_outputs_and_backend( ++ b"engine-dumper-auth", ++ default_stream_output_specs(), ++ Arc::new(FakeChatBackend::new()), ++ ) ++ .await; ++ let state = ++ Arc::new(AppState::new(vec!["model".into()], chat).with_api_keys(vec!["secret".into()])); ++ let mut app = super::build_router_with_options(state, false, false, false, true); ++ for token in [None, Some("Bearer wrong")] { ++ let request = Request::builder().method("POST").uri("/dumper/reset"); ++ let request = if let Some(token) = token { ++ request.header("authorization", token) ++ } else { ++ request ++ }; ++ let response = app.call(request.body(Body::empty()).unwrap()).await.unwrap(); ++ assert_eq!(response.status(), StatusCode::UNAUTHORIZED); ++ } ++ let response = app ++ .call( ++ Request::builder() ++ .method("GET") ++ .uri("/dumper/get_state") ++ .header("authorization", "Bearer secret") ++ .body(Body::empty()) ++ .unwrap(), ++ ) ++ .await ++ .unwrap(); ++ assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +diff --git a/rust/src/server/src/routes/tokenize/types.rs b/rust/src/server/src/routes/tokenize/types.rs +--- a/rust/src/server/src/routes/tokenize/types.rs ++++ b/rust/src/server/src/routes/tokenize/types.rs +@@ -54,6 +54,7 @@ + add_special_tokens: self.add_special_tokens, + data_parallel_rank: None, + session_id: None, ++ trace_headers: None, + kv_hints: None, + reasoning_parser_kwargs: Default::default(), + reasoning_ended: None, +@@ -126,6 +127,7 @@ + add_special_tokens: self.add_special_tokens, + data_parallel_rank: None, + session_id: None, ++ trace_headers: None, + lora_request: None, + }) + } +diff --git a/rust/src/server/src/utils.rs b/rust/src/server/src/utils.rs +--- a/rust/src/server/src/utils.rs ++++ b/rust/src/server/src/utils.rs +@@ -1,7 +1,7 @@ + // SPDX-License-Identifier: Apache-2.0 + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +-use std::collections::HashMap; ++use std::collections::{BTreeMap, HashMap}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use axum::BoxError; +@@ -21,6 +21,7 @@ + pub data_parallel_rank: Option, + pub priority: Option, + pub session_id: Option, ++ pub trace_headers: Option>, + } + + /// Return the current Unix timestamp in seconds for OpenAI response objects. +@@ -167,6 +168,19 @@ + data_parallel_rank, + priority, + session_id, ++ trace_headers: { ++ let headers: BTreeMap<_, _> = ["traceparent", "tracestate"] ++ .into_iter() ++ .filter_map(|name| { ++ headers ++ .get(name)? ++ .to_str() ++ .ok() ++ .map(|value| (name.to_owned(), value.to_owned())) ++ }) ++ .collect(); ++ (!headers.is_empty()).then_some(headers) ++ }, + } + } + +diff --git a/rust/src/text/src/lower.rs b/rust/src/text/src/lower.rs +--- a/rust/src/text/src/lower.rs ++++ b/rust/src/text/src/lower.rs +@@ -69,7 +69,7 @@ + reasoning_ended: request.reasoning_ended, + lora_request: request.lora_request.clone(), + arrival_time: request.arrival_time, +- trace_headers: None, ++ trace_headers: request.trace_headers.clone(), + }; + + Ok(PreparedTextRequest { +diff --git a/rust/src/text/src/request.rs b/rust/src/text/src/request.rs +--- a/rust/src/text/src/request.rs ++++ b/rust/src/text/src/request.rs +@@ -1,7 +1,7 @@ + // SPDX-License-Identifier: Apache-2.0 + // SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +-use std::collections::HashMap; ++use std::collections::{BTreeMap, HashMap}; + + use enum_as_inner::EnumAsInner; + use serde::{Deserialize, Deserializer, Serialize}; +@@ -239,6 +239,8 @@ + /// Stable session identity shared by related requests. + #[serde(default)] + pub session_id: Option, ++ #[serde(default)] ++ pub trace_headers: Option>, + /// Optional orchestrator-originated KV hints. + #[serde(default)] + pub kv_hints: Option, +@@ -282,6 +284,7 @@ + add_special_tokens: false, + data_parallel_rank: None, + session_id: None, ++ trace_headers: None, + kv_hints: None, + reasoning_parser_kwargs: Default::default(), + reasoning_ended: None, diff --git a/docker/patch/latest/vllm-score-centering.patch b/docker/patch/latest/vllm-score-centering.patch index 6a88de058..556cde34e 100644 --- a/docker/patch/latest/vllm-score-centering.patch +++ b/docker/patch/latest/vllm-score-centering.patch @@ -1,8 +1,6 @@ -diff --git a/vllm/config/model.py b/vllm/config/model.py -index 8f7fb671525..3cdf929306b 100644 --- a/vllm/config/model.py +++ b/vllm/config/model.py -@@ -253,6 +253,8 @@ class ModelConfig: +@@ -279,6 +279,8 @@ equivalent to setting `-cc.mode=none -cc.cudagraph_mode=none`.""" return_sampling_mask: bool = False """Whether to return the post-processing token support for each sample.""" @@ -11,7 +9,7 @@ index 8f7fb671525..3cdf929306b 100644 max_logprobs: int = Field(default=20, ge=-1) """Maximum number of log probabilities to return when `logprobs` is specified in `SamplingParams`. The default value comes the default for the -@@ -448,6 +450,7 @@ class ModelConfig: +@@ -477,6 +479,7 @@ "spec_target_max_model_len", "enforce_eager", "return_sampling_mask", @@ -19,7 +17,7 @@ index 8f7fb671525..3cdf929306b 100644 "logprobs_mode", "use_fp64_gumbel", "enable_trace_replay", -@@ -1014,6 +1017,10 @@ class ModelConfig: +@@ -1048,6 +1051,10 @@ @model_validator(mode="after") def validate_model_config_after(self: "ModelConfig") -> "ModelConfig": """Called after __post_init__.""" @@ -30,11 +28,9 @@ index 8f7fb671525..3cdf929306b 100644 if not isinstance(self.tokenizer, str): raise ValueError( f"tokenizer must be a string, got " -diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py -index 7369989bb78..a1f801cc66c 100644 --- a/vllm/engine/arg_utils.py +++ b/vllm/engine/arg_utils.py -@@ -452,6 +452,7 @@ class EngineArgs: +@@ -470,6 +470,7 @@ # AuxOutputConfig. enable_return_routed_experts: bool = False return_sampling_mask: bool = ModelConfig.return_sampling_mask @@ -42,7 +38,7 @@ index 7369989bb78..a1f801cc66c 100644 model_weights: str = ModelConfig.model_weights served_model_name: str | list[str] | None = ModelConfig.served_model_name tokenizer: str | None = ModelConfig.tokenizer -@@ -944,6 +945,10 @@ class EngineArgs: +@@ -974,6 +975,10 @@ "--return-sampling-mask", **model_kwargs["return_sampling_mask"], ) @@ -53,7 +49,7 @@ index 7369989bb78..a1f801cc66c 100644 model_group.add_argument("--max-logprobs", **model_kwargs["max_logprobs"]) model_group.add_argument("--logprobs-mode", **model_kwargs["logprobs_mode"]) model_group.add_argument("--use-fp64-gumbel", **model_kwargs["use_fp64_gumbel"]) -@@ -1886,6 +1891,7 @@ class EngineArgs: +@@ -1964,6 +1969,7 @@ allow_deprecated_quantization=self.allow_deprecated_quantization, enforce_eager=self.enforce_eager, return_sampling_mask=self.return_sampling_mask, @@ -61,31 +57,27 @@ index 7369989bb78..a1f801cc66c 100644 max_logprobs=self.max_logprobs, logprobs_mode=self.logprobs_mode, use_fp64_gumbel=self.use_fp64_gumbel, -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index eb4000c7e29..b2be4c2c663 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -333,6 +333,7 @@ class GenerateResponseChoice(BaseModel): - # or (b) ``enable_return_routed_experts`` is off server-side. +@@ -411,6 +411,7 @@ + # or (b) `enable_return_routed_experts` is off server-side. routed_experts: str | None = None sampling_mask: list[list[int]] | None = None + sampling_mask_logprobs: list[list[float]] | None = None @field_validator("token_ids") @classmethod -@@ -349,6 +350,7 @@ class GenerateResponseStreamChoice(BaseModel): +@@ -443,6 +444,7 @@ token_ids: list[int] | None = None routed_experts: str | None = None sampling_mask: list[list[int]] | None = None + sampling_mask_logprobs: list[list[float]] | None = None - class GenerateStreamResponse(BaseModel): -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index 6e86ac83417..397902ddca1 100644 + class GenerateTokensStreamChoice(GenerateStreamChoiceBase): --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -349,8 +349,10 @@ class ServingTokens(GenerateBaseServing): +@@ -443,8 +443,10 @@ ) sampling_mask = None @@ -94,33 +86,31 @@ index 6e86ac83417..397902ddca1 100644 sampling_mask = output.sampling_mask.token_ids + sampling_mask_logprobs = output.sampling_mask.logprobs - choice_data = GenerateResponseChoice( + choice_fields: dict[str, Any] = dict( index=output.index, -@@ -359,6 +361,7 @@ class ServingTokens(GenerateBaseServing): +@@ -453,6 +455,7 @@ token_ids=as_list(output.token_ids), routed_experts=routed_experts_b64, sampling_mask=sampling_mask, + sampling_mask_logprobs=sampling_mask_logprobs, ) - - choices.append(choice_data) -@@ -510,6 +513,11 @@ class ServingTokens(GenerateBaseServing): - if output.sampling_mask is not None - else None - ), -+ sampling_mask_logprobs=( -+ output.sampling_mask.logprobs -+ if output.sampling_mask is not None -+ else None -+ ), - ) - ], + if text_mode: + text_choices.append( +@@ -626,6 +629,11 @@ + token_ids=as_list(delta_token_ids), + routed_experts=routed_experts_b64, + sampling_mask=sampling_mask, ++ sampling_mask_logprobs=( ++ output.sampling_mask.logprobs ++ if output.sampling_mask is not None ++ else None ++ ), ) -diff --git a/vllm/outputs.py b/vllm/outputs.py -index 0507f2555df..814fa16d86b 100644 + chunk: GenerateTokensStreamResponse | GenerateTextStreamResponse + if text_mode: --- a/vllm/outputs.py +++ b/vllm/outputs.py -@@ -27,6 +27,7 @@ class SamplingMask: +@@ -36,6 +36,7 @@ """ token_ids: list[list[int]] @@ -128,10 +118,19 @@ index 0507f2555df..814fa16d86b 100644 @dataclass -@@ -202,6 +203,11 @@ class RequestOutput: - completion.sampling_mask.token_ids.extend( - next_completion.sampling_mask.token_ids - ) +@@ -212,10 +213,16 @@ + completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type] + if next_completion.sampling_mask is not None: + if completion.sampling_mask is None: +- completion.sampling_mask = SamplingMask([]) +- completion.sampling_mask.token_ids.extend( +- next_completion.sampling_mask.token_ids +- ) ++ completion.sampling_mask = next_completion.sampling_mask ++ else: ++ completion.sampling_mask.token_ids.extend( ++ next_completion.sampling_mask.token_ids ++ ) + if next_completion.sampling_mask.logprobs is not None: + assert completion.sampling_mask.logprobs is not None + completion.sampling_mask.logprobs.extend( @@ -140,77 +139,102 @@ index 0507f2555df..814fa16d86b 100644 if next_completion.spec_decode_metrics is not None: completion.spec_decode_metrics = ( next_completion.spec_decode_metrics -diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py -index 09618d7eb66..92ad049f5dc 100644 --- a/vllm/v1/engine/input_processor.py +++ b/vllm/v1/engine/input_processor.py -@@ -146,6 +146,13 @@ class InputProcessor: +@@ -189,17 +189,10 @@ + vocab_size=self.model_config.get_vocab_size(), ) - if self.model_config.return_sampling_mask: -+ if ( -+ self.model_config.return_sampling_mask_logprobs -+ and self.vllm_config.speculative_config is not None -+ ): -+ raise ValueError( -+ "sampling-mask logprobs do not support speculative decoding" -+ ) - if params.temperature <= 0: - raise ValueError( - "sampling distribution replay requires temperature > 0" -diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index e739e83b1c8..41ad126c67d 100644 +- if self.model_config.return_sampling_mask: +- if params.temperature <= 0: +- raise ValueError( +- "sampling distribution replay requires temperature > 0" +- ) +- if params.top_k <= 0: +- raise ValueError( +- "sampling distribution replay requires top_k > 0 to " +- "bound sampling mask size, reduce transfer overhead, " +- "and avoid potential OOMs" +- ) ++ if self.model_config.return_sampling_mask and params.temperature <= 0: ++ raise ValueError( ++ "sampling distribution replay requires temperature > 0" ++ ) + if params.thinking_token_budget is not None and ( + self.vllm_config.reasoning_config is None + or not self.vllm_config.reasoning_config.enabled --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -435,12 +435,18 @@ class RequestState: +@@ -449,13 +449,21 @@ + logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] sampling_mask = None - if self.sampling_mask_chunks and (delta or finished): -+ chunks = self.sampling_mask_chunks[: len(token_ids)] +- if (delta or finished) and self.sampling_mask_chunks: ++ if self.sampling_mask_chunks and (delta or finished): ++ chunks = self.sampling_mask_chunks sampling_mask = SamplingMask( - [ - mask +- [ +- position - for chunk in self.sampling_mask_chunks -+ for chunk in chunks - for mask in chunk.to_nested_list() -- ][: len(token_ids)] +- for position in chunk.to_nested_list() +- ] ++ [mask for chunk in chunks for mask in chunk.to_nested_list()][ ++ : len(token_ids) + ], + ( -+ [chunk.logprobs.tolist() for chunk in chunks] -+ if chunks and chunks[0].logprobs is not None ++ [ ++ scores ++ for chunk in chunks ++ for scores in chunk.to_nested_logprobs() ++ ][: len(token_ids)] ++ if chunks[0].logprobs is not None + else None + ), ) if delta: self.sampling_mask_chunks.clear() -diff --git a/vllm/v1/outputs.py b/vllm/v1/outputs.py -index c38eafa4a48..933a0e1e116 100644 --- a/vllm/v1/outputs.py +++ b/vllm/v1/outputs.py -@@ -65,11 +65,17 @@ class SamplingMaskLists(NamedTuple): +@@ -65,6 +65,7 @@ offsets: np.ndarray | None = None - # Unused with one position per request; kept for the wire layout. + # [num_requests + 1] for multi-position request batches. cu_num_generated_tokens: list[int] | None = None + logprobs: np.ndarray | None = None def slice_request(self, req_idx: int, num_positions: int) -> "SamplingMaskLists": - assert num_positions == 1 and self.offsets is not None + assert self.offsets is not None +@@ -72,10 +73,11 @@ + start = req_idx if cu is None else cu[req_idx] + end = start + num_positions + lo, hi = self.offsets[start], self.offsets[end] ++ scores = self.logprobs[lo:hi] if self.logprobs is not None else None + if num_positions == 1: +- return SamplingMaskLists(self.token_ids[lo:hi]) ++ return SamplingMaskLists(self.token_ids[lo:hi], logprobs=scores) return SamplingMaskLists( -- self.token_ids[self.offsets[req_idx] : self.offsets[req_idx + 1]] -+ self.token_ids[self.offsets[req_idx] : self.offsets[req_idx + 1]], -+ logprobs=( -+ self.logprobs[self.offsets[req_idx] : self.offsets[req_idx + 1]] -+ if self.logprobs is not None -+ else None -+ ), +- self.token_ids[lo:hi], self.offsets[start : end + 1] - lo ++ self.token_ids[lo:hi], self.offsets[start : end + 1] - lo, logprobs=scores ) def to_nested_list(self) -> list[list[int]]: -diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py -index 57f0180edc9..3a4d262210d 100644 +@@ -84,6 +86,14 @@ + return [token_ids] + offsets = self.offsets.tolist() + return [token_ids[offsets[i] : offsets[i + 1]] for i in range(len(offsets) - 1)] ++ ++ def to_nested_logprobs(self) -> list[list[float]]: ++ assert self.logprobs is not None ++ logprobs = self.logprobs.tolist() ++ if self.offsets is None: ++ return [logprobs] ++ offsets = self.offsets.tolist() ++ return [logprobs[offsets[i] : offsets[i + 1]] for i in range(len(offsets) - 1)] + + + class LogprobsTensors(NamedTuple): --- a/vllm/v1/worker/gpu/model_runner.py +++ b/vllm/v1/worker/gpu/model_runner.py -@@ -453,6 +453,9 @@ class GPUModelRunner(LoRAModelRunnerMixin): +@@ -468,6 +468,9 @@ "use_fp64_gumbel": self.model_config.use_fp64_gumbel, "enable_trace_replay": self.model_config.enable_trace_replay, "return_sampling_mask": self.model_config.return_sampling_mask, @@ -220,10 +244,9 @@ index 57f0180edc9..3a4d262210d 100644 "custom_logits_processors": custom_logits_processors, } if self.vllm_config.watermark_config is None: -diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/output.py --- a/vllm/v1/worker/gpu/sample/output.py +++ b/vllm/v1/worker/gpu/sample/output.py -@@ -10,6 +10,7 @@ import torch +@@ -11,6 +11,7 @@ from vllm.triton_utils import tl, triton from vllm.v1.outputs import LogprobsTensors, SamplingMaskLists @@ -231,22 +254,22 @@ diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/out @dataclass -@@ -36,6 +37,7 @@ def _compact_sampling_mask_kernel( - vocab_size, +@@ -39,6 +40,7 @@ max_num_kept, + ROWS_PER_REQUEST: tl.constexpr, BLOCK_SIZE: tl.constexpr, + WRITE_PACKED_MASK: tl.constexpr, ): """Per row: first ``max_num_kept`` finite-logit ids, the full count, the bitmask.""" - req_idx = tl.program_id(0) -@@ -59,24 +61,30 @@ def _compact_sampling_mask_kernel( + output_row = tl.program_id(0) +@@ -68,13 +70,16 @@ ) count += tl.sum(keep_i32) - bits = tl.reshape(keep_i32, (BLOCK_SIZE // 8, 8)) << tl.arange(0, 8)[None, :] - byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8) - tl.store( -- packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets, +- packed_mask_ptr + output_row * packed_mask_row_stride + byte_offsets, - tl.sum(bits, axis=1).to(tl.uint8), - mask=byte_offsets < tl.cdiv(vocab_size, 8), - ) @@ -256,45 +279,30 @@ diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/out + ) + byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8) + tl.store( -+ packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets, ++ packed_mask_ptr + output_row * packed_mask_row_stride + byte_offsets, + tl.sum(bits, axis=1).to(tl.uint8), + mask=byte_offsets < tl.cdiv(vocab_size, 8), + ) - tl.store(counts_ptr + req_idx, count) - - --# Bounds the [num_reqs, width] int32 buffer; wider rows use the bitmask instead. - MAX_COMPACT_SUPPORT = 2048 + tl.store(counts_ptr + output_row, count) - - class SamplingMaskTensors(NamedTuple): - """Device-side masks pending async D2H: compact ids, plus the bitmask as -- the exact fallback for rows wider than ``max_num_kept``.""" -+ the exact fallback for rows wider than ``max_num_kept``. -+ -+ With ``logprobs``, full-vocabulary buffers preserve pure top-p support -+ without synchronizing the device. -+ """ - - # [num_requests, max_num_kept] - token_ids: torch.Tensor -@@ -85,6 +93,7 @@ class SamplingMaskTensors(NamedTuple): - # [num_requests] - counts: torch.Tensor +@@ -96,6 +101,7 @@ vocab_size: int + # Fixed request-major output slots. Ordinary sampling has one slot. + rows_per_request: int = 1 + logprobs: torch.Tensor | None = None @classmethod def from_logits( -@@ -92,18 +101,33 @@ class SamplingMaskTensors(NamedTuple): - logits: torch.Tensor, +@@ -105,19 +111,29 @@ num_sampled_tokens: torch.Tensor, max_num_kept: int, + rows_per_request: int = 1, + return_logprobs: bool = False, ) -> SamplingMaskTensors: - """Capture the finite-logit support of every row with a sampled token.""" - num_reqs, vocab_size = logits.shape + """Capture committed target supports in fixed request-major slots.""" + num_reqs = num_sampled_tokens.shape[0] + vocab_size = logits.shape[1] - max_num_kept = min(max_num_kept, vocab_size, MAX_COMPACT_SUPPORT) + max_num_kept = ( + vocab_size @@ -305,82 +313,83 @@ diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/out + MAX_COMPACT_SUPPORT, + ) + ) + num_output_rows = num_reqs * rows_per_request device = logits.device - token_ids = torch.empty( -- (num_reqs, max_num_kept), dtype=torch.int32, device=device -- ) -- packed_mask = torch.empty( -- (num_reqs, (vocab_size + 7) // 8), dtype=torch.uint8, device=device -- ) -+ if return_logprobs: -+ token_ids = torch.zeros( -+ (num_reqs, max_num_kept), dtype=torch.int32, device=device -+ ) -+ packed_mask = torch.empty((num_reqs, 0), dtype=torch.uint8, device=device) -+ else: -+ token_ids = torch.empty( -+ (num_reqs, max_num_kept), dtype=torch.int32, device=device -+ ) -+ packed_mask = torch.empty( -+ (num_reqs, (vocab_size + 7) // 8), dtype=torch.uint8, device=device -+ ) - counts = torch.empty(num_reqs, dtype=torch.int32, device=device) - _compact_sampling_mask_kernel[(num_reqs,)]( - logits, -@@ -118,26 +142,45 @@ class SamplingMaskTensors(NamedTuple): - vocab_size, ++ allocate = torch.zeros if return_logprobs else torch.empty ++ token_ids = allocate( + (num_output_rows, max_num_kept), dtype=torch.int32, device=device + ) + packed_mask = torch.empty( +- (num_output_rows, (vocab_size + 7) // 8), ++ (num_output_rows, 0 if return_logprobs else (vocab_size + 7) // 8), + dtype=torch.uint8, + device=device, + ) +@@ -137,8 +153,19 @@ max_num_kept, + ROWS_PER_REQUEST=rows_per_request, BLOCK_SIZE=8192, +- ) +- return cls(token_ids, packed_mask, counts, vocab_size, rows_per_request) + WRITE_PACKED_MASK=not return_logprobs, - ) -- return cls(token_ids, packed_mask, counts, vocab_size) ++ ) + logprobs = None + if return_logprobs: -+ logprobs = compute_token_logprobs(logits, token_ids) -+ return cls(token_ids, packed_mask, counts, vocab_size, logprobs) ++ slots = torch.arange(rows_per_request, device=device) ++ source_rows = cu_num_logits[:-1, None] + slots ++ source_rows = torch.where( ++ slots < num_sampled_tokens[:, None], source_rows, 0 ++ ).flatten() ++ logprobs = compute_token_logprobs(logits[source_rows], token_ids) ++ return cls( ++ token_ids, packed_mask, counts, vocab_size, rows_per_request, logprobs ++ ) + + @classmethod + def cat(cls, chunks: Sequence[SamplingMaskTensors]) -> SamplingMaskTensors: +@@ -151,6 +178,9 @@ + torch.cat([chunk.counts for chunk in chunks]), + first.vocab_size, + first.rows_per_request, ++ torch.cat([chunk.logprobs for chunk in chunks]) ++ if first.logprobs is not None ++ else None, + ) def to_cpu_nonblocking(self) -> SamplingMaskTensors: - if self.token_ids.device.type == "cpu": - return self -+ logprobs = self.logprobs -+ if logprobs is not None: -+ logprobs = logprobs.to("cpu", non_blocking=True) - return SamplingMaskTensors( - self.token_ids.to("cpu", non_blocking=True), - self.packed_mask.to("cpu", non_blocking=True), +@@ -162,6 +192,9 @@ self.counts.to("cpu", non_blocking=True), self.vocab_size, -+ logprobs, + self.rows_per_request, ++ self.logprobs.to("cpu", non_blocking=True) ++ if self.logprobs is not None ++ else None, ) - def tolists(self) -> SamplingMaskLists: - """CSR over all requests; rows without a sampled token are empty.""" - counts = self.counts.cpu().numpy() - token_ids = self.token_ids.cpu().numpy() -- packed_mask = self.packed_mask.cpu().numpy() - width = token_ids.shape[1] + def tolists( +@@ -186,6 +219,17 @@ + np.concatenate(([0], num_sampled_tokens)) + ).tolist() + if self.logprobs is not None: -+ valid = np.arange(width)[None, :] < counts[:, None] -+ offsets = np.zeros(len(counts) + 1, dtype=np.int64) -+ np.cumsum(counts, out=offsets[1:]) ++ valid = np.arange(width)[None, :] < counts[sampled_rows, None] ++ offsets = np.zeros(len(sampled_rows) + 1, dtype=np.int64) ++ np.cumsum(counts[sampled_rows], out=offsets[1:]) + return SamplingMaskLists( -+ token_ids[valid], ++ token_ids[sampled_rows][valid], + offsets, -+ logprobs=self.logprobs.cpu().numpy()[valid], ++ cu_num_generated_tokens, ++ self.logprobs.cpu().numpy()[sampled_rows][valid], + ) -+ -+ packed_mask = self.packed_mask.cpu().numpy() + def support(row: int) -> np.ndarray: if counts[row] <= width: return token_ids[row, : counts[row]] -diff --git a/vllm/v1/worker/gpu/sample/sampler.py b/vllm/v1/worker/gpu/sample/sampler.py -index 802adb6f642..0deb66ec5a2 100644 --- a/vllm/v1/worker/gpu/sample/sampler.py +++ b/vllm/v1/worker/gpu/sample/sampler.py -@@ -52,6 +52,7 @@ class Sampler: +@@ -52,6 +52,7 @@ use_fp64_gumbel: bool = False, enable_trace_replay: bool = False, return_sampling_mask: bool = False, @@ -388,7 +397,7 @@ index 802adb6f642..0deb66ec5a2 100644 custom_logits_processors: Sequence[LogitsProcessor] = (), ): self.logprobs_mode = logprobs_mode -@@ -85,6 +86,7 @@ class Sampler: +@@ -85,6 +86,7 @@ self.needs_logits_processing = np.zeros(max_num_reqs, dtype=bool) self.num_speculative_tokens = num_speculative_tokens self.return_sampling_mask = return_sampling_mask @@ -396,15 +405,21 @@ index 802adb6f642..0deb66ec5a2 100644 self.use_flashinfer = ( not return_sampling_mask and flashinfer_sampler_supported() ) -@@ -203,7 +205,10 @@ class Sampler: - # Size by the validated top_k batch max; wider supports use the bitmask. - max_num_kept = int(np.max(self.sampling_states.top_k.np[idx_mapping_np])) - sampling_mask_tensors = SamplingMaskTensors.from_logits( -- processed_logits, num_sampled, max_num_kept -+ processed_logits, -+ num_sampled, -+ max_num_kept, +@@ -207,6 +209,7 @@ + input_batch.cu_num_logits, + num_sampled, + max_num_kept, + return_logprobs=self.return_sampling_mask_logprobs, ) # These are GPU tensors. +--- a/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py ++++ b/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py +@@ -367,6 +367,7 @@ + num_sampled, + max_num_kept, + self.num_speculative_steps + 1, ++ return_logprobs=self.sampler.return_sampling_mask_logprobs, + ) + ) + del processed_logits diff --git a/docker/patch/latest/vllm-tensor-dump.patch b/docker/patch/latest/vllm-tensor-dump.patch new file mode 100644 index 000000000..514993757 --- /dev/null +++ b/docker/patch/latest/vllm-tensor-dump.patch @@ -0,0 +1,3130 @@ +diff --git a/vllm/config/vllm.py b/vllm/config/vllm.py +--- a/vllm/config/vllm.py ++++ b/vllm/config/vllm.py +@@ -2110,6 +2110,13 @@ + self._resolve_mm_video_decode_device() + self._validate_mm_processor_device() + ++ from vllm.utils.debug_utils.dumper import dumper ++ ++ if dumper.may_enable and ( ++ not self.use_v2_model_runner or not self.model_config.enforce_eager ++ ): ++ raise ValueError("Tensor dumping requires eager Model Runner V2") ++ + if self.use_v2_model_runner: + self._disable_cudagraphs_for_v2_stock_torch_compile() + self._validate_v2_model_runner() +diff --git a/vllm/entrypoints/serve/__init__.py b/vllm/entrypoints/serve/__init__.py +--- a/vllm/entrypoints/serve/__init__.py ++++ b/vllm/entrypoints/serve/__init__.py +@@ -20,6 +20,10 @@ + from .instrumentator import register_instrumentator_api_routers + + register_instrumentator_api_routers(app) ++ ++ from .tensor_dump.api_router import attach_router as attach_tensor_dump_router ++ ++ attach_tensor_dump_router(app) + + from vllm.entrypoints.serve.lora.api_router import ( + attach_router as attach_lora_router, +diff --git a/vllm/entrypoints/serve/middleware/authenticate.py b/vllm/entrypoints/serve/middleware/authenticate.py +--- a/vllm/entrypoints/serve/middleware/authenticate.py ++++ b/vllm/entrypoints/serve/middleware/authenticate.py +@@ -8,7 +8,7 @@ + from starlette.responses import JSONResponse + from starlette.types import ASGIApp, Receive, Scope, Send + +-GUARDED_PREFIX = ("/v1", "/v2", "/inference", "/cohere") ++GUARDED_PREFIX = ("/v1", "/v2", "/inference", "/cohere", "/dumper") + + + class AuthenticationMiddleware: +diff --git a/vllm/entrypoints/serve/tensor_dump/__init__.py b/vllm/entrypoints/serve/tensor_dump/__init__.py +new file mode 100644 +--- /dev/null ++++ b/vllm/entrypoints/serve/tensor_dump/__init__.py +@@ -0,0 +1 @@ ++ +diff --git a/vllm/entrypoints/serve/tensor_dump/api_router.py b/vllm/entrypoints/serve/tensor_dump/api_router.py +new file mode 100644 +--- /dev/null ++++ b/vllm/entrypoints/serve/tensor_dump/api_router.py +@@ -0,0 +1,25 @@ ++import json ++import os ++from typing import Literal ++ ++from fastapi import APIRouter, FastAPI, Request ++from fastapi.responses import JSONResponse ++ ++router = APIRouter() ++ ++ ++@router.post("/dumper/{action}") ++async def control_dumper( ++ action: Literal["configure", "reset", "get_state"], ++ request: Request, ++ body: dict | None = None, ++): ++ results = await request.app.state.engine_client.collective_rpc( ++ "dumper_control", args=(action, json.dumps(body or {})) ++ ) ++ return JSONResponse(content=results) ++ ++ ++def attach_router(app: FastAPI): ++ if os.getenv("DUMPER_SERVER_PORT") == "reuse": ++ app.include_router(router) +diff --git a/vllm/ray/ray_env.py b/vllm/ray/ray_env.py +--- a/vllm/ray/ray_env.py ++++ b/vllm/ray/ray_env.py +@@ -34,6 +34,7 @@ + # VLLM_RAY_EXTRA_ENV_VARS_TO_COPY (additive, not replacing). + # --------------------------------------------------------------------------- + DEFAULT_ENV_VAR_PREFIXES: set[str] = { ++ "DUMPER_", + "VLLM_", + "FLASH_ATTENTION_", + "LMCACHE_", +diff --git a/vllm/utils/debug_utils/__init__.py b/vllm/utils/debug_utils/__init__.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/__init__.py +@@ -0,0 +1 @@ ++ +diff --git a/vllm/utils/debug_utils/dump_comparator.py b/vllm/utils/debug_utils/dump_comparator.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/dump_comparator.py +@@ -0,0 +1,302 @@ ++"""Simplified dump comparator — a self-contained single-file script for comparing ++two dump directories tensor-by-tensor. ++ ++For advanced features (unshard, token alignment, per-dimension annotations), see the ++SGLang's ``sglang.srt.debug_utils.comparator`` package can read these dumps. ++""" ++ ++import argparse ++import functools ++import re ++from collections.abc import Callable ++from dataclasses import dataclass ++from pathlib import Path ++from typing import Optional ++ ++import torch ++ ++from vllm.utils.debug_utils.dumper import get_device, get_truncated_value ++ ++ ++def main(args): ++ import polars as pl ++ ++ from vllm.utils.debug_utils.dump_loader import find_row, read_meta ++ ++ df_target = read_meta(args.target_path) ++ df_target = df_target.filter( ++ (pl.col("step") >= args.start_step) & (pl.col("step") <= args.end_step) ++ ) ++ if args.filter: ++ df_target = df_target.filter(pl.col("filename").str.contains(args.filter)) ++ assert all(c in df_target.columns for c in ["rank", "step", "dump_index", "name"]) ++ ++ df_baseline = read_meta(args.baseline_path) ++ print("df_target", df_target) ++ print("df_baseline", df_baseline) ++ ++ tensor_dim_descs: list[TensorDimDesc] = _get_tensor_dim_descs() ++ ++ for row in df_target.iter_rows(named=True): ++ path_target = Path(args.target_path) / row["filename"] ++ ++ tensor_dim_desc: TensorDimDesc | None = None ++ if tensor_dim_descs: ++ matched: list[TensorDimDesc] = [ ++ desc ++ for desc in tensor_dim_descs ++ if re.search(desc.pattern, row["filename"]) is not None ++ ] ++ if matched: ++ tensor_dim_desc = matched[0] ++ ++ row_baseline = find_row( ++ df_baseline, ++ conditions=dict( ++ step=row["step"], ++ **{ ++ k: v ++ for k, v in row.items() ++ if k not in ["step", "dump_index", "filename"] ++ }, ++ ), ++ ) ++ ++ if row_baseline is None: ++ print(f"Skip: target={str(path_target)} since no baseline") ++ x_target = _load_object(path_target) ++ if x_target is not None: ++ print(f"x_target(sample)={get_truncated_value(x_target)}") ++ continue ++ ++ path_baseline = Path(args.baseline_path) / row_baseline["filename"] ++ print( ++ f"Check:\n" ++ f"target={str(path_target)} (duplicate_index={row['duplicate_index']})\n" ++ f"baseline={str(path_baseline)} " ++ f"(duplicate_index={row_baseline['duplicate_index']})" ++ ) ++ check_tensor_pair( ++ path_baseline=path_baseline, ++ path_target=path_target, ++ diff_threshold=args.diff_threshold, ++ name=row["name"], ++ tensor_dim_desc=tensor_dim_desc, ++ ) ++ print() ++ ++ ++def check_tensor_pair( ++ path_baseline, ++ path_target, ++ diff_threshold: float = 1e-3, ++ name="", ++ tensor_dim_desc: Optional["TensorDimDesc"] = None, ++): ++ x_baseline = _load_object(path_baseline) ++ x_target = _load_object(path_target) ++ ++ if x_baseline is None or x_target is None: ++ print( ++ f"Skip comparison because of None: x_baseline={x_baseline}, " ++ f"x_target={x_target}" ++ ) ++ return ++ ++ print( ++ f"Raw " ++ f"[shape] {x_baseline.shape} vs {x_target.shape}\t" ++ f"[{'' if x_baseline.dtype == x_target.dtype else '🟠'}dtype] " ++ f"{x_baseline.dtype} vs {x_target.dtype}" ++ ) ++ ++ if tensor_dim_desc is not None: ++ import einops ++ ++ x_baseline = einops.rearrange( ++ x_baseline, ++ tensor_dim_desc.baseline_desc + " -> " + tensor_dim_desc.target_desc, ++ ) ++ if tensor_dim_desc.baseline_cropper is not None: ++ print("Apply baseline_cropper") ++ x_baseline = tensor_dim_desc.baseline_cropper(x_baseline) ++ ++ x_baseline, x_target = _comparison_preprocessor(x_baseline, x_target, name=name) ++ x_baseline = _try_unify_shape(x_baseline, target_shape=x_target.shape) ++ ++ print( ++ f"After preprocessor " ++ f"[shape] {x_baseline.shape} vs {x_target.shape}\t" ++ f"[dtype] {x_baseline.dtype} vs {x_target.dtype}" ++ ) ++ ++ x_baseline_original_dtype = x_baseline.dtype ++ x_target_original_dtype = x_target.dtype ++ ++ x_target = x_target.float() ++ x_baseline = x_baseline.float() ++ ++ for name, fn in [ ++ ("mean", torch.mean), ++ ("std", torch.std), ++ ("min", torch.min), ++ ("max", torch.max), ++ *( ++ [ ++ ("p1", functools.partial(torch.quantile, q=0.01)), ++ ("p5", functools.partial(torch.quantile, q=0.05)), ++ ("p95", functools.partial(torch.quantile, q=0.95)), ++ ("p99", functools.partial(torch.quantile, q=0.99)), ++ ] ++ if x_baseline.numel() < 10_000_000 ++ else [] ++ ), ++ ]: ++ value_baseline = fn(x_baseline).item() ++ value_target = fn(x_target).item() ++ print( ++ f"[{name}] {value_baseline:.4f} vs {value_target:.4f} (diff: " ++ f"{value_target - value_baseline:.4f})" ++ ) ++ ++ if x_baseline.shape != x_target.shape: ++ print("⚠️ Shape mismatch") ++ return ++ ++ diff_info = _compute_and_print_diff( ++ x_baseline=x_baseline, ++ x_target=x_target, ++ diff_threshold=diff_threshold, ++ ) ++ needs_print = diff_info["max_abs_diff"] > 1e-3 ++ ++ if (x_baseline_original_dtype != x_target_original_dtype) and ( ++ ( ++ downcast_dtype := _compute_smaller_dtype( ++ x_baseline_original_dtype, x_target_original_dtype ++ ) ++ ) ++ is not None ++ ): ++ _compute_and_print_diff( ++ x_baseline=x_baseline.to(downcast_dtype), ++ x_target=x_target.to(downcast_dtype), ++ diff_threshold=diff_threshold, ++ prefix_text=f"When downcast to {downcast_dtype}: ", ++ ) ++ ++ if needs_print: ++ print(f"x_baseline(sample)={get_truncated_value(x_baseline)}") ++ print(f"x_target(sample)={get_truncated_value(x_target)}") ++ ++ ++def _compute_and_print_diff( ++ x_baseline, x_target, diff_threshold: float, prefix_text="" ++): ++ raw_abs_diff = (x_target - x_baseline).abs() ++ ++ max_abs_diff = raw_abs_diff.max().item() ++ mean_abs_diff = raw_abs_diff.mean().item() ++ rel_diff = _calc_rel_diff(x_target, x_baseline) ++ ++ rel_diff_marker: str = "❌" if rel_diff > diff_threshold else "✅" ++ print( ++ prefix_text ++ + f"{rel_diff_marker} rel_diff={rel_diff}\t" ++ + f"max_abs_diff={max_abs_diff}\t" ++ + f"mean_abs_diff={mean_abs_diff}" ++ ) ++ ++ max_diff_coord = _argmax_coord(raw_abs_diff) ++ print( ++ f"max_abs_diff happens at coord={max_diff_coord} with " ++ f"baseline={x_baseline[max_diff_coord].item()} " ++ f"target={x_target[max_diff_coord].item()}" ++ ) ++ ++ return dict(max_abs_diff=max_abs_diff) ++ ++ ++def _argmax_coord(x: torch.Tensor) -> tuple: ++ flat_idx = x.argmax() ++ return tuple(idx.item() for idx in torch.unravel_index(flat_idx, x.shape)) ++ ++ ++def _compute_smaller_dtype(dtype_a, dtype_b): ++ info_dict = { ++ (torch.float32, torch.bfloat16): torch.bfloat16, ++ # ... add more ... ++ } ++ return info_dict.get((dtype_a, dtype_b)) or info_dict.get((dtype_b, dtype_a)) ++ ++ ++def _try_unify_shape(x: torch.Tensor, target_shape): ++ x_shape = x.shape ++ num_dim_to_remove = len(x_shape) - len(target_shape) ++ if (x_shape[num_dim_to_remove:] == target_shape) and all( ++ val == 1 for val in x_shape[:num_dim_to_remove] ++ ): ++ out = functools.reduce(lambda a, _: a.squeeze(0), range(num_dim_to_remove), x) ++ print(f"Unify shape: {x_shape} -> {out.shape} (to match {target_shape})") ++ return out ++ ++ return x ++ ++ ++# Copied from DeepGEMM ++def _calc_rel_diff(x: torch.Tensor, y: torch.Tensor): ++ x, y = x.double(), y.double() ++ denominator = (x * x + y * y).sum() ++ sim = 2 * (x * y).sum() / denominator ++ return 1 - sim ++ ++ ++def _load_object(path): ++ try: ++ x = torch.load(path, weights_only=False) ++ except Exception as e: ++ print(f"Skip load {path} since error {e}") ++ return None ++ ++ if isinstance(x, dict) and "value" in x: ++ x = x["value"] ++ ++ if not isinstance(x, torch.Tensor): ++ print(f"Skip load {path} since {type(x)=} is not a Tensor ({x=})") ++ return None ++ return x.to(get_device()) ++ ++ ++def _comparison_preprocessor(x_baseline, x_target, name): ++ """Customization endpoint. Can insert arbitrary adhoc postprocessing logic here.""" ++ return x_baseline, x_target ++ ++ ++@dataclass ++class TensorDimDesc: ++ pattern: str ++ baseline_desc: str ++ target_desc: str ++ baseline_cropper: Callable[[torch.Tensor], torch.Tensor] | None = None ++ ++ ++def _get_tensor_dim_descs() -> list[TensorDimDesc]: ++ """Customization endpoint. Return a list of TensorDimDesc to rearrange baseline ++ dimensions to match target layout via einops before comparison.""" ++ return [] ++ ++ ++if __name__ == "__main__": ++ # python -m vllm.utils.debug_utils.dump_comparator --baseline-path ... --target-path ++ # ... ++ parser = argparse.ArgumentParser() ++ parser.add_argument("--baseline-path", type=str) ++ parser.add_argument("--target-path", type=str) ++ parser.add_argument("--start-step", type=int, default=0) ++ parser.add_argument("--end-step", type=int, default=1000000) ++ parser.add_argument("--diff-threshold", type=float, default=1e-3) ++ parser.add_argument( ++ "--filter", type=str, default=None, help="Regex to filter filenames" ++ ) ++ args = parser.parse_args() ++ main(args) +diff --git a/vllm/utils/debug_utils/dump_loader.py b/vllm/utils/debug_utils/dump_loader.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/dump_loader.py +@@ -0,0 +1,186 @@ ++import functools ++import os ++from collections.abc import Callable ++from dataclasses import dataclass ++from pathlib import Path ++from typing import Any ++ ++import polars as pl ++import torch ++ ++LOAD_FAILED: object = object() ++ ++ ++def parse_meta_from_filename(path: Path) -> dict[str, Any]: ++ stem = Path(path).stem ++ result: dict[str, Any] = {} ++ for kv in stem.split("___"): ++ if "=" in kv: ++ k, v = kv.split("=", 1) ++ result[k] = v ++ for field_name, converter in _TYPED_FIELDS: ++ if field_name in result: ++ result[field_name] = converter(result[field_name]) ++ return result ++ ++ ++@dataclass ++class ValueWithMeta: ++ value: Any ++ meta: dict[str, Any] ++ ++ @staticmethod ++ def load(path: Path) -> "ValueWithMeta": ++ path = Path(path) ++ meta_from_filename = parse_meta_from_filename(path) ++ ++ try: ++ raw = torch.load(path, weights_only=False, map_location="cpu") ++ except Exception as e: ++ print(f"Skip load {path} since error {e}") ++ return ValueWithMeta( ++ value=LOAD_FAILED, meta={**meta_from_filename, "filename": path.name} ++ ) ++ ++ value, meta_from_embedded = _unwrap_dict_format(raw) ++ return ValueWithMeta( ++ value=value, ++ meta={**meta_from_filename, **meta_from_embedded, "filename": path.name}, ++ ) ++ ++ ++def _unwrap_dict_format(obj: Any) -> tuple[Any, dict[str, Any]]: ++ if isinstance(obj, dict) and "value" in obj: ++ meta = obj.get("meta", {}) ++ assert isinstance(meta, dict), f"Expected meta to be dict, got {type(meta)}" ++ return obj["value"], meta ++ return obj, {} ++ ++ ++class DumpLoader: ++ def __init__(self): ++ directory = os.environ.get("VLLM_DUMP_LOADER_DIR") ++ ++ self._enable = directory is not None ++ if self._enable: ++ self._directory = Path(directory) ++ self._df = read_meta(directory) ++ ++ @property ++ def enable(self): ++ return self._enable ++ ++ def load(self, name, **kwargs): ++ assert self._enable, "Please call DumpLoader.load only when it is enabled" ++ ++ from vllm.utils.debug_utils.dumper import dumper ++ ++ step = dumper._state.step ++ conditions = dict(name=name, step=step, **kwargs) ++ row = find_row(self._df, conditions=conditions) ++ assert row is not None, ( ++ f"DumpLoader cannot find row given query {name=} {kwargs=} " ++ f"{self._directory=}" ++ ) ++ ++ path = self._directory / row["filename"] ++ output = torch.load(path, weights_only=False) ++ if isinstance(output, dict) and "value" in output: ++ output = output["value"] ++ ++ print( ++ f"[DumpLoader] load from {path=} (query: {name=} {kwargs=}, output: " ++ f"{type(output)})" ++ ) ++ return output ++ ++ ++def read_meta(directory): ++ directory = Path(directory) ++ assert directory.is_dir(), f"{directory=} should be a directory" ++ ++ rows = [] ++ for p in directory.glob("*.pt"): ++ try: ++ full_kwargs = parse_meta_from_filename(p) ++ rows.append( ++ { ++ "filename": str(p.name), ++ **full_kwargs, ++ } ++ ) ++ except Exception as e: ++ print(f"[DumpLoader] skip loading {p} due to error {e}") ++ ++ df = pl.DataFrame(rows) ++ df = df.with_columns( ++ pl.col("step").cast(int), ++ pl.col("rank").cast(int), ++ pl.col("dump_index").cast(int), ++ ) ++ df = _add_duplicate_index(df) ++ df = df.sort("rank", "dump_index") ++ return df ++ ++ ++def _add_duplicate_index(df: pl.DataFrame) -> pl.DataFrame: ++ group_cols = [c for c in df.columns if c not in ["filename", "dump_index"]] ++ df = df.sort(group_cols + ["dump_index"]) ++ df = df.with_columns( ++ pl.cum_count("dump_index").over(group_cols).sub(1).alias("duplicate_index") ++ ) ++ return df ++ ++ ++def filter_rows(df: pl.DataFrame, conditions: dict[str, Any]) -> list[dict]: ++ filter_exprs = [ ++ ( ++ pl.col(col) == _cast_to_polars_dtype(conditions[col], df.schema[col]) ++ if conditions[col] is not None ++ else pl.col(col).is_null() ++ ) ++ for col in conditions ++ if col in df.columns ++ ] ++ if not filter_exprs: ++ return [] ++ return df.filter(functools.reduce(lambda a, b: a & b, filter_exprs)).to_dicts() ++ ++ ++def find_row(df: pl.DataFrame, conditions: dict[str, Any]): ++ rows = filter_rows(df, conditions) ++ if len(rows) > 1: ++ print(f"find_row find ambiguous results: {rows=}") ++ return None ++ return rows[0] if rows else None ++ ++ ++def _cast_to_polars_dtype(value, target_dtype): ++ if target_dtype in (pl.Int64, pl.Int32, pl.UInt64, pl.UInt32): ++ return int(value) ++ elif target_dtype in (pl.Float64, pl.Float32): ++ return float(value) ++ elif target_dtype == pl.Boolean: ++ return bool(value) ++ elif target_dtype == pl.String: ++ return str(value) ++ else: ++ return value ++ ++ ++def read_tokenizer_path(directory: Path) -> str | None: ++ """Read tokenizer_path from any .pt file's embedded metadata in a dump directory.""" ++ for p in directory.glob("*.pt"): ++ item: ValueWithMeta = ValueWithMeta.load(p) ++ tokenizer_path: str | None = item.meta.get("tokenizer_path") ++ if tokenizer_path is not None: ++ return str(tokenizer_path) ++ return None ++ ++ ++_TYPED_FIELDS: list[tuple[str, Callable[[str], Any]]] = [ ++ ("rank", int), ++] ++ ++ ++dump_loader = DumpLoader() +diff --git a/vllm/utils/debug_utils/dumper.py b/vllm/utils/debug_utils/dumper.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/dumper.py +@@ -0,0 +1,2004 @@ ++import enum ++import functools ++import json ++import os ++import random ++import re ++import socket ++import threading ++import time ++import traceback ++from abc import ABC, abstractmethod ++from collections.abc import Callable ++from contextlib import contextmanager ++from copy import deepcopy ++from dataclasses import asdict, dataclass, field, fields, replace ++from functools import cached_property ++from http.server import BaseHTTPRequestHandler, HTTPServer ++from pathlib import Path ++from typing import Any, Literal, Optional, get_args, get_type_hints ++ ++import torch ++import torch.distributed as dist ++import zmq ++ ++ ++def get_device(): ++ from vllm.platforms import current_platform ++ ++ return torch.device(current_platform.device_type) ++ ++ ++# config base ++ ++ ++@dataclass(frozen=True) ++class _BaseConfig(ABC): ++ def __post_init__(self) -> None: ++ self._verify_types() ++ ++ def _verify_types(self) -> None: ++ hints = get_type_hints(type(self)) ++ cls_name = type(self).__name__ ++ for f in fields(self): ++ value = getattr(self, f.name) ++ if value is None: ++ continue ++ expected = self._unwrap_type(hints[f.name]) ++ if not isinstance(value, expected): ++ raise TypeError( ++ f"{cls_name}.{f.name}: expected {expected.__name__}, " ++ f"got {type(value).__name__}" ++ ) ++ ++ @classmethod ++ @abstractmethod ++ def _env_prefix(cls) -> str: ... ++ ++ @classmethod ++ def _env_name(cls, field_name: str) -> str: ++ return f"{cls._env_prefix()}{field_name.upper()}" ++ ++ @classmethod ++ def from_env(cls) -> "_BaseConfig": ++ return cls( ++ **{ ++ f.name: cls._parse_env_field(cls._env_name(f.name), f.default) ++ for f in fields(cls) ++ } ++ ) ++ ++ def with_defaults(self, **kwargs) -> "_BaseConfig": ++ cls = type(self) ++ actual = { ++ key: value ++ for key, value in kwargs.items() ++ if os.getenv(cls._env_name(key)) is None ++ } ++ return replace(self, **actual) if actual else self ++ ++ @staticmethod ++ def _unwrap_type(hint) -> type: ++ args = get_args(hint) ++ if args: ++ return next(a for a in args if a is not type(None)) ++ return hint ++ ++ @classmethod ++ def _parse_env_field(cls, env_name: str, default): ++ return cls._parse_env_value(os.getenv(env_name), default) ++ ++ @staticmethod ++ def _parse_env_value(raw, default): ++ if raw is None or not raw.strip(): ++ return default ++ if isinstance(default, bool): ++ return raw.lower() in ("true", "1") ++ if isinstance(default, int): ++ return int(raw) ++ return raw ++ ++ @classmethod ++ def from_kv_pairs(cls, pairs: list[str] | None) -> "_BaseConfig": ++ return cls(**cls._kv_pairs_to_dict(pairs)) ++ ++ @classmethod ++ def _kv_pairs_to_dict(cls, pairs: list[str] | None) -> dict: ++ if not pairs: ++ return {} ++ ++ missing = object() ++ defaults = {f.name: f.default for f in fields(cls)} ++ result: dict = {} ++ ++ for pair in pairs: ++ key, sep, value = pair.partition("=") ++ if not sep: ++ raise ValueError(f"Invalid config pair (missing '='): {pair!r}") ++ default = defaults.get(key, missing) ++ if default is missing: ++ raise ValueError( ++ f"Unknown config key {key!r}. Valid keys: {sorted(defaults)}" ++ ) ++ try: ++ result[key] = cls._parse_env_value(value, default) ++ except (ValueError, TypeError) as exc: ++ field_type = type(default).__name__ ++ raise TypeError(f"{key}: expected {field_type}, got {value!r}") from exc ++ ++ return result ++ ++ ++_DEFAULT_EXP_NAME_PREFIX = "dump_" ++ ++ ++@dataclass(frozen=True) ++class DumperConfig(_BaseConfig): ++ enable: bool = False ++ filter: str | None = None ++ dir: str = "/tmp/dumper" ++ enable_output_file: bool = True ++ enable_output_console: bool = True ++ enable_value: bool = True ++ enable_grad: bool = False ++ enable_model_value: bool = False ++ enable_model_grad: bool = False ++ exp_name: str | None = None ++ cleanup_previous: bool = False ++ collective_timeout: int = 60 ++ server_port: str = "-1" ++ non_intrusive_mode: str = "core" ++ source_patcher_config: str | None = None ++ grafter_enable: bool = False ++ grafter_role: str = "" # required if enabled: "baseline" or "target" ++ grafter_b2t_filter: str | None = None # names flowing baseline -> target ++ grafter_t2b_filter: str | None = None # names flowing target -> baseline ++ grafter_master_address: str = "" # required if enabled ++ grafter_master_port: int = -1 # required if enabled (positive port) ++ grafter_baseline_world_size: int = -1 # required if enabled ++ grafter_target_world_size: int = -1 # required if enabled ++ grafter_backend: str = "nccl" ++ grafter_group_name: str = "graft" ++ grafter_timeout: int = 300 ++ # Fully-qualified Python path "pkg.subpkg.module.fn_name" ++ # None -> use the default identity-by-rank fallback in _Grafter._default_transform. ++ grafter_transform_path: str | None = None ++ # When True, append parallel-rank tags (pp_rank/tp_rank/...) to dump filenames so ++ # tensors from different ranks do not collide when dumped into a shared directory. ++ include_parallel_rank_in_filename: bool = False ++ ++ @classmethod ++ def _env_prefix(cls) -> str: ++ # NOTE: should not be `SGLANG_DUMPER_`, otherwise it is weird when dumping ++ # Megatron in Miles ++ return "DUMPER_" ++ ++ def __post_init__(self) -> None: ++ super().__post_init__() ++ if self.grafter_enable: ++ assert self.grafter_role in ("baseline", "target"), ( ++ f"grafter_role must be 'baseline' or 'target' when " ++ f"grafter_enable=True, " ++ f"got {self.grafter_role!r}" ++ ) ++ assert self.grafter_master_address, ( ++ "grafter_master_address must be set when grafter_enable=True" ++ ) ++ assert self.grafter_master_port > 0, ( ++ f"grafter_master_port must be a positive port when " ++ f"grafter_enable=True, " ++ f"got {self.grafter_master_port}" ++ ) ++ assert self.grafter_baseline_world_size > 0, ( ++ f"grafter_baseline_world_size must be > 0 when grafter_enable=True, " ++ f"got {self.grafter_baseline_world_size}" ++ ) ++ assert self.grafter_target_world_size > 0, ( ++ f"grafter_target_world_size must be > 0 when grafter_enable=True, " ++ f"got {self.grafter_target_world_size}" ++ ) ++ assert ( ++ self.grafter_b2t_filter is not None ++ or self.grafter_t2b_filter is not None ++ ), ( ++ "grafter_enable=True but neither grafter_b2t_filter nor " ++ "grafter_t2b_filter is set; nothing would ever be grafted" ++ ) ++ ++ @property ++ def server_port_parsed(self) -> int | Literal["reuse"] | None: ++ raw = self.server_port ++ if raw == "reuse": ++ return "reuse" ++ port = int(raw) ++ if port <= 0: ++ return None ++ return port ++ ++ ++# dumper core ++ ++ ++@dataclass ++class _DumperState: ++ dump_index: int = 0 ++ step: int = 0 ++ global_ctx: dict = field(default_factory=dict) ++ captured_output_data: dict | None = None ++ cleanup_previous_handled: bool = False ++ ++ ++class _Dumper: ++ """Utility to dump tensors, which can be useful when comparison checking models. ++ ++ Example usage: ++ dumper.dump("layer_start__hidden_states", hidden_states, layer_id=self.layer_id) ++ dumper.step() ++ ++ Import from non-SGLang system: ++ ``` ++ import sys ++ ++ sys.path.append("/YOUR_PATH/vllm/vllm/utils/debug_utils") ++ from dumper import dumper ++ ``` ++ ++ Then run the program: ++ `DUMPER_ENABLE=1 python ...` ++ ++ Auto-cleanup old dumps before first write: ++ `DUMPER_CLEANUP_PREVIOUS=1 python ...` ++ ++ Alternatively, disable at startup and configure via HTTP: ++ 1. `python ...` ++ 2. vLLM mode: `curl -X POST http://localhost:30000/dumper/configure -d ++ '{"enable": true}'` ++ standalone: `curl -X POST http://localhost:40000/dumper/configure -d ++ '{"enable": true}'` ++ 3. `curl -X POST http://localhost:30000/dumper/configure -d '{"enable": true, ++ "filter": "layer_id=[0-3]"}'` ++ 4. `curl -X POST http://localhost:30000/dumper/reset` ++ ++ Related: `vllm.utils.debug_utils.dump_comparator` for dump comparison ++ """ ++ ++ def __init__(self, *, config: DumperConfig): ++ self._config = config ++ self._state = _DumperState() ++ self._non_intrusives: list[_NonIntrusiveDumper] = [] ++ self._grafter = _Grafter(config=config) ++ ++ # ------------------------------- public :: core --------------------------------- ++ ++ @property ++ def may_enable(self) -> bool: ++ return self._config.enable or self._config.server_port_parsed is not None ++ ++ def step(self): ++ """This should be called on all ranks at the end of each iteration.""" ++ self._http_manager # noqa: B018 ++ ++ if not self._config.enable: ++ return ++ ++ # Users may want to `dump` only on some ranks, thus determine name here ++ self._ensure_exp_name() ++ ++ self._state.step += 1 ++ _log(f"step={self._state.step}") ++ ++ def dump( ++ self, ++ name: str, ++ value, ++ save: bool = True, ++ dims: str | None = None, ++ dims_grad: str | None = None, ++ grafter_extras: dict | None = None, ++ **kwargs, ++ ) -> None: ++ value_meta: dict = {} ++ grad_meta: dict = {} ++ if dims is not None: ++ value_meta["dims"] = dims ++ grad_meta["dims"] = dims ++ if dims_grad is not None: ++ value_meta["dims_grad"] = dims_grad ++ grad_meta["dims"] = dims_grad ++ ++ self._dump_inner( ++ name=name, ++ value=value, ++ extra_kwargs=kwargs, ++ save=save, ++ enable_value=self._config.enable_value, ++ enable_curr_grad=False, ++ enable_future_grad=self._config.enable_grad, ++ value_tag="Dumper.Value", ++ grad_tag="Dumper.Grad", ++ value_meta_only_fields=value_meta, ++ grad_meta_only_fields=grad_meta, ++ grafter_extras=grafter_extras, ++ ) ++ ++ def dump_model( ++ self, ++ model: "torch.nn.Module", ++ name_prefix: str = "param", ++ save: bool = True, ++ get_grad: Callable | None = None, ++ step: int | None = None, ++ **kwargs, ++ ) -> None: ++ for param_name, param in model.named_parameters(): ++ for plugin in _plugins: ++ param_name = ( ++ plugin.transform_model_param_name(model, param_name) or param_name ++ ) ++ self._dump_inner( ++ name=f"{name_prefix}__{param_name}", ++ value=param, ++ extra_kwargs=kwargs, ++ save=save, ++ enable_value=self._config.enable_model_value, ++ enable_curr_grad=self._config.enable_model_grad, ++ enable_future_grad=False, ++ value_tag="Dumper.ParamValue", ++ grad_tag="Dumper.ParamGrad", ++ get_grad=get_grad, ++ step=step, ++ ) ++ ++ def dump_dict(self, name_prefix, data, save: bool = True, **kwargs): ++ data = _obj_to_dict(data) ++ for name, value in data.items(): ++ self.dump(f"{name_prefix}_{name}", value, save=save, **kwargs) ++ ++ def set_ctx(self, **kwargs): ++ """Example: ++ dumper.configure_default(filter='layer_id=[0-3]') ++ dumper.set_ctx(layer_id=self.layer_id) ++ ... ++ dumper.set_ctx(layer_id=None) ++ ++ """ ++ self._state.global_ctx = { ++ k: v for k, v in (self._state.global_ctx | kwargs).items() if v is not None ++ } ++ ++ def ctx( ++ self, ++ _extractor: Callable[..., dict] | None = None, ++ **static_ctx: Any, ++ ) -> Callable: ++ """Decorator that sets context before calling the wrapped function and clears ++ it after. ++ ++ Two forms: ++ @dumper.ctx(lambda self: dict(layer_id=self.layer_id)) ++ def forward(self, x): ... ++ ++ @dumper.ctx(phase="decode") ++ def decode_step(self, x): ... ++ """ ++ if _extractor is not None and static_ctx: ++ raise ValueError("cannot mix lambda extractor with static kwargs") ++ if _extractor is None and not static_ctx: ++ raise ValueError("must provide either a lambda or static kwargs") ++ ++ def decorator(fn: Callable) -> Callable: ++ @functools.wraps(fn) ++ def wrapper(*args: Any, **kwargs: Any) -> Any: ++ ctx_dict: dict = _extractor(args[0]) if _extractor else static_ctx ++ self.set_ctx(**ctx_dict) ++ try: ++ return fn(*args, **kwargs) ++ finally: ++ self.set_ctx(**{k: None for k in ctx_dict}) ++ ++ return wrapper ++ ++ return decorator ++ ++ def apply_source_patches(self) -> None: ++ """Apply source patches from DUMPER_SOURCE_PATCHER_CONFIG if set. ++ ++ Automatically injects ``from vllm.utils.debug_utils.dumper import dumper`` ++ into every replacement block so users don't need to write it in YAML. ++ """ ++ config_path = self._config.source_patcher_config ++ if not config_path: ++ return ++ ++ from vllm.utils.debug_utils.source_patcher import apply_patches_from_config ++ ++ yaml_content: str = Path(config_path).read_text() ++ _log(f"[source_patcher] loading config from {config_path}") ++ apply_patches_from_config( ++ yaml_content, ++ extra_imports=["from vllm.utils.debug_utils.dumper import dumper"], ++ ) ++ ++ def register_non_intrusive_dumper( ++ self, ++ model: "torch.nn.Module", ++ ) -> Optional["_NonIntrusiveDumper"]: ++ self._http_manager # noqa: B018 ++ mode = self._config.non_intrusive_mode ++ if mode == "off": ++ return None ++ non_intrusive = _NonIntrusiveDumper(dumper=self, model=model, mode=mode) ++ self._non_intrusives.append(non_intrusive) ++ return non_intrusive ++ ++ # public :: secondary ++ ++ def configure(self, **kwargs) -> None: ++ self._config = replace(self._config, **kwargs) ++ ++ def control(self, method: str, body: dict | str | None = None) -> dict: ++ if method == "get_state": ++ return self.get_state() ++ if method == "configure": ++ if isinstance(body, str): ++ body = json.loads(body) ++ self.configure(**(body or {})) ++ elif method == "reset": ++ self.reset() ++ else: ++ raise ValueError(f"Unknown dumper control method: {method!r}") ++ return {} ++ ++ def configure_default(self, **kwargs) -> None: ++ self._config = self._config.with_defaults(**kwargs) ++ ++ def reset(self) -> None: ++ for non_intrusive in self._non_intrusives: ++ non_intrusive.remove() ++ self._non_intrusives.clear() ++ self._state = _DumperState() ++ ++ @contextmanager ++ def capture_output(self): ++ assert self._state.captured_output_data is None ++ self._state.captured_output_data = {} ++ try: ++ yield self._state.captured_output_data ++ finally: ++ self._state.captured_output_data = None ++ ++ def get_state(self) -> dict: ++ return { ++ "config": asdict(self._config), ++ "dump_index": self._state.dump_index, ++ "step": self._state.step, ++ } ++ ++ @cached_property ++ def _http_manager(self) -> Optional["_DumperHttpManager"]: ++ if self._config.server_port_parsed in (None, "reuse"): ++ return None ++ return _DumperHttpManager(self) ++ ++ # ------------------------- private :: related to dump ----------------------------- ++ ++ def _dump_inner( ++ self, ++ *, ++ name: str, ++ value, ++ extra_kwargs: dict, ++ save: bool, ++ enable_value: bool, ++ enable_curr_grad: bool, ++ enable_future_grad: bool, ++ value_tag: str, ++ grad_tag: str, ++ value_meta_only_fields: dict | None = None, ++ grad_meta_only_fields: dict | None = None, ++ grafter_extras: dict | None = None, ++ get_grad: Callable | None = None, ++ step: int | None = None, ++ ) -> None: ++ self._http_manager # noqa: B018 ++ ++ if not self._config.enable: ++ return ++ ++ recompute_status = _detect_recompute_status() ++ tags = dict( ++ name=name, ++ recompute_status=recompute_status.value, ++ **extra_kwargs, ++ **self._state.global_ctx, ++ ) ++ ++ if (f := self._config.filter) is not None and not _evaluate_filter(f, tags): ++ return ++ ++ if not (enable_value or enable_curr_grad or enable_future_grad): ++ return ++ ++ recompute_meta = recompute_status.to_pseudo_parallel_meta() ++ value = _materialize_value(value) ++ self._grafter.maybe_intercept(value=value, tags=tags, extras=grafter_extras) ++ ++ if enable_value: ++ self._dump_single( ++ tag=value_tag, ++ tags=tags, ++ value=value, ++ save=save, ++ step=step, ++ meta_only_fields={**(value_meta_only_fields or {}), **recompute_meta}, ++ ) ++ ++ if enable_curr_grad and isinstance(value, torch.Tensor): ++ g = get_grad(value) if get_grad is not None else value.grad ++ else: ++ g = None ++ ++ if g is not None: ++ self._dump_single( ++ tag=grad_tag, ++ tags={**tags, "name": f"grad__{name}"}, ++ value=g, ++ save=save, ++ step=step, ++ meta_only_fields={**(grad_meta_only_fields or {}), **recompute_meta}, ++ ) ++ ++ if enable_future_grad: ++ self._register_dump_grad_hook( ++ name=name, ++ tensor=value, ++ extra_kwargs=extra_kwargs, ++ save=save, ++ meta_only_fields=grad_meta_only_fields or {}, ++ ) ++ ++ def _register_dump_grad_hook( ++ self, ++ *, ++ name: str, ++ tensor, ++ extra_kwargs: dict, ++ save: bool, ++ meta_only_fields: dict | None = None, ++ ) -> None: ++ if not isinstance(tensor, torch.Tensor): ++ return ++ if not tensor.requires_grad: ++ return ++ ++ captured_step = self._state.step ++ captured_tags = dict( ++ name=f"grad__{name}", ++ **deepcopy(extra_kwargs), ++ ) ++ captured_meta_only = meta_only_fields or {} ++ ++ def grad_hook(grad: torch.Tensor) -> None: ++ self._dump_single( ++ tag="Dumper.Grad", ++ tags=captured_tags, ++ value=grad, ++ save=save, ++ step=captured_step, ++ meta_only_fields=captured_meta_only, ++ ) ++ ++ tensor.register_hook(grad_hook) ++ ++ def _dump_single( ++ self, ++ *, ++ tag: str, ++ tags: dict, ++ value, ++ save: bool, ++ step: int | None = None, ++ meta_only_fields: dict | None = None, ++ ) -> None: ++ self._ensure_exp_name() ++ self._state.dump_index += 1 ++ ++ rank = _get_rank() ++ full_kwargs = dict( ++ step=(step if step is not None else self._state.step), ++ rank=rank, ++ dump_index=self._state.dump_index, ++ **tags, ++ ) ++ if self._config.include_parallel_rank_in_filename: ++ full_kwargs.update(_collect_parallel_rank_tags()) ++ full_filename = _format_tags(full_kwargs) + ".pt" ++ path = Path(self._config.dir) / self._config.exp_name / full_filename ++ ++ if self._config.enable_output_console: ++ _log( ++ f"[{tag}] {path} " ++ f"type={type(value)} " ++ f"shape={value.shape if isinstance(value, torch.Tensor) else None} " ++ f"dtype={value.dtype if isinstance(value, torch.Tensor) else None} " ++ f"device={value.device if isinstance(value, torch.Tensor) else None} " ++ f"id={id(value)} " ++ f"sample_value={get_truncated_value(value)}" ++ ) ++ ++ capturing = self._state.captured_output_data is not None ++ if save and (self._config.enable_output_file or capturing): ++ output_data = { ++ "value": value, ++ "meta": dict( ++ **full_kwargs, ++ **self._static_meta, ++ **(meta_only_fields or {}), ++ ), ++ } ++ ++ if capturing: ++ output_data["value"] = _deepcopy_or_clone(output_data["value"]) ++ self._state.captured_output_data[tags["name"]] = output_data ++ else: ++ if ( ++ not self._state.cleanup_previous_handled ++ and self._config.cleanup_previous ++ ): ++ self._state.cleanup_previous_handled = True ++ _cleanup_old_dumps( ++ Path(self._config.dir), exp_name=self._config.exp_name ++ ) ++ ++ path.parent.mkdir(parents=True, exist_ok=True) ++ _torch_save(output_data, str(path)) ++ ++ # ------------------------------- private :: misc --------------------------------- ++ ++ @cached_property ++ def _static_meta(self) -> dict: ++ return _compute_static_meta() ++ ++ def _ensure_exp_name(self): ++ if self._config.exp_name is None: ++ name = _get_default_exp_name( ++ timeout_seconds=self._config.collective_timeout ++ ) ++ self.configure(exp_name=name) ++ _log(f"Choose exp_name={name}") ++ ++ ++# hook dumper ++ ++ ++class _NonIntrusiveDumper: ++ _NAME_PREFIX = "non_intrusive__" ++ _LAYER_NAME_RE = re.compile(r"(?:.+\.)?layers\.(\d+)$") ++ ++ def __init__( ++ self, ++ dumper: _Dumper, ++ model: "torch.nn.Module", ++ mode: str, ++ ): ++ self._dumper = dumper ++ self._mode = mode ++ self._handles: list = [] ++ self._core_fields: frozenset[str] = frozenset().union( ++ *(p.core_fields() for p in _plugins) ++ ) ++ ++ for module_name, module in model.named_modules(): ++ if ctx := self._detect_module_ctx(module_name, module): ++ self._register_ctx_hooks(module, ctx=ctx) ++ ++ is_root = module_name == "" ++ pre_hook = self._make_forward_pre_hook( ++ module_name=module_name, is_root=is_root ++ ) ++ hook = self._make_forward_hook(module_name=module_name, is_root=is_root) ++ self._handles += _register_forward_hook_or_replace_fn( ++ module, ++ pre_hook=pre_hook, ++ hook=hook, ++ mode="replace_fn" if is_root else "hook", ++ ) ++ ++ def remove(self) -> None: ++ for handle in self._handles: ++ handle.remove() ++ self._handles.clear() ++ ++ @classmethod ++ def _detect_module_ctx( ++ cls, module_name: str, module: "torch.nn.Module" ++ ) -> dict | None: ++ match = cls._LAYER_NAME_RE.fullmatch(module_name) ++ if match: ++ for plugin in _plugins: ++ layer_id = plugin.detect_layer_id(module) ++ if layer_id is not None: ++ return {"layer_id": layer_id} ++ return {"layer_id": int(match.group(1))} ++ return None ++ ++ def _register_ctx_hooks(self, module: "torch.nn.Module", *, ctx: dict) -> None: ++ clear_ctx = {k: None for k in ctx} ++ self._handles.append( ++ module.register_forward_pre_hook( ++ lambda _mod, _input, _ctx=ctx: self._dumper.set_ctx(**_ctx) ++ ) ++ ) ++ self._handles.append( ++ module.register_forward_hook( ++ lambda _mod, _input, _output, _clear=clear_ctx: self._dumper.set_ctx( ++ **_clear ++ ) ++ ) ++ ) ++ ++ def _make_forward_pre_hook(self, *, module_name: str, is_root: bool): ++ def _hook(_module, args, kwargs): ++ for i, item in enumerate(args): ++ self._dump_value( ++ module_name, item, sub_name=f"inputs.{i}", is_root=is_root ++ ) ++ for name, value in kwargs.items(): ++ self._dump_value( ++ module_name, ++ value, ++ sub_name=f"inputs.{name}", ++ is_root=is_root, ++ ) ++ ++ return _hook ++ ++ def _make_forward_hook(self, *, module_name: str, is_root: bool): ++ def _hook(_module, input, output): ++ if output is not None: ++ self._dump_value(module_name, output, sub_name="output", is_root=False) ++ ++ return _hook ++ ++ def _dump_value( ++ self, module_name: str, value: Any, sub_name: str, *, is_root: bool ++ ) -> None: ++ for key, item in self._convert_value( ++ value, skip_forward_batch=(not is_root) ++ ).items(): ++ effective_key = key or sub_name.rsplit(".", 1)[-1] ++ if effective_key in self._core_fields: ++ self._dumper.dump(effective_key, item) ++ elif self._mode == "all": ++ parts = [p for p in (module_name, sub_name, key) if p] ++ self._dumper.dump(self._NAME_PREFIX + ".".join(parts), item) ++ ++ @staticmethod ++ def _convert_value(value, *, skip_forward_batch: bool = False) -> dict[str, Any]: ++ if isinstance(value, torch.Tensor): ++ return {"": value} ++ ++ if isinstance(value, (tuple, list)): ++ tensors = [t for t in value if isinstance(t, torch.Tensor)] ++ if len(tensors) == 1: ++ return {"": tensors[0]} ++ return {str(i): t for i, t in enumerate(tensors)} ++ ++ for plugin in _plugins: ++ result = plugin.convert_value(value, skip_forward_batch=skip_forward_batch) ++ if result is not None: ++ return result ++ ++ return {} ++ ++ ++def _register_forward_hook_or_replace_fn( ++ module: "torch.nn.Module", ++ *, ++ pre_hook, ++ hook, ++ mode: str, ++) -> list: ++ """Attach pre/post forward hooks to *module*. ++ ++ mode="hook" — standard ``register_forward_pre_hook`` / ++ ``register_forward_hook`` ++ (fires only via ``__call__``). ++ mode="replace_fn" — monkey-patch ``module.forward`` so hooks fire even when ++ callers invoke ``.forward()`` directly (as sglang does for the ++ root model). ++ ++ Returns a list of handle objects with a ``.remove()`` method that undoes ++ the registration. ++ """ ++ if mode == "hook": ++ return [ ++ module.register_forward_pre_hook(pre_hook, with_kwargs=True), ++ module.register_forward_hook(hook), ++ ] ++ elif mode == "replace_fn": ++ original_forward = module.forward ++ ++ @functools.wraps(original_forward) ++ def _wrapped(*args, **kwargs): ++ pre_hook(module, args, kwargs) ++ output = original_forward(*args, **kwargs) ++ hook(module, args, output) ++ return output ++ ++ module.forward = _wrapped ++ ++ class _Handle: ++ def remove(self) -> None: ++ assert module.forward is _wrapped ++ module.forward = original_forward ++ ++ return [_Handle()] ++ else: ++ raise ValueError(f"Unknown mode {mode!r}") ++ ++ ++# grafter ++ ++ ++class _GraftRole(enum.Enum): ++ BASELINE = "baseline" ++ TARGET = "target" ++ ++ ++class _GraftDirection(enum.Enum): ++ B2T = "b2t" # name flows baseline -> target ++ T2B = "t2b" # name flows target -> baseline ++ ++ ++@dataclass ++class GraftTransformInput: ++ """Single argument passed to a user-supplied transform function. ++ ++ User transforms have signature:: ++ ++ def transform(graft_input: GraftTransformInput) -> torch.Tensor: ... ++ ++ The dataclass shape lets us add fields (e.g., direction, sender ranks) ++ later without breaking existing transforms. ++ """ ++ ++ # Full dumper.dump tags dict (name + recompute_status + extra_kwargs + ctx). ++ tags: "dict[str, Any]" ++ # One tensor per sender rank, in sender-rank order. ++ received_list: "list[torch.Tensor]" ++ # Parallel list of per-sender `grafter_extras` (the dict passed to ++ # dumper.dump on each sender; None if the sender omitted it). ++ received_extras_list: "list[dict | None]" ++ # Recv side's local tensor that will be copy_'d into. ++ target: "torch.Tensor" ++ ++ ++class _Grafter: ++ """Cross-system tensor transplant. Triggered silently from dumper.dump. ++ ++ Both sides set the SAME grafter_b2t_filter (names that flow baseline -> ++ target) and grafter_t2b_filter (names that flow target -> baseline). The ++ only per-side difference is grafter_role ("baseline" | "target"), which ++ determines whether a name match means send or recv on this side. ++ ++ Graft global rank layout: baseline occupies ranks 0..baseline_world-1; ++ target occupies ranks baseline_world..baseline_world+target_world-1. Each ++ side derives its own rank from its local default PG via dist.get_rank(). ++ ++ Please refer to TestGrafterE2eExample in tests for an example. ++ """ ++ ++ def __init__(self, *, config: DumperConfig): ++ self._config = config ++ self._pg = None ++ ++ @property ++ def enabled(self) -> bool: ++ return self._config.grafter_enable ++ ++ def maybe_intercept( ++ self, *, value: Any, tags: dict, extras: dict | None = None ++ ) -> None: ++ """Intercept a dumper.dump call. `extras` is per-call auxiliary data ++ (e.g., shard layout, dtype hint) that the sender attaches and the ++ recv side's transform receives as `received_extras_list`.""" ++ cfg = self._config ++ if not cfg.grafter_enable: ++ return ++ ++ direction = self._classify_direction(tags) ++ if direction is None: ++ return ++ ++ if not isinstance(value, torch.Tensor): ++ _log( ++ f"[Grafter] tags={tags} matched grafter_{direction.value}_filter but " ++ f"value is not a torch.Tensor (got type={type(value).__name__}); " ++ f"skipping graft. Common cause: dumper.dump called with a non-tensor " ++ f"value (dict, list, ...) on this name. Either narrow the filter or " ++ f"wrap the value in a tensor." ++ ) ++ return ++ ++ self._ensure_group() ++ role = _GraftRole(cfg.grafter_role) ++ is_send = self._is_sender(role=role, direction=direction) ++ ++ # all-gather over the graft world; sender ranks contribute (value, ++ # extras) tuples, recv ranks contribute None (their local target is ++ # private and shouldn't leak). all_gather_object is pickle-routed, ++ # so tensor shapes may differ across sender ranks. ++ total_world = cfg.grafter_baseline_world_size + cfg.grafter_target_world_size ++ my_contribution = (value, extras) if is_send else None ++ gathered: list = [None] * total_world ++ dist.all_gather_object(gathered, my_contribution, group=self._pg) ++ ++ if is_send: ++ _log( ++ f"[Grafter] send role={role.value} dir={direction.value} " ++ f"tags={tags} extras={extras} local={get_tensor_info(value)}" ++ ) ++ return ++ ++ sender_contribs = self._sender_slice(direction=direction, gathered=gathered) ++ # Pickled CUDA tensors are restored on their original-device name; ++ # that may not match this process's local device, so normalize. ++ sender_tensors = [ ++ (c[0].to(value.device) if isinstance(c[0], torch.Tensor) else c[0]) ++ for c in sender_contribs ++ ] ++ sender_extras = [c[1] for c in sender_contribs] ++ ++ # Transform + copy_ are wrapped: a buggy user transform must NOT ++ # crash the whole training/inference run. On error we log the full ++ # traceback and skip this graft point; downstream sees the recv ++ # side's original tensor unchanged. ++ info_before_overridden = get_tensor_info(value) ++ try: ++ value_to_override = self._apply_transform( ++ tags=tags, ++ received_list=sender_tensors, ++ received_extras_list=sender_extras, ++ target=value, ++ ) ++ diff = _compare_tensors_quick(value, value_to_override) ++ _log( ++ f"[Grafter] recv role={role.value} dir={direction.value} " ++ f"tags={tags} n_senders={len(sender_tensors)} " ++ f"sender_extras={sender_extras} " ++ f"before_overridden={info_before_overridden} " ++ f"to_override={get_tensor_info(value_to_override)} " ++ f"diff_pre_vs_new={diff}" ++ ) ++ value.copy_(value_to_override) ++ except Exception as e: ++ _log( ++ f"[Grafter] recv role={role.value} dir={direction.value} " ++ f"tags={tags} transform/copy_ raised {type(e).__name__}: {e}; " ++ f"skipping graft for this call (target tensor unchanged)\n" ++ f"{traceback.format_exc()}" ++ ) ++ ++ def _classify_direction(self, tags: dict) -> Optional["_GraftDirection"]: ++ cfg = self._config ++ match_b2t = self._match(cfg.grafter_b2t_filter, tags) ++ match_t2b = self._match(cfg.grafter_t2b_filter, tags) ++ if match_b2t and match_t2b: ++ raise RuntimeError( ++ f"[Grafter] tags={tags} matched BOTH grafter_b2t_filter and " ++ f"grafter_t2b_filter" ++ ) ++ if match_b2t: ++ return _GraftDirection.B2T ++ if match_t2b: ++ return _GraftDirection.T2B ++ return None ++ ++ @staticmethod ++ def _is_sender(*, role: "_GraftRole", direction: "_GraftDirection") -> bool: ++ # baseline is the sender for B2T names; target is the sender for T2B. ++ return (role == _GraftRole.BASELINE) == (direction == _GraftDirection.B2T) ++ ++ def _sender_slice(self, *, direction: "_GraftDirection", gathered: list) -> list: ++ cfg = self._config ++ if direction == _GraftDirection.B2T: ++ return gathered[: cfg.grafter_baseline_world_size] ++ return gathered[cfg.grafter_baseline_world_size :] ++ ++ @staticmethod ++ def _match(expr: str | None, tags: dict) -> bool: ++ if expr is None: ++ return False ++ return _evaluate_filter(expr, tags) ++ ++ def _ensure_group(self) -> None: ++ if self._pg is not None: ++ return ++ ++ cfg = self._config ++ assert dist.is_initialized(), ( ++ "[Grafter] default torch.distributed must be initialized" ++ ) ++ role = _GraftRole(cfg.grafter_role) ++ local_world = dist.get_world_size() ++ local_rank = dist.get_rank() ++ if role == _GraftRole.BASELINE: ++ assert local_world == cfg.grafter_baseline_world_size, ( ++ f"[Grafter] " ++ f"grafter_baseline_world_size={cfg.grafter_baseline_world_size} " ++ f"but dist.get_world_size()={local_world}; they must match on " ++ f"the baseline side" ++ ) ++ global_rank = local_rank ++ else: ++ assert local_world == cfg.grafter_target_world_size, ( ++ f"[Grafter] grafter_target_world_size={cfg.grafter_target_world_size} " ++ f"but dist.get_world_size()={local_world}; they must match on " ++ f"the target side" ++ ) ++ global_rank = cfg.grafter_baseline_world_size + local_rank ++ total_world = cfg.grafter_baseline_world_size + cfg.grafter_target_world_size ++ init_method = f"tcp://{cfg.grafter_master_address}:{cfg.grafter_master_port}" ++ _log( ++ f"[Grafter] init group: role={role.value} " ++ f"baseline_world={cfg.grafter_baseline_world_size} " ++ f"target_world={cfg.grafter_target_world_size} " ++ f"rank={global_rank} init_method={init_method} " ++ f"backend={cfg.grafter_backend} name={cfg.grafter_group_name}" ++ ) ++ self._pg = _collective_with_timeout( ++ lambda: _init_custom_process_group( ++ backend=cfg.grafter_backend, ++ init_method=init_method, ++ world_size=total_world, ++ rank=global_rank, ++ group_name=cfg.grafter_group_name, ++ ), ++ operation_name="_init_custom_process_group in _Grafter", ++ timeout_seconds=cfg.grafter_timeout, ++ ) ++ ++ def _apply_transform( ++ self, ++ *, ++ tags: dict, ++ received_list: list, ++ received_extras_list: list, ++ target: torch.Tensor, ++ ) -> torch.Tensor: ++ # TODO: integrate with dump_comparator unsharder annotations once ++ # full inverse (sharded -> global -> sharded) transforms exist. ++ graft_input = GraftTransformInput( ++ tags=tags, ++ received_list=received_list, ++ received_extras_list=received_extras_list, ++ target=target, ++ ) ++ path = self._config.grafter_transform_path ++ fn = self._default_transform if path is None else _load_function(path) ++ return fn(graft_input) ++ ++ @staticmethod ++ def _default_transform(graft_input: GraftTransformInput) -> torch.Tensor: ++ """Identity-by-rank fallback. Requires #senders == #recvs and ++ shape(received_list[my_recv_rank]) == shape(target). Otherwise raises ++ and asks the user for a transform.""" ++ received_list = graft_input.received_list ++ target = graft_input.target ++ my_recv_rank = dist.get_rank() ++ recv_world_size = dist.get_world_size() ++ if len(received_list) != recv_world_size: ++ raise RuntimeError( ++ _Grafter._default_transform_error( ++ f"requires #senders == #recvs but got " ++ f"#senders={len(received_list)} vs #recvs={recv_world_size}" ++ ) ++ ) ++ candidate = received_list[my_recv_rank] ++ if candidate.shape != target.shape: ++ raise RuntimeError( ++ _Grafter._default_transform_error( ++ f"requires matching shapes but " ++ f"received_list[{my_recv_rank}].shape={tuple(candidate.shape)} " ++ f"!= target.shape={tuple(target.shape)}" ++ ) ++ ) ++ return candidate ++ ++ @staticmethod ++ def _default_transform_error(detail: str) -> str: ++ return ( ++ f"[Grafter] no grafter_transform_path set; default identity-by-rank " ++ f"{detail}. Provide a transform via " ++ f"DUMPER_GRAFTER_TRANSFORM_PATH=pkg.module.symbol defining " ++ f"`transform(graft_input: GraftTransformInput) -> Tensor`." ++ ) ++ ++ ++# util fn ++ ++ ++def _torch_save(value, path: str): ++ value = _clone_if_view(value) ++ try: ++ try: ++ return torch.save(value, path) ++ except RuntimeError as e: ++ if "not pickleable" in str(e): ++ stripped = _strip_parameter(value) ++ if stripped is not value: ++ _log(f"Observe error={e} and try pickling .data") ++ return _torch_save(stripped, path) ++ raise ++ except Exception as e: ++ _log(f"Observe error={e} when saving data, skip the tensor") ++ ++ ++def _map_tensor(value, fn: Callable[[torch.Tensor], torch.Tensor]): ++ if isinstance(value, dict): ++ return {k: _map_tensor(v, fn) for k, v in value.items()} ++ if isinstance(value, torch.Tensor): ++ return fn(value) ++ return value ++ ++ ++def _clone_if_view(value): ++ def _fn(t: torch.Tensor) -> torch.Tensor: ++ if t.untyped_storage().nbytes() > t.nelement() * t.element_size(): ++ return t.clone() ++ return t ++ ++ return _map_tensor(value, _fn) ++ ++ ++def _strip_parameter(value): ++ def _fn(t: torch.Tensor) -> torch.Tensor: ++ if isinstance(t, torch.nn.Parameter): ++ return t.data ++ return t ++ ++ return _map_tensor(value, _fn) ++ ++ ++def _collective_with_timeout(fn, operation_name: str, timeout_seconds: int = 60): ++ completed = threading.Event() ++ ++ def watchdog(): ++ if not completed.wait(timeout=timeout_seconds): ++ _log( ++ f"WARNING: '{operation_name}' has not completed after " ++ f"{timeout_seconds}s. This usually means not all ranks are " ++ f"participating in this collective operation." ++ ) ++ ++ thread = threading.Thread(target=watchdog, daemon=True) ++ thread.start() ++ try: ++ return fn() ++ finally: ++ completed.set() ++ ++ ++def _get_default_exp_name(timeout_seconds: int = 60): ++ rank = _get_rank() ++ now = time.time() ++ ms = int((now % 1) * 1000) ++ rand_suffix = random.randint(0, 999) ++ object_list = [ ++ ( ++ ( ++ f"{_DEFAULT_EXP_NAME_PREFIX}" ++ f"{time.strftime('%Y%m%d_%H%M%S', time.gmtime(now))}" ++ f"_{ms:03d}{rand_suffix:03d}" ++ ) ++ if rank == 0 ++ else None ++ ) ++ ] ++ ++ if dist.is_initialized(): ++ _collective_with_timeout( ++ lambda: dist.broadcast_object_list(object_list, device=get_device()), ++ operation_name="broadcast_object_list in _get_default_exp_name", ++ timeout_seconds=timeout_seconds, ++ ) ++ ++ return object_list[0] ++ ++ ++def _cleanup_old_dumps(base_dir: Path, exp_name: str | None = None) -> None: ++ import shutil ++ ++ if _get_rank() == 0: ++ targets = {entry for entry in base_dir.glob(f"{_DEFAULT_EXP_NAME_PREFIX}*")} ++ if exp_name: ++ targets.add(base_dir / exp_name) ++ targets = {d for d in targets if d.is_dir()} ++ ++ for entry in targets: ++ shutil.rmtree(entry) ++ _log(f"Cleaned up {entry}") ++ ++ if dist.is_initialized(): ++ _collective_with_timeout( ++ dist.barrier, ++ operation_name="barrier in _cleanup_old_dumps", ++ ) ++ ++ ++def _get_rank(): ++ if dist.is_initialized(): ++ return dist.get_rank() ++ else: ++ return 0 ++ ++ ++def _get_world_size(): ++ if dist.is_initialized(): ++ return dist.get_world_size() ++ else: ++ return 1 ++ ++ ++def _log(msg: str) -> None: ++ """Print a log line tagged with the current rank and wall-clock time.""" ++ print(f"[Dumper, rank={_get_rank()}, t={time.time():.3f}] {msg}", flush=True) ++ ++ ++def _compare_tensors_quick(a: "torch.Tensor", b: "torch.Tensor") -> str: ++ """One-line summary of how close two tensors are. Inspired by ++ vllm.utils.debug_utils.dump_comparator._compute_and_print_diff; ++ intentionally inlined here to keep dumper.py free of cross-file imports. ++ ++ Different dtypes are fine — we unify by casting both to fp32, which is ++ enough for the order-of-magnitude diff summary we log.""" ++ if a.shape != b.shape: ++ return f"shape mismatch (a={tuple(a.shape)} vs b={tuple(b.shape)})" ++ if a.numel() == 0: ++ return "empty" ++ a_float = a.detach().to(torch.float32) ++ b_float = b.detach().to(torch.float32) ++ raw_abs = (a_float - b_float).abs() ++ max_abs = raw_abs.max().item() ++ mean_abs = raw_abs.mean().item() ++ rel_diff = _calc_rel_diff(a_float, b_float).item() ++ return f"rel_diff={rel_diff:.6g} max_abs={max_abs:.6g} mean_abs={mean_abs:.6g}" ++ ++ ++# Copied verbatim from vllm.utils.debug_utils.dump_comparator (originally from ++# DeepGEMM). Kept inline here so dumper.py has no cross-file imports. ++def _calc_rel_diff(x: "torch.Tensor", y: "torch.Tensor"): ++ x, y = x.double(), y.double() ++ denominator = (x * x + y * y).sum() ++ sim = 2 * (x * y).sum() / denominator ++ return 1 - sim ++ ++ ++def _obj_to_dict(obj): ++ if isinstance(obj, dict): ++ return obj ++ for plugin in _plugins: ++ converted = plugin.convert_value(obj, skip_forward_batch=False) ++ if converted is not None: ++ return converted ++ ret = {} ++ for k in dir(obj): ++ if k.startswith("__") and k.endswith("__"): ++ continue ++ try: ++ v = getattr(obj, k) ++ if not callable(v): ++ ret[k] = v ++ except Exception: ++ # Skip attributes that raise an exception on access ++ continue ++ return ret ++ ++ ++def _materialize_value(value): ++ if callable(value): ++ value = value() ++ return value ++ ++ ++_PARALLEL_RANK_KEYS = ("pp_rank", "tp_rank", "cp_rank", "ep_rank", "etp_rank") ++ ++ ++def _collect_parallel_rank_tags() -> dict[str, int]: ++ """Collect parallel-rank tags from framework plugins for use in dump filenames. ++ ++ Merges the ``_PARALLEL_RANK_KEYS`` reported by each plugin's ++ ``collect_parallel_info()``; the first plugin to report a given key wins. ++ """ ++ result: dict[str, int] = {} ++ for plugin in _plugins: ++ info = plugin.collect_parallel_info() ++ if not info: ++ continue ++ for key in _PARALLEL_RANK_KEYS: ++ if key in info and key not in result: ++ result[key] = info[key] ++ return result ++ ++ ++def _format_tags(kwargs: dict) -> str: ++ return "___".join(f"{k}={v}" for k, v in kwargs.items()) ++ ++ ++class _DefaultNoneDict(dict): ++ """dict subclass that returns None for missing keys, for filter expression eval.""" ++ ++ def __missing__(self, key: str): ++ return None ++ ++ ++_FILTER_BUILTINS: dict[str, Any] = {"search": re.search, "match": re.match} ++ ++ ++def _evaluate_filter(filter_expr: str, tags: dict[str, Any]) -> bool: ++ """Evaluate a Python filter expression against the tags dict. ++ ++ Unknown tag keys resolve to None, so `layer_id is None` works when layer_id is ++ absent. ++ `re.search` and `re.match` are available as `search()` and `match()`. ++ """ ++ namespace = _DefaultNoneDict(tags) ++ namespace.update(_FILTER_BUILTINS) ++ return bool(eval(filter_expr, {"__builtins__": {}}, namespace)) ++ ++ ++def _deepcopy_or_clone(x): ++ if isinstance(x, torch.Tensor): ++ return x.clone() ++ return deepcopy(x) ++ ++ ++# static meta ++ ++ ++def _compute_static_meta(): ++ result = { ++ "world_rank": _get_rank(), ++ "world_size": _get_world_size(), ++ } ++ ++ for plugin in _plugins: ++ if info := plugin.collect_parallel_info(): ++ result[f"{plugin.name}_parallel_info"] = info ++ ++ for plugin in _plugins: ++ tokenizer_path: str | None = plugin.get_tokenizer_path() ++ if tokenizer_path is not None: ++ result["tokenizer_path"] = tokenizer_path ++ break ++ ++ return result ++ ++ ++# http manager ++ ++ ++class _DumperHttpManager: ++ def __init__(self, dumper: "_Dumper"): ++ self._dumper = dumper ++ http_port = self._dumper._config.server_port_parsed ++ ++ rpc_broadcast = _create_zmq_rpc_broadcast( ++ self, ++ timeout_seconds=self._dumper._config.collective_timeout, ++ ) ++ ++ if _get_rank() == 0: ++ assert rpc_broadcast is not None ++ self._rpc_broadcast = rpc_broadcast ++ ++ if http_port == "reuse": ++ _log("Standalone HTTP server disabled, reusing existing ports") ++ else: ++ _start_http_server(prefix="/dumper/", target=self, http_port=http_port) ++ _log(f"HTTP server started on port {http_port}") ++ ++ # ------------------------------- public --------------------------------- ++ ++ def handle_request(self, *, method: str, body: dict[str, Any]) -> list[dict]: ++ return self._rpc_broadcast._handle_request_inner(method=method, body=body) ++ ++ # ------------------------------- private --------------------------------- ++ ++ def _handle_request_inner(self, *, method: str, body: dict[str, Any]) -> dict: ++ return self._dumper.control(method, body) ++ ++ ++# http control server ++ ++ ++def _start_http_server(*, prefix: str, target: object, http_port: int): ++ handler_class = _make_http_handler(prefix=prefix, target=target) ++ server = HTTPServer(("0.0.0.0", http_port), handler_class) ++ thread = threading.Thread(target=server.serve_forever, daemon=True) ++ thread.start() ++ ++ ++def _make_http_handler(*, prefix: str, target): ++ class _HTTPHandler(BaseHTTPRequestHandler): ++ def do_POST(self): ++ if not self.path.startswith(prefix): ++ self.send_error(404) ++ return ++ method = self.path[len(prefix) :] ++ try: ++ req_body = self._get_request_body() ++ _log(f"HTTP {self.path} {req_body=}") ++ result = target.handle_request(method=method, body=req_body) ++ resp_body = json.dumps(result).encode() ++ self.send_response(200) ++ self.send_header("Content-Type", "application/json") ++ self.send_header("Content-Length", str(len(resp_body))) ++ self.end_headers() ++ self.wfile.write(resp_body) ++ except Exception as e: ++ self.send_error(400, str(e)) ++ ++ def _get_request_body(self) -> dict: ++ content_length = int(self.headers.get("Content-Length", 0)) ++ if content_length == 0: ++ return {} ++ return json.loads(self.rfile.read(content_length)) ++ ++ return _HTTPHandler ++ ++ ++# zmq rpc ++ ++ ++def _create_zmq_rpc_broadcast( ++ handler, timeout_seconds: int = 60 ++) -> Optional["_ZmqRpcBroadcast"]: ++ """A general-purpose minimal RPC to support broadcasting executions to multi ++ processes ++ """ ++ rank = _get_rank() ++ world_size = dist.get_world_size() if dist.is_initialized() else 1 ++ ++ ctx = zmq.Context() ++ sock = ctx.socket(zmq.REP) ++ sock.bind("tcp://*:0") ++ bound_port = int(sock.getsockopt_string(zmq.LAST_ENDPOINT).rsplit(":", 1)[1]) ++ local_addr = f"tcp://{_get_local_ip_by_remote()}:{bound_port}" ++ ++ def serve_loop(): ++ while True: ++ try: ++ req = sock.recv_pyobj() ++ result = getattr(handler, req["method"])(*req["args"], **req["kwargs"]) ++ resp = {"result": result, "error": None} ++ except Exception as e: ++ _log(f"[ZmqRpc] error inside handler: {e}") ++ resp = {"result": None, "error": str(e)} ++ sock.send_pyobj(resp) ++ ++ thread = threading.Thread(target=serve_loop, daemon=True) ++ thread.start() ++ _log(f"[ZmqRpc] server started at {local_addr}") ++ ++ if dist.is_initialized(): ++ all_addresses = [None] * world_size ++ _collective_with_timeout( ++ lambda: dist.all_gather_object(all_addresses, local_addr), ++ operation_name="all_gather_object in _create_zmq_rpc_broadcast", ++ timeout_seconds=timeout_seconds, ++ ) ++ else: ++ all_addresses = [local_addr] ++ _log(f"[ZmqRpc] all_addresses={all_addresses}") ++ ++ if rank == 0: ++ handles = [] ++ for i, addr in enumerate(all_addresses): ++ req_socket = ctx.socket(zmq.REQ) ++ req_socket.connect(addr) ++ handles.append(_ZmqRpcHandle(req_socket, debug_name=f"rank-{i}")) ++ return _ZmqRpcBroadcast(handles) ++ else: ++ return None ++ ++ ++class _ZmqRpcHandle: ++ """Proxy object to call remote handler methods via ZMQ.""" ++ ++ def __init__(self, socket, debug_name: str): ++ self._socket = socket ++ self._debug_name = debug_name ++ ++ def __getattr__(self, method_name: str): ++ def call(*args, **kwargs): ++ self._socket.send_pyobj( ++ { ++ "method": method_name, ++ "args": args, ++ "kwargs": kwargs, ++ } ++ ) ++ response = self._socket.recv_pyobj() ++ if response["error"]: ++ raise RuntimeError( ++ f"RPC error on {self._debug_name}: {response['error']}" ++ ) ++ return response["result"] ++ ++ return call ++ ++ ++class _RpcBroadcastBase: ++ """Base for broadcasting method calls to dumper instance(s).""" ++ ++ def __getattr__(self, method_name: str): ++ raise NotImplementedError ++ ++ def __init__(self, handles: list[_ZmqRpcHandle]): ++ self._handles = handles ++ ++ ++class _ZmqRpcBroadcast(_RpcBroadcastBase): ++ """Broadcasts method calls to all ZMQ RPC handles. ++ ++ Returns a list of results, one per rank (ordered by rank). ++ """ ++ ++ def __init__(self, handles: list[_ZmqRpcHandle]): ++ self._handles = handles ++ ++ def __getattr__(self, method_name: str): ++ def call(*args, **kwargs): ++ return [ ++ getattr(handle, method_name)(*args, **kwargs) ++ for handle in self._handles ++ ] ++ ++ return call ++ ++ ++# copied code (avoid dependency) ++ ++ ++def _get_local_ip_by_remote() -> str | None: ++ # try ipv4 ++ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) ++ try: ++ s.connect(("8.8.8.8", 80)) # Doesn't need to be reachable ++ return s.getsockname()[0] ++ except Exception: ++ pass ++ ++ try: ++ hostname = socket.gethostname() ++ ip = socket.gethostbyname(hostname) ++ if ip and ip != "127.0.0.1" and ip != "0.0.0.0": ++ return ip ++ except Exception: ++ pass ++ ++ # try ipv6 ++ try: ++ s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) ++ # Google's public DNS server, see ++ # https://developers.google.com/speed/public-dns/docs/using#addresses ++ s.connect(("2001:4860:4860::8888", 80)) # Doesn't need to be reachable ++ return s.getsockname()[0] ++ except Exception: ++ _log("Can not get local ip by remote") ++ return None ++ ++ ++def _load_function(path: str) -> Callable: ++ """Resolve a fully-qualified Python path 'pkg.module.symbol' to its object. ++ ++ Copied (verbatim, minus the function-registry branch) from ++ miles.utils.misc.load_function — kept inline so dumper.py has no ++ cross-package dependency. ++ """ ++ import importlib ++ ++ module_path, _, attr = path.rpartition(".") ++ if not module_path: ++ raise ValueError( ++ f"_load_function expects 'pkg.module.symbol', got {path!r} " ++ f"(missing dotted prefix)" ++ ) ++ module = importlib.import_module(module_path) ++ return getattr(module, attr) ++ ++ ++def _init_custom_process_group( ++ *, ++ backend: str, ++ init_method: str, ++ world_size: int, ++ rank: int, ++ group_name: str, ++ timeout=None, ++): ++ """Build a fresh torch.distributed process group, separate from the default ++ one and any other custom groups (e.g. RLHF weight-update groups). Used by ++ the grafter to bridge baseline and target systems. ++ ++ Adapted from sglang.srt.utils.common.init_custom_process_group; inlined ++ here to keep dumper.py free of cross-file imports. ++ """ ++ from torch.distributed.distributed_c10d import ( ++ Backend, ++ PrefixStore, ++ _new_process_group_helper, ++ _world, ++ default_pg_timeout, ++ rendezvous, ++ ) ++ ++ if timeout is None: ++ timeout = default_pg_timeout ++ ++ rendezvous_iterator = rendezvous(init_method, rank, world_size, timeout=timeout) ++ store, rank, world_size = next(rendezvous_iterator) ++ store.set_timeout(timeout) ++ store = PrefixStore(group_name, store) ++ ++ backend_obj = Backend(backend) ++ # PyTorch 2.6 renamed `pg_options` to `backend_options`. ++ torch_major_minor = tuple( ++ int(x) for x in torch.__version__.split("+")[0].split(".")[:2] ++ ) ++ pg_options_param_name = ( ++ "backend_options" if torch_major_minor >= (2, 6) else "pg_options" ++ ) ++ pg, _ = _new_process_group_helper( ++ world_size, ++ rank, ++ [], ++ backend_obj, ++ store, ++ group_name=group_name, ++ **{pg_options_param_name: None}, ++ timeout=timeout, ++ ) ++ _world.pg_group_ranks[pg] = {i: i for i in range(world_size)} ++ return pg ++ ++ ++# framework plugins ++ ++ ++class _RecomputeStatus(enum.Enum): ++ DISABLED = "disabled" ++ ORIGINAL = "original" # inside checkpoint, original forward ++ RECOMPUTE = "recompute" # inside checkpoint, recompute forward ++ ++ def to_pseudo_parallel_meta(self) -> dict[str, Any]: ++ if self == _RecomputeStatus.DISABLED: ++ return {} ++ return { ++ "recompute_pseudo_rank": 1 if self == _RecomputeStatus.RECOMPUTE else 0, ++ "recompute_pseudo_size": 2, ++ } ++ ++ ++class _FrameworkPlugin(ABC): ++ @property ++ @abstractmethod ++ def name(self) -> str: ... ++ ++ @abstractmethod ++ def collect_parallel_info(self) -> dict: ... ++ ++ @abstractmethod ++ def convert_value( ++ self, value: Any, *, skip_forward_batch: bool ++ ) -> dict[str, Any] | None: ++ """Return converted dict, or None if this plugin doesn't handle the value.""" ++ ... ++ ++ @abstractmethod ++ def detect_layer_id(self, module: "torch.nn.Module") -> int | None: ++ """Return 0-indexed layer_id, or None if not detectable.""" ++ ... ++ ++ def core_fields(self) -> frozenset[str]: ++ return frozenset() ++ ++ def get_tokenizer_path(self) -> str | None: ++ return None ++ ++ def detect_recompute_status(self) -> _RecomputeStatus: ++ return _RecomputeStatus.DISABLED ++ ++ def transform_model_param_name( ++ self, model: "torch.nn.Module", param_name: str ++ ) -> str | None: ++ """Return a rewritten parameter name, or None to keep the original. ++ ++ Used by ``dump_model`` to canonicalize parameter names across parallel ++ layouts (e.g. mapping pipeline-local layer indices to global ones). ++ """ ++ return None ++ ++ ++class _VLLMPlugin(_FrameworkPlugin): ++ @property ++ def name(self) -> str: ++ return "vllm" ++ ++ def collect_parallel_info(self) -> dict: ++ try: ++ from vllm.distributed import parallel_state ++ except ImportError: ++ return {} ++ ++ info = {} ++ for name in ("tp", "pp", "dp", "ep", "etp", "dcp", "pcp"): ++ try: ++ group = getattr(parallel_state, f"get_{name}_group")() ++ info[f"{name}_rank"] = group.rank_in_group ++ info[f"{name}_size"] = group.world_size ++ except (AssertionError, RuntimeError): ++ continue ++ return info ++ ++ def convert_value( ++ self, value: Any, *, skip_forward_batch: bool ++ ) -> dict[str, Any] | None: ++ if not type(value).__module__.startswith("vllm."): ++ return None ++ from vllm.sequence import IntermediateTensors ++ from vllm.v1.worker.gpu.input_batch import InputBatch ++ ++ if isinstance(value, InputBatch): ++ if skip_forward_batch: ++ return {} ++ return { ++ "input_ids": value.input_ids[: value.num_tokens], ++ "positions": value.positions[: value.num_tokens], ++ "seq_lens": value.seq_lens[: value.num_reqs], ++ "rids": value.req_ids, ++ "extend_seq_lens": value.num_scheduled_tokens.tolist(), ++ } ++ if isinstance(value, IntermediateTensors): ++ return dict(value.tensors) ++ return None ++ ++ def detect_layer_id(self, module: "torch.nn.Module") -> int | None: ++ return getattr(module, "layer_id", None) ++ ++ def core_fields(self) -> frozenset[str]: ++ return frozenset({"input_ids", "positions", "seq_lens", "rids"}) ++ ++ def get_tokenizer_path(self) -> str | None: ++ try: ++ from vllm.config import get_current_vllm_config ++ ++ return get_current_vllm_config().model_config.tokenizer ++ except (ImportError, AssertionError, RuntimeError): ++ return None ++ ++ ++class _MegatronPlugin(_FrameworkPlugin): ++ _available = True ++ try: ++ from megatron.core import parallel_state as _mpu ++ from megatron.core.packed_seq_params import PackedSeqParams ++ except ImportError: ++ _available = False ++ ++ @property ++ def name(self) -> str: ++ return "megatron" ++ ++ def collect_parallel_info(self) -> dict: ++ if not self._available: ++ return {} ++ ++ info = {} ++ try: ++ info["tp_rank"] = self._mpu.get_tensor_model_parallel_rank() ++ info["tp_size"] = self._mpu.get_tensor_model_parallel_world_size() ++ info["pp_rank"] = self._mpu.get_pipeline_model_parallel_rank() ++ info["pp_size"] = self._mpu.get_pipeline_model_parallel_world_size() ++ info["dp_rank"] = self._mpu.get_data_parallel_rank() ++ info["dp_size"] = self._mpu.get_data_parallel_world_size() ++ info["cp_rank"] = self._mpu.get_context_parallel_rank() ++ info["cp_size"] = self._mpu.get_context_parallel_world_size() ++ info["vpp_rank"] = self._mpu.get_virtual_pipeline_model_parallel_rank() ++ info["vpp_size"] = ( ++ self._mpu.get_virtual_pipeline_model_parallel_world_size() ++ ) ++ info["ep_rank"] = self._mpu.get_expert_model_parallel_rank() ++ info["ep_size"] = self._mpu.get_expert_model_parallel_world_size() ++ info["etp_rank"] = self._mpu.get_expert_tensor_parallel_rank() ++ info["etp_size"] = self._mpu.get_expert_tensor_parallel_world_size() ++ info["edp_rank"] = self._mpu.get_expert_data_parallel_rank() ++ info["edp_size"] = self._mpu.get_expert_data_parallel_world_size() ++ info["tcp_rank"] = self._mpu.get_tensor_and_context_parallel_rank() ++ info["tcp_size"] = self._mpu.get_tensor_and_context_parallel_world_size() ++ info["etmp_rank"] = self._mpu.get_expert_tensor_and_model_parallel_rank() ++ info["etmp_size"] = ( ++ self._mpu.get_expert_tensor_and_model_parallel_world_size() ++ ) ++ info["tp_src_rank"] = self._mpu.get_tensor_model_parallel_src_rank() ++ info["mp_src_rank"] = self._mpu.get_model_parallel_src_rank() ++ info["dp_src_rank"] = self._mpu.get_data_parallel_src_rank() ++ except (AttributeError, AssertionError): ++ info["megatron_error"] = True ++ ++ # Megatron sequence parallel reuses the TP group (no dedicated parallel state ++ # API). ++ # When sequence_parallel=True, inject sp_rank/sp_size for the comparator ++ # unsharder. ++ try: ++ from megatron.training.global_vars import get_args ++ ++ args = get_args() ++ if getattr(args, "sequence_parallel", False) and "tp_rank" in info: ++ info["sp_rank"] = info["tp_rank"] ++ info["sp_size"] = info["tp_size"] ++ except (ImportError, AssertionError, AttributeError): ++ pass ++ ++ return info ++ ++ def convert_value( ++ self, value: Any, *, skip_forward_batch: bool ++ ) -> dict[str, Any] | None: ++ if not self._available: ++ return None ++ if isinstance(value, self.PackedSeqParams): ++ return { ++ "cu_seqlens_q": value.cu_seqlens_q, ++ "cu_seqlens_kv": value.cu_seqlens_kv, ++ "qkv_format": value.qkv_format, ++ } ++ return None ++ ++ def detect_layer_id(self, module: "torch.nn.Module") -> int | None: ++ if hasattr(module, "layer_number"): ++ return module.layer_number - 1 ++ return None ++ ++ def core_fields(self) -> frozenset[str]: ++ return frozenset( ++ {"input_ids", "position_ids", "cu_seqlens_q", "cu_seqlens_kv", "qkv_format"} ++ ) ++ ++ def detect_recompute_status(self) -> _RecomputeStatus: ++ if not self._available: ++ return _RecomputeStatus.DISABLED ++ try: ++ from megatron.core.tensor_parallel.random import is_checkpointing ++ ++ if not is_checkpointing(): ++ return _RecomputeStatus.DISABLED ++ if torch.is_grad_enabled(): ++ return _RecomputeStatus.RECOMPUTE ++ return _RecomputeStatus.ORIGINAL ++ except (ImportError, AttributeError): ++ return _RecomputeStatus.DISABLED ++ ++ def transform_model_param_name( ++ self, model: "torch.nn.Module", param_name: str ++ ) -> str | None: ++ """Rewrite pipeline-local layer indices to global ones in a param name. ++ ++ With pipeline parallelism, ``model.named_parameters()`` reports layer ++ indices local to the current PP stage (e.g. ``layers.0`` on every stage). ++ Adding the stage's ``get_transformer_layer_offset`` makes the dumped ++ names globally unique and comparable across stages. Returns None (keep the ++ original name) when not applicable. ++ """ ++ if not self._available: ++ return None ++ ++ try: ++ pp_size = self._mpu.get_pipeline_model_parallel_world_size() ++ except (AttributeError, AssertionError): ++ return None ++ if pp_size <= 1: ++ return None ++ ++ config = self._get_model_config(model) ++ if config is None: ++ return None ++ ++ offset = self._get_transformer_layer_offset(config) ++ if not offset: ++ return None ++ ++ def _add_offset(match: "re.Match") -> str: ++ return f"layers.{int(match.group(1)) + offset}" ++ ++ return re.sub(r"layers\.(\d+)", _add_offset, param_name) ++ ++ @staticmethod ++ def _get_transformer_layer_offset(config) -> int: ++ """Return the PP-stage layer offset for ``config``, or 0 if unavailable.""" ++ try: ++ from megatron.core.transformer.transformer_layer import ( ++ get_transformer_layer_offset, ++ ) ++ ++ return get_transformer_layer_offset(config) ++ except (ImportError, AttributeError, AssertionError): ++ return 0 ++ ++ @staticmethod ++ def _get_model_config(model: "torch.nn.Module"): ++ """Unwrap nested ``.module`` wrappers to reach the Megatron model config.""" ++ inner = model ++ for _ in range(10): ++ if hasattr(inner, "config"): ++ return inner.config ++ if hasattr(inner, "module"): ++ inner = inner.module ++ else: ++ break ++ return None ++ ++ ++_plugins: list[_FrameworkPlugin] = [_VLLMPlugin(), _MegatronPlugin()] ++ ++ ++def _detect_recompute_status() -> _RecomputeStatus: ++ for plugin in _plugins: ++ info = plugin.detect_recompute_status() ++ if info != _RecomputeStatus.DISABLED: ++ return info ++ return _RecomputeStatus.DISABLED ++ ++ ++# singleton ++ ++ ++dumper = _Dumper(config=DumperConfig.from_env()) ++ ++ ++# other utility functions ++ ++ ++def get_truncated_value(value): ++ if value is None: ++ return None ++ ++ if isinstance(value, tuple): ++ return [get_truncated_value(x) for x in value] ++ ++ if not isinstance(value, torch.Tensor): ++ return value ++ ++ if value.numel() < 200: ++ return value ++ ++ slices = [slice(0, 5) if dim_size > 50 else slice(None) for dim_size in value.shape] ++ return value[tuple(slices)] ++ ++ ++def get_tensor_info(x): ++ """From vllm.utils.debug_utils.dumper import get_tensor_info""" ++ if not isinstance(x, torch.Tensor): ++ return f"type={type(x)} value={x}" ++ min = x.float().min() if x.numel() > 0 else None ++ max = x.float().max() if x.numel() > 0 else None ++ mean = x.float().mean() if x.numel() > 0 else None ++ torch.set_printoptions(precision=10) ++ x_sample_head = str(x.flatten()[:5]) ++ x_sample_tail = str(x.flatten()[-5:]) ++ torch.set_printoptions(precision=4) ++ return ( ++ f"type={type(x)} " ++ f"shape={x.shape} " ++ f"dtype={x.dtype} " ++ f"device={x.device} " ++ f"stride={x.stride()} " ++ f"req_grad={x.requires_grad} " ++ f"min={min} " ++ f"max={max} " ++ f"mean={mean} " ++ f"x_sample_head={x_sample_head} " ++ f"x_sample_tail={x_sample_tail}" ++ ) +diff --git a/vllm/utils/debug_utils/source_patcher/__init__.py b/vllm/utils/debug_utils/source_patcher/__init__.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/source_patcher/__init__.py +@@ -0,0 +1,23 @@ ++from vllm.utils.debug_utils.source_patcher.code_patcher import ( ++ CodePatcher, ++ apply_patches_from_config, ++ patch_function, ++) ++from vllm.utils.debug_utils.source_patcher.types import ( ++ EditSpec, ++ PatchApplicationError, ++ PatchConfig, ++ PatchSpec, ++ PatchState, ++) ++ ++__all__ = [ ++ "CodePatcher", ++ "apply_patches_from_config", ++ "patch_function", ++ "EditSpec", ++ "PatchApplicationError", ++ "PatchConfig", ++ "PatchSpec", ++ "PatchState", ++] +diff --git a/vllm/utils/debug_utils/source_patcher/code_patcher.py b/vllm/utils/debug_utils/source_patcher/code_patcher.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/source_patcher/code_patcher.py +@@ -0,0 +1,200 @@ ++import __future__ ++ ++import importlib ++import inspect ++import textwrap ++import types ++from collections.abc import Callable ++from typing import Any ++ ++import yaml ++ ++from vllm.utils.debug_utils.source_patcher.source_editor import apply_edits ++from vllm.utils.debug_utils.source_patcher.types import ( ++ EditSpec, ++ PatchConfig, ++ PatchSpec, ++ PatchState, ++) ++ ++ ++def apply_patches_from_config( ++ yaml_content: str, ++ *, ++ extra_imports: list[str] | None = None, ++) -> list[PatchState]: ++ """Parse a YAML config string and apply all patches. ++ ++ Args: ++ yaml_content: YAML string with patch specifications. ++ extra_imports: Import lines inserted once at the top of each patched ++ function body (e.g. ["from pkg import foo"]). The caller (dumper) ++ uses this so users don't have to write boilerplate in YAML. ++ ++ """ ++ raw: dict[str, Any] = yaml.safe_load(yaml_content) ++ config: PatchConfig = PatchConfig(**raw) ++ ++ if extra_imports: ++ config = _inject_preamble(config=config, extra_imports=extra_imports) ++ ++ return _apply_specs(config.patches) ++ ++ ++class CodePatcher: ++ """Context manager that patches functions on enter and restores on exit.""" ++ ++ def __init__(self, *, patches: list[PatchSpec]) -> None: ++ self._patches = patches ++ self._states: list[PatchState] = [] ++ ++ def __enter__(self) -> "CodePatcher": ++ self._states = _apply_specs(self._patches) ++ return self ++ ++ def __exit__( ++ self, ++ exc_type: type | None, ++ exc_val: BaseException | None, ++ exc_tb: Any | None, ++ ) -> None: ++ for state in reversed(self._states): ++ state.restore() ++ self._states.clear() ++ ++ ++def patch_function( ++ *, ++ target: Callable[..., Any], ++ edits: list[EditSpec], ++ preamble: str = "", ++) -> PatchState: ++ """Patch a function by modifying its source and replacing __code__. ++ ++ 1. inspect.getsource -> get original source ++ 2. apply_edits -> modify source text ++ 3. optionally prepend preamble (e.g. import lines) inside the function body ++ 4. compile + exec -> get new code object ++ 5. replace target.__code__ ++ ++ Returns PatchState that can restore the original code. ++ """ ++ original_code: types.CodeType = target.__code__ ++ ++ source: str = inspect.getsource(target) ++ modified_source: str = apply_edits(source=source, edits=edits) ++ modified_source = textwrap.dedent(modified_source) ++ ++ if preamble.strip(): ++ modified_source = _insert_preamble(source=modified_source, preamble=preamble) ++ ++ code: types.CodeType = compile( ++ modified_source, ++ inspect.getfile(target), ++ "exec", ++ flags=__future__.annotations.compiler_flag, ++ ) ++ temp_namespace: dict[str, Any] = {} ++ exec(code, target.__globals__, temp_namespace) ++ ++ new_fn: Any = temp_namespace[target.__name__] ++ target.__code__ = new_fn.__code__ ++ ++ return PatchState(target_fn=target, original_code=original_code) ++ ++ ++# --------------------------------- private --------------------------------- ++ ++ ++def _apply_specs(specs: list[PatchSpec]) -> list[PatchState]: ++ states: list[PatchState] = [] ++ for spec in specs: ++ target_fn: Callable[..., Any] = _resolve_target(spec.target) ++ print(f"[source_patcher] patching {spec.target}") ++ state: PatchState = patch_function( ++ target=target_fn, edits=spec.edits, preamble=spec.preamble ++ ) ++ states.append(state) ++ return states ++ ++ ++def _inject_preamble(*, config: PatchConfig, extra_imports: list[str]) -> PatchConfig: ++ """Set preamble on every PatchSpec so imports are inserted once at function top.""" ++ import_block: str = "\n".join(extra_imports) ++ new_patches: list[PatchSpec] = [] ++ ++ for spec in config.patches: ++ existing: str = spec.preamble ++ combined: str = ( ++ import_block + "\n" + existing if existing.strip() else import_block ++ ) ++ new_patches.append( ++ PatchSpec(target=spec.target, edits=spec.edits, preamble=combined) ++ ) ++ ++ return PatchConfig(patches=new_patches) ++ ++ ++def _insert_preamble(*, source: str, preamble: str) -> str: ++ """Insert preamble lines right after the function signature (and optional ++ docstring). ++ """ ++ lines: list[str] = source.splitlines() ++ ++ signature_end: int = _find_signature_end(lines) ++ ++ body_start: int = signature_end + 1 ++ body_indent: str = "" ++ for i in range(body_start, len(lines)): ++ if lines[i].strip(): ++ body_indent = " " * (len(lines[i]) - len(lines[i].lstrip())) ++ body_start = i ++ break ++ ++ preamble_lines: list[str] = [ ++ body_indent + pl for pl in preamble.strip().splitlines() ++ ] ++ return "\n".join(lines[:body_start] + preamble_lines + lines[body_start:]) ++ ++ ++def _find_signature_end(lines: list[str]) -> int: ++ """Find the line index where the function signature ends (the line with trailing ++ colon). ++ """ ++ for i, line in enumerate(lines): ++ if line.rstrip().endswith(":"): ++ return i ++ return 0 ++ ++ ++def _resolve_target(qualified_name: str) -> Callable[..., Any]: ++ """Resolve 'pkg.mod.Class.method' to the actual function object. ++ ++ Tries progressively shorter module paths from right to left, ++ then uses getattr for the remaining attribute chain. ++ """ ++ parts: list[str] = qualified_name.split(".") ++ ++ target: Any = None ++ for split_idx in range(len(parts), 0, -1): ++ module_path: str = ".".join(parts[:split_idx]) ++ try: ++ target = importlib.import_module(module_path) ++ attr_parts: list[str] = parts[split_idx:] ++ break ++ except ImportError: ++ continue ++ else: ++ raise ImportError(f"could not import any module prefix of '{qualified_name}'") ++ ++ for attr_name in attr_parts: ++ target = getattr(target, attr_name) ++ ++ if isinstance(target, classmethod): ++ target = target.__func__ ++ if not callable(target): ++ raise TypeError( ++ f"resolved target '{qualified_name}' is not callable: {type(target)}" ++ ) ++ ++ return target +diff --git a/vllm/utils/debug_utils/source_patcher/source_editor.py b/vllm/utils/debug_utils/source_patcher/source_editor.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/source_patcher/source_editor.py +@@ -0,0 +1,148 @@ ++from vllm.utils.debug_utils.source_patcher.types import EditSpec, PatchApplicationError ++ ++ ++def apply_edits(*, source: str, edits: list[EditSpec]) -> str: ++ """Apply a sequence of match/replacement edits to source text. ++ ++ Each edit is applied sequentially so later edits see the result of earlier ones. ++ """ ++ result: str = source ++ for edit in edits: ++ result = _apply_single_edit(source=result, edit=edit) ++ return result ++ ++ ++def _apply_single_edit(*, source: str, edit: EditSpec) -> str: ++ """Apply a single match/replacement edit to the source text.""" ++ match_text: str = edit.match.strip() ++ if not match_text: ++ raise PatchApplicationError("empty match text") ++ ++ source_lines: list[str] = source.splitlines() ++ match_lines: list[str] = match_text.splitlines() ++ ++ start_idx: int = _find_match(source_lines=source_lines, match_lines=match_lines) ++ match_len: int = len(match_lines) ++ ++ original_indent: int = _leading_spaces(source_lines[start_idx]) ++ ++ effective_replacement: str = _resolve_replacement(edit=edit, match_text=match_text) ++ replacement_lines: list[str] = ( ++ effective_replacement.splitlines() if effective_replacement else [] ++ ) ++ aligned: list[str] = _realign_replacement( ++ replacement_lines=replacement_lines, original_indent=original_indent ++ ) ++ new_lines: list[str] = ( ++ source_lines[:start_idx] + aligned + source_lines[start_idx + match_len :] ++ ) ++ ++ trailing_newline: str = "\n" if source.endswith("\n") else "" ++ return "\n".join(new_lines) + trailing_newline ++ ++ ++def _resolve_replacement(*, edit: EditSpec, match_text: str) -> str: ++ """Return the effective replacement text, handling replacement, prepend, and ++ append modes. ++ """ ++ if edit.prepend.strip(): ++ return edit.prepend.strip() + "\n" + match_text ++ if edit.append.strip(): ++ return match_text + "\n" + edit.append.strip() ++ return edit.replacement.strip() ++ ++ ++def _find_match(*, source_lines: list[str], match_lines: list[str]) -> int: ++ """Find the start index of match_lines in source_lines (strip-compared). ++ ++ Returns the index of the first matching line. ++ Raises PatchApplicationError if not found or found multiple times. ++ """ ++ stripped_source: list[str] = [line.strip() for line in source_lines] ++ stripped_match: list[str] = [line.strip() for line in match_lines] ++ match_len: int = len(stripped_match) ++ ++ found_indices: list[int] = [ ++ i ++ for i in range(len(stripped_source) - match_len + 1) ++ if stripped_source[i : i + match_len] == stripped_match ++ ] ++ ++ if len(found_indices) == 0: ++ raise PatchApplicationError( ++ _not_found_diagnostic(stripped_source, stripped_match) ++ ) ++ if len(found_indices) > 1: ++ preview = "\n".join(match_lines) ++ raise PatchApplicationError( ++ f"match text found multiple times ({len(found_indices)} " ++ f"occurrences) in source:\n{preview}" ++ ) ++ ++ return found_indices[0] ++ ++ ++def _not_found_diagnostic(stripped_source: list[str], stripped_match: list[str]) -> str: ++ preview = "\n".join(stripped_match) ++ lines = [ ++ f"match text not found in source:\n{preview}", ++ "", ++ f"source_len={len(stripped_source)} lines", ++ ] ++ ++ if not stripped_match: ++ return "\n".join(lines) ++ first_match_line = stripped_match[0] ++ hits = [i for i, line in enumerate(stripped_source) if line == first_match_line] ++ if not hits: ++ lines.append( ++ f"first match line {first_match_line!r} does NOT appear anywhere in source" ++ ) ++ return "\n".join(lines) ++ ++ lines.append( ++ f"first match line {first_match_line!r} appears {len(hits)} time(s); " ++ f"showing up to 8 windows with context:" ++ ) ++ for i in hits[:8]: ++ lo = max(0, i - 2) ++ hi = min(len(stripped_source), i + len(stripped_match) + 2) ++ block: list[str] = [] ++ for j in range(lo, hi): ++ marker = ( ++ ">" if lo + (j - lo) >= i and (j - i) < len(stripped_match) else " " ++ ) ++ block.append(f"{marker} {j:4d}: {stripped_source[j]}") ++ lines.append("--") ++ lines.extend(block) ++ return "\n".join(lines) ++ ++ ++def _realign_replacement( ++ *, replacement_lines: list[str], original_indent: int ++) -> list[str]: ++ """Realign replacement lines to the original indentation level. ++ ++ Strategy: ++ - Take the leading spaces of the first non-empty replacement line as base_indent ++ - For each replacement line: remove base_indent, add original_indent ++ """ ++ non_empty: list[str] = [line for line in replacement_lines if line.strip()] ++ if not non_empty: ++ return [] ++ ++ base_indent: int = _leading_spaces(non_empty[0]) ++ result: list[str] = [] ++ ++ for line in replacement_lines: ++ if not line.strip(): ++ result.append("") ++ else: ++ stripped = line[min(base_indent, len(line) - len(line.lstrip())) :] ++ result.append(" " * original_indent + stripped) ++ ++ return result ++ ++ ++def _leading_spaces(line: str) -> int: ++ return len(line) - len(line.lstrip(" ")) +diff --git a/vllm/utils/debug_utils/source_patcher/types.py b/vllm/utils/debug_utils/source_patcher/types.py +new file mode 100644 +--- /dev/null ++++ b/vllm/utils/debug_utils/source_patcher/types.py +@@ -0,0 +1,63 @@ ++import types ++from collections.abc import Callable ++from typing import Any ++ ++from pydantic import BaseModel, ConfigDict, model_validator ++ ++ ++class PatchApplicationError(Exception): ++ """match text not found or not unique in source.""" ++ ++ ++class _StrictBase(BaseModel): ++ model_config = ConfigDict(extra="forbid") ++ ++ ++class EditSpec(_StrictBase): ++ """Specify one edit: replace, prepend before, or append after the matched text. ++ ++ Use ``replacement`` to substitute the matched text (empty string = delete). ++ Use ``prepend`` to keep the matched text and add lines before it. ++ Use ``append`` to keep the matched text and add lines after it. ++ Only one of ``replacement``, ``prepend``, and ``append`` may be set. ++ """ ++ ++ match: str ++ replacement: str = "" ++ prepend: str = "" ++ append: str = "" ++ ++ @model_validator(mode="after") ++ def _check_modes_mutually_exclusive(self) -> "EditSpec": ++ active: list[str] = [ ++ name ++ for name in ("replacement", "prepend", "append") ++ if getattr(self, name).strip() ++ ] ++ if len(active) > 1: ++ raise ValueError( ++ f"only one of 'replacement', 'prepend', 'append' may be set, " ++ f"got: {', '.join(active)}" ++ ) ++ return self ++ ++ ++class PatchSpec(_StrictBase): ++ target: str ++ edits: list[EditSpec] ++ preamble: str = "" ++ ++ ++class PatchConfig(_StrictBase): ++ patches: list[PatchSpec] ++ ++ ++class PatchState: ++ def __init__( ++ self, *, target_fn: Callable[..., Any], original_code: types.CodeType ++ ) -> None: ++ self.target_fn = target_fn ++ self.original_code = original_code ++ ++ def restore(self) -> None: ++ self.target_fn.__code__ = self.original_code +diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py +--- a/vllm/v1/worker/gpu/model_runner.py ++++ b/vllm/v1/worker/gpu/model_runner.py +@@ -32,7 +32,7 @@ + from vllm.compilation.counter import compilation_counter + from vllm.compilation.cuda_graph import CUDAGraphStat + from vllm.compilation.wrapper import compile_model_with_stock_torch +-from vllm.config import VllmConfig ++from vllm.config import VllmConfig, set_current_vllm_config + from vllm.config.compilation import CompilationMode, CUDAGraphMode + from vllm.distributed.aux_output_connector.worker import ( + AuxOutputWorkerConnector, +@@ -60,6 +60,7 @@ + from vllm.platforms import current_platform + from vllm.sequence import IntermediateTensors + from vllm.tasks import SupportedTask ++from vllm.utils.debug_utils.dumper import dumper + from vllm.utils.gc_utils import freeze_gc_for_cudagraph_capture + from vllm.utils.mem_utils import DeviceMemoryProfiler, format_gib + from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE, async_tensor_h2d +@@ -201,6 +202,7 @@ + ) + self.observability_config = vllm_config.observability_config + self.jit_warmup_registry = JitWarmupRegistry(vllm_config) ++ self.tensor_dumper = dumper if dumper.may_enable else None + + self.device = device + self.dtype = self.model_config.dtype +@@ -407,6 +409,9 @@ + eplb_models_added = self.eplb.maybe_register_speculator( + self.speculator, self.speculative_config, load_dummy_weights + ) ++ if self.tensor_dumper is not None: ++ self.tensor_dumper.apply_source_patches() ++ self.tensor_dumper.register_non_intrusive_dumper(self.model) + time_after_load = time.perf_counter() + + self.model_memory_usage = m.consumed_memory +@@ -1970,6 +1975,10 @@ + model_inputs["intermediate_tensors"] = IntermediateTensors(new_tensors) + del intermediate_tensors + ++ if not dummy_run and self.tensor_dumper is not None: ++ with set_current_vllm_config(self.vllm_config): ++ self.tensor_dumper.dump_dict("forward_batch_info", input_batch) ++ + # Update the EPLB meta. + ubatch_slices = ubatch_state.slices if ubatch_state is not None else None + self.eplb.prepare_forward( +@@ -2039,6 +2048,8 @@ + model_output = self.model(**model_inputs) + + self.kv_connector.finish_forward() ++ if not dummy_run and self.tensor_dumper is not None: ++ self.tensor_dumper.step() + + if self.is_last_pp_rank: + if self.use_aux_hidden_state_outputs: +diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py +--- a/vllm/v1/worker/gpu_worker.py ++++ b/vllm/v1/worker/gpu_worker.py +@@ -1464,6 +1464,11 @@ + def pin_lora(self, lora_id: int) -> bool: + return self.model_runner.pin_lora(lora_id) + ++ def dumper_control(self, action: str, body: str = "{}") -> dict: ++ if not self.use_v2_model_runner or self.model_runner.tensor_dumper is None: ++ raise RuntimeError("Dumper was not enabled at startup.") ++ return self.model_runner.tensor_dumper.control(action, body) ++ + def check_health(self) -> None: + # worker will always be healthy as long as it's running. + return diff --git a/docker/patch/latest/vllm.patch b/docker/patch/latest/vllm.patch index 2fc6421a5..03726896c 100644 --- a/docker/patch/latest/vllm.patch +++ b/docker/patch/latest/vllm.patch @@ -1,28 +1,24 @@ -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -index fb2b9387238..c05faefa4c6 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py -@@ -365,6 +365,7 @@ class GenerateStreamResponse(BaseModel): - prompt_token_ids: list[int] | None = None +@@ -469,6 +469,7 @@ + prompt_token_ids: list[NonNegativeInt] | None = None mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None metrics: PerRequestMetrics | None = None + weight_version: str | None = None - class GenerateResponse(BaseModel): -@@ -384,6 +385,7 @@ class GenerateResponse(BaseModel): - prompt_token_ids: list[int] | None = None + class GenerateTokensStreamResponse(GenerateStreamResponseBase): +@@ -498,6 +499,7 @@ + prompt_token_ids: list[NonNegativeInt] | None = None mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None metrics: PerRequestMetrics | None = None + weight_version: str | None = None kv_transfer_params: dict[str, Any] | None = Field( default=None, -diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -index 59fda2dbf8c..862413c2da4 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py -@@ -394,6 +394,7 @@ class ServingTokens(GenerateBaseServing): +@@ -504,6 +504,7 @@ ), mm_placeholders=request._response_mm_placeholders, metrics=per_request_metrics, @@ -30,37 +26,23 @@ index 59fda2dbf8c..862413c2da4 100644 kv_transfer_params=final_res.kv_transfer_params, ec_transfer_params=final_res.ec_transfer_params, ) -@@ -464,8 +465,6 @@ class ServingTokens(GenerateBaseServing): - # Still emit a terminal empty chunk while prompt metadata - # is pending, so zero-token completions deliver it. -- if not delta_token_ids and ( -- finish_reason is None or prompt_token_ids is None -- ): -+ if not delta_token_ids and finish_reason is None: - continue - - if sampling_params.logprobs is not None: -@@ -487,6 +488,7 @@ class ServingTokens(GenerateBaseServing): +@@ -630,6 +631,7 @@ + if text_mode: + chunk = GenerateTextStreamResponse( + request_id=request_id, ++ weight_version=res.weight_version, + choices=[ + GenerateTextStreamChoice( + text=output.text, **choice_fields +@@ -639,6 +641,7 @@ + else: + chunk = GenerateTokensStreamResponse( + request_id=request_id, ++ weight_version=res.weight_version, + choices=[GenerateTokensStreamChoice(**choice_fields)], + ) - chunk = GenerateStreamResponse( - request_id=request_id, -+ weight_version=res.weight_version, - choices=[ - GenerateResponseStreamChoice( - index=i, -@@ -494,6 +496,11 @@ class ServingTokens(GenerateBaseServing): - finish_reason=finish_reason, - token_ids=as_list(delta_token_ids), - routed_experts=routed_experts_b64, -+ sampling_mask=( -+ output.sampling_mask.token_ids -+ if output.sampling_mask is not None -+ else None -+ ), - ) - ], - ) -@@ -511,7 +518,12 @@ class ServingTokens(GenerateBaseServing): +@@ -656,7 +659,12 @@ # Omit fields that are absent from token-bearing chunks. exclude = { name @@ -74,19 +56,25 @@ index 59fda2dbf8c..862413c2da4 100644 if getattr(chunk, name) is None } yield f"data: {chunk.model_dump_json(exclude=exclude)}\n\n" -@@ -541,6 +553,7 @@ class ServingTokens(GenerateBaseServing): - choices=[], - usage=final_usage_info, - metrics=per_request_metrics, -+ weight_version=last_res.weight_version if last_res else None, - ) +@@ -688,6 +696,7 @@ + choices=[], + usage=final_usage_info, + metrics=per_request_metrics, ++ weight_version=last_res.weight_version if last_res else None, + ) + else: + final_chunk = GenerateTokensStreamResponse( +@@ -695,6 +704,7 @@ + choices=[], + usage=final_usage_info, + metrics=per_request_metrics, ++ weight_version=last_res.weight_version if last_res else None, + ) yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n" -diff --git a/vllm/outputs.py b/vllm/outputs.py -index e9a08f7c462..a5c41c99e9a 100644 --- a/vllm/outputs.py +++ b/vllm/outputs.py -@@ -130,6 +130,7 @@ class RequestOutput: +@@ -143,6 +143,7 @@ prefix-cache writes for this request. kv_transfer_params: The params for remote K/V transfer. ec_transfer_params: The params for remote encoder-cache transfer. @@ -94,15 +82,15 @@ index e9a08f7c462..a5c41c99e9a 100644 """ -@@ -150,6 +151,7 @@ class RequestOutput: - *, +@@ -164,6 +165,7 @@ + prompt_token_id_logprobs: np.ndarray | None = None, kv_transfer_params: dict[str, Any] | None = None, ec_transfer_params: dict[str, Any] | None = None, + weight_version: str | None = None, # Forward compatibility, code that uses args added in new release can # still run with older versions of vLLM without breaking. **kwargs: Any, -@@ -172,12 +174,14 @@ class RequestOutput: +@@ -187,12 +189,14 @@ self.num_cache_creation_tokens = num_cache_creation_tokens self.kv_transfer_params = kv_transfer_params self.ec_transfer_params = ec_transfer_params @@ -117,41 +105,9 @@ index e9a08f7c462..a5c41c99e9a 100644 for next_completion in next_output.outputs: for i, completion in enumerate(self.outputs): -@@ -191,6 +195,17 @@ class RequestOutput: - if next_completion.logprobs: - assert completion.logprobs is not None - completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type] -+ if next_completion.sampling_mask is not None: -+ if completion.sampling_mask is None: -+ completion.sampling_mask = next_completion.sampling_mask -+ else: -+ completion.sampling_mask.token_ids.extend( -+ next_completion.sampling_mask.token_ids -+ ) -+ if next_completion.spec_decode_metrics is not None: -+ completion.spec_decode_metrics = ( -+ next_completion.spec_decode_metrics -+ ) - completion.cumulative_logprob = ( - next_completion.cumulative_logprob - ) -diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py -index c868d249c8a..c9f757042f7 100644 ---- a/vllm/v1/core/sched/interface.py -+++ b/vllm/v1/core/sched/interface.py -@@ -94,6 +94,7 @@ class SchedulerInterface(ABC): - self, - scheduler_output: "SchedulerOutput", - model_runner_output: "ModelRunnerOutput", -+ weight_version: str | None = None, - ) -> dict[int, "EngineCoreOutputs"]: - """Update the scheduler state based on the model runner output. - -diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py -index 18bbc102830..c81921d8725 100644 --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py -@@ -1907,6 +1907,7 @@ class Scheduler(SchedulerInterface): +@@ -2003,6 +2003,7 @@ self, scheduler_output: SchedulerOutput, model_runner_output: ModelRunnerOutput, @@ -159,7 +115,7 @@ index 18bbc102830..c81921d8725 100644 ) -> dict[int, EngineCoreOutputs]: sampled_token_ids = model_runner_output.sampled_token_ids logprobs = model_runner_output.logprobs -@@ -2139,6 +2140,7 @@ class Scheduler(SchedulerInterface): +@@ -2240,6 +2241,7 @@ EngineCoreOutput( request_id=req_id, new_token_ids=new_token_ids, @@ -167,7 +123,7 @@ index 18bbc102830..c81921d8725 100644 finish_reason=finish_reason, new_logprobs=new_logprobs, new_sampling_mask=new_sampling_mask, -@@ -2189,6 +2191,7 @@ class Scheduler(SchedulerInterface): +@@ -2295,6 +2297,7 @@ EngineCoreOutput( request_id=request.request_id, new_token_ids=[], @@ -175,23 +131,21 @@ index 18bbc102830..c81921d8725 100644 finish_reason=request.get_finished_reason(), events=request.take_events(), trace_headers=request.trace_headers, -diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py -index e5bbb96a9cf..e7a849297ed 100644 --- a/vllm/v1/engine/__init__.py +++ b/vllm/v1/engine/__init__.py -@@ -235,6 +235,7 @@ class EngineCoreOutput( - # Per-request spec-decode acceptance; attached only on the final output. - # Appended last so `array_like` positional serialization stays compatible. - spec_decode_metrics: RequestSpecDecodeMetrics | None = None +@@ -238,6 +238,9 @@ + + # [num_scored_rows, num_token_ids]; appended last for array_like compatibility. + prompt_token_id_logprobs: torch.Tensor | None = None + weight_version: str | None = None ++ remote_kv_wait_time: float | None = None ++ kv_transfer_metrics: dict[str, float] | None = None @property def finished(self) -> bool: -diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py -index 9b27f50edc0..3a35a40e4e1 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py -@@ -655,7 +655,7 @@ class EngineCore: +@@ -652,7 +652,7 @@ # during the model execution. self._process_aborts_queue() engine_core_outputs = self.scheduler.update_from_output( @@ -200,7 +154,7 @@ index 9b27f50edc0..3a35a40e4e1 100644 ) self._attach_iteration_details(engine_core_outputs, iteration_details) -@@ -756,7 +756,7 @@ class EngineCore: +@@ -753,7 +753,7 @@ # during the model execution. self._process_aborts_queue() engine_core_outputs = self.scheduler.update_from_output( @@ -209,7 +163,7 @@ index 9b27f50edc0..3a35a40e4e1 100644 ) self._attach_iteration_details(engine_core_outputs, iteration_details) -@@ -2036,7 +2036,12 @@ class EngineCoreProc(EngineCore): +@@ -2050,7 +2050,12 @@ self, req_ids: list[str], client_index: int, finish_reason: FinishReason ) -> None: outputs = [ @@ -223,28 +177,9 @@ index 9b27f50edc0..3a35a40e4e1 100644 for req_id in req_ids ] eco = EngineCoreOutputs(finished_requests=req_ids, outputs=outputs) -diff --git a/vllm/v1/engine/input_processor.py b/vllm/v1/engine/input_processor.py -index cb7d3d47183..09618d7eb66 100644 ---- a/vllm/v1/engine/input_processor.py -+++ b/vllm/v1/engine/input_processor.py -@@ -150,12 +150,6 @@ class InputProcessor: - raise ValueError( - "sampling distribution replay requires temperature > 0" - ) -- if params.top_k <= 0: -- raise ValueError( -- "sampling distribution replay requires top_k > 0 to " -- "bound sampling mask size, reduce transfer overhead, " -- "and avoid potential OOMs" -- ) - if params.thinking_token_budget is not None and ( - self.vllm_config.reasoning_config is None - or not self.vllm_config.reasoning_config.enabled -diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py -index b4f7338048c..f712c9b4962 100644 --- a/vllm/v1/engine/output_processor.py +++ b/vllm/v1/engine/output_processor.py -@@ -183,6 +183,7 @@ class RequestState: +@@ -184,6 +184,7 @@ # Per-sequence spec-decode accumulator; arrives once (on finish) via # EngineCoreOutput, then attached to this sequence's CompletionOutput. self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None @@ -252,31 +187,31 @@ index b4f7338048c..f712c9b4962 100644 self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None -@@ -304,6 +305,7 @@ class RequestState: - stop_reason: int | str | None, +@@ -306,6 +307,7 @@ kv_transfer_params: dict[str, Any] | None = None, ec_transfer_params: dict[str, Any] | None = None, + error: RequestError | None = None, + weight_version: str | None = None, ) -> RequestOutput | PoolingRequestOutput | None: finished = finish_reason is not None final_only = self.output_kind == RequestOutputKind.FINAL_ONLY -@@ -360,6 +362,7 @@ class RequestState: +@@ -363,6 +365,7 @@ finished, kv_transfer_params, ec_transfer_params, -+ weight_version, ++ weight_version=weight_version, ) def _new_request_output( -@@ -369,6 +372,7 @@ class RequestState: - finished: bool, +@@ -373,6 +377,7 @@ kv_transfer_params: dict[str, Any] | None = None, ec_transfer_params: dict[str, Any] | None = None, + error: RequestError | None = None, + weight_version: str | None = None, ) -> RequestOutput | PoolingRequestOutput: # If prompt embeds were used, put placeholder prompt token ids prompt_token_ids = self.prompt_token_ids -@@ -403,6 +407,7 @@ class RequestState: +@@ -413,6 +418,7 @@ finished=finished, kv_transfer_params=kv_transfer_params, ec_transfer_params=ec_transfer_params, @@ -284,26 +219,7 @@ index b4f7338048c..f712c9b4962 100644 num_cached_tokens=self.num_cached_tokens, num_cache_creation_tokens=self.num_cache_creation_tokens, metrics=self.stats, -@@ -434,10 +439,16 @@ class RequestState: - logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0] - - sampling_mask = None -- if finished and self.sampling_mask_chunks: -+ if self.sampling_mask_chunks and (delta or finished): - sampling_mask = SamplingMask( -- [chunk.token_ids.tolist() for chunk in self.sampling_mask_chunks] -+ [ -+ mask -+ for chunk in self.sampling_mask_chunks -+ for mask in chunk.to_nested_list() -+ ][: len(token_ids)] - ) -+ if delta: -+ self.sampling_mask_chunks.clear() - - # Concatenate routed experts on finish - routed_experts = None -@@ -560,6 +571,7 @@ class OutputProcessor: +@@ -576,6 +582,7 @@ stop_reason=None, kv_transfer_params=None, ec_transfer_params=None, @@ -311,7 +227,7 @@ index b4f7338048c..f712c9b4962 100644 ) ): req_state.queue.put(request_output) -@@ -724,6 +736,7 @@ class OutputProcessor: +@@ -753,6 +760,7 @@ req_state.logprobs_processor.update_from_output(engine_core_output) # 4) Create and handle RequestOutput objects. @@ -319,11 +235,21 @@ index b4f7338048c..f712c9b4962 100644 if request_output := req_state.make_request_output( new_token_ids, pooling_output, -@@ -731,6 +744,7 @@ class OutputProcessor: - stop_reason, +@@ -761,6 +769,7 @@ kv_transfer_params, ec_transfer_params, -+ engine_core_output.weight_version, + error=request_error, ++ weight_version=engine_core_output.weight_version, ): if req_state.streaming_input: request_output.finished = False +--- a/vllm/v1/core/sched/interface.py ++++ b/vllm/v1/core/sched/interface.py +@@ -94,6 +94,7 @@ + self, + scheduler_output: "SchedulerOutput", + model_runner_output: "ModelRunnerOutput", ++ weight_version: str | None = None, + ) -> dict[int, "EngineCoreOutputs"]: + """Update the scheduler state based on the model runner output. + diff --git a/docs/en/advanced/delta-weight-sync.md b/docs/en/advanced/delta-weight-sync.md index 781169681..ed59879c5 100644 --- a/docs/en/advanced/delta-weight-sync.md +++ b/docs/en/advanced/delta-weight-sync.md @@ -6,9 +6,9 @@ training/inference disaggregation across clusters or datacenters, where writing every sync is the dominant cost. It is **disk-transport only**. The trainer publishes each sync as a canonical HF checkpoint -directory; the engine's `/pull_weights` endpoint (shipped in vime's vllm patch) fans the +directory; the engine's `pull_weights` worker RPC (shipped in vime's vllm patch) fans the apply out to **every host the engine spans** and verifies it, then the engine reloads the -patched local checkpoint through the **ordinary** `update_weights_from_disk` endpoint. vime +patched local checkpoint through the **ordinary** `update_weights_from_disk` adapter. vime only ever talks to one endpoint per engine, so multi-node serving and external rollout engines need nothing extra on the vime side. @@ -26,7 +26,7 @@ need nothing extra on the vime side. | Flag | Role | |---|---| | `--update-weight-disk-dir` | Shared filesystem directory the trainer publishes deltas to and the rollout hosts read from. | -| `--update-weight-local-checkpoint-dir` | Host-local (e.g. NVMe) full HF checkpoint that `/pull_weights` keeps in sync — deltas are applied into it in place; a published full checkpoint replaces it. Each host seeds it from the engine's model path on the first `/pull_weights`. | +| `--update-weight-local-checkpoint-dir` | Host-local (e.g. NVMe) full HF checkpoint that `pull_weights` keeps in sync. The initial full publication establishes its baseline; later deltas apply in place, and a new full publication replaces it. | | `--update-weight-delta-encoding` | On-disk delta encoding: `xor` (default) or `overwrite`. | | `--update-weight-delta-checksum` | Per-tensor integrity checksum: `xxh3-128` (default), `blake3`, or `adler32`. | @@ -34,11 +34,11 @@ Deltas are always zstd-compressed (level 1); profiling showed it dominates lz4 / ## How it works -1. **Seed.** On the first sync the trainer captures a CPU snapshot of every parameter — seeded - from `--hf-checkpoint`, which is exactly what each rollout host materializes its local - checkpoint from. Nothing is published; this snapshot is the base the next sync diffs against. - The trainer also issues `/pull_weights` with `target_version=0` so every host materializes - its local base now, overlapped with the snapshot capture. +1. **Seed.** The first sync, including after trainer recovery or an engine replacement, + publishes a full HF checkpoint from the trainer's current weights. It captures the CPU + snapshot from those exact tensors and reloads every engine before generation resumes. + The version exceeds every existing version directory, including an update whose reply + was lost. Existing versions are retained; restarting does not delete the stream. 2. **Publish.** On every later sync the trainer diffs each gathered HF tensor against the snapshot, encodes and compresses the change, and writes a new version directory `weight_v{N:06d}/` under `--update-weight-disk-dir`. The directory is a canonical HF @@ -46,13 +46,13 @@ Deltas are always zstd-compressed (level 1); profiling showed it dominates lz4 / `model.safetensors.index.json` (tensor name → file) carrying the apply metadata — so the artifact is portable, not tied to the trainer's parallelism layout. The snapshot is then advanced to the new values for the next diff. -3. **Pull.** The trainer calls `/pull_weights` on each engine. Inside the engine the request is +3. **Pull.** The trainer calls `pull_weights` on each engine. Inside the engine the request is broadcast to every rank on every node; each host applies the new version's delta into its local checkpoint in place (a per-host file lock collapses co-located ranks to one apply). The apply is parallelized across tensors and verified per-tensor (see Integrity); the call only reports success once **every host** holds a checksum-verified checkpoint. - `/pull_weights` is not delta-specific: each published version is self-describing, and a + `pull_weights` is not delta-specific: each published version is self-describing, and a version that is an ordinary full HF checkpoint (no delta metadata in its index) is pulled by copying it as-is — resetting the chain, so a fresh host joining late seeds from the newest full version instead of replaying every delta, and older deltas can be pruned. vime's @@ -60,9 +60,9 @@ Deltas are always zstd-compressed (level 1); profiling showed it dominates lz4 / 4. **Reload.** The engines reload the patched local checkpoint through the vanilla `update_weights_from_disk` path — the weight-loading code never sees the delta format. -Because the snapshot is seeded from `--hf-checkpoint` (the engine's actual base) rather than -from the current GPU weights, the scheme is correct for any model even where the Megatron→HF -round-trip is not byte-exact (e.g. trimmed vocab-padding rows in the embedding / LM head). +The snapshot and the engines start from the same published HF bytes, including when the +trainer restored a newer model than `--hf-checkpoint`. Megatron→HF conversion differences +(such as trimmed embedding / LM-head padding) therefore do not corrupt the next delta. ## Encodings @@ -81,7 +81,7 @@ The engine reads the choice from each version's index metadata. The trainer stores a per-tensor checksum of each tensor's new state in the version. After applying, every host recomputes the checksum and **raises on any mismatch** — the failure -propagates through the `/pull_weights` response, so a corrupt delta or a wrong base fails loud +propagates through the `pull_weights` response, so a corrupt delta or a wrong base fails loud instead of serving bad weights. The apply also refuses to run out of order: a version only applies on top of its declared base version. @@ -100,5 +100,5 @@ optional hooks, loaded by import path — no vendor-specific code lives in vime written, before the engines are told to read it (e.g. upload pending writes to the backing object store). Signature: `hook(args, version_dir, rollout_engines)`. - `--vllm-custom-pull-weights-pre-read-hook` (vllm server arg, engine side): called on each host - inside the engine before `/pull_weights` reads the delta directory (e.g. refresh the mount's view). + inside the engine before `pull_weights` reads the delta directory (e.g. refresh the mount's view). Signature: `hook(delta_dir, target_version)`. diff --git a/docs/en/advanced/fault-tolerance.md b/docs/en/advanced/fault-tolerance.md index 97e504bcf..31873159f 100644 --- a/docs/en/advanced/fault-tolerance.md +++ b/docs/en/advanced/fault-tolerance.md @@ -1,76 +1,129 @@ # Fault Tolerance -Long-running RL jobs fail in different ways from short supervised runs. Rollout engines can hang, long-tail samples can keep a round open, and serving state must be refreshed after weight updates. vime's fault-tolerance support focuses on making the rollout side observable, restartable, and debuggable without changing the training / rollout / Data Buffer loop. +vime checks vLLM engines, removes failed engines from service, and restarts them before the next weight update. When you use Straw transport or save rollout debug data, you can also retain the serving cluster after a Megatron failure, adjust the training configuration, and resume training. -Enable fault tolerance with: +These features are enabled by default for serving clusters started by vime. You do not need `--use-fault-tolerance`; the flag remains for compatibility with older commands. External serving clusters retain their existing policy. + +## Resuming After a Megatron Failure + +This recovery workflow requires the **Ray cluster to remain running**. Losing the training driver or `RolloutManager` does not destroy the vLLM engines or routers. When you resubmit training, vime finds and reuses the existing serving cluster. This requires no changes to vLLM and does not automatically retry the training job. + +### Retain Recovery Data from the First Launch + +Choose either option below. vime automatically persists recovery data in these modes, independently of `--use-fault-tolerance`. + +Use Straw to store rollout and training data: + +```bash +--rollout-data-transport straw --rollout-data-dir /shared/my-run/queue +``` + +Or save rollout debug data. The path must include `{rollout_id}`: ```bash ---use-fault-tolerance +--save-debug-rollout-data '/shared/my-run/rollout_{rollout_id}.pt' ``` -## Current Scope +Use `--save` and `--save-interval` to save model checkpoints regularly. Longer intervals mean more batches to retrain after a failure and more recovery data to retain. + +Recovery requires `torch_dist` checkpoints with RNG state and, for a stateful optimizer, optimizer state. vime automatically enables an optimizer checkpoint format that supports resharding across parallel configurations. `--no-save-rng` is rejected. + +With `--use-stateless-adam --no-save-optim`, Adam keeps no moments between optimizer steps, so recovery can omit optimizer tensors. Vime saves the LR/WD scheduler in `opt_param_scheduler.pt` inside each model checkpoint and restores it at the same boundary; a missing scheduler file prevents resume. FP32 master parameters are rebuilt from the saved model precision, so this mode does not promise bitwise-identical updates after a restart. Ordinary Adam still requires its optimizer checkpoint. + +If saving a large MoE optimizer exhausts GPU memory, use `--distrib-optim-fully-reshardable-mem-efficient`. Megatron then gathers checkpoint tensors through Gloo on CPU, preserving resharding support while trading GPU temporary memory for host memory and save time. `--overlap-grad-reduce --ddp-bucket-size 40000000` bounds the temporary buffers used for each checkpoint gather; reserve host memory for the full optimizer state as well. + +### Resubmit After the Failed Job Exits + +For example, after a Megatron OOM: + +1. Wait for the failed training job to exit. +2. Adjust TP/CP/EP, microbatch size, or the token limit per GPU. +3. Submit `train.py` again to the **same Ray cluster**. + +Keep the model, rollout configuration, global batch size, and session identity unchanged. With colocated training and rollout, the trainers must fit within the retained GPU allocation. With separate training and rollout, you can allocate training resources again without moving the rollout GPUs. + +Retained workers keep their original configuration. Sampling, filters, reward hooks, and custom arguments are checked on reattachment; adding or removing an argument also counts as a change. Only explicitly supported trainer and operational settings may change. -vime currently provides rollout-engine fault tolerance: +Between attempts, do not stop Ray, recreate the serving container, or run cleanup commands such as `pkill vllm`. `vime.utils.external_utils.command_utils.execute_train` preserves a running Ray head and vLLM. If your shell launcher cleans up processes on every launch, skip that step when restarting. Once training finishes successfully, vime releases the retained session and its resources. -- health checks for vLLM rollout servers; -- timeout-based rollout server restart; -- correct parameter update after restart; -- debug rollout dumps for replaying training-side issues without rerunning rollout; -- trace/profiling hooks for inspecting long-tail rollout behavior. +### Where Training Resumes -Cluster-level preemption, trainer-rank failure, and full-job resume should still be handled through your cluster scheduler, Ray restart policy, and vime checkpointing. In practice, production jobs combine rollout fault tolerance with frequent checkpoints and debug dumps. +The new trainer loads the last successfully saved model, scheduler, RNG, and any stateful optimizer state, then retrains the batches after that checkpoint. This includes batches that finished training but whose model updates were not saved. If no checkpoint exists yet, replay starts from the original model. -## Rollout Health Checks +Retained data includes generated samples, tokens, and reward postprocessing results. When you change training parallelism, vime repartitions this data without regenerating samples or repeating reward postprocessing. Recovery data is kept until the corresponding model checkpoint commits or training finishes successfully. Serving weight versions continue to increase across restarts. -During rollout, vime periodically sends heartbeat requests (`/health`) to all vLLM servers. If a heartbeat times out, the unhealthy vLLM server is stopped. After the current rollout round completes, vime restarts the server and updates it with the correct parameters before it serves future rollout requests. +Megatron restores RNG state when the parallel layout is compatible and reinitializes it when TP/PP changes. You can therefore resume with a different layout, but the results are not guaranteed to be bitwise identical. -The main arguments are: +## Finding the Existing Serving Cluster -- `--rollout-health-check-first-wait`: wait before starting heartbeat checks for the first rollout. Large MoE models may compile kernels on first run. Default: `300` seconds. -- `--rollout-health-check-interval`: interval between heartbeat checks. Default: `10` seconds. -- `--rollout-health-check-timeout`: timeout for one heartbeat request. Default: `5` seconds. +vime manages the serving cluster through a named Ray actor. A new job looks up that actor by its session name and gets the existing router and engine information from it. It does not scan for router processes. -Example: +Set `--rollout-session-id` to choose a session identity explicitly. Otherwise, vime selects an identity in the following order and hashes it to produce a stable name: + +1. With Straw, use the absolute storage directory path and `--rollout-queue-run-id`. +2. Otherwise, use the absolute path template from `--save-debug-rollout-data`. +3. If debug data is not saved, use the absolute `--save` directory path. +4. If none of these is configured, use the model and rollout configuration. + +Use the same identity for both attempts of a training run. Independent runs should use different identities to avoid connecting to the same serving cluster. + +Three components handle recovery: + +- `ServingCluster` manages routers, vLLM engines, GPU resources, the Straw queue controller, and the weight-update lock. It is a named, detached Ray actor that survives the job that created it. +- `RolloutManager` handles generation, data reading, sample conversion, and training-data partitioning. It can be reused or recreated after it exits; recreating it does not destroy resources held by `ServingCluster`. +- `TrainingRecovery` owns the model checkpoint boundary and retains the batches needed after it. The Straw accepted log owns raw and converted batch receipts; recovery reconciles those receipts if a manager or RPC fails before the journal is updated. A converted batch is stored once, and each DP rank receives an index view that can be rebuilt for a new parallel configuration. + +If `RolloutManager` survives, it pauses admission of new generation tasks. If it exits, its replacement reconnects to `ServingCluster`, restores data-source progress, and uses the queue controller to prevent old readers from taking more tasks. Completed prefetch results that have not reached training remain available. Trainer processes are also registered with `ServingCluster`, so it can clean up old trainers even after the manager exits. + +The driver owns the lifetime of each training attempt, including rollback when startup fails after attaching to serving. On failure, it gives the manager a bounded opportunity to pause, then asks serving to release trainers directly. A manager that fails to pause is terminated and recreated on the next attempt. Successful completion disposes the manager and serving independently, so an error in a custom data source's `close()` does not skip serving cleanup. Cleanup errors are logged without replacing the original training failure. + +`--rollout-cleanup-timeout` defaults to **60 seconds** for detaching or disposing an attempt. It is independent of engine health-check and warmup timeouts. Cleanup attempts remaining resource releases even after a step fails; blocked actors are terminated when graceful disposal times out. + +Checkpoint resolution returns a separate configuration and restore plan. Retained checkpoint state is immutable between progress updates; each trainer attempt gets a fresh configuration containing the selected checkpoint and serving weight version. Data sources and long-lived rollout workers keep their original configuration. Existing custom functions still receive the same args-based interfaces. + +## Engine Health Checks and Restarts + +During rollout, vime periodically requests vLLM's `/health_generate` endpoint to check whether each engine responds. It also checks immediately when rollout finishes, **regardless of the background interval or initial wait**. + +Synchronous rollout removes failed services from the router before sending abort and drain requests. Before returning to training, `ServingCluster` checks the engines again, unregisters failed services, and clears their Ray actor handles. This prevents later memory-offload or weight-update requests from reaching stopped engines. Both HTTP requests and Ray calls have timeouts. + +Missing engines restart before the next weight update and then load the trainer's weights. Background checks and rollout-completion checks use the same failure handling. Background checks pause while weights or memory allocation change. + +Reattachment also bounds the time spent resetting old trainer connections. An engine that cannot acknowledge is unregistered and terminated with its serving children; healthy peers remain running. This applies to prefill and decode groups as well as ordinary serving groups. All groups receive reset requests before waiting for replies, so prefill and decode can leave their shared NCCL group together. + +| Argument | Default | Description | +|---|---|---| +| `--rollout-health-check-first-wait` | `600` seconds | Wait after each resumption of background checks to allow model warmup and kernel compilation. Rollout-completion checks bypass this wait. | +| `--rollout-health-check-interval` | `600` seconds | Interval between background health checks. | +| `--rollout-health-check-timeout` | `600` seconds | Timeout for each health check, including the wait for the Ray health-check call. | + +For example, a large MoE model that needs more warmup time can use: ```bash ---use-fault-tolerance \ --rollout-health-check-first-wait 600 \ --rollout-health-check-interval 10 \ ---rollout-health-check-timeout 5 +--rollout-health-check-timeout 600 ``` -## Debug and Replay Path - -Fault tolerance is more useful when failures are reproducible. vime provides separate rollout-only and train-only debugging paths: +Increase the timeout if load spikes cause false failures. If engines repeatedly fail after weight updates, inspect the vLLM logs and recent rollout dumps. -- `--debug-rollout-only`: run rollout and save generated data without training; -- `--save-debug-rollout-data /path/to/rollout_{rollout_id}.pt`: save rollout samples for later inspection or replay; -- `--load-debug-rollout-data /path/to/rollout_{rollout_id}.pt`: replay saved rollout data and skip vLLM initialization; -- `--debug-train-only`: run training-side logic without rollout. +## Debugging Rollout and Training Separately -This lets you isolate whether a failure belongs to serving/rollout, data conversion, reward/verifier logic, or Megatron training. +Saving rollout data lets you hold training inputs fixed while investigating training failures: -## Recommended Production Pattern +- `--debug-rollout-only`: initialize only rollout, without training. Combine it with the save option to inspect generation and reward results. +- `--save-debug-rollout-data /path/to/rollout_{rollout_id}.pt`: save samples from each rollout. +- `--load-debug-rollout-data /path/to/rollout_{rollout_id}.pt`: train on saved samples and skip vLLM initialization. +- `--debug-train-only`: initialize only training, without starting vLLM. -For long-running jobs: +For slow generation requests, use [Trace Viewer](../developer_guide/trace.md) to inspect time spent on generation, rewards, and model calls, then use [Profiling](../developer_guide/profiling.md) to locate bottlenecks. See [vLLM Config](vllm-config.md) for multi-model or PD-separated deployments. -1. Enable `--use-fault-tolerance`. -2. Save checkpoints regularly with `--save-interval`. -3. Save rollout debug dumps for new agentic or verifier-heavy workloads. -4. Use [Trace Viewer](../developer_guide/trace.md) to inspect long-tail samples and reward/model-call spans. -5. Use [Profiling](../developer_guide/profiling.md) to separate rollout bottlenecks from training bottlenecks. -6. Keep vLLM deployment explicit with [vLLM Config](vllm-config.md) for complex multi-model or PD topologies. +## Recovery Scope and Limitations -## What to Watch +Without Straw or rollout debug dumps, you can still retain the serving cluster, but you cannot replay training batches after the last checkpoint. -- If startup health checks fail on large MoE models, increase `--rollout-health-check-first-wait`. -- If transient load spikes cause false positives, increase `--rollout-health-check-timeout`. -- If a server repeatedly restarts after weight sync, inspect the vLLM logs and the latest rollout debug dump. -- If the trainer fails rather than rollout, resume from checkpoint and use debug replay to isolate whether the saved rollout batch is valid. +Recreating `RolloutManager` supports the built-in data sources. Custom sources need compatible `state_dict` / `load_state_dict` methods and queue controllers that survive independently of the manager. Data-source constructors and rollout hook signatures are unchanged. -## Related Docs +If `ServingCluster` or the entire Ray cluster is lost, you must start serving again and recover from a checkpoint. Cluster preemption and node loss still require coordination between your scheduler and checkpoint recovery. -- [Debugging](../developer_guide/debug.md) -- [Trace Viewer](../developer_guide/trace.md) -- [Profiling](../developer_guide/profiling.md) -- [CI](../developer_guide/ci.md) +See [Debugging](../developer_guide/debug.md) for more debugging options and [CI](../developer_guide/ci.md) for recovery-test coverage. diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md index 25794b260..4cc02944b 100644 --- a/docs/en/advanced/straw.md +++ b/docs/en/advanced/straw.md @@ -62,9 +62,12 @@ straw without the package fails at startup with the installation command. both the groups and its updated cursor to the queue. 2. Workers generate and score samples. Returned partial groups are persisted for continuation; completed, accepted groups become available for training. -3. The batch builder applies reward/conversion hooks and divides selected - samples among training ranks. After training finishes, vime acknowledges - consumption so storage can be reclaimed when no other references need it. +3. The batch builder applies global reward/conversion hooks and accepts the + result as a queue control task. It writes the converted batch once; training + ranks receive indices and read their samples directly from that shared batch. + After training finishes, vime advances the consumer cursor and drops the + unfinished-batch reference. The model checkpoint separately controls how + long recovery retains the batch. Rollout and training data share the same storage pool. Large R3 and SC tensors can be reused across these stages without duplicating their bytes. Queue tasks @@ -86,11 +89,18 @@ for custom rollout functions and queue readers. ## Packed storage and supported data -Each writer appends multiple samples and tensors to pack files. There is no -file per sample or tensor, reducing small-file metadata overhead on shared -storage. Immutable tensor references let rollouts, training batches and -checkpoints share data; updates write new records while retained references -continue to identify their original contents. +Packing is enabled whenever Straw transport is selected. Each writer appends +multiple samples and tensors to the same file (a pack), reducing small-file +metadata overhead on shared storage. vime uses Straw's default target size, +currently **1 GiB**. Use `--rollout-queue-segment-mib` to override it in MiB. + +The target size controls file rotation; readers can access each publication +without waiting for the pack to fill. A publication larger than the target +stays intact, and explicit sealing may leave smaller packs. + +Immutable tensor references let rollouts, training batches, and checkpoints +share data. Updates write new records while retained references continue to +identify their original contents. The sample codec supports nested lists, tuples and dictionaries; scalar and byte values; NumPy arrays; PyTorch tensors; PIL images; and Sample fields, @@ -106,7 +116,7 @@ and ordinary fields still occupy manager memory. | Option | Default | Purpose | |---|---|---| -| `--rollout-queue-segment-mib` | `256` | Pack rotation target in MiB | +| `--rollout-queue-segment-mib` | Unset; uses Straw's default (currently `1024`) | Override the target pack rotation size in MiB; explicit values must be positive. | | `--rollout-io-concurrency` | `4` | Bound concurrent serialization and filesystem I/O submissions | | `--rollout-queue-lease-seconds` | `300` | Worker lease duration; active readers renew it | @@ -137,9 +147,14 @@ inputs, ready groups, and training progress. Payloads already in straw are referenced rather than copied into each checkpoint. Retaining a checkpoint keeps its referenced data available. -Stop the entire previous job before restarting, including remote workers. -The save-directory lock rejects concurrent coordinators, but does not stop -orphaned readers. There is no automatic coordinator failover. +This section covers restarting with a new serving cluster. Stop the entire +previous job first, including remote workers. The save-directory lock rejects +concurrent coordinators, but does not stop orphaned readers. There is no automatic +coordinator failover. + +If only Megatron training failed and Ray and serving are still running, follow +the [fault-tolerance guide](fault-tolerance.md) to resubmit training while retaining +the serving cluster and queue controller. ### Resume or select a step diff --git a/docs/en/developer_guide/ci.md b/docs/en/developer_guide/ci.md index f1ae1c20e..dec3a5786 100644 --- a/docs/en/developer_guide/ci.md +++ b/docs/en/developer_guide/ci.md @@ -45,6 +45,31 @@ that policy weights change. The matrix covers NCCL and full disk sync with `test_pipeline_rl.py` checks the schedule on CPU. These tests do not measure learning quality or throughput gains. +### Manual Megatron Restart + +`test_qwen2.5_0.5B_training_recovery.py` uses 4 GPUs and two successive training jobs on the same Ray cluster. The first uses TP=1 and deliberately triggers a real CUDA OOM. After verifying that serving still responds when the job exits, it resubmits training with TP=2, which also changes the DP size. + +The test checks that healthy vLLM processes, routers, and GPU placements are reused; replayed batch contents match; and training scheduler progress, finite nonzero gradients, and the final checkpoint are correct. The fixed test list includes: + +| Data storage | RolloutManager state | Checks | +|---|---|---| +| Straw with online GC | Remains alive | Reconnect trainers and replay completed training batches that were not checkpointed. | +| Straw with online GC | Killed after failure | Reconnect a new manager to the original serving cluster and replay the same batches. | +| Straw with a model/optimizer checkpoint and Megatron YAML configuration | Killed during training | Restore from the checkpoint and check configuration and recovery state. | +| Rollout debug files | Killed after failure | Restore data from debug files and reconnect a new manager to the original serving cluster. | +| Straw with disk-delta weight synchronization | Killed after failure | Publish restored weights as a new full baseline, then continue delta updates. | +| Straw with PD/NIXL serving | Killed after failure | Wedge the prefill actor, replace it within the reset timeout, and retain the healthy decode actor. | + +`test_qwen3_30B_A3B_training_recovery.py` uses 8 GPUs for the same OOM/checkpoint/manager-loss workflow with a MoE model, R3, and stateless Adam. It omits optimizer tensors while checking scheduler progress, compares persisted routing bytes across the TP/DP change, and completes training after recovery. The dense cases cover ordinary Adam with optimizer checkpoints. + +Internal serving health checks are enabled with or without the compatibility flag `--use-fault-tolerance`. CPU coverage includes configuration and checkpoint boundaries in `test_training_recovery.py`, lost disk-update replies in `test_disk_delta_recovery.py`, and real Ray manager SIGKILL or conversion-reply loss in `test_rollout_manager_recovery.py`. + +### Removing Failed Engines at Rollout Completion + +`test_qwen2.5_0.5B_rollout_health.py` stops a real vLLM HTTP server just before rollout completes, leaving its router registration intact. Two four-GPU cases either retain or kill the corresponding Ray actor. They check bounded rollout completion, deregistration before training, engine recovery at weight update, and a final training checkpoint. + +Both cases omit `--use-fault-tolerance` and set the background interval and initial wait to 600 seconds. This verifies that rollout-completion checks run immediately without waiting for background checks. + ## Registering tests - Add always-on CPU tests to the appropriate command in diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index 39d66b25d..bd4a8b66b 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -506,10 +506,12 @@ publish the writes on a non-POSIX shared filesystem — e.g. upload pending writ backing object store — where another host cannot see the files without an explicit sync. The hook is called on every rank and must gate itself (e.g. once per container). -The post-write hook must make the completed version directory visible before it -returns. Host-local full-checkpoint copies then use that published directory as -their source. See [Delta Weight Sync](../advanced/delta-weight-sync.md) for the -delta mechanism. +The read-side counterpart runs inside the inference engine, on every host it spans, and is +therefore a vLLM server argument rather than a vime hook: pass +`--vllm-custom-pull-weights-pre-read-hook ` with signature +`hook(source_dir: str, target_version: int)` — called before `pull_weights` reads the +published weights (e.g. refresh the mount's view). See +[Delta Weight Sync](../advanced/delta-weight-sync.md) for the full mechanism. ## Testing Custom Function Paths diff --git a/docs/en/get_started/quick_start.md b/docs/en/get_started/quick_start.md index 8d6cf6b52..5cc23c4bc 100644 --- a/docs/en/get_started/quick_start.md +++ b/docs/en/get_started/quick_start.md @@ -46,7 +46,7 @@ docker run --rm --gpus all --ipc=host --shm-size=16g \ ### Install vime -vime is already installed in the docker image. To update to the latest verison, please execute the following command: +vime is already installed in the docker image. To update to the latest version, please execute the following command: ```bash # Path can be adjusted according to actual situation @@ -97,6 +97,7 @@ PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ ``` For larger models, you can use `torchrun` to start the conversion script to convert with multi-gpus or even multi-nodes. +Note: When converting the kimi-k2 model weights, you need to open config.json in the model path and change "model_type": "kimi_k2" to "model_type": "deepseek_v3". ### Convert from Megatron Format to Hugging Face Format @@ -297,7 +298,7 @@ OPTIMIZER_ARGS=( ### VLLM_ARGS: vLLM Service Parameters This part of parameters is used to configure the vLLM inference service. -- `--rollout-num-gpus-per-engine`: Total worker GPUs used by one rollout engine. It equals vLLM's `tensor_parallel_size` only when data and pipeline parallelism are both 1. +- `--rollout-num-gpus-per-engine`: Total worker GPUs used by one rollout engine. It equals vLLM's `tensor_parallel_size` only when data, pipeline, and prefill context parallelism are all 1. - Other vLLM parameters can be passed to vime by adding the `--vllm-` prefix, and vime will automatically forward them to vLLM. For example, to set vLLM's `--uvicorn-log-level info` parameter, use `--vllm-uvicorn-log-level info`. > ⚠️ **Note**: @@ -395,6 +396,34 @@ That is, take out the first `num_samples` prompts corresponding to `num_samples > 💡 **Tip**: > The `sample.metadata` of each partial rollout sample stores the rollout id of the first generation, which can be used for data filtering. +### bf16 Training fp8 Inference + +vime directly supports bf16 training and fp8 inference. For Qwen3-4B model, you only need to download the following model: + +```bash +hf download Qwen/Qwen3-4B-FP8 --local-dir /root/Qwen3-4B-FP8 +``` + +And replace `--hf-checkpoint` with: + +```bash + # Used to load tokenizer and other information, actually won't use model weight parameters from hf path + --hf-checkpoint /root/Qwen3-4B-FP8 + + # The megatron checkpoint still needs to be the dist weights converted from bf16 huggingface at the beginning, not modified because of FP8 rollout. + --ref-load /root/Qwen3-4B_torch_dist +``` + +This will trigger fp8 inference. Currently, we will directly cast bf16 weights to fp8, and we will gradually add quantization schemes with less impact on accuracy in the future. + +For long-context rollout, you can also enable FP8 KV cache in vLLM to increase effective KV cache capacity: + +```bash +--vllm-kv-cache-dtype fp8_e4m3 +``` + +⚠️ The training megatron checkpoint still needs to be the one converted from bf16 huggingface at the beginning. + ## Multiturn Adaptation The vime framework is highly extensible and supports complex Agent scenarios (such as multi-turn interaction and tool calling). Its core mechanism is to rewrite the default data generation (Rollout) and reward calculation (Reward) logic through custom functions. diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index 86fe0c90f..f97722ace 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -19,7 +19,7 @@ There are four main parameters for cluster resource allocation: - `--actor-num-nodes`: The number of nodes required for RL actor training. - `--actor-num-gpus-per-node`: The number of GPUs per node for RL actor training. - `--rollout-num-gpus`: The total number of GPUs required for rollout (inference). Set it to `0` to still parse vLLM arguments and launch the router without launching local vLLM servers. - - `--rollout-num-gpus-per-engine`: The total worker GPU count for one inference engine. It equals vLLM's `tensor_parallel_size` only when data and pipeline parallelism are both 1. For example, if one model is served across 2 nodes and 16 GPUs, this value should be 16. + - `--rollout-num-gpus-per-engine`: The total worker GPU count for one inference engine. It equals vLLM's `tensor_parallel_size` only when data, pipeline, and prefill context parallelism are all 1. For example, if one model is served across 2 nodes and 16 GPUs, this value should be 16. With the default configuration, we use these parameters to allocate `actor_num_nodes * actor_num_gpus_per_node` GPUs for training and `rollout_num_gpus` GPUs for inference via Ray, thus achieving a separation of training and inference resources. @@ -283,9 +283,9 @@ Add the following options to an existing RL launch: **Combining with top-p replay:** Change `--rollout-top-p 1.0` above to, for example, `--rollout-top-p 0.9`. SC automatically sums over the complete replay support and ignores `--score-centering-top-k`. Rollout returns all support IDs and their post-truncation, normalized sampler logprobs. The trainer normalizes on the same support and computes the correction `sum(stop_gradient(q * weight) * log p)`. This uses no tail approximation and excludes tokens outside the support. Full trainer logits cannot reconstruct the sampler probabilities, so the original probabilities must still be stored. Payload size varies with the support and can greatly exceed fixed top-k heads when top-p approaches one. Exactness is relative to the recorded replay support, including replay's existing rule for retaining sampled boundary tokens. -**vLLM support:** For `top_p=1`, Vime requests `k+1` native vLLM top logprobs and retains the highest-probability `k`. For `top_p<1`, it requests all logprobs, intersects them with vLLM's sampling mask, and normalizes the recorded support. Custom generators should call `score_centering_request` from `vime.utils.score_centering` and pass equivalent metadata to `Sample.append_response_tokens`. +**vLLM support:** For `top_p=1`, Vime requests `k+1` native vLLM top logprobs and retains the highest-probability `k`. For `top_p<1`, it requests normalized logprobs aligned with vLLM's sampling mask. Custom generators should call `score_centering_request` from `vime.utils.score_centering` and pass equivalent metadata to `Sample.append_response_tokens`. -Top-p probabilities stay on CPU with replay IDs/offsets and support partial rollouts, masked tool tokens, DP, TP, and both CP layouts. Exact top-p SC has a larger response payload because vLLM currently returns the full logprob vector before Vime selects the replay support. +Top-p probabilities stay on CPU with replay IDs/offsets and support partial rollouts, masked tool tokens, DP, TP, and both CP layouts. Payload size follows the recorded support rather than a full-vocabulary response. Sampler heads survive partial-rollout continuation, masked tool tokens, DP partitioning, microbatch selection, TP and both CP layouts. With the R3 spill hook, they share its file lifetime. Logged metrics include `sc_correction`, `sc_sampler_head_mass`, `sc_train_head_mass` and `sc_importance_weight`. @@ -404,33 +404,21 @@ To enable distributed fully async rollout with straw, add: --rollout-data-dir /shared/run/rollout_data ``` -All nodes must mount the directory at the same absolute path and install `straw-queue>=0.1.2`. - -With straw transport, `--use-rollout-routing-replay` stores completed samples' R3 routes in straw with their owning sample group. SC tensors are also stored in the same publication when `--use-score-centering` is enabled, including SC without R3. The default object-store transport keeps these tensors in memory unless the existing disk-spill hook is configured; that hook remains available for object-store rollouts. User sample hooks run first; R3, SC and sample metadata are published together into shared pack files. Later queue publications reuse their references. Large group bundles are split by the native record-count limit. Aborted prefixes are persisted by the queue continuation path. `--rollout-queue-online-gc` is a separate opt-in: after training acknowledges completion, straw can reclaim sealed packs whose owners have all released them. It is disabled by default. - -One job-owned, non-restarting Ray actor coordinates task leases and a serialized dataset producer. Dataset cursor advancement and task submission are committed together. Workers receive small task references and read prompt groups directly from shared storage. This replaces the former in-memory distributed index allocator; no per-reader index ranges are abandoned on failure. Seeded shuffle and sample/group identities remain those of the existing data source. - -`get_samples(n)` and `add_samples(groups)` retain their interfaces. Custom producers can pass `source.reader_config("unique_reader_id")` to a remote process and call `config.open()` there. IDs must be unique; `owner` is reserved. Returned partial groups are persisted and become available to every reader; there is no reader-local sample buffer. Readers renew leases; a stopped reader returns unfinished tasks, while lost workers' tasks become available after lease expiry. Newly generated retries have new attempts. Recovery uses the normal `--load`/`--save` checkpoint flow, with optional `--ckpt-step`; the selected checkpoint restores the queue branch and dataset cursor, so no separate queue-resume flag is needed. - -Fully async starts one generation process per Ray node with CPU resources. Each reserves one CPU and receives a share of the configured concurrency in complete prompt groups. Faster workers may fill a global batch without waiting for slower ones. Local generation queues and collector prefetch remain bounded. Complete groups pass through the existing dynamic filter before publication; rejected groups contribute filter metrics. Distributed fully async still rejects `--rollout-all-samples-process-path`. The synchronous all-samples hook retains its existing Samples and calling order. - -The default logical result is a complete filtered group; physical segment boundaries do not define training groups. Workers encode Samples explicitly and store large tensors as typed dependencies. The format uses immutable committed extents in append-only `.pack` files, relative references and checksums, with no pickle payload protocol. Unsupported custom field types fail explicitly. See the [straw architecture, deployment and recovery guide](../advanced/straw.md). - -Built-in producers return a collection manifest. Legacy custom rollouts can still return Sample lists; Manager publishes and accepts one compatibility collection. New producers may return an accepted `RawRolloutRef`; Manager validates its receipt and passes it to the same BatchBuilder without rewriting its payload. BatchBuilder preserves reward/conversion hooks and DP scheduling, records the selection plan, and publishes all rank shards before exposing `TrainBatchRef`s. All rank references identify the same batch and plan. - -The vime adapter currently binds the shared root at the same absolute path on all nodes and checks visibility in both directions. The underlying store's references are relocatable. r3/sc dependencies are adopted into queue storage before references escape the producer; old rollout spill cleanup cannot remove them. Queue files are retained by default. Offline inspection/cleanup requires stopped writers, coordinator and readers; ordinary reads or batch-ready events never delete data. Debug archives can reuse immutable Straw records; `.pt` exports remain available. Evaluation follows the existing path. - -Straw checkpoints commit model, optimizer/RNG, queue, builder and dataset-cursor state together. `--load` restores the latest valid joint checkpoint by default; `--ckpt-step` selects a step in the active branch history. Restore creates an isolated branch from that snapshot, leaving the source run unchanged and excluding later-produced samples. Repeated rollback follows the active branch and cannot cross a fork point. If a model checkpoint predates a straw queue snapshot, recovery starts with an empty queue and restores the saved dataset cursor when available; a damaged or incomplete joint snapshot fails rather than silently falling back. This is durable queue/data state; it does not restore GPU KV cache or generation RNG. - -R3 training reads only the CP/TP rows assigned to the current rank. Batched continuation publication, loading and state persistence reuse authenticated indices within a bounded read session; receipt lookups use batch RPCs. These optimizations preserve checksums, WAL durability and GC ownership. Read sessions do not replace ownership pins. - -Distributed fully async producers stop admitting new groups while weights synchronize, then resume if another training rollout remains. In-flight results still finish and persist. - -Joint checkpoint restore defers background GC until the saved training consumer state and its restored storage ownership are durable. Unavailable checkpoint data fails restoration before GC starts. - -Restored reader buffers keep an explicit storage reference independent of filter decisions. A later data-source checkpoint moves that reference only after all active and not-yet-started consumers are captured in a complete, retained, durable snapshot. Retiring an older checkpoint can then release its obsolete tensors without losing resumable prefixes. A failed save keeps the previous reference; checkpoint retirement remains the caller's responsibility. - -The manager still materializes the selected batch for conversion, so manager memory and shared-storage bandwidth remain limits. `--rollout-io-concurrency` bounds publication I/O submissions. straw's storage, journal and GC implementation is Rust; vime retains Python sample conversion and training integration. Measure throughput for the intended model, concurrency and shared filesystem before changing deployment defaults. +All nodes must mount the directory at the same absolute path and have +`straw-queue` installed. The standard vime installation includes it; for an +existing environment, run `pip install 'straw-queue>=0.1.2'`. Configure the JuiceFS +storage profile and deployment declaration as described in the +[straw guide](../advanced/straw.md#enable-it). Selecting straw alone uses the +synchronous rollout entrypoint. For a fresh run, omitting `--rollout-data-dir` +uses `/rollout_data` when `--save` is set. + +With straw, `--use-rollout-routing-replay` and `--use-score-centering` persist +R3 and SC tensors with their samples, including partial continuations. +`--rollout-queue-online-gc` optionally reclaims unused storage; it is disabled +by default. Recovery uses `--load`, `--save` and optional `--ckpt-step`. +See the [straw guide](../advanced/straw.md) for scheduling, +checkpoint recovery and debug replay, and [customization](customization.md) +for custom rollout functions. ### Preserve KV across weight updates (PipelineRL) @@ -482,7 +470,7 @@ vime incorporates almost all vLLM parameters by forwarding vLLM's `EngineArgs` C Some parameters related to vime's resource scheduling are configured by vime itself, for example: - - `--tensor-parallel-size` in vime is set using `--rollout-num-gpus-per-engine`. + - `--tensor-parallel-size` in vime is derived from `--rollout-num-gpus-per-engine` and the configured DP, PP, and PCP sizes. - `--model` in vime is set using `--hf-checkpoint`. The way vLLM parameters are integrated into vime can be found in [vime/backends/vllm_utils/arguments.py](https://github.com/vllm-project/vime/blob/main/vime/backends/vllm_utils/arguments.py). diff --git a/docs/zh/advanced/delta-weight-sync.md b/docs/zh/advanced/delta-weight-sync.md index 8ecf6f83d..c10261efd 100644 --- a/docs/zh/advanced/delta-weight-sync.md +++ b/docs/zh/advanced/delta-weight-sync.md @@ -2,7 +2,7 @@ Delta 权重同步只发送两次同步之间发生变化的字节,而不是每次都写一份完整 checkpoint,以此让非 colocate 的 rollout engine 保持最新。它面向大模型、跨集群或跨数据中心的训推解耦场景——这种场景下每次都写整份 actor 权重是主要开销。 -它**只支持 disk transport**。训练端把每次同步发布为一份 canonical HF checkpoint 目录;engine 的 `/pull_weights` 端点(随 vime 的 vllm patch 提供)把 apply 扇出到 **engine 覆盖的每一个 host** 并校验,随后 engine 通过**原生**的 `update_weights_from_disk` 端点 reload 打过补丁的本地 checkpoint。vime 对每个 engine 只与一个端点通信,所以多节点 serving 和外部 rollout engine 在 vime 侧都不需要任何额外支持。 +它**只支持 disk transport**。训练端把每次同步发布为一份 canonical HF checkpoint 目录;engine 的 `pull_weights` worker RPC(随 vime 的 vllm patch 提供)把 apply 扇出到 **engine 覆盖的每一个 host** 并校验,随后 engine 通过 `update_weights_from_disk` 适配接口调用原生 reload RPC,加载打过补丁的本地 checkpoint。vime 对每个 engine 只与一个端点通信,所以多节点 serving 和外部 rollout engine 在 vime 侧都不需要任何额外支持。 ## 配置 @@ -18,7 +18,7 @@ Delta 权重同步只发送两次同步之间发生变化的字节,而不是 | 参数 | 作用 | |---|---| | `--update-weight-disk-dir` | 训练端发布 delta、rollout host 读取 delta 的共享文件系统目录。 | -| `--update-weight-local-checkpoint-dir` | host 本地(如 NVMe)的完整 HF checkpoint,由 `/pull_weights` 保持同步——delta 原地 apply,发布的完整 checkpoint 则整份替换。每个 host 在第一次 `/pull_weights` 时由 engine 的 model path seed。 | +| `--update-weight-local-checkpoint-dir` | host 本地(如 NVMe)的完整 HF checkpoint,由 `pull_weights` 保持同步。首次完整发布建立基准,后续 delta 原地 apply,新的完整发布则整份替换。 | | `--update-weight-delta-encoding` | 磁盘上的 delta 编码:`xor`(默认)或 `overwrite`。 | | `--update-weight-delta-checksum` | 逐 tensor 完整性 checksum:`xxh3-128`(默认)、`blake3` 或 `adler32`。 | @@ -26,17 +26,17 @@ delta 始终用 zstd(level 1)压缩;profiling 显示对这类数据它在 ## 工作原理 -1. **Seed。** 第一次同步时,训练端为每个参数捕获一份 CPU snapshot——从 `--hf-checkpoint` seed,而这正是每个 rollout host 物化本地 checkpoint 的来源。此次不发布任何东西;这份 snapshot 就是下一次同步 diff 的基准。训练端同时发出 `target_version=0` 的 `/pull_weights`,让每个 host 现在就物化本地 base,与 snapshot 捕获重叠进行。 +1. **Seed。** 首次同步,以及训练端恢复或替换 engine 后的首次同步,会从训练端当前权重发布完整 HF checkpoint,并从同一批 tensor 捕获 CPU snapshot。所有 engine 加载成功后才恢复生成。版本号高于目录中所有已有版本,包括已经写盘但丢失应答的更新;重启不会删除旧版本流。 2. **Publish。** 之后每次同步,训练端把每个 gather 出的 HF tensor 与 snapshot 做 diff,编码、压缩,写到 `--update-weight-disk-dir` 下的新版本目录 `weight_v{N:06d}/`。该目录是一份 canonical HF checkpoint——`model-NNNNN.safetensors` 文件装着压缩后的 diff tensor,外加 `model.safetensors.index.json`(tensor 名 → 文件)承载 apply 元数据——所以这个产物是可移植的,不绑定训练端的并行 layout。随后 snapshot 推进到新值,供下次 diff。 -3. **Pull。** 训练端对每个 engine 调用 `/pull_weights`。engine 内部把请求广播到每个节点的每个 rank;每个 host 把新版本的 delta 原地 apply 进它的本地 checkpoint(host 级文件锁把同 host 的多个 rank 合并成一次 apply)。apply 在 tensor 之间并行,并逐 tensor 校验(见"完整性");只有**每一个 host** 都持有校验通过的 checkpoint,该调用才报告成功。 +3. **Pull。** 训练端对每个 engine 调用 `pull_weights`。engine 内部把请求广播到每个节点的每个 rank;每个 host 把新版本的 delta 原地 apply 进它的本地 checkpoint(host 级文件锁把同 host 的多个 rank 合并成一次 apply)。apply 在 tensor 之间并行,并逐 tensor 校验(见"完整性");只有**每一个 host** 都持有校验通过的 checkpoint,该调用才报告成功。 - `/pull_weights` 并不绑定 delta:每个发布的版本是自描述的。若某个版本是一份普通的完整 HF + `pull_weights` 并不绑定 delta:每个发布的版本是自描述的。若某个版本是一份普通的完整 HF checkpoint(index 中没有 delta 元数据),pull 就直接整份拷贝——同时重置链条,因此晚加入的 新 host 从最近的完整版本 seed,而不必回放全部 delta,旧的 delta 也可以被清理。vime 的 full 模式 disk 同步在设置了 `--update-weight-local-checkpoint-dir` 时正是走这条路径。 4. **Reload。** engine 通过原生 `update_weights_from_disk` 路径 reload 打过补丁的本地 checkpoint——权重加载代码从不接触 delta 格式。 -由于 snapshot 是从 `--hf-checkpoint`(engine 真正的 base)seed,而不是从当前 GPU 权重 seed,即使 Megatron→HF 往返不是逐字节相等(例如 embedding / LM head 中被裁掉的 vocab padding 行),该方案对任意模型也都正确。 +snapshot 与 engine 都以同一份已发布的 HF 字节为基准,即使训练端恢复的是比 `--hf-checkpoint` 更新的模型也成立。因此,Megatron→HF 转换中的差异(例如 embedding / LM head 被裁掉的 vocab padding 行)不会破坏下一次 delta。 ## 编码 @@ -47,7 +47,7 @@ delta 始终用 zstd(level 1)压缩;profiling 显示对这类数据它在 ## 完整性 -训练端把每个 tensor 新状态的逐 tensor checksum 存进版本里。apply 之后每个 host 重新计算 checksum,**任何不匹配都会 raise**——失败会通过 `/pull_weights` 的响应传回,所以损坏的 delta 或错误的 base 会直接报错失败,而不会把坏权重提供出去。apply 还拒绝乱序执行:一个版本只会在它声明的 base 版本之上 apply。 +训练端把每个 tensor 新状态的逐 tensor checksum 存进版本里。apply 之后每个 host 重新计算 checksum,**任何不匹配都会 raise**——失败会通过 `pull_weights` 的响应传回,所以损坏的 delta 或错误的 base 会直接报错失败,而不会把坏权重提供出去。apply 还拒绝乱序执行:一个版本只会在它声明的 base 版本之上 apply。 `--update-weight-delta-checksum` 选择算法。checksum 不是 apply 的瓶颈(apply 受解压 + XOR 限制),所以这是一个 digest 属性的选择,而非速度选择:`xxh3-128`(默认)是最宽的快速非加密 digest;`blake3` 是加密 digest,用于不可信存储;`adler32` 用于与期望它的系统互操作。 @@ -56,4 +56,4 @@ delta 始终用 zstd(level 1)压缩;profiling 显示对这类数据它在 在 POSIX 共享文件系统(NFS、Lustre……)上不需要额外步骤。对于需要显式 commit/refresh 才能让写入跨 host 可见的对象存储挂载,可以提供两个可选 hook(通过 import 路径加载——vime 和 vllm 里都不存在任何厂商特定代码): - `--custom-update-weight-post-write-path`(vime,训练端):在一个版本的文件写完之后、通知 engine 读取之前调用(例如把待写入数据上传到底层对象存储)。签名:`hook(args, version_dir, rollout_engines)`。 -- `--vllm-custom-pull-weights-pre-read-hook`(vllm server 参数,engine 端):在每个 host 上、`/pull_weights` 读取 delta 目录之前于 engine 内部调用(例如刷新挂载视图)。签名:`hook(delta_dir, target_version)`。 +- `--vllm-custom-pull-weights-pre-read-hook`(vllm server 参数,engine 端):在每个 host 上、`pull_weights` 读取 delta 目录之前于 engine 内部调用(例如刷新挂载视图)。签名:`hook(delta_dir, target_version)`。 diff --git a/docs/zh/advanced/fault-tolerance.md b/docs/zh/advanced/fault-tolerance.md index b22f322cd..bc14322c2 100644 --- a/docs/zh/advanced/fault-tolerance.md +++ b/docs/zh/advanced/fault-tolerance.md @@ -1,76 +1,131 @@ # 容灾 -长时间 RL 任务的失败模式和短 SFT 任务很不一样:rollout engine 可能 hang,long-tail sample 可能拖住整个 round,serving state 也必须在权重更新后保持一致。vime 的容灾能力主要聚焦在 rollout 侧:让 rollout engine 可观测、可重启、可调试,同时不改变 training / rollout / Data Buffer 主路径。 +vime 会检查 vLLM 推理引擎是否正常工作,移除失效的引擎,并在下次更新权重前重启它们。使用 Straw 传输或保存 rollout 调试数据时,还支持在 Megatron 训练失败后保留推理集群,调整训练配置,再继续训练。 -开启容灾: +这些能力默认对 vime 启动的推理集群生效,不需要传入 `--use-fault-tolerance`。该参数保留用于兼容旧命令;使用外部推理集群时,仍按原来的策略处理。 + +## Megatron 失败后如何继续训练 + +这个恢复流程要求 **Ray 集群仍在运行**。训练入口进程或 `RolloutManager` 退出,不会销毁 vLLM 引擎和路由器;重新提交训练任务后,vime 会找到原来的推理集群并继续使用。整个流程不需要修改 vLLM,也不会自动重试训练任务。 + +### 首次启动时保留恢复数据 + +以下两种方式任选其一。选择后,vime 会自动保存恢复所需的数据,不依赖 `--use-fault-tolerance`。 + +使用 Straw 保存 rollout 和训练数据: + +```bash +--rollout-data-transport straw --rollout-data-dir /shared/my-run/queue +``` + +或者保存 rollout 调试数据,路径中必须包含 `{rollout_id}`: ```bash ---use-fault-tolerance +--save-debug-rollout-data '/shared/my-run/rollout_{rollout_id}.pt' ``` -## 当前覆盖范围 +通过 `--save` 和 `--save-interval` 定期保存模型 checkpoint。保存间隔越长,失败后需要重新训练的批次越多,也需要保留更多恢复数据。 + +恢复模式要求使用 `torch_dist` checkpoint,保存随机数状态,并为有状态的优化器保存优化器状态。vime 会自动启用支持跨并行配置重新分片的优化器保存格式。不能设置 `--no-save-rng`。 + +使用 `--use-stateless-adam --no-save-optim` 时,Adam 不在 optimizer step 之间保留动量,因此恢复时可以省略优化器张量。Vime 会在每个模型 checkpoint 目录中保存 `opt_param_scheduler.pt`,使学习率和 weight decay 的 scheduler 进度与模型一起恢复;缺少此文件时会拒绝恢复。FP32 主参数会从保存的模型精度重建,因此不保证重启后的更新逐比特一致。普通 Adam 仍必须保存优化器 checkpoint。 + +如果保存大 MoE 的优化器时显存不足,可以使用 `--distrib-optim-fully-reshardable-mem-efficient`。Megatron 会通过 Gloo 在 CPU 上聚合 checkpoint 张量,仍支持重新分片,但会增加主机内存和保存时间的开销。`--overlap-grad-reduce --ddp-bucket-size 40000000` 可以限制每次聚合的临时缓冲区大小;还需为完整的优化器状态预留主机内存。 + +### 失败后重新提交 + +例如,Megatron 因 OOM 退出后: + +1. 等待失败的训练任务结束。 +2. 调整 TP/CP/EP、microbatch 大小或每张卡的 token 上限。 +3. 向**同一个 Ray 集群**重新提交 `train.py`。 + +重新提交时,模型、rollout 配置、global batch size 和会话标识要保持一致。训推共置(colocate)时,训练进程需要放得进原先分配的 GPU 资源;训推分离时,可以重新分配训练侧资源,推理侧 GPU 不会移动。 + +保留的 worker 继续使用原配置。重新接入时会检查采样、过滤器、reward hook 和自定义参数;新增或删除参数也视为变更。只有明确支持的训练侧和运行控制参数可以调整。 -vime 当前提供 rollout-engine fault tolerance: +两次提交之间不要停止 Ray、重建推理服务容器,或执行 `pkill vllm` 等清理命令。`vime.utils.external_utils.command_utils.execute_train` 会保留正在运行的 Ray head 和 vLLM;如果使用的 shell 脚本每次启动都会清理进程,重启时需要跳过这一步。训练正常结束后,vime 会释放保留的会话及其资源。 -- 对 vLLM rollout server 做 health check; -- heartbeat timeout 后重启 rollout server; -- 重启后正确更新参数; -- 保存 debug rollout dump,用于不重新跑 rollout 的情况下 replay 训练侧问题; -- trace/profiling hook,用于检查 long-tail rollout 行为。 +### 从哪一步恢复 -集群级抢占、trainer rank failure 和 full-job resume 仍应由集群调度器、Ray restart policy 和 vime checkpointing 共同处理。实际生产任务中,建议把 rollout fault tolerance、定期 checkpoint 和 debug dump 组合使用。 +新训练进程会加载最近一次成功保存的模型、scheduler、随机数状态,以及有状态优化器的状态,并重新训练该 checkpoint 之后的批次。已经训练完成、但模型更新尚未保存的批次也会重放;如果还没有 checkpoint,则从最初的模型开始重放。 -## Rollout Health Checks +保留的数据包括生成结果、token 和 reward 后处理结果。修改训练并行配置后,vime 会重新分片这些数据,不会重新生成样本,也不会再次执行 reward 后处理。恢复数据会保留到对应的模型 checkpoint 提交成功,或训练正常结束。推理侧的权重版本号在重启后继续递增。 -rollout 过程中,vime 会定期向所有 vLLM server 发送 heartbeat 请求(`/health`)。如果 heartbeat timeout,异常 vLLM server 会被停止。当前 rollout round 完成后,vime 会重启 server,并在其继续服务后续 rollout 请求前更新到正确参数。 +Megatron 会在并行布局兼容时恢复随机数状态;TP/PP 改变时会重新初始化。因此,改变并行布局后可以继续训练,但不保证结果逐比特一致。 -主要参数: +## 如何找到原来的推理集群 -- `--rollout-health-check-first-wait`:第一次 rollout 前等待多久再开始 heartbeat。大 MoE 模型首次运行可能需要 kernel compilation。默认 `300` 秒。 -- `--rollout-health-check-interval`:heartbeat 间隔。默认 `10` 秒。 -- `--rollout-health-check-timeout`:单次 heartbeat timeout。默认 `5` 秒。 +vime 使用具名 Ray actor 管理推理集群。新任务根据会话名称找到该 actor,由它返回已有的路由器和引擎信息,不需要扫描路由器进程。 -示例: +可以用 `--rollout-session-id` 显式指定会话标识。未指定时,按以下顺序选择标识来源,再计算哈希得到稳定的名称: + +1. 使用 Straw 时,取存储目录的绝对路径和 `--rollout-queue-run-id`。 +2. 否则,取 `--save-debug-rollout-data` 路径模板的绝对路径。 +3. 未保存调试数据时,取 `--save` 目录的绝对路径。 +4. 以上都没有时,取模型和 rollout 配置。 + +同一任务的两次提交需要使用相同标识;独立任务应使用不同标识,避免接入同一个推理集群。 + +代码中有三个组件负责恢复: + +- `ServingCluster` 管理路由器、vLLM 引擎、GPU 资源、Straw 队列控制器和权重更新锁。它是具名的 detached Ray actor,不会随创建它的训练任务退出。 +- `RolloutManager` 负责生成、读取数据、转换样本和划分训练数据。它可以继续使用,也可以在退出后重建;重建不会销毁 `ServingCluster` 持有的资源。 +- `TrainingRecovery` 管理模型 checkpoint 边界,并保留该边界之后需要重放的批次。原始批次和转换结果是否已接收,由 Straw 接收日志中的回执决定;manager 或 RPC 在更新恢复日志前失败时,会根据回执补齐记录。转换结果只存一份,各 DP rank 获取自己的索引视图,改变并行配置只需重建视图。 + +如果 `RolloutManager` 还活着,它会暂停接收新的生成任务。如果它已经退出,新实例会接回 `ServingCluster`,恢复数据源进度,并由队列控制器阻止旧读取进程继续取任务。已经完成、但尚未交给训练的预取结果仍可使用。训练进程也登记在 `ServingCluster` 中,因此 manager 退出后仍能清理旧训练进程。 + +训练 driver 负责本次训练尝试的退出策略,也负责启动中途失败后的回滚。失败时,先限时等待 manager 暂停,再直接通知 serving 释放 trainer;无法暂停的 manager 会被终止,由下一次尝试重建。正常结束时分别清理 manager 和 serving,因此自定义 data source 的 `close()` 报错也不会跳过 serving 清理。清理失败会记录日志,保留原始训练异常。 + +`--rollout-cleanup-timeout` 默认 **60 秒**,限制一次 detach 或 dispose 的等待时间,与健康检查超时和预热等待独立。某个步骤失败后仍会尝试释放其余资源;无法正常退出的 actor 会被终止。 + +Checkpoint 选择会返回独立的配置与恢复计划。恢复边界通过不可变对象交接,每次训练尝试根据它和 serving 权重版本生成自己的配置副本;data source 和长驻 rollout worker 保留原来的配置。所有 custom function 仍使用原有的 args 接口。 + +## 推理引擎的健康检查与重启 + +rollout 过程中,vime 定期请求 vLLM 的 `/health_generate` 接口,检查引擎是否正常响应。rollout 结束时还会立即检查一次,**不受后台检查间隔和首次等待时间限制**。 + +同步 rollout 在发送中止生成和等待请求结束的控制命令前,会先从路由器中移除失效的服务。返回训练前,`ServingCluster` 会再次检查引擎,注销失效服务并清除对应的 Ray actor 引用,避免后续显存卸载或权重更新请求访问已经停止的引擎。HTTP 请求和 Ray 调用都有超时限制。 + +缺失的引擎在下一次更新权重前重启,随后加载训练侧的权重。后台检查和 rollout 收尾检查使用同一套故障处理流程;更新权重或调整显存占用时会暂停后台检查。 + +重新接入时,清理旧训练连接也有超时限制。无法应答的 engine 会被注销,并连同其推理子进程一起终止;健康的其他 engine 保持运行。该机制同样适用于 prefill 和 decode 分组。 + +重连时会先向所有 prefill/decode 组发起重置,再等待回复,避免共享 NCCL 通信组的两端因串行退出而互相等待。 + +| 参数 | 默认值 | 说明 | +|---|---|---| +| `--rollout-health-check-first-wait` | `600` 秒 | 每次恢复后台检查后,先等待这段时间,给模型预热和算子编译留出时间。rollout 收尾检查不受影响。 | +| `--rollout-health-check-interval` | `600` 秒 | 后台健康检查的间隔。 | +| `--rollout-health-check-timeout` | `600` 秒 | 单次健康检查的超时,也限制等待 Ray 健康检查调用的时间。 | + +例如,大 MoE 模型需要较长的预热时间时,可以设置: ```bash ---use-fault-tolerance \ --rollout-health-check-first-wait 600 \ --rollout-health-check-interval 10 \ ---rollout-health-check-timeout 5 +--rollout-health-check-timeout 600 ``` -## Debug 与 Replay 路径 - -容灾只有在问题可复现时才真正有用。vime 提供 rollout-only 和 train-only 分离调试路径: +如果负载高峰导致健康检查误判,可以增大超时。如果引擎在权重更新后反复失败,应检查 vLLM 日志和最近保存的 rollout 数据。 -- `--debug-rollout-only`:只跑 rollout 并保存生成数据,不训练; -- `--save-debug-rollout-data /path/to/rollout_{rollout_id}.pt`:保存 rollout samples,后续可以检查或 replay; -- `--load-debug-rollout-data /path/to/rollout_{rollout_id}.pt`:加载已保存 rollout data,并跳过 vLLM 初始化; -- `--debug-train-only`:只跑训练侧逻辑,不跑 rollout。 +## 分开调试推理和训练 -这可以帮助定位问题属于 serving/rollout、data conversion、reward/verifier 逻辑,还是 Megatron training。 +保存 rollout 数据后,可以固定训练输入,单独排查训练问题: -## 推荐生产模式 +- `--debug-rollout-only`:只初始化推理侧,不训练;可配合保存参数检查生成和打分结果。 +- `--save-debug-rollout-data /path/to/rollout_{rollout_id}.pt`:保存每轮 rollout 的样本。 +- `--load-debug-rollout-data /path/to/rollout_{rollout_id}.pt`:加载已保存的样本用于训练,跳过 vLLM 初始化。 +- `--debug-train-only`:只初始化训练侧,不启动 vLLM。 -对于长时间任务: +对于耗时较长的生成请求,可以用[请求追踪](../developer_guide/trace.md)查看生成、打分和模型调用各自的耗时,再用[性能分析](../developer_guide/profiling.md)定位瓶颈。多模型或 PD 分离部署的配置见 [vLLM 配置](vllm-config.md)。 -1. 开启 `--use-fault-tolerance`。 -2. 通过 `--save-interval` 定期保存 checkpoint。 -3. 对新的 agentic 或 verifier-heavy workload 保存 rollout debug dump。 -4. 使用 [Trace Viewer](../developer_guide/trace.md) 检查 long-tail samples 和 reward/model-call spans。 -5. 使用 [Profiling](../developer_guide/profiling.md) 区分 rollout bottleneck 和 training bottleneck。 -6. 对复杂 multi-model 或 PD topology,使用 [vLLM Config](vllm-config.md) 显式管理 vLLM 部署。 +## 恢复范围与限制 -## 需要关注的信号 +未使用 Straw、也未保存 rollout 调试数据时,仍可保留推理集群,但无法重放未保存到 checkpoint 的训练批次。 -- 如果大 MoE 模型启动阶段 health check 失败,增大 `--rollout-health-check-first-wait`。 -- 如果短暂负载高峰导致误判,增大 `--rollout-health-check-timeout`。 -- 如果某个 server 在 weight sync 后反复重启,检查 vLLM log 和最近的 rollout debug dump。 -- 如果失败发生在 trainer 而非 rollout,从 checkpoint 恢复,并用 debug replay 确认保存的 rollout batch 是否有效。 +`RolloutManager` 重建支持内置数据源。自定义数据源需要提供兼容的 `state_dict` / `load_state_dict`,并让自己的队列控制器独立于 manager 存活;数据源构造函数和 rollout hook 的签名不变。 -## 相关文档 +如果 `ServingCluster` 或整个 Ray 集群已经丢失,需要重新启动推理服务并从 checkpoint 恢复。集群抢占和节点丢失仍需要调度系统与 checkpoint 配合处理。 -- [Debugging](../developer_guide/debug.md) -- [Trace Viewer](../developer_guide/trace.md) -- [Profiling](../developer_guide/profiling.md) -- [CI](../developer_guide/ci.md) +更多调试方式见[调试指南](../developer_guide/debug.md),恢复测试的覆盖范围见[持续集成](../developer_guide/ci.md)。 diff --git a/docs/zh/advanced/straw.md b/docs/zh/advanced/straw.md index 6f3c005c2..75c3f62de 100644 --- a/docs/zh/advanced/straw.md +++ b/docs/zh/advanced/straw.md @@ -40,7 +40,7 @@ Fully async 在每个有 CPU 资源的 Ray 节点启动一个生成进程,分 1. Worker 请求 prompt group,数据源从 dataset 读取数据,将 group 和更新后的游标一起保存到队列。 2. Worker 生成和打分。归还的 partial group 持久化后等待续跑;已接收的完整 group 可供训练使用。 -3. Batch builder 执行 reward/conversion hook,将选中的 sample 分配给各训练 rank。训练结束后,vime 确认数据已消费;没有其他引用需要它们时即可回收存储。 +3. Batch builder 执行全局 reward/conversion hook,并将结果作为队列控制任务接收。转换后的 batch 只写一次,各训练 rank 按索引直接读取自己的 sample。训练结束后推进消费游标,移除未完成批次引用;恢复所需数据的保留时间由模型 checkpoint 边界单独决定。 Rollout 和训练数据共用存储池,R3、SC 等大张量可以跨阶段复用。队列通过可续租的 lease 将任务分配给 worker;worker 失联后,未完成任务在 lease 到期后可重新领取。 @@ -50,7 +50,11 @@ straw 不支持 `--buffer-filter-path`。`--buffer-sort-by-staleness` 适用于 ## 数据打包与支持类型 -每个 writer 向 pack 文件追加多条 sample 和 tensor,不会为每个 sample 或 tensor 单独创建文件,从而减少共享存储上的小文件元数据开销。不可变张量引用允许 rollout、训练 batch 和 checkpoint 共享数据;修改时写入新记录,保留的引用始终指向原有内容。 +选择 Straw 传输后,数据默认打包写入:每个写入进程将多个样本和张量追加到同一个文件(pack)中,减少共享存储上大量小文件的管理开销。vime 默认沿用 Straw 的包大小,当前为 **1 GiB**;可以通过 `--rollout-queue-segment-mib` 指定其他大小,单位为 MiB。 + +包大小控制何时切换到新文件,不影响数据何时可读:每次发布的数据都可以立即读取,无需等待包写满。单次发布超过目标大小时会完整保留,不会为了满足包大小而拆开;显式封存也可能产生小于目标大小的包。 + +已写入的张量通过不可变引用在 rollout、训练批次和 checkpoint 之间共享。修改数据时会写入新记录,已有引用仍指向原来的内容。 Sample codec 支持嵌套 list、tuple、dict、标量、字节数据、NumPy 数组、PyTorch 张量、PIL 图像和 Sample 字段,包括受支持的自定义字段。R3 routes、SC top-k 和 ragged top-p 数据以 typed tensor 保存。不支持的 Python 对象和循环引用会在发布时报错。记录和张量读取都有校验和检查。 @@ -58,7 +62,7 @@ R3 使用 `--use-rollout-routing-replay`,SC 使用 `--use-score-centering`。 | 参数 | 默认值 | 用途 | |---|---|---| -| `--rollout-queue-segment-mib` | `256` | Pack 轮转的目标大小,单位 MiB | +| `--rollout-queue-segment-mib` | 不设置,沿用 Straw 默认值(当前为 `1024`) | 切换到新包的目标大小,单位 MiB;显式设置时必须为正数。 | | `--rollout-io-concurrency` | `4` | 限制并发序列化与文件系统 I/O 提交 | | `--rollout-queue-lease-seconds` | `300` | Worker 租期,活跃 reader 会续租 | @@ -74,7 +78,9 @@ GC 失败会通过后续队列操作和关闭流程报告。容量耗尽时需 正常训练 checkpoint 同时保存模型和 rollout 状态,包括 dataset 游标、sample/group 编号、pending/partial 输入、ready group 和训练进度。已经保存在 straw 中的载荷通过引用保存,不会在每次 checkpoint 时重新复制。保留 checkpoint 会保留它依赖的数据。 -重启前需要停止整个原任务,包括远端 worker。Save 目录锁会拒绝并发 coordinator,但不会终止遗留 reader。目前没有 coordinator 自动故障转移。 +本节介绍推理集群也需要重新启动时的恢复方式。启动前需要停止整个原任务,包括远端生成进程;保存目录的锁会拒绝同时运行的队列控制器,但不会替你停止遗留的读取进程。目前不支持队列控制器自动故障转移。 + +如果只是 Megatron 训练失败,Ray 和推理集群仍在运行,应按[容灾文档](fault-tolerance.md)重新提交训练任务,保留原来的推理服务和队列控制器。 ### 续跑或选择 step diff --git a/docs/zh/developer_guide/ci.md b/docs/zh/developer_guide/ci.md index 83551c1d3..d6de7b243 100644 --- a/docs/zh/developer_guide/ci.md +++ b/docs/zh/developer_guide/ci.md @@ -42,6 +42,31 @@ job。验证 Dockerfile 或 vLLM patch 修改时,通过 `VIME_CI_IMAGE` 指定 权重同步,以及 NCCL 下 interval `2` 的周期性刷新。`test_pipeline_rl.py` 在 CPU 上检查刷新周期。这些测试不衡量学习效果或吞吐增益。 +### Megatron 手动重启 + +`test_qwen2.5_0.5B_training_recovery.py` 使用 4 张 GPU,在同一个 Ray 集群中先后运行两次训练任务。第一次使用 TP=1,故意触发真实的 CUDA OOM;确认训练任务退出后推理服务仍能响应,再改成 TP=2 重新提交,此时 DP 大小也会改变。 + +测试检查是否复用了健康的 vLLM 进程、路由器和 GPU 资源,重放的批次内容是否一致,以及训练调度器进度、非零且有限的梯度和最终 checkpoint。固定测试列表包括: + +| 数据保存方式 | RolloutManager 状态 | 检查内容 | +|---|---|---| +| Straw,开启在线 GC | 保持存活 | 重新连接训练进程,重放已经训练但尚未保存到 checkpoint 的批次。 | +| Straw,开启在线 GC | 失败后被杀掉 | 新 manager 接回原推理集群,并重放同样的批次。 | +| Straw,已有模型和优化器 checkpoint,使用 Megatron YAML 配置 | 训练过程中被杀掉 | 从 checkpoint 恢复,核对配置与恢复状态。 | +| Rollout 调试文件 | 失败后被杀掉 | 从调试文件恢复数据,新 manager 接回原推理集群。 | +| Straw,使用 disk-delta 同步权重 | 失败后被杀掉 | 以恢复后的权重发布新的完整基准,再继续 delta 更新。 | +| Straw,使用 PD/NIXL 推理 | 失败后被杀掉 | 卡住 prefill actor,在连接重置超时后替换它,并保留健康的 decode actor。 | + +`test_qwen3_30B_A3B_training_recovery.py` 使用 8 张 GPU,在 MoE 模型、R3 和 stateless Adam 配置下覆盖 OOM、checkpoint 恢复及 manager 丢失。它不保存优化器张量,但会检查 scheduler 进度,比对 TP/DP 改变前后的持久化路由字节,并在恢复后完成训练。Dense 测试覆盖普通 Adam 的优化器 checkpoint 恢复。 + +无论是否传入兼容参数 `--use-fault-tolerance`,内部推理健康检查都会启用。CPU 测试还包括:`test_training_recovery.py` 的配置与 checkpoint 边界检查,`test_disk_delta_recovery.py` 的权重更新应答丢失,以及 `test_rollout_manager_recovery.py` 中真实 Ray manager 的 SIGKILL 和转换应答丢失。 + +### Rollout 收尾时清理失效引擎 + +`test_qwen2.5_0.5B_rollout_health.py` 在 rollout 收尾前停掉真实的 vLLM HTTP 服务,但保留它在路由器中的注册信息。两个四卡场景分别保留或杀掉对应的 Ray actor,检查收尾是否有超时限制、返回训练前是否注销失效服务、更新权重时能否恢复引擎,以及训练能否保存最终 checkpoint。 + +两种情况都不传 `--use-fault-tolerance`,并将后台检查间隔和首次等待时间设为 600 秒,以验证收尾检查会立即执行,不必等待后台定时检查。 + ## 注册测试 - 始终运行的 CPU 测试加入 `.buildkite/pipeline.yml` 中对应的命令。 diff --git a/docs/zh/get_started/customization.md b/docs/zh/get_started/customization.md index 1f7855baa..2b2d4a15e 100644 --- a/docs/zh/get_started/customization.md +++ b/docs/zh/get_started/customization.md @@ -492,9 +492,10 @@ engine 读取之前,在每个训练 rank 上调用。用于在非 POSIX 共享 一个对象存储挂载——否则其他 host 无法看到这些文件。hook 会在每个 rank 上被调用,需要自行去重 (例如每个容器只执行一次)。 -post-write hook 返回前必须保证完整版本目录对读取端可见。host-local 的完整 checkpoint -复制随后直接使用该目录作为来源。delta 机制见 -[Delta 权重同步](../advanced/delta-weight-sync.md)。 +读取侧的对应 hook 运行在推理引擎内部、engine 覆盖的每个 host 上,因此它是一个 vllm server +参数而不是 vime hook:传入 `--vllm-custom-pull-weights-pre-read-hook `,签名为 +`hook(source_dir: str, target_version: int)`——在 `pull_weights` 读取已发布权重之前调用 +(例如刷新挂载视图)。完整机制见 [Delta 权重同步](../advanced/delta-weight-sync.md)。 ## 自定义函数路径的测试 diff --git a/docs/zh/get_started/quick_start.md b/docs/zh/get_started/quick_start.md index 42342ee54..8cff774a3 100644 --- a/docs/zh/get_started/quick_start.md +++ b/docs/zh/get_started/quick_start.md @@ -96,6 +96,7 @@ PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ ``` 对于更大的模型,可以使用 `torchrun` 来启动转换脚本,从而使用多张 GPU 甚至多机进行权重转换。 +注意:kimi-k2模型权重转换时,需打开模型路径中的config.json,将"model_type": "kimi_k2"修改为"model_type": "deepseek_v3"。 ### Megatron 格式 转换为 Hugging Face 格式 @@ -296,7 +297,7 @@ OPTIMIZER_ARGS=( ### VLLM_ARGS: vLLM 服务参数 这部分参数用于配置 vLLM 推理服务。 -- `--rollout-num-gpus-per-engine`:单个 rollout engine 使用的 worker GPU 总数;只有 data parallel 和 pipeline parallel 都为 1 时,它才等于 vLLM 的 `tensor_parallel_size`。 +- `--rollout-num-gpus-per-engine`:单个 rollout engine 使用的 worker GPU 总数;只有 data parallel、pipeline parallel 和 prefill context parallel 都为 1 时,它才等于 vLLM 的 `tensor_parallel_size`。 - 其他 vLLM 参数可以通过添加 `--vllm-` 前缀传递给 vime,vime 会自动透传给 vLLM。例如,要设置 vLLM 的 `--uvicorn-log-level info` 参数,只需使用 `--vllm-uvicorn-log-level info`。 > ⚠️ **注意**: @@ -399,6 +400,35 @@ def pop_first(args, rollout_id, buffer: list[list[Sample]], num_samples: int) -> +### bf16 训练 fp8 推理 + +vime 直接支持 bf16 训练,fp8 推理。对于 Qwen3-4B 模型,只需要下载如下模型: + +```bash +hf download Qwen/Qwen3-4B-FP8 --local-dir /root/Qwen3-4B-FP8 +``` + +并将 `--hf-checkpoint` 替换为: + +```bash + # 用于加载 tokenizer 等其他信息,实际上不会使用 hf 路径中的模型权重参数 + --hf-checkpoint /root/Qwen3-4B-FP8 + + # megatron checkpoint 还需要是最开始用 bf16 的 huggingface 转换的 dist 权重,不因为 FP8 rollout 而去做修改。 + --ref-load /root/Qwen3-4B_torch_dist +``` + +即可触发 fp8 推理。目前我们会将 bf16 权重直接 cast 为 fp8,后续会逐渐添加对精度影响更小的量化方案。 + +对于 long-context rollout,也可以开启 vLLM FP8 KV cache 来提升有效 KV cache 容量: + +```bash +--vllm-kv-cache-dtype fp8_e4m3 +``` + +⚠️ 训练的 megatron checkpoint 还需要是最开始用 bf16 的 huggingface 转换的。 + + ## Multiturn 适配 vime 框架高度可扩展,支持复杂的 Agent 场景(如多轮交互与工具调用)。其核心机制是通过自定义函数,重写默认的数据生成 (Rollout) 与奖励计算 (Reward) 逻辑。 diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 3c29dbe9b..2a53f320c 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -21,7 +21,7 @@ - `--rollout-num-gpus`:rollout (inference)一共需要多少卡。设置为 `0` 时,vime 仍会解析 vLLM 参数并启动 router,但不会启动本地 vLLM server; -- `--rollout-num-gpus-per-engine`:单个 inference engine 使用的 worker GPU 总数;只有 data parallel 和 pipeline parallel 都为 1 时,它才等于 vLLM 的 `tensor_parallel_size`。例如用 2 机 16 卡 serving 一个模型时,这里的值应为 16。 +- `--rollout-num-gpus-per-engine`:单个 inference engine 使用的 worker GPU 总数;只有 data parallel、pipeline parallel 和 prefill context parallel 都为 1 时,它才等于 vLLM 的 `tensor_parallel_size`。例如用 2 机 16 卡 serving 一个模型时,这里的值应为 16。 在默认的配置下,我们会根据这些参数,通过 ray 给训练部分分配 `actor_num_nodes * actor_num_gpus_per_node` 张 GPU,给推理分配 `rollout_num_gpus` 张 GPU,也就是实现了训推分离。 @@ -286,9 +286,9 @@ PPO 相关参数: **与 top-p replay 组合:** 将上面的 `--rollout-top-p 1.0` 改为例如 `--rollout-top-p 0.9`,SC 会自动改用精确支持集求和,`--score-centering-top-k` 不再生效。rollout 返回每个 token 完整的 replay 支持集及其截断、归一化后的 sampler logprobs;trainer 在同一份支持集上归一化,并计算 `sum(stop_gradient(q * weight) * log p)` 的校正项。不使用论文的长尾近似,也不把支持集外的 token 纳入求和。训练端的完整 logits 本身无法恢复 sampler 概率,因此仍需要保存原始采样概率。传输量随每步支持集大小变化,top-p 接近 1 时可能明显大于固定的 top-k。精确性针对保存的 replay 支持集;沿用 replay 对边界 sampled token 的保留规则。 -**vLLM 支持:** `top_p=1` 时,Vime 请求 vLLM 原生的 `k+1` 个 top logprobs,再保留概率最高的 `k` 个;`top_p<1` 时请求全部 logprobs,与 vLLM sampling mask 取交集,并对记录的支持集归一化。自定义 generator 应调用 `vime.utils.score_centering` 中的 `score_centering_request`,并将等价 metadata 传给 `Sample.append_response_tokens`。 +**vLLM 支持:** `top_p=1` 时,Vime 请求 vLLM 原生的 `k+1` 个 top logprobs,再保留概率最高的 `k` 个;`top_p<1` 时请求与 vLLM sampling mask 对齐的归一化 logprobs。自定义 generator 应调用 `vime.utils.score_centering` 中的 `score_centering_request`,并将等价 metadata 传给 `Sample.append_response_tokens`。 -top-p 概率与 replay ID/offset 一起驻留在 CPU,支持 partial rollout、masked tool token、DP、TP 和两种 CP 布局。由于 vLLM 当前会先返回完整 logprob 向量,再由 Vime 选择 replay support,因此精确 top-p SC 的响应载荷更大。 +top-p 概率与 replay ID/offset 一起驻留在 CPU,支持 partial rollout、masked tool token、DP、TP 和两种 CP 布局。响应载荷随记录的支持集大小变化,不再先传输完整词表的 logprob 向量。 sampler top-k 数据支持 partial rollout 续接、masked tool token、DP 划分、microbatch 选择、TP 及两种 CP 布局;使用 R3 spill hook 时共享其文件生命周期。相关日志指标包括 `sc_correction`、`sc_sampler_head_mass`、`sc_train_head_mass` 和 `sc_importance_weight`。 @@ -402,33 +402,9 @@ vime 支持不同程度的自定义数据生成(rollout)。 --rollout-data-dir /shared/run/rollout_data ``` -所有节点必须以相同绝对路径挂载该目录,并安装 `straw-queue>=0.1.2`。 +所有节点必须以相同绝对路径挂载该目录,并安装 `straw-queue`。标准 vime 安装已包含此依赖;已有环境可以执行 `pip install 'straw-queue>=0.1.2'`。JuiceFS storage profile 和部署声明的配置见 [straw 指南](../advanced/straw.md#启用方式)。只选择 straw 时使用同步 rollout 入口。新任务已设置 `--save` 时,省略 `--rollout-data-dir` 会使用 `/rollout_data`。 -straw 传输下,开启 `--use-rollout-routing-replay` 后会将已完成 sample 的 R3 routes 随所属 group 一起写入 straw;开启 `--use-score-centering` 时也会在同一次发布中保存 SC 张量,包括只使用 SC 的情况。默认 object-store 传输将这些张量保存在内存中,但仍保留原有的可选磁盘 spill hook。自定义 sample hook 先执行,R3、SC 与 sample 元数据一起发布到共享 pack,后续队列发布复用引用。大批 group 按 native record 数量上限拆分。Aborted prefix 由队列 continuation 路径持久化。`--rollout-queue-online-gc` 是独立开关,默认关闭:训练确认使用完成后,straw 可回收所有 owner 都已释放的 sealed pack。 - -作业内一个禁止自动重启的 Ray actor 管理任务 lease 和串行 dataset producer。Dataset 游标与任务提交在同一日志事务中保存,worker 通过小引用直接读取共享存储中的 prompt group,替代原来的内存索引分配器。Shuffle、group/sample 编号沿用原有数据源逻辑,故障不会丢弃 reader 预留的索引区间。 - -保留 `get_samples(n)`、`add_samples(groups)` 接口。Custom producer 可以传递 `source.reader_config("unique_reader_id")`,在远端调用 `config.open()`。Reader ID 必须唯一,`owner` 为保留名称。归还的 partial group 持久化后可由任意 reader 领取,不再保留 reader 本地 sample buffer。Reader 定期续租,正常关闭归还未完成任务,故障 worker 的任务在 lease 到期后重试。恢复使用常规的 `--load`/`--save` checkpoint 流程,并可用 `--ckpt-step` 选择步骤;队列分支和 dataset 游标从所选 checkpoint 恢复,不再需要单独的 queue-resume 参数。 - -Fully async 在每个有 CPU 资源的 Ray 节点启动一个常驻生成进程,每个占用一个 CPU,并按完整 prompt group 分配总并发。快速 worker 可以独立补足全局 batch,保留原有有界生成队列和 collector 预取。完整 group 通过现有 dynamic filter 后才提交;丢弃结果计入过滤指标。分布式 fully async 仍不支持 `--rollout-all-samples-process-path`;同步入口保留其 Samples 和调用顺序。 - -逻辑提交单位是过滤后的完整 group,物理 segment 不决定训练分组。Sample 使用显式编码,大张量成为 typed blob 依赖;采用`.pack` 追加文件中的不可变已提交区间、相对引用和校验和,不以 pickle 作为 payload 协议。不支持的自定义字段类型会报错。参阅 [straw 架构、部署与恢复指南](../advanced/straw.md)。 - -内置 producer 返回 collection manifest。旧 custom rollout 仍可返回 Sample 列表,由 Manager 兜底持久化并接受一个兼容 collection;新 producer 可以直接返回关联有效 receipt 的 `RawRolloutRef`。两者进入同一个 BatchBuilder,保留 reward/conversion hook 和 DP 调度。Builder 先保存选择计划,全部 rank shard 持久化后才返回同一 batch ID/plan 的 `TrainBatchRef`。 - -Vime adapter 当前要求各节点使用同一个绝对挂载路径,并双向检查可见性;底层引用支持重新绑定 root。r3/sc 的临时文件依赖会先复制进队列,旧 spill 清理不会使已提交数据失效。默认保留队列文件;读取、batch-ready 都不删除数据。离线清理要求停止 coordinator、writer 和 reader。Debug 归档可以复用 Straw 的不可变记录,也可导出 `.pt` 文件;evaluation 路径保持原有行为。 - -Straw checkpoint 会将模型、optimizer/RNG、队列、builder 和 dataset 游标作为一个整体提交。默认 `--load` 恢复最近的有效联合 checkpoint;`--ckpt-step` 可选择当前分支历史中的步骤。恢复会从快照创建隔离分支,不改动源运行,也不会带入快照之后产生的样本。多次回退沿当前分支查找,不能越过分叉点。若模型 checkpoint 没有 straw 队列快照,则从空队列恢复,并在可用时恢复保存的 dataset 游标;联合快照损坏或不完整时直接报错,不会静默降级。这会持久化队列和数据状态,但不会恢复 GPU KV cache 或生成 RNG。 - -R3 训练只读取当前 CP/TP rank 分配到的行。续跑批量发布、加载和状态保存通过有界读取会话复用已认证索引,结果回执采用批量 RPC 查询。这些优化保留 checksum、WAL 持久化和 GC 所有权约束;读取会话不能替代所有权 pin。 - -分布式 fully async producer 在权重同步期间停止补发新 group;还有下一次训练 rollout 时,同步后恢复。已开始的请求继续返回并持久化。 - -从联合 checkpoint 恢复时,先持久化恢复后的训练 consumer 状态及存储引用,再启动后台 GC。所需 checkpoint 数据不可用时,恢复失败且不启动 GC。 - -恢复的 reader buffer 通过独立于过滤决策的存储引用受到保护。后续数据源 checkpoint 必须完整保存活跃及尚未启动的 consumer,并保留、持久化新快照,才能交接该引用。之后正常退役旧 checkpoint 时,可以回收过期张量,同时保留可续跑前缀。保存失败会保留原引用;checkpoint 退役仍由调用方负责。 - -Manager 仍会读取选中的 batch 做转换,共享存储带宽和 manager 内存仍可能成为瓶颈。`--rollout-io-concurrency` 限制队列发布的 I/O 待办数量。straw 的存储、日志和 GC 由 Rust 实现,vime 保留 Python sample 转换和训练集成。改变部署默认值前,需针对实际模型、并发和共享文件系统测量吞吐。 +使用 straw 时,`--use-rollout-routing-replay` 和 `--use-score-centering` 将 R3、SC 张量随 sample 持久化,也支持 partial continuation。`--rollout-queue-online-gc` 可开启未使用存储的回收,默认关闭。恢复使用 `--load`、`--save` 和可选的 `--ckpt-step`。调度、checkpoint 恢复和 debug 回放见 [straw 指南](../advanced/straw.md),自定义 rollout 函数见[自定义功能](customization.md)。 ### 跨权重更新保留 KV(PipelineRL) @@ -476,7 +452,7 @@ vime 通过转发 vLLM 的 `EngineArgs` CLI 参数,引入了几乎所有的 vL 有部分参数和 vime 的资源调度相关,会由 vime 自行配置,例如: -- `--tensor-parallel-size` 在 vime 中会使用 `--rollout-num-gpus-per-engine` +- `--tensor-parallel-size` 在 vime 中由 `--rollout-num-gpus-per-engine` 和配置的 DP、PP、PCP 大小共同确定 - `--model` 在 vime 中会使用 `--hf-checkpoint` vLLM 参数引入 vime 的方式可以参考 [vime/backends/vllm_utils/arguments.py](https://github.com/vllm-project/vime/blob/main/vime/backends/vllm_utils/arguments.py)。 diff --git a/examples/tau-bench/trainable_agents.py b/examples/tau-bench/trainable_agents.py index 2fe5118e8..53a00cd99 100644 --- a/examples/tau-bench/trainable_agents.py +++ b/examples/tau-bench/trainable_agents.py @@ -17,12 +17,9 @@ from tau_bench.types import Action, RunConfig from token_delta import get_token_delta -from vime.rollout.vllm_rollout import ( - GenerateState, - _build_inference_sampling_params, - _coerce_flat_int_token_ids, - _mm_render_response_to_generate_body, -) +from vime.rollout.vllm_rollout import GenerateState, _build_inference_sampling_params, _coerce_flat_int_token_ids +from vime.rollout.vllm_rollout import _inference_generate_tokens_and_logprobs as _parse_choice_tokens_and_logprobs +from vime.rollout.vllm_rollout import _mm_render_response_to_generate_body from vime.utils.http_utils import post from vime.utils.types import Sample @@ -40,25 +37,6 @@ def patch_tau_user_retries() -> None: pass -def _parse_choice_tokens_and_logprobs(choice: dict[str, Any]) -> tuple[list[int], list[float]]: - """Parse token_ids + logprobs from vLLM /inference/v1/generate choice.""" - tids_raw = choice.get("token_ids") - if not (isinstance(tids_raw, list) and tids_raw and all(isinstance(x, int) for x in tids_raw)): - return [], [] - tids = [int(x) for x in tids_raw] - lp = choice.get("logprobs") - if not isinstance(lp, dict): - return tids, [0.0] * len(tids) - content = lp.get("content") - if isinstance(content, list) and content: - log_probs = [ - float(content[i].get("logprob", 0.0)) if i < len(content) and isinstance(content[i], dict) else 0.0 - for i in range(len(tids)) - ] - return tids, log_probs - return tids, [0.0] * len(tids) - - def _maybe_apply_routed_experts(args: Any, sample: Sample, choice: dict[str, Any]) -> None: if choice.get("routed_experts") is None: return diff --git a/requirements.txt b/requirements.txt index fc3c4fbe1..0422daa73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,5 +23,5 @@ tensorboard transformers vllm-router>=0.1.15 wandb -xxhash # disk delta weight sync checksum +xxhash # disk delta weight sync (checksum + codec) zstandard diff --git a/tests/rollout_health_test_helpers.py b/tests/rollout_health_test_helpers.py new file mode 100644 index 000000000..8150122d9 --- /dev/null +++ b/tests/rollout_health_test_helpers.py @@ -0,0 +1,81 @@ +"""Crash serving without unregistering it at the actual rollout drain boundary.""" + +import json +import os +import time +from pathlib import Path + +import ray +import requests +import torch.distributed as dist + +from vime.ray.training_recovery import RECOVERY_NAMESPACE, training_session_name + + +async def reward(args, sample, **kwargs): + return float(sample.index % args.n_samples_per_prompt) + + +def _stop_server(engine): + from vllm.utils.system_utils import kill_process_tree + + identity = {"url": engine.get_url(), "pid": engine.process.pid} + kill_process_tree(engine.process.pid) + return identity + + +def _engines(serving): + return [engine for server in serving.servers.values() for engine in server.engines] + + +def generate_rollout(args, rollout_id, data_source, evaluation=False): + from vime.rollout import vllm_rollout + + original_abort = vllm_rollout.abort + + async def crash_then_abort(config, current_rollout): + assert vllm_rollout.GenerateState(config).active_server_generations + owner = ray.get_actor(training_session_name(config) + ":serving", namespace=RECOVERY_NAMESPACE) + engines = ray.get(owner.__ray_call__.remote(_engines)) + start = time.monotonic() + identity = ray.get(engines[0].__ray_call__.remote(_stop_server), timeout=30) + if os.environ["VIME_HEALTH_CRASH_MODE"] == "actor": + ray.kill(engines[0], no_restart=True) + router = f"http://{config.vllm_router_ip}:{config.vllm_router_port}" + response = requests.get(f"{router}/workers", timeout=5) + response.raise_for_status() + assert identity["url"] in {worker["url"] for worker in response.json()["workers"]} + result = await original_abort(config, current_rollout) + response = requests.get(f"{router}/workers", timeout=5) + response.raise_for_status() + urls = {worker["url"] for worker in response.json()["workers"]} + assert identity["url"] not in urls and len(urls) == 1 + identity["drain_seconds"] = time.monotonic() - start + assert identity["drain_seconds"] < 60 + Path(os.environ["VIME_HEALTH_TEST_DIR"], "crash.json").write_text(json.dumps(identity)) + return result + + if rollout_id == 0 and not evaluation: + vllm_rollout.abort = crash_then_abort + try: + return vllm_rollout.generate_rollout(args, rollout_id, data_source, evaluation=evaluation) + finally: + vllm_rollout.abort = original_abort + + +def before_train_step(args, rollout_id, step_id, model, optimizer, opt_param_scheduler): + if dist.get_rank() != 0 or step_id != 0: + return + serving = ray.get_actor(training_session_name(args) + ":serving", namespace=RECOVERY_NAMESPACE) + engines = ray.get(serving.__ray_call__.remote(_engines)) + live = [engine for engine in engines if engine is not None] + assert len(live) == (1 if rollout_id == 0 else 2) + router = f"http://{args.vllm_router_ip}:{args.vllm_router_port}" + response = requests.get(f"{router}/workers", timeout=5) + response.raise_for_status() + assert len(response.json()["workers"]) == len(live) + versions = ray.get([engine.get_weight_version.remote() for engine in live]) + assert versions == [str(rollout_id + 1)] * len(live) + Path(os.environ["VIME_HEALTH_TEST_DIR"], f"trained_{rollout_id}.json").write_text( + json.dumps({"live_engines": len(live), "weight_versions": versions}) + ) diff --git a/tests/test_disk_delta_checkpoint_index.py b/tests/test_disk_delta_checkpoint_index.py new file mode 100644 index 000000000..17097b4b1 --- /dev/null +++ b/tests/test_disk_delta_checkpoint_index.py @@ -0,0 +1,114 @@ +"""Disk delta must update the same checkpoint shards the model loader reads.""" + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +import safetensors.numpy +import zstandard + +NUM_GPUS = 0 +ROOT = Path(__file__).resolve().parents[1] +PATCHES = sorted((ROOT / "docker" / "patch").glob("*/vllm-pull_weights.patch")) + + +def _load_trainer(): + spec = importlib.util.spec_from_file_location("disk_delta", ROOT / "vime/utils/disk_delta.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_receiver(patch): + # Test the exact standalone module shipped to vLLM in each patch bundle. + text = patch.read_text() + section = text.split("+++ b/vllm/utils/local_checkpoint.py\n", 1)[1] + section = section.split("\ndiff --git ", 1)[0] + source = "\n".join(line[1:] for line in section.splitlines() if line.startswith("+")) + module = ModuleType("local_checkpoint") + exec(compile(source, str(patch), "exec"), module.__dict__) + return module + + +def _write_checkpoint(path): + path.mkdir() + safetensors.numpy.save_file({"weight": np.ones((2, 2), dtype=np.float32)}, path / "model.safetensors") + safetensors.numpy.save_file({"weight": np.zeros((2, 2), dtype=np.float32)}, path / "stale.safetensors") + (path / "model.safetensors.index.json").write_text(json.dumps({"weight_map": {"weight": "model.safetensors"}})) + + +@pytest.fixture(params=[None, *PATCHES], ids=["trainer", *(p.parent.name for p in PATCHES)]) +def reader(request, monkeypatch): + module = _load_trainer() if request.param is None else _load_receiver(request.param) + # scandir/glob order is unspecified; force the stale file to be visited last. + original_glob = module.glob.glob + monkeypatch.setattr(module.glob, "glob", lambda pattern: sorted(original_glob(pattern))) + return module + + +def test_index_excludes_stale_shards(reader, tmp_path): + checkpoint = tmp_path / "checkpoint" + _write_checkpoint(checkpoint) + locations = reader._tensor_locations(str(checkpoint)) + assert Path(locations["weight"][0]).name == "model.safetensors" + + +def test_missing_indexed_shard_does_not_fall_back_to_stale_weights(reader, tmp_path): + checkpoint = tmp_path / "checkpoint" + _write_checkpoint(checkpoint) + (checkpoint / "model.safetensors").unlink() + with pytest.raises(FileNotFoundError): + reader._tensor_locations(str(checkpoint)) + + +def test_unindexed_checkpoint_remains_supported(reader, tmp_path): + safetensors.numpy.save_file({"weight": np.ones(4, dtype=np.float32)}, tmp_path / "model.safetensors") + assert set(reader._tensor_locations(str(tmp_path))) == {"weight"} + + +@pytest.mark.parametrize("patch", PATCHES, ids=[p.parent.name for p in PATCHES]) +@pytest.mark.parametrize("encoding", ["xor", "overwrite"]) +def test_delta_updates_indexed_weights(patch, encoding, tmp_path, monkeypatch): + trainer, receiver = _load_trainer(), _load_receiver(patch) + original_glob = trainer.glob.glob + monkeypatch.setattr(trainer.glob, "glob", lambda pattern: sorted(original_glob(pattern))) + base, local, stream = tmp_path / "base", tmp_path / "local", tmp_path / "stream" + _write_checkpoint(base) + stream.mkdir() + receiver.pull_checkpoint(str(local), str(base), str(stream), 0) + read = trainer.make_tensor_reader(str(base)) + old = read("weight") + new = np.full((2, 2), 2, dtype=np.float32).view(np.uint8).reshape(-1) + diff = new ^ old if encoding == "xor" else trainer.overwrite_encode(new, new != old) + version = stream / "weight_v000001" + version.mkdir() + compressed = np.frombuffer(zstandard.ZstdCompressor().compress(diff), dtype=np.uint8) + safetensors.numpy.save_file( + {"weight": compressed}, version / "model.safetensors", metadata={"weight": trainer.checksum("adler32", new)} + ) + (version / "model.safetensors.index.json").write_text( + json.dumps( + { + "metadata": { + "version": "000001", + "base_version": "000000", + "delta_encoding": encoding, + "compression_format": "zstd", + "checksum_format": "adler32", + }, + "weight_map": {"weight": "model.safetensors"}, + } + ) + ) + receiver.pull_checkpoint(str(local), str(base), str(stream), 1) + # The delta's own checksum can pass while the indexed model stays stale. + loaded = safetensors.numpy.load_file(local / "model.safetensors")["weight"] + np.testing.assert_array_equal(loaded.view(np.uint8).reshape(-1), new) + np.testing.assert_array_equal(safetensors.numpy.load_file(local / "stale.safetensors")["weight"], 0) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_disk_delta_recovery.py b/tests/test_disk_delta_recovery.py new file mode 100644 index 000000000..548b30501 --- /dev/null +++ b/tests/test_disk_delta_recovery.py @@ -0,0 +1,150 @@ +"""Disk publication/restart contracts without Megatron, CUDA, or a serving process. + +The GPU recovery suite additionally exercises vLLM's actual pull/reload path. +Here the filesystem and tensor encoders are real; only collectives and RPCs are +replaced so CI can cover lost replies and restored weights on a CPU runner. +""" + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import numpy as np +import pytest +import safetensors.numpy +import safetensors.torch +import torch +import zstandard + +NUM_GPUS = 0 + + +@pytest.fixture +def delta_module(monkeypatch): + module_name = "vime.backends.megatron_utils.update_weight.update_weight_from_distributed" + base = ModuleType(module_name) + + class Updater: + def __init__(self, args, *a, **kw): + self.args = args + self.weight_version = 0 + + base.UpdateWeightFromDistributed = Updater + monkeypatch.setitem(sys.modules, module_name, base) + megatron, core = ModuleType("megatron"), ModuleType("megatron.core") + core.mpu = SimpleNamespace(get_data_parallel_rank=lambda **kw: 0, get_tensor_model_parallel_rank=lambda: 0) + megatron.core = core + monkeypatch.setitem(sys.modules, "megatron", megatron) + monkeypatch.setitem(sys.modules, "megatron.core", core) + name = "vime.backends.megatron_utils.update_weight._delta_recovery_test" + path = ( + Path(__file__).resolve().parents[1] + / "vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py" + ) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "get_gloo_group", lambda: None) + monkeypatch.setattr(module.dist, "get_rank", lambda: 0) + monkeypatch.setattr(module.dist, "get_world_size", lambda: 1) + monkeypatch.setattr(module.dist, "barrier", lambda **kw: None) + monkeypatch.setattr(module.dist, "broadcast_object_list", lambda *a, **kw: None) + monkeypatch.setattr(module.dist, "all_gather_object", lambda out, value, **kw: out.__setitem__(0, value)) + # A CPU regression must not allocate a CUDA context even on a GPU runner. + empty = torch.empty + + def cpu_empty(*args, **kwargs): + if kwargs.get("pin_memory"): + raise RuntimeError("CPU test") + return empty(*args, **kwargs) + + monkeypatch.setattr(module.torch, "empty", cpu_empty) + return module + + +@pytest.mark.parametrize("encoding", ["xor", "overwrite"]) +def test_restart_publishes_restored_weights_above_every_attempt(tmp_path, delta_module, encoding): + base = tmp_path / "base" + base.mkdir() + (base / "config.json").write_text('{"model_type":"test"}') + safetensors.torch.save_file({"w": torch.zeros(4)}, base / "model.safetensors") + source = tmp_path / "versions" + source.mkdir() + # Version 7 reached a serving host before the old trainer lost its reply. + (source / "weight_v000007").mkdir() + args = SimpleNamespace( + hf_checkpoint=str(base), + update_weight_disk_dir=str(source), + update_weight_delta_encoding=encoding, + update_weight_delta_checksum="adler32", + custom_update_weight_post_write_path=None, + ) + current = {"w": torch.arange(4, dtype=torch.float32) + 5} + applied = {} + versions = [] + + def make_updater(): + updater = delta_module.UpdateWeightFromDiskDelta( + args, [], lambda: current, model_name="test", quantization_config=None + ) + updater.connect_rollout_engines(["engine"], None) + iterator = SimpleNamespace(get_hf_weight_chunks=lambda weights: iter([list(weights.items())])) + updater._source = SimpleNamespace(iterator=iterator, weights_getter=lambda: current) + updater._iter_hf_tensors = lambda: iter(current.items()) + updater._record_metrics = lambda: None + + def reload(): + directory = Path(updater._version_dir) + index = json.loads((directory / "model.safetensors.index.json").read_text()) + versions.append(updater.weight_version) + if "delta_encoding" not in index["metadata"]: + applied.clear() + for filename in set(index["weight_map"].values()): + for name, value in safetensors.torch.load_file(directory / filename).items(): + applied[name] = value.view(torch.uint8).numpy().reshape(-1).copy() + else: + assert int(index["metadata"]["base_version"]) == versions[-2] + for filename in set(index["weight_map"].values()): + for name, value in safetensors.numpy.load_file(directory / filename).items(): + delta = np.frombuffer(zstandard.ZstdDecompressor().decompress(value), dtype=np.uint8) + if encoding == "xor": + applied[name] ^= delta + else: + count = int(np.frombuffer(delta[:4], dtype="= 5 ] diff --git a/tests/test_queue_sample_codec.py b/tests/test_queue_sample_codec.py index 7303090a9..8c81423c9 100644 --- a/tests/test_queue_sample_codec.py +++ b/tests/test_queue_sample_codec.py @@ -368,6 +368,42 @@ def test_unvalidated_capture_roundtrip_never_narrows_values(codec, status): torch.testing.assert_close(ref.load(), getattr(sample, key), rtol=0, atol=0) +def test_training_projection_reads_only_selected_tensor_records(codec, monkeypatch): + from vime.data.transport import TrainBatchRef + + ref = codec.publish( + { + "tokens": [torch.tensor([index, index + 1]) for index in range(8)], + "loss_masks": [torch.ones(2, dtype=torch.int) for _ in range(8)], + "total_lengths": [2] * 8, + "unused": torch.zeros(10000), + }, + submission_id="conversion", + ) + loaded = [] + original = QueueTensorRef.load + + def tracked(value, *args, **kwargs): + loaded.append(value) + return original(value, *args, **kwargs) + + monkeypatch.setattr(QueueTensorRef, "load", tracked) + view = TrainBatchRef( + ref, + str(codec.store.backend.root), + "batch", + 0, + "layout", + selection={"tokens": [7, 2], "loss_masks": [7, 2], "total_lengths": None}, + schedule={"partition": [7, 2], "num_microbatches": [1]}, + ) + restored = codec.load(codec.publish(view, submission_id="view")) + data = restored.load() + assert [tensor.tolist() for tensor in data["tokens"]] == [[7, 8], [2, 3]] + assert len(loaded) == 4 + assert data["partition"] == [7, 2] and data["total_lengths"] == [2] * 8 + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_qwen2.5_0.5B_rollout_health.py b/tests/test_qwen2.5_0.5B_rollout_health.py new file mode 100644 index 000000000..bd13e6588 --- /dev/null +++ b/tests/test_qwen2.5_0.5B_rollout_health.py @@ -0,0 +1,93 @@ +"""Real training after a server/actor crash with a stale router registration.""" + +import argparse +import importlib.util +import json +import os +import subprocess +import tempfile +from pathlib import Path + +import ray + +import vime.utils.external_utils.command_utils as U +from vime.ray.training_recovery import RECOVERY_NAMESPACE, training_session_name + +MODEL_NAME = "Qwen2.5-0.5B-Instruct" +MODEL_TYPE = "qwen2.5-0.5B" +NUM_GPUS = 4 + + +def prepare(): + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def execute(crash_mode, directory=None): + directory = Path(directory or tempfile.mkdtemp(prefix="vime_rollout_health_")) + directory.mkdir(parents=True, exist_ok=True) + source = Path(__file__).with_name("test_qwen2.5_0.5B_training_recovery.py") + spec = importlib.util.spec_from_file_location("recovery_e2e", source) + recovery = importlib.util.module_from_spec(spec) + spec.loader.exec_module(recovery) + arguments = recovery.train_args( + directory, "debug", 1, f"/root/models/{MODEL_NAME}", "/root/datasets/gsm8k/train.parquet", 3, False + ) + arguments = arguments.replace("--num-rollout 3", "--num-rollout 2") + arguments = arguments.replace("training_recovery_test_helpers", "rollout_health_test_helpers") + arguments += ( + "--rollout-function-path rollout_health_test_helpers.generate_rollout " + "--over-sampling-batch-size 8 --rollout-health-check-interval 600 " + "--rollout-health-check-timeout 5 " + f"--rollout-session-id {directory.name} " + ) + try: + U.execute_train( + train_args=arguments, + num_gpus_per_node=NUM_GPUS, + megatron_model_type=MODEL_TYPE, + extra_env_vars={ + "PYTHONPATH": f"{U.repo_base_dir}/tests:{U.repo_base_dir}:/root/Megatron-LM", + "RAY_ADDRESS": os.environ.get("VIME_TEST_RAY_ADDRESS", "127.0.0.1:6379"), + "VIME_HEALTH_CRASH_MODE": crash_mode, + "VIME_HEALTH_TEST_DIR": str(directory), + "NO_PROXY": "*", + "no_proxy": "*", + **{ + key: "" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy") + }, + }, + ) + crash = json.loads((directory / "crash.json").read_text()) + assert crash["drain_seconds"] < 60 + assert json.loads((directory / "trained_0.json").read_text())["live_engines"] == 1 + assert json.loads((directory / "trained_1.json").read_text())["live_engines"] == 2 + assert (directory / "checkpoint/iter_0000001").exists() + print(f"Rollout health verified: crash={crash_mode}, drain={crash['drain_seconds']:.2f}s, training completed.") + finally: + ray.init(address=os.environ.get("VIME_TEST_RAY_ADDRESS", "127.0.0.1:6379"), namespace=RECOVERY_NAMESPACE) + name = training_session_name(argparse.Namespace(rollout_session_id=directory.name)) + for suffix in ("", ":serving"): + try: + retained = ray.get_actor(name + suffix, namespace=RECOVERY_NAMESPACE) + except ValueError: + continue + try: + ray.get(retained.dispose.remote(), timeout=60) + finally: + ray.kill(retained, no_restart=True) + ray.shutdown() + if os.environ.get("VIME_SCRIPT_EXTERNAL_RAY") != "1": + subprocess.run(["ray", "stop", "--force"], check=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--crash-mode", choices=["server", "actor"], default="server") + parser.add_argument("--directory") + parser.add_argument("--no-prepare", action="store_true") + args = parser.parse_args() + if not args.no_prepare: + prepare() + execute(args.crash_mode, args.directory) diff --git a/tests/test_qwen2.5_0.5B_score_centering.py b/tests/test_qwen2.5_0.5B_score_centering.py index 6186e4221..489ee54d5 100644 --- a/tests/test_qwen2.5_0.5B_score_centering.py +++ b/tests/test_qwen2.5_0.5B_score_centering.py @@ -40,7 +40,7 @@ def execute(top_p=1.0): "--tensor-model-parallel-size 2 --sequence-parallel --pipeline-model-parallel-size 1 " "--context-parallel-size 1 --expert-model-parallel-size 1 --expert-tensor-parallel-size 1 " "--use-dynamic-batch-size --max-tokens-per-gpu 4096 --log-probs-chunk-size 128 " - "--rollout-num-gpus-per-engine 1 --vllm-mem-fraction-static 0.6 --vllm-cuda-graph-max-bs 8 " + "--rollout-num-gpus-per-engine 1 --vllm-gpu-memory-utilization 0.6 --vllm-max-cudagraph-capture-size 8 " "--attention-dropout 0 --hidden-dropout 0 --attention-backend flash " "--accumulate-allreduce-grads-in-fp32 --attention-softmax-in-fp32 " "--actor-num-nodes 1 --actor-num-gpus-per-node 2 --colocate --ci-test " diff --git a/tests/test_qwen2.5_0.5B_training_recovery.py b/tests/test_qwen2.5_0.5B_training_recovery.py new file mode 100644 index 000000000..4341748d0 --- /dev/null +++ b/tests/test_qwen2.5_0.5B_training_recovery.py @@ -0,0 +1,417 @@ +"""Restart Megatron with TP=2 after a real OOM, retaining the vLLM cluster.""" + +import argparse +import json +import os +import re +import subprocess +import tempfile +import time +from pathlib import Path +from shlex import quote +from types import SimpleNamespace + +import pytest +import ray +import requests +from ray.job_submission import JobStatus, JobSubmissionClient + +import vime.utils.external_utils.command_utils as U +from vime.ray.training_recovery import RECOVERY_NAMESPACE, training_session_name + +MODEL_NAME = "Qwen2.5-0.5B-Instruct" +MODEL_TYPE = "qwen2.5-0.5B" +NUM_GPUS = 4 +REPO = Path(__file__).resolve().parents[1] + + +def prepare(): + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def train_args(directory, mode, tp_size, model_path, dataset_path, save_interval, fault_tolerance): + arguments = ( + f"--hf-checkpoint {quote(model_path)} --ref-load {quote(model_path)} " + "--rollout-health-check-first-wait 600 " + f"--rollout-data-transport {'straw' if mode == 'straw' else 'object-store'} " + "--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async " + f"--prompt-data {quote(dataset_path)} --input-key messages --label-key label --apply-chat-template " + "--custom-rm-path training_recovery_test_helpers.reward " + "--num-rollout 3 --rollout-batch-size 4 --n-samples-per-prompt 4 --global-batch-size 16 " + "--rollout-max-response-len 128 --rollout-temperature 0.8 " + "--actor-num-nodes 1 --actor-num-gpus-per-node 2 --rollout-num-gpus 2 --rollout-num-gpus-per-engine 1 " + f"--tensor-model-parallel-size {tp_size} --pipeline-model-parallel-size 1 --context-parallel-size 1 " + "--expert-model-parallel-size 1 --expert-tensor-parallel-size 1 " + "--use-dynamic-batch-size --max-tokens-per-gpu 2048 " + "--vllm-server-concurrency 8 --vllm-gpu-memory-utilization 0.65 --vllm-max-cudagraph-capture-size 16 " + "--advantage-estimator grpo --use-rollout-logprobs --eps-clip 0.2 " + "--optimizer adam --lr 1e-6 --lr-decay-style constant --weight-decay 0.1 --adam-beta1 0.9 --adam-beta2 0.98 " + "--attention-dropout 0.0 --hidden-dropout 0.0 --accumulate-allreduce-grads-in-fp32 " + "--attention-softmax-in-fp32 --attention-backend flash " + "--custom-megatron-before-train-step-hook-path training_recovery_test_helpers.before_train_step " + f"--save {quote(str(directory / 'checkpoint'))} --save-interval {save_interval} " + ) + if fault_tolerance: + arguments += "--use-fault-tolerance " + if tp_size > 1: + arguments += "--sequence-parallel " + if mode == "straw": + arguments += f"--rollout-data-dir {quote(str(directory / 'queue'))} --rollout-queue-online-gc " + else: + arguments += f"--save-debug-rollout-data {quote(str(directory / 'rollout_{rollout_id}.pt'))} " + return arguments + + +def wait_job(client, submission_id, path): + # Large models also save and reload checkpoints on shared storage. + deadline = time.monotonic() + 3600 + while time.monotonic() < deadline: + status = client.get_job_status(submission_id) + if status.is_terminal(): + path.write_text(client.get_job_logs(submission_id)) + return status + print(f"{submission_id}: {status}", flush=True) + time.sleep(10) + raise TimeoutError(f"Training job did not finish: {submission_id}") + + +def verify(directory, mode, failure_rollout, save_interval, kill_manager, wedge_engine=False, train_gpus=2): + before = json.loads((directory / "before.json").read_text()) + after = json.loads((directory / "after.json").read_text()) + assert before["trainer"]["job_id"] != after["trainer"]["job_id"] + assert before["trainer"]["pid"] != after["trainer"]["pid"] + assert before["trainer"]["tp_size"] == 1 and after["trainer"]["tp_size"] == 2 + for name in ( + "serving_pid", + "router_pids", + "engines", + "engine_actor_ids", + "router", + "controller_id", + "rollout_pg_id", + "sample_digest", + "route_digest", + "sample_indices", + ): + if wedge_engine and name in {"engines", "engine_actor_ids"}: + # Reattachment retires only the peer that cannot fence its old RPCs. + assert before[name][0] != after[name][0] + assert before[name][1:] == after[name][1:] + else: + assert before[name] == after[name], (name, before[name], after[name]) + assert (before["manager_pid"] != after["manager_pid"]) == kill_manager + assert before["parallel"]["dp_size"] == train_gpus and after["parallel"]["dp_size"] == train_gpus // 2 + restore_start = failure_rollout if save_interval == 1 else 0 + assert after["start_rollout_id"] == restore_start + for rollout_id in range(restore_start, failure_rollout + 1): + original = json.loads((directory / f"original_{rollout_id}.json").read_text()) + replayed = json.loads((directory / f"replayed_{rollout_id}.json").read_text()) + assert original["sample_digest"] == replayed["sample_digest"] + assert original["route_digest"] == replayed["route_digest"] + assert original["sample_indices"] == replayed["sample_indices"] + assert before["scheduler_num_steps"] == after["scheduler_num_steps"] == 16 * failure_rollout + assert all( + int(new) > int(old) for old, new in zip(before["weight_versions"], after["weight_versions"], strict=True) + ) + failed_log = (directory / "failed.log").read_text() + assert "CUDA out of memory" in failed_log or "torch.OutOfMemoryError" in failed_log + resumed_log = (directory / "resumed.log").read_text() + gradients = [float(value) for value in re.findall(r"'train/grad_norm': ([^,}]+)", resumed_log)] + # Include NaN/Inf in parsing and require every resumed step: a later failed + # update must not be hidden by an earlier finite gradient in the same log. + assert len(gradients) == 3 - restore_start and all(0 < value < float("inf") for value in gradients), gradients + if mode == "straw": + committed = json.loads((directory / "checkpoint/rollout/committed_2.json").read_text()) + assert committed["weight_version"] == int(after["weight_versions"][0]) + 2 - failure_rollout + else: + assert (directory / "checkpoint/iter_0000002").exists() + continued = json.loads((directory / "continued_2.json").read_text()) + previous_indices = { + index + for rollout_id in range(failure_rollout + 1) + for index in json.loads((directory / f"original_{rollout_id}.json").read_text())["sample_indices"] + } + assert previous_indices.isdisjoint(continued["sample_indices"]) + assert json.loads((directory / "serving_after_failure.json").read_text())["choices"][0]["text"] + assert not list(directory.glob("*.train-recovery.pt")) + print("Trainer recovery verified: serving processes retained, data replayed with new DP, real training completed.") + + +def execute( + mode="straw", + failure_rollout=0, + directory=None, + model_path=None, + dataset_path=None, + role_config=False, + save_interval=None, + kill_manager=False, + fault_tolerance=True, + manager_crash_phase="after-failure", + weight_sync="nccl", + wedge_engine=False, + pd=False, + model_type=MODEL_TYPE, + train_gpus=2, + rollout_gpus=2, + extra_args="", +): + directory = Path(directory or tempfile.mkdtemp(prefix="vime_training_recovery_")) + directory.mkdir(parents=True, exist_ok=True) + save_interval = save_interval or (1 if failure_rollout else 3) + # prepare() may need the download proxy; the driver and Ray head must then + # contact cluster services directly, before any job runtime_env is applied. + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + os.environ.pop(name, None) + external_ray = os.environ.get("VIME_SCRIPT_EXTERNAL_RAY") == "1" + if not external_ray: + subprocess.run( + [ + "ray", + "start", + "--head", + "--node-ip-address", + "127.0.0.1", + "--num-gpus", + str(train_gpus + rollout_gpus), + "--disable-usage-stats", + ], + check=True, + ) + client = JobSubmissionClient(os.environ.get("VIME_TEST_RAY_DASHBOARD", "http://127.0.0.1:8265")) + runtime_env = { + "env_vars": { + "PYTHONPATH": f"{REPO}/tests:{REPO}:/root/Megatron-LM", + "VIME_RECOVERY_TEST_DIR": str(directory), + "VIME_RECOVERY_TEST_FAILURE_ROLLOUT": str(failure_rollout), + "VIME_RECOVERY_TEST_KILL_MANAGER": str(int(kill_manager and manager_crash_phase == "training")), + "CUDA_DEVICE_MAX_CONNECTIONS": "1", + "RAY_USE_UVLOOP": "0", + "PYTHONUNBUFFERED": "1", + "OMP_NUM_THREADS": "1", + "NO_PROXY": "*", + "no_proxy": "*", + **{ + name: os.environ[name] + for name in ("NCCL_SOCKET_IFNAME", "GLOO_SOCKET_IFNAME", "NCCL_NVLS_ENABLE") + if name in os.environ + }, + **{ + name: "" + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy") + }, + } + } + model_path = model_path or f"/root/models/{MODEL_NAME}" + dataset_path = dataset_path or "/root/datasets/gsm8k/train.parquet" + session_args = SimpleNamespace( + rollout_data_transport="straw" if mode == "straw" else "object-store", + rollout_data_dir=str(directory / "queue"), + rollout_queue_run_id="rollout", + save_debug_rollout_data=str(directory / "rollout_{rollout_id}.pt"), + ) + manager = None + try: + for tp_size in (1, 2): + cluster_address = os.environ.get("VIME_TEST_RAY_ADDRESS", "127.0.0.1:6379") + command = ( + f"cd {quote(str(REPO))} && source scripts/models/{model_type}.sh " + f'&& export RAY_ADDRESS={quote(cluster_address)} && python train.py "${{MODEL_ARGS[@]}}" ' + ) + command += train_args(directory, mode, tp_size, model_path, dataset_path, save_interval, fault_tolerance) + command += f"--actor-num-gpus-per-node {train_gpus} --rollout-num-gpus {rollout_gpus} " + extra_args + " " + if pd: + config = directory / "serving.json" + config.write_text( + json.dumps( + { + "vllm": [ + { + "name": "default", + "server_groups": [ + { + "worker_type": side, + "num_gpus": rollout_gpus // 2, + "num_gpus_per_engine": rollout_gpus // 2, + "overrides": { + "kv_transfer_config": { + "kv_connector": "NixlConnector", + "kv_role": "kv_both", + } + }, + } + for side in ("prefill", "decode") + ], + } + ] + } + ) + ) + command += f"--vllm-config {quote(str(config))} " + + if weight_sync == "disk-delta": + command += ( + "--update-weight-mode delta --update-weight-transport disk " + f"--update-weight-disk-dir {quote(str(directory / 'weights'))} " + f"--update-weight-local-checkpoint-dir {quote('/tmp/vime-delta-' + directory.name)} " + ) + if wedge_engine: + command += "--rollout-health-check-timeout 5 " + if role_config: + path = directory / f"megatron_tp{tp_size}.json" + path.write_text( + json.dumps( + { + "megatron": [ + { + "name": "actor", + "role": "actor", + "overrides": { + "load": model_path, + "save": str(directory / "checkpoint"), + "tensor_model_parallel_size": tp_size, + "sequence_parallel": tp_size > 1, + "update_weight_start_version": 0, + }, + } + ] + } + ) + ) + command += "--megatron-config-path " + quote(str(path)) + submission_id = client.submit_job(entrypoint="bash -c " + quote(command), runtime_env=runtime_env) + (directory / f"submission_tp{tp_size}.json").write_text( + json.dumps( + {"submission_id": submission_id, "entrypoint": command, "runtime_env": runtime_env}, indent=2 + ) + ) + status = wait_job(client, submission_id, directory / ("failed.log" if tp_size == 1 else "resumed.log")) + expected = JobStatus.FAILED if tp_size == 1 else JobStatus.SUCCEEDED + assert status == expected, (status, directory) + if tp_size == 1: + # Fail at the actual injection boundary, rather than accepting + # an unrelated initialization failure as the expected OOM. + assert (directory / "before.json").exists(), directory / "failed.log" + ray.init( + address=os.environ.get("VIME_TEST_RAY_ADDRESS", "127.0.0.1:6379"), + namespace=RECOVERY_NAMESPACE, + ignore_reinit_error=True, + ) + serving = ray.get_actor(training_session_name(session_args) + ":serving", namespace=RECOVERY_NAMESPACE) + assert ray.get(serving.get_updatable_engines_and_lock.remote())[0] + if kill_manager and manager_crash_phase == "training": + try: + manager = ray.get_actor(training_session_name(session_args), namespace=RECOVERY_NAMESPACE) + except ValueError: + pass + else: + with pytest.raises(ray.exceptions.RayActorError): + ray.get(manager.get_updatable_engines_and_lock.remote()) + else: + manager = ray.get_actor(training_session_name(session_args), namespace=RECOVERY_NAMESPACE) + assert ray.get(manager.get_updatable_engines_and_lock.remote())[0] + before = json.loads((directory / "before.json").read_text()) + if kill_manager and manager_crash_phase == "after-failure": + ray.kill(manager, no_restart=True) + assert ray.get(serving.get_updatable_engines_and_lock.remote())[0] + with requests.Session() as http: + http.trust_env = False + response = http.post( + f"http://{before['router'][0]}:{before['router'][1]}/v1/completions", + json={ + "model": model_path, + "prompt": "The result of 1 + 1 is", + "max_tokens": 8, + "temperature": 0, + }, + timeout=60, + ) + response.raise_for_status() + (directory / "serving_after_failure.json").write_text(json.dumps(response.json())) + if wedge_engine: + # Queue an RPC that never reaches reset/shutdown. The next + # attachment must time out, kill this actor and its vLLM + # children, then allocate a healthy replacement on its GPUs. + engine = ray.get(serving.get_updatable_engines_and_lock.remote())[0][0] + started = directory / "wedged_engine.pid" + + def block(actor, started=started): + started.write_text(str(os.getpid())) + time.sleep(300) + + engine.__ray_call__.remote(block) + deadline = time.monotonic() + 10 + while not started.exists(): + assert time.monotonic() < deadline + time.sleep(0.1) + + else: + before = json.loads((directory / "before.json").read_text()) + # Successful completion must release retained serving resources, + # without depending on the runner's final ray stop cleanup. + with requests.Session() as http: + http.trust_env = False + for engine in before["engines"]: + deadline = time.monotonic() + 20 + while True: + try: + http.get(engine["url"] + "/health", timeout=2) + except requests.ConnectionError: + break + assert time.monotonic() < deadline, engine + time.sleep(1) + verify(directory, mode, failure_rollout, save_interval, kill_manager, wedge_engine, train_gpus) + finally: + if not ray.is_initialized(): + ray.init(address=os.environ.get("VIME_TEST_RAY_ADDRESS", "127.0.0.1:6379"), namespace=RECOVERY_NAMESPACE) + for suffix in ("", ":serving"): + try: + retained = ray.get_actor(training_session_name(session_args) + suffix, namespace=RECOVERY_NAMESPACE) + except ValueError: + continue + try: + ray.get(retained.dispose.remote(), timeout=60) + except ray.exceptions.RayActorError: + pass + finally: + ray.kill(retained, no_restart=True) + ray.shutdown() + if not external_ray: + subprocess.run(["ray", "stop", "--force"], check=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=["straw", "debug"], default="straw") + parser.add_argument("--failure-rollout", type=int, choices=[0, 1], default=0) + parser.add_argument("--directory") + parser.add_argument("--model-path") + parser.add_argument("--dataset-path") + parser.add_argument("--no-prepare", action="store_true") + parser.add_argument("--role-config", action="store_true") + parser.add_argument("--save-interval", type=int, choices=[1, 3]) + parser.add_argument("--kill-manager", action="store_true") + parser.add_argument("--no-fault-tolerance", action="store_true") + parser.add_argument("--manager-crash-phase", choices=["after-failure", "training"], default="after-failure") + parser.add_argument("--weight-sync", choices=["nccl", "disk-delta"], default="nccl") + parser.add_argument("--wedge-engine", action="store_true") + parser.add_argument("--pd", action="store_true") + cli = parser.parse_args() + if not cli.no_prepare: + prepare() + execute( + cli.mode, + cli.failure_rollout, + cli.directory, + cli.model_path, + cli.dataset_path, + cli.role_config, + cli.save_interval, + cli.kill_manager, + not cli.no_fault_tolerance, + cli.manager_crash_phase, + cli.weight_sync, + cli.wedge_engine, + cli.pd, + ) diff --git a/tests/test_qwen3_30B_A3B_training_recovery.py b/tests/test_qwen3_30B_A3B_training_recovery.py new file mode 100644 index 000000000..7347241d7 --- /dev/null +++ b/tests/test_qwen3_30B_A3B_training_recovery.py @@ -0,0 +1,59 @@ +"""MoE/R3 recovery with stateless Adam: OOM, manager loss, resharding and route replay.""" + +import argparse +import runpy +from pathlib import Path + +import vime.utils.external_utils.command_utils as U + +MODEL_NAME = "Qwen3-30B-A3B" +NUM_GPUS = 8 + + +def prepare(): + U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir /root/models/{MODEL_NAME}") + U.hf_download_dataset("zhuzilin/gsm8k") + + +def execute(directory=None, model_path=None, dataset_path=None, pd=False): + suite = runpy.run_path(str(Path(__file__).with_name("test_qwen2.5_0.5B_training_recovery.py"))) + suite["execute"]( + directory=directory, + model_path=model_path or f"/root/models/{MODEL_NAME}", + dataset_path=dataset_path, + failure_rollout=1, + save_interval=1, + kill_manager=True, + manager_crash_phase="training", + fault_tolerance=False, + model_type="qwen3-30B-A3B", + train_gpus=4, + rollout_gpus=4, + pd=pd, + extra_args=( + "--rollout-num-gpus-per-engine 2 --expert-model-parallel-size 4 " + # Exercise recovery without Adam moments; scheduler/RNG still resume + # with the model, and accepted R3 routes must replay byte-for-byte. + "--use-stateless-adam --no-save-optim " + "--use-rollout-routing-replay --optimizer-cpu-offload --overlap-cpu-optimizer-d2h-h2d " + "--use-precision-aware-optimizer --recompute-granularity full --recompute-method uniform " + "--recompute-num-layers 1 --vllm-server-concurrency 32 " + # Parallelize the CPU master-parameter updates for this full-size model. + '--train-env-vars \'{"OMP_NUM_THREADS":"8"}\' ' + # Keep communication scratch bounded across repeated training calls. + "--overlap-grad-reduce --ddp-bucket-size 40000000 " + ), + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--no-prepare", action="store_true") + parser.add_argument("--directory") + parser.add_argument("--model-path") + parser.add_argument("--dataset-path") + parser.add_argument("--pd", action="store_true") + args = parser.parse_args() + if not args.no_prepare: + prepare() + execute(args.directory, args.model_path, args.dataset_path, args.pd) diff --git a/tests/test_rollout_manager_recovery.py b/tests/test_rollout_manager_recovery.py new file mode 100644 index 000000000..b5da9b9eb --- /dev/null +++ b/tests/test_rollout_manager_recovery.py @@ -0,0 +1,403 @@ +"""Lose a manager or RPC reply at persistence boundaries and consume its replay. + +Two local Ray nodes run the production manager, distributed generators and Straw +controller. Inference and the optimizer use CPU fixtures; this does not exercise +vLLM or Megatron GPU recovery. +""" + +import json +import os +import signal +import sys +from pathlib import Path + +import pytest +import ray +import torch +from ray.cluster_utils import Cluster +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy +from test_distributed_rollout import _rollout_args + +from vime.data.checkpoint import RestorePlan +from vime.data.queue_data_source import create_queue_controller +from vime.data.transport import load_rollout_samples +from vime.ray.rollout import RolloutManager +from vime.ray.serving import ServingCluster, ServingDeployment +from vime.rollout.base_types import iter_samples +from vime.utils.data import process_rollout_data + +NUM_GPUS = 0 + + +@ray.remote(num_cpus=0) +class _ServingFixture: + """Keep the production queue alive without starting GPU inference servers.""" + + def __init__(self, args): + self.controller = create_queue_controller(args) + + def deployment(self, reused): + return ServingDeployment( + placements={"rollout": None}, + servers={}, + controller=self.controller, + restore_plan=RestorePlan(), + routers={}, + engine_lock=None, + reused=reused, + ) + + def identity(self): + return os.getpid(), self.controller._actor_id.hex() + + def health_monitoring_resume(self): + return {} + + def finish_rollout(self): + return {} + + def get_weight_version(self, *, allow_inconsistent=False): + return 1 + + def dispose(self): + ray.get(self.controller.close.remote()) + ray.kill(self.controller, no_restart=True) + + +def _post_process_once(args, samples): + # A successfully journaled conversion must never invoke the hook again. + indices = sorted(sample.index for sample in samples) + path = Path(args.rollout_data_dir).parent / f"converted_{indices[0]}.json" + with path.open("x") as stream: + json.dump(indices, stream) + rewards = [sample.reward for sample in samples] + return rewards, [3 * reward for reward in rewards] + + +def _install_crash(manager, phase): + from straw.reporting import write_report + + def crash(raw): + samples = list(iter_samples(load_rollout_samples(raw))) + write_report( + Path(manager.args.rollout_data_dir).parent / "crash.json", + { + "manager_pid": os.getpid(), + "samples": [ + {"index": sample.index, "tokens": sample.tokens, "reward": sample.reward} for sample in samples + ], + }, + ) + if phase == "conversion_reply_lost": + raise TimeoutError("Conversion committed but its RPC reply was lost") + os.kill(os.getpid(), signal.SIGKILL) + + if phase == "raw_accepted": + original = manager._get_rollout_data + + def accept(*args, **kwargs): + original(*args, **kwargs) + crash(manager.batch_builder.raw_ref) + + manager._get_rollout_data = accept + elif phase in {"conversion_accepted", "conversion_reply_lost"}: + original = manager.batch_builder.publish_converted + + def publish(data): + original(data) + manager.batch_builder.publish_converted = original + crash(manager.batch_builder.raw_ref) + + manager.batch_builder.publish_converted = publish + else: + original = manager.recovery.remember_converted + + def remember(rollout_id, data, batch_id): + original(rollout_id, data, batch_id) + crash(manager.recovery.batches[rollout_id].raw) + + manager.recovery.remember_converted = remember + + +def _consume_batch(refs, rank): + data = process_rollout_data(refs, rank, len(refs)) + weight = torch.nn.Parameter(torch.zeros(1)) + optimizer = torch.optim.SGD([weight], lr=0.01) + inputs = torch.tensor([float(tokens.sum()) for tokens in data["tokens"]]) + targets = torch.tensor(data["local_raw_reward"]) + 1 + loss = ((inputs * weight - targets) ** 2).mean() + loss.backward() + optimizer.step() + assert torch.isfinite(loss) and weight.item() > 0 + return { + "node": ray.get_runtime_context().get_node_id(), + "samples": [ + {"index": index, "tokens": tokens.tolist(), "reward": reward} + for index, tokens, reward in zip(data["sample_indices"], data["tokens"], data["rewards"], strict=True) + ], + } + + +@pytest.fixture(scope="module") +def cluster(): + cluster = Cluster() + try: + for _ in range(2): + cluster.add_node(num_cpus=3, num_gpus=0, object_store_memory=128 * 1024**2, include_dashboard=False) + ray.init( + address=cluster.address, + runtime_env={ + "env_vars": { + "PYTHONPATH": os.pathsep.join( + [str(Path(__file__).parent), str(Path(__file__).resolve().parents[1])] + ), + "OMP_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + } + }, + ) + yield sorted(node["NodeID"] for node in ray.nodes() if node["Alive"]) + finally: + ray.shutdown() + cluster.shutdown() + + +@pytest.mark.parametrize("online_gc", [False, True], ids=["retain", "online-gc"]) +@pytest.mark.parametrize("phase", ["raw_accepted", "conversion_accepted", "converted", "conversion_reply_lost"]) +def test_manager_failure_replays_accepted_batch_with_new_dp(tmp_path, cluster, phase, online_gc): + args = _rollout_args(tmp_path) + args.rollout_queue_run_id = "manager-recovery" + args.rollout_queue_online_gc = online_gc + args.rollout_queue_segment_mib = 1 + args.start_rollout_id = 0 + args.save_debug_rollout_data = None + args.custom_reward_post_process_path = "test_rollout_manager_recovery._post_process_once" + parallel = dict(dp_size=1, cp_size=1, vpp_size=1, microbatch_group_size_per_vp_stage=1) + serving = _ServingFixture.remote(args) + managers = [] + try: + identity = ray.get(serving.identity.remote(), timeout=60) + deployment = ray.get(serving.deployment.remote(False), timeout=60) + manager = RolloutManager.options(num_cpus=1).remote(args, None, serving=serving, deployment=deployment) + managers.append(manager) + ray.get(manager.attach_training.remote(args, deployment), timeout=60) + ray.get(manager.set_train_parallel_config.remote(parallel), timeout=60) + ray.get(manager.load.remote(-1), timeout=60) + ray.get(manager.__ray_call__.remote(_install_crash, phase), timeout=60) + failure = ray.exceptions.RayTaskError if phase == "conversion_reply_lost" else ray.exceptions.RayActorError + with pytest.raises(failure): + ray.get(manager.generate.remote(0), timeout=90) + before = json.loads((tmp_path / "crash.json").read_text()) + assert len(before["samples"]) == args.global_batch_size + + deployment = ray.get(serving.deployment.remote(True), timeout=60) + if phase != "conversion_reply_lost": + manager = RolloutManager.options(num_cpus=1).remote(args, None, serving=serving, deployment=deployment) + managers.append(manager) + resumed = ray.get(manager.attach_training.remote(args, deployment), timeout=60) + assert resumed.checkpoint.start_rollout_id == 0 + ray.get(manager.set_train_parallel_config.remote({**parallel, "dp_size": 2}), timeout=60) + ray.get(manager.load.remote(-1), timeout=60) + refs = ray.get(manager.generate.remote(0), timeout=90) + assert len(refs) == 2 + results = ray.get( + [ + ray.remote(_consume_batch) + .options(scheduling_strategy=NodeAffinitySchedulingStrategy(node, soft=False)) + .remote(refs, rank) + for rank, node in enumerate(cluster) + ], + timeout=60, + ) + assert len({result["node"] for result in results}) == 2 + expected = [{**sample, "reward": 3 * sample["reward"]} for sample in before["samples"]] + actual = [sample for result in results for sample in result["samples"]] + assert sorted(actual, key=lambda sample: sample["index"]) == sorted( + expected, key=lambda sample: sample["index"] + ) + assert ray.get(serving.identity.remote(), timeout=30) == identity + same_manager = ( + ray.get(manager.__ray_call__.remote(lambda self: os.getpid()), timeout=30) == before["manager_pid"] + ) + assert same_manager == (phase == "conversion_reply_lost") + assert len(list(tmp_path.glob("converted_*.json"))) == 1 + ray.get(manager.training_completed.remote(0), timeout=60) + + # The retained controller must advance past the replayed selection. + refs = ray.get(manager.generate.remote(1), timeout=90) + next_samples = [sample for rank in range(2) for sample in _consume_batch(refs, rank)["samples"]] + assert {sample["index"] for sample in actual}.isdisjoint(sample["index"] for sample in next_samples) + ray.get(manager.training_completed.remote(1), timeout=60) + ray.get(manager.dispose.remote(), timeout=60) + finally: + for manager in managers: + ray.kill(manager, no_restart=True) + ray.kill(serving, no_restart=True) + + +@ray.remote(num_cpus=0) +class _ResetPeer: + def __init__(self, wedged): + self.wedged = wedged + + def pid(self): + return os.getpid() + + def reset_weights_update_groups(self): + if self.wedged: + import time + + time.sleep(300) + + def shutdown(self): + pass + + +def test_serving_reset_retires_blocked_actor_and_keeps_healthy_peer(cluster, monkeypatch): + import time + from types import SimpleNamespace + from vime.backends.vllm_utils import engine_group + + blocked, healthy = _ResetPeer.remote(True), _ResetPeer.remote(False) + pids = ray.get([blocked.pid.remote(), healthy.pid.remote()], timeout=30) + removed = [] + monkeypatch.setattr(engine_group, "unregister_worker", lambda router, url, **kw: removed.append(url)) + group = engine_group.ServerGroup( + args=SimpleNamespace(num_gpus_per_node=1), + pg=None, + all_engines=[blocked, healthy], + num_gpus_per_engine=1, + num_new_engines=0, + router_ip="router", + router_port=1, + engine_urls={0: "http://blocked", 1: "http://healthy"}, + ) + try: + start = time.monotonic() + engine_group.reset_weights_update_groups([group], timeout=0.5) + assert time.monotonic() - start < 5 + assert group.all_engines == [None, healthy] + assert removed == ["http://blocked"] + assert ray.get(healthy.pid.remote(), timeout=5) == pids[1] + with pytest.raises(ray.exceptions.RayActorError): + ray.get(blocked.pid.remote(), timeout=5) + finally: + ray.kill(blocked, no_restart=True) + ray.kill(healthy, no_restart=True) + + +@ray.remote(num_cpus=0) +class _AttemptManager: + def __init__(self, blocked): + self.blocked = blocked + + def ready(self): + return True + + def detach_training(self): + if self.blocked: + import time + + time.sleep(300) + raise RuntimeError("consumer pause failed") + + +@ray.remote(num_cpus=0) +class _ResetBarrier: + """Model NCCL group destruction: every peer must enter before any can leave.""" + + def __init__(self): + self.entered = set() + + def enter(self, name): + self.entered.add(name) + + def ready(self): + return len(self.entered) == 2 + + +@ray.remote(num_cpus=0) +class _PDResetPeer: + def __init__(self, barrier, name): + self.barrier, self.name = barrier, name + + def reset_weights_update_groups(self): + import time + + ray.get(self.barrier.enter.remote(self.name)) + while not ray.get(self.barrier.ready.remote()): + time.sleep(0.01) + + def ready(self): + return True + + +def test_prefill_and_decode_enter_group_reset_together(cluster): + from types import SimpleNamespace + from unittest.mock import Mock + + from vime.backends.vllm_utils.engine_group import reset_weights_update_groups + + barrier = _ResetBarrier.remote() + peers = [_PDResetPeer.remote(barrier, name) for name in ("prefill", "decode")] + try: + ray.get([peer.ready.remote() for peer in peers], timeout=30) + groups = [SimpleNamespace(all_engines=[peer], nodes_per_engine=1, retire_engine=Mock()) for peer in peers] + reset_weights_update_groups(groups, timeout=2) + for group in groups: + group.retire_engine.assert_not_called() + assert ray.get(barrier.ready.remote(), timeout=5) + finally: + for actor in [*peers, barrier]: + ray.kill(actor, no_restart=True) + + +@ray.remote(num_cpus=0) +class _AttemptServing(ServingCluster.__ray_metadata__.modified_class): + """Use production detach/fencing with a CPU trainer and no inference GPUs.""" + + def __init__(self, trainer, job_id): + from types import SimpleNamespace + + self.args = SimpleNamespace(rollout_cleanup_timeout=2) + self.training_actors = {"actor": [trainer]} + self.driver_job_id = job_id + self._health_monitors = [] + + def state(self): + return os.getpid(), self.driver_job_id, self.training_actors + + +@pytest.mark.parametrize("blocked", [False, True], ids=["pause-error", "wedged-manager"]) +def test_driver_detach_releases_real_trainer_when_manager_cannot_pause(cluster, blocked): + import time + from types import SimpleNamespace + + from vime.ray.placement_group import RolloutStartup + + job_id = ray.get_runtime_context().get_job_id() + trainer = _ResetPeer.remote(False) + manager = _AttemptManager.remote(blocked) + serving = _AttemptServing.remote(trainer, job_id) + try: + identity = ray.get(serving.state.remote(), timeout=30)[0] + ray.get([manager.ready.remote(), trainer.pid.remote()], timeout=30) + startup = RolloutStartup(SimpleNamespace(rollout_cleanup_timeout=2), manager=manager, serving=serving) + start = time.monotonic() + startup.close(failed=True) + assert time.monotonic() - start < 5 + assert ray.get(serving.state.remote(), timeout=5) == (identity, None, {"actor": []}) + with pytest.raises(ray.exceptions.RayActorError): + ray.get(trainer.pid.remote(), timeout=5) + # Repeat a lost-reply cleanup and reject a stale driver's later request. + ray.get(serving.detach_training.remote(job_id), timeout=5) + ray.get(serving.__ray_call__.remote(lambda self: setattr(self, "driver_job_id", "successor")), timeout=5) + with pytest.raises(ray.exceptions.RayTaskError, match="owning driver"): + ray.get(serving.detach_training.remote(job_id), timeout=5) + finally: + for actor in (manager, serving, trainer): + ray.kill(actor, no_restart=True) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, *sys.argv[1:]])) diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py index a882a815a..3b6280933 100644 --- a/tests/test_rollout_straw_transport.py +++ b/tests/test_rollout_straw_transport.py @@ -18,6 +18,7 @@ pack_rollout_group, pack_rollout_payload, resolve_rollout_data_dir, + rollout_store, seal_rollout_store, unpack_rollout_payload, ) @@ -26,6 +27,77 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("missing_engine", [False, True]) +@pytest.mark.parametrize("previous_rollout", [-1, 7]) +def test_weight_update_preserves_connections_or_recovers_missing_engine(monkeypatch, missing_engine, previous_rollout): + from vime.ray import rollout + from vime.ray.serving import ServingCluster + + manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager.rollout_id = previous_rollout + manager.servers = {} + calls = [] + + def recover(): + calls.append("recover") + server.num_new_engines = 1 + + # A fresh or reattached trainer needs these pending connection markers even + # when a retained manager still has the old driver's rollout ID. + server = SimpleNamespace( + update_weights=True, + all_engines=[None if missing_engine else object(), object()], + num_new_engines=2, + recover=recover, + ) + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.servers = {"model": server} + serving._health_monitors = [] + manager.serving = SimpleNamespace( + recover_updatable_engines=SimpleNamespace(remote=serving.recover_updatable_engines) + ) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + manager.recover_updatable_engines() + assert calls == (["recover"] if missing_engine else []) + assert manager.servers["model"].num_new_engines == (1 if missing_engine else 2) + + +@pytest.mark.parametrize("evaluation", [False, True]) +def test_rollout_boundary_checks_serving_before_returning_data(monkeypatch, evaluation): + from vime.ray import rollout + + events = [] + manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager.args = SimpleNamespace( + ci_test=False, use_fault_tolerance=False, debug_train_only=False, save_debug_rollout_data=None + ) + manager.batch_builder = SimpleNamespace() + manager.data_source = object() + manager.eval_generate_rollout = object() + manager.health_monitoring_resume = lambda: events.append("resume") + manager._generate = lambda *a: events.append("generate") or "batch" + + def finish(): + events.append("check") + return {"live": object()} + + manager.serving = SimpleNamespace(finish_rollout=SimpleNamespace(remote=finish)) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + monkeypatch.setattr( + rollout, + "call_rollout_fn", + lambda *a, **kw: events.append("generate") or SimpleNamespace(data=[], metrics=None), + ) + monkeypatch.setattr(rollout, "save_debug_rollout_data", lambda *a, **kw: None) + monkeypatch.setattr(rollout, "log_eval_rollout_data", lambda *a, **kw: None) + if evaluation: + manager.eval(0) + else: + assert manager.generate(0) == "batch" + assert events == ["resume", "generate", "check"] + assert set(manager.servers) == {"live"} + + def test_cpu_rollout_imports_do_not_require_vllm(): subprocess.run( [ @@ -60,7 +132,8 @@ def args(tmp_path): @pytest.mark.parametrize("queue_source", [False, True]) -def test_custom_source_constructor_keeps_args_only_contract(args, monkeypatch, queue_source): +@pytest.mark.parametrize("serving_owned", [False, True]) +def test_custom_source_constructor_keeps_args_only_contract(args, monkeypatch, queue_source, serving_owned): from vime.data import queue_data_source, transport from vime.data.checkpoint import RestorePlan from vime.ray import rollout @@ -95,18 +168,33 @@ def create_controller(config, *, restore_plan): monkeypatch.setattr(rollout, "init_tracking", lambda *a, **kw: None) monkeypatch.setattr(rollout.logging_utils, "finish_tracking", lambda _: None) monkeypatch.setattr(rollout, "Lock", SimpleNamespace(options=lambda **kw: SimpleNamespace(remote=lambda: None))) - monkeypatch.setattr(rollout.ray, "get", lambda value: value) + monkeypatch.setattr(rollout.ray, "get", lambda value, **kw: value) monkeypatch.setattr(rollout.ray, "kill", lambda *a, **kw: None) monkeypatch.setattr(transport, "seal_rollout_store", lambda _: None) - manager = rollout.RolloutManager.__ray_metadata__.modified_class(args, None, restore_plan=plan) + serving, deployment = None, None + if serving_owned: + from vime.ray.serving import ServingDeployment + + serving = SimpleNamespace( + get_queue_controller=SimpleNamespace(remote=lambda: create_controller(args, restore_plan=plan)), + dispose=SimpleNamespace(remote=lambda: None if queue_source else calls.append("controller_close")), + ) + deployment = ServingDeployment( + placements={"rollout": None}, servers={}, controller=None, restore_plan=plan, routers={}, engine_lock=None + ) + manager = rollout.RolloutManager.__ray_metadata__.modified_class( + args, None, restore_plan=plan, serving=serving, deployment=deployment + ) assert manager.controller is manager.batch_builder.controller is handle assert vars(args) == original manager.dispose() - assert calls == ( - ["source_init", "source_close"] - if queue_source - else ["source_init", "controller_init", "source_close", "controller_close"] - ) + expected = ["source_init"] + if not queue_source: + expected.append("controller_init") + expected.append("source_close") + if not queue_source and not serving_owned: + expected.append("controller_close") + assert calls == expected def test_directory_requires_explicit_shared_root_or_checkpoint(tmp_path): @@ -137,6 +225,7 @@ def test_local_test_launcher_does_not_invent_storage_paths(options, monkeypatch) commands = [] monkeypatch.setattr(command_utils, "exec_command", commands.append) monkeypatch.setattr(command_utils, "check_has_nvlink", lambda: False) + monkeypatch.setattr(command_utils.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=1)) monkeypatch.setenv("VIME_SCRIPT_EXTERNAL_RAY", "0") monkeypatch.setenv("VIME_SCRIPT_ENABLE_RAY_SUBMIT", "1") command_utils.execute_train(options, num_gpus_per_node=1, megatron_model_type=None) @@ -144,6 +233,22 @@ def test_local_test_launcher_does_not_invent_storage_paths(options, monkeypatch) assert options in commands[-1] +@pytest.mark.parametrize("segment_mib", [None, 1]) +def test_packs_are_readable_before_full_and_respect_size_override(args, segment_mib): + args.rollout_queue_segment_mib = segment_mib + store, _, _ = rollout_store(args) + assert store.segment_target_bytes == (1024**3 if segment_mib is None else 1024**2) + references = [] + for index in range(4): + reference = pack_rollout_payload([Sample(index=index, response="x" * 400_000)], args, 0) + assert reference.load()[0].response == "x" * 400_000 + references.append(reference) + packs = list(Path(args.rollout_data_dir).rglob("*.pack")) + assert len(packs) == 1 if segment_mib is None else len(packs) > 1 + # Rotation must preserve every earlier publication, without sealing the writer. + assert [reference.load()[0].index for reference in references] == list(range(4)) + + def test_payload_reference_is_small_and_readable_by_independent_process(args, tmp_path): tensor = torch.arange(1_000_000, dtype=torch.int32) sample = Sample(tokens=[1, 2], response_length=1, rollout_routed_experts=tensor) @@ -241,8 +346,8 @@ def take(limit): assert saved[0].load()[0].index == 2 return [(len(saved), [Sample(index=2 - len(saved), reward=1)])] - worker = SimpleNamespace(queue_size=lambda: 0, get_completed_groups=take) - monkeypatch.setattr(fa, "_get_global_worker", lambda *a: worker) + worker = SimpleNamespace(queue_size=lambda: 0, get_completed_groups=take, resume=lambda: None, error=None) + monkeypatch.setattr(fa, "_get_worker", lambda *a: worker) from vime.data import transport as rollout_transport monkeypatch.setattr(rollout_transport, "pack_rollout_group", pack) @@ -335,6 +440,7 @@ def test_manager_adapts_legacy_samples_and_validates_accepted_refs(args, monkeyp ) monkeypatch.setattr(rollout.ray, "get", lambda value: value) manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager.serving = None manager.args = args manager.controller = handle args.load_debug_rollout_data = None @@ -368,6 +474,7 @@ def test_train_partitions_preserve_top_p_and_multimodal(args, monkeypatch, trans if transport != "straw": args.rollout_data_dir = None manager = object.__new__(rollout.BatchBuilder) + manager._plan = manager.batch_id = None manager.args = SimpleNamespace(**vars(args), global_batch_size=2) manager.rollout_id = 0 manager.train_parallel_config = {"dp_size": 2} @@ -377,7 +484,7 @@ def test_train_partitions_preserve_top_p_and_multimodal(args, monkeypatch, trans def put(value, **kwargs): if transport == "straw": assert isinstance(value, DiskPayloadRef) - assert len(pickle.dumps(value)) < 1024 + assert len(pickle.dumps(value)) < 2048 else: assert isinstance(value, dict) assert kwargs == ({"_tensor_transport": "nixl"} if transport == "nixl" else {}) @@ -471,18 +578,18 @@ def test_durable_batch_replays_one_plan_and_rejects_mixed_ranks(args, monkeypatc process_rollout_data(mixed, 0, 2) args.save = str(Path(args.rollout_data_dir) / "checkpoint") builder.save(0) - assert controller.queue._usage()["ready_bytes"] > 0 + assert controller.queue._usage()["accepted_unprocessed"]["bytes"] > 0 builder.training_completed(builder.rollout_id) - assert controller.queue._usage()["ready_bytes"] == 0 + assert controller.queue._usage()["accepted_unprocessed"]["bytes"] == 0 assert not controller.queue.checkpoints # runtime completion is not a checkpoint # Later production facts survive restoring the earlier training view. later = accept_raw_rollout(RolloutFnTrainOutput(samples=samples), args, 1, controller=handle) - assert later.receipt.position == 1 + assert later.receipt.position == 2 # raw collection and accepted conversion precede it args.load = args.save builder.load(0) - assert controller.queue._usage()["ready_bytes"] > 0 # earlier consumer view is restored + assert controller.queue._usage()["accepted_unprocessed"]["bytes"] > 0 # earlier consumer view is restored assert controller.training_state()["processed_cursor"] == 1 - assert controller.queue.read_commits().cursor == 2 + assert controller.queue.read_commits().cursor == 3 args.global_batch_size = 4 with pytest.raises(ValueError, match="different selection or conversion plan"): builder.begin(samples) @@ -710,6 +817,7 @@ def test_debug_archive_replay_reuses_records_and_isolates_training( } ) manager = object.__new__(RolloutManager.__ray_metadata__.modified_class) + manager.serving = None manager.args, manager.controller = replay_args, handle builder = manager.batch_builder = BatchBuilder(replay_args, controller=handle) builder.train_parallel_config = dict( @@ -732,13 +840,13 @@ def test_debug_archive_replay_reuses_records_and_isolates_training( assert [s.index for s in iter_samples(load_rollout_samples(builder.raw_ref))] == [0, 1] assert store.metrics["payload_bytes"] - written < 10000 assert builder.begin(loaded) is None - assert builder._positions == [step - 7] + assert replay.batch(builder.batch_id)["input_positions"] == [2 * (step - 7)] refs = builder.split_by_dp(builder.convert(loaded)) batch = process_rollout_data(refs, 0, 1) assert [t.tolist() for t in batch["tokens"]] == [[1, 2, 3], [1, 2, 3]] assert [box.inner for box in builder.begin(loaded)] == [box.inner for box in refs] builder.training_completed(step) - assert replay.training_state()["processed_cursor"] == 2 + assert replay.training_state()["processed_cursor"] == 4 assert original.queue.tasks == source_tasks assert not (tmp_path / "unused-pool").exists() @@ -762,6 +870,7 @@ def test_debug_archive_replay_rejects_changing_storage(args, tmp_path, different args.start_rollout_id = 0 resolve_rollout_data_dir(args) manager = object.__new__(RolloutManager.__ray_metadata__.modified_class) + manager.serving = None manager.args = args with pytest.raises(ValueError, match="same Straw storage pool and run"): manager._get_rollout_data(1) @@ -827,14 +936,14 @@ def test_fork_requires_committed_model_and_source_and_restores_weight_version(tm ckpt_step=step, start_rollout_id=None, ) - resolve_checkpoint(restore) + restore, _ = resolve_checkpoint(restore) assert restore.ckpt_step == step and restore.start_rollout_id == step + 1 assert restore.update_weight_start_version == step + 1 assert restore.rollout_data_dir == args.rollout_data_dir assert not (tmp_path / "child").exists() # Selection does not mutate the parent or create a job. (rollout / f"queue_state_{step}.json").write_text("changed") with pytest.raises(ValueError, match="changed"): - resolve_checkpoint(restore) + restore, _ = resolve_checkpoint(restore) @pytest.mark.parametrize("fail_at", [None, "actor", "critic", "rollout"]) @@ -896,21 +1005,17 @@ def save_rollout(step): manager = Mock() manager.save.remote.side_effect = save_rollout + manager.get_weight_version.remote.side_effect = lambda: start + actor.update_weights.call_count monkeypatch.setattr(module.ray, "get", lambda value: value) - monkeypatch.setattr(module, "configure_logger", lambda: None) - monkeypatch.setattr(module, "init_tracking", lambda args: None) - monkeypatch.setattr(module, "finish_tracking", lambda args: None) - monkeypatch.setattr(module, "create_placement_groups", lambda args: {"rollout": None}) - monkeypatch.setattr(module, "create_rollout_manager", lambda *a, **kw: (manager, None)) monkeypatch.setattr(module, "create_training_models", lambda *a: (actor, critic)) marker = tmp_path / "rollout" / f"committed_{start}.json" if fail_at: with pytest.raises(OSError, match=f"{fail_at} save failed"): - module.train(args) + module.train(args, {}, manager, None, None) assert saved == ["actor", "critic", "rollout"][: ["actor", "critic", "rollout"].index(fail_at) + 1] assert not marker.exists() else: - module.train(args) + module.train(args, {}, manager, None, None) assert saved == ["actor", "critic", "rollout"] * 3 assert actor.update_weights.call_count == 4 # Initial sync, then once per rollout. for step in range(start, start + 3): @@ -997,7 +1102,7 @@ def test_checkpoint_helper_preserves_save_modes(tmp_path, monkeypatch, mode): save_checkpoint(args, 0, actor, critic, manager, actor_trains=mode != "critic-only") if mode == "critic-only": actor.save_model.assert_not_called() - critic.save_model.assert_called_once_with(0, force_sync=False) + critic.save_model.assert_called_once_with(0, force_sync=True) else: actor.save_model.assert_called_once_with(0, force_sync=False) critic.save_model.assert_not_called() @@ -1024,6 +1129,32 @@ def test_checkpoint_retains_critic_save_policy(tmp_path, omitted): resolve_checkpoint(_checkpoint_args(root, tmp_path / "child", step=0)) +def test_stateless_checkpoint_requires_scheduler_and_allows_optimizer_omission(tmp_path): + import json + + from vime.data.checkpoint import commit_checkpoint, resolve_checkpoint + + root, pool = tmp_path / "run", tmp_path / "pool" + _checkpoint_fixture(root, 0, pool, queue=False) + for name in ("queue_state", "builder_state"): + (root / "rollout" / f"{name}_0.json").write_text("{}") + args = SimpleNamespace(save=str(root), rollout_data_dir=str(pool), use_stateless_adam=True, no_save_optim=True) + with pytest.raises(ValueError, match="Missing stateless Adam scheduler"): + commit_checkpoint(args, 0, model_args=[args]) + scheduler = root / "iter_0000000/opt_param_scheduler.pt" + torch.save({"num_steps": 16}, scheduler) + commit_checkpoint(args, 0, model_args=[args]) + committed = json.loads((root / "rollout/committed_0.json").read_text()) + assert committed["resumable"] + assert "iter_0000000/opt_param_scheduler.pt" in committed["files"] + restored = _checkpoint_args(root, tmp_path / "child", step=0) + restored.use_stateless_adam = restored.no_load_optim = True + assert resolve_checkpoint(restored)[1].mode == "snapshot" + scheduler.unlink() + with pytest.raises((FileNotFoundError, ValueError), match="opt_param_scheduler"): + resolve_checkpoint(_checkpoint_args(root, tmp_path / "broken", step=0)) + + def _checkpoint_fixture(root, step, pool, *, queue=True, restore_plan=None): """Write real joint-commit metadata around tiny model payload fixtures.""" import json @@ -1066,7 +1197,7 @@ def test_automatic_checkpoint_branches_repeat_save_and_resume_current(tmp_path): branches = [] for _ in range(2): args = _checkpoint_args(root, root, step=1) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) assert args.ckpt_step == 1 and args.start_rollout_id == 2 assert (plan_args.mode == "snapshot") and not (plan_args.mode == "resume") destination = Path(args.save) @@ -1080,7 +1211,7 @@ def test_automatic_checkpoint_branches_repeat_save_and_resume_current(tmp_path): (branches[-1] / "iter_0000003").mkdir() (branches[-1] / "latest_checkpointed_iteration.txt").write_text("3") resumed = _checkpoint_args(root, root) - resolve_checkpoint(resumed) + resumed, _ = resolve_checkpoint(resumed) assert resumed.load == str(branches[-1]) and resumed.ckpt_step == 2 assert resumed.save not in map(str, branches) assert resumed.rollout_data_dir == str(pool) @@ -1093,16 +1224,16 @@ def test_manual_branch_and_commit_selection_exclude_abandoned_future(tmp_path): _checkpoint_fixture(root, 1, pool) _checkpoint_fixture(root, 3, pool) fork = _checkpoint_args(root, root, step=1) - plan_fork = resolve_checkpoint(fork) + fork, plan_fork = resolve_checkpoint(fork) with closing(RolloutQueueController(fork, restore_plan=plan_fork)): _checkpoint_fixture(Path(fork.save), 2, pool) with pytest.raises(ValueError, match="No committed"): resolve_checkpoint(_checkpoint_args(root, tmp_path / "other", step=3)) exact = _checkpoint_args(root / "rollout/committed_3.json", tmp_path / "manual") - resolve_checkpoint(exact) + exact, _ = resolve_checkpoint(exact) assert exact.load == str(root) and exact.ckpt_step == 3 branch = _checkpoint_args(fork.save, tmp_path / "branch", step=2) - resolve_checkpoint(branch) + branch, _ = resolve_checkpoint(branch) assert branch.load == fork.save and branch.ckpt_step == 2 with pytest.raises(ValueError, match="differs"): resolve_checkpoint(_checkpoint_args(root / "rollout/committed_3.json", tmp_path / "bad", step=1)) @@ -1116,10 +1247,10 @@ def test_edited_tracker_rolls_back_and_explicit_step_takes_precedence(tmp_path): _checkpoint_fixture(root, step, pool) (root / "latest_checkpointed_iteration.txt").write_text("0") args = _checkpoint_args(root, root) - resolve_checkpoint(args) + args, _ = resolve_checkpoint(args) assert args.ckpt_step == 0 and args.start_rollout_id == 1 explicit = _checkpoint_args(root, root, step=1) - plan_explicit = resolve_checkpoint(explicit) + explicit, plan_explicit = resolve_checkpoint(explicit) assert explicit.ckpt_step == 1 with closing(RolloutQueueController(explicit, restore_plan=plan_explicit)): assert (root / "latest_checkpointed_iteration.txt").read_text() == "1" @@ -1128,21 +1259,21 @@ def test_edited_tracker_rolls_back_and_explicit_step_takes_precedence(tmp_path): # A normal restart selects the child, without treating its parent's model # tracker (at the same logical root) as an instruction to leave the branch. resumed = _checkpoint_args(root, root) - resolve_checkpoint(resumed) + resumed, _ = resolve_checkpoint(resumed) assert resumed.ckpt_step == 2 and resumed.load == explicit.save (root / "latest_checkpointed_iteration.txt").write_text("1") edited = _checkpoint_args(root, root) - resolve_checkpoint(edited) + edited, _ = resolve_checkpoint(edited) assert edited.ckpt_step == 1 and edited.load == str(root) # Users can also edit a physical branch's tracker. _checkpoint_fixture(Path(explicit.save), 3, pool, restore_plan=plan_explicit) (Path(explicit.save) / "latest_checkpointed_iteration.txt").write_text("2") physical = _checkpoint_args(explicit.save, tmp_path / "physical") - resolve_checkpoint(physical) + physical, _ = resolve_checkpoint(physical) assert physical.ckpt_step == 2 (root / "latest_checkpointed_iteration.txt").write_text("3\n") through_logical_root = _checkpoint_args(root, root) - resolve_checkpoint(through_logical_root) + through_logical_root, _ = resolve_checkpoint(through_logical_root) assert through_logical_root.ckpt_step == 2 and through_logical_root.load == explicit.save @@ -1154,7 +1285,7 @@ def test_edited_tracker_with_missing_queue_snapshot_starts_empty(tmp_path): _checkpoint_fixture(root, 2, pool) (root / "latest_checkpointed_iteration.txt").write_text("1") args = _checkpoint_args(root, root) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) assert args.ckpt_step == 1 and (plan_args.mode == "empty") assert args.update_weight_start_version == 2 (root / "latest_checkpointed_iteration.txt").write_text("0") @@ -1168,11 +1299,11 @@ def test_branch_without_first_commit_recovers_its_parent(tmp_path): parent, child = tmp_path / "parent", tmp_path / "child" _checkpoint_fixture(parent, 7, tmp_path / "pool") args = _checkpoint_args(parent, child, step=7) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) with closing(RolloutQueueController(args, restore_plan=plan_args)): pass # Crash before the child has produced a complete checkpoint. again = _checkpoint_args(child, child) - resolve_checkpoint(again) + again, _ = resolve_checkpoint(again) assert again.load == str(parent) and again.ckpt_step == 7 assert Path(again.save).parent == child / "branches" @@ -1183,14 +1314,14 @@ def test_initial_run_recovers_wal_without_queue_flags(tmp_path): root = tmp_path / "run" first = _checkpoint_args(None, root) first.hf_checkpoint = "initial-model" - plan_first = resolve_checkpoint(first) + first, plan_first = resolve_checkpoint(first) first.rollout_data_dir = str(root / "rollout_data") with closing(RolloutQueueController(first, restore_plan=plan_first)): pass for load in (root, None): again = _checkpoint_args(load, root) again.hf_checkpoint = "initial-model" - plan_again = resolve_checkpoint(again) + again, plan_again = resolve_checkpoint(again) assert (plan_again.mode == "resume") and not (plan_again.mode == "snapshot") assert (plan_again.queue_id) == (plan_first.queue_id) assert again.load is None and again.save == first.save @@ -1198,7 +1329,7 @@ def test_initial_run_recovers_wal_without_queue_flags(tmp_path): changed = _checkpoint_args(root, root) changed.hf_checkpoint = "different-model" with pytest.raises(ValueError, match="Initial model/input"): - resolve_checkpoint(changed) + changed, _ = resolve_checkpoint(changed) def test_checkpoint_directory_lock_and_stale_selection(tmp_path): @@ -1207,8 +1338,8 @@ def test_checkpoint_directory_lock_and_stale_selection(tmp_path): root = tmp_path / "run" _checkpoint_fixture(root, 1, tmp_path / "pool") first, stale = (_checkpoint_args(root, root) for _ in range(2)) - plan_first = resolve_checkpoint(first) - plan_stale = resolve_checkpoint(stale) + first, plan_first = resolve_checkpoint(first) + stale, plan_stale = resolve_checkpoint(stale) with closing(RolloutQueueController(first, restore_plan=plan_first)): with pytest.raises(RuntimeError, match="Another training job"): with closing(RolloutQueueController(stale, restore_plan=plan_stale)): @@ -1219,7 +1350,7 @@ def test_checkpoint_directory_lock_and_stale_selection(tmp_path): # Both failed constructors must release their descriptors, even while their # exceptions may still be retained by the caller. again = _checkpoint_args(root, root) - plan_again = resolve_checkpoint(again) + again, plan_again = resolve_checkpoint(again) with closing(RolloutQueueController(again, restore_plan=plan_again)): pass @@ -1231,7 +1362,7 @@ def test_checkpoint_lock_released_when_controller_initialization_fails(tmp_path, root = tmp_path / "run" args = _checkpoint_args(None, root) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) def fail(*args, **kwargs): raise OSError("interrupted controller initialization") @@ -1248,7 +1379,7 @@ def fail(*args, **kwargs): # Keep the failed constructor's traceback alive: release cannot rely on GC. assert error.value.__traceback__ is not None again = _checkpoint_args(None, root) - plan_again = resolve_checkpoint(again) + again, plan_again = resolve_checkpoint(again) with closing(RolloutQueueController(again, restore_plan=plan_again)): pass @@ -1258,10 +1389,10 @@ def test_checkpoint_lock_covers_cleanup_and_releases_on_seal_failure(tmp_path, m root = tmp_path / "run" args = _checkpoint_args(None, root) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) controller = RolloutQueueController(args, restore_plan=plan_args) again = _checkpoint_args(None, root) - plan_again = resolve_checkpoint(again) + again, plan_again = resolve_checkpoint(again) def fail_seal(): with pytest.raises(RuntimeError, match="Another training job"): @@ -1283,7 +1414,7 @@ def test_checkpoint_lock_released_when_controller_process_is_killed(tmp_path): root = tmp_path / "run" args = _checkpoint_args(None, root) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) process = subprocess.Popen( [ sys.executable, @@ -1308,7 +1439,7 @@ def test_checkpoint_lock_released_when_controller_process_is_killed(tmp_path): assert select.select([process.stdout], [], [], 60)[0], "Controller did not start" assert process.stdout.readline() == b"ready\n" again = _checkpoint_args(None, root) - plan_again = resolve_checkpoint(again) + again, plan_again = resolve_checkpoint(again) with pytest.raises(RuntimeError, match="Another training job"): RolloutQueueController(again, restore_plan=plan_again) finally: @@ -1333,7 +1464,7 @@ def test_missing_queue_snapshot_starts_empty_without_touching_old_model(tmp_path torch.save({"sample_offset": 30}, cursor) before = (root / "iter_0000007/weights.pt").read_bytes() args = _checkpoint_args(root, root, step=7) - plan_args = resolve_checkpoint(args) + args, plan_args = resolve_checkpoint(args) assert (plan_args.mode == "empty") and not (plan_args.mode == "snapshot") assert plan_args.dataset_cursor == (str(cursor) if has_cursor else None) assert args.start_rollout_id == 8 and args.load == str(root) @@ -1344,7 +1475,7 @@ def test_missing_queue_snapshot_starts_empty_without_touching_old_model(tmp_path with closing(RolloutQueueController(args, restore_plan=plan_args)): pass resumed = _checkpoint_args(root, root) - plan_resumed = resolve_checkpoint(resumed) + resumed, plan_resumed = resolve_checkpoint(resumed) assert (plan_resumed.mode == "empty") and resumed.ckpt_step == 7 assert (root / "iter_0000007/weights.pt").read_bytes() == before diff --git a/tests/test_score_centering.py b/tests/test_score_centering.py index 33e902d16..692dae1ef 100644 --- a/tests/test_score_centering.py +++ b/tests/test_score_centering.py @@ -23,6 +23,7 @@ def args(mode="none", **overrides): values = dict( + rollout_data_transport="object-store", pg_loss_type=None, use_score_centering=True, score_centering_top_k=3, @@ -604,7 +605,7 @@ def test_full_argument_validation_accepts_sc_tis_with_rollout_logprobs(monkeypat loss_type="policy_loss", eval_resume_step=None, ) - module.vime_validate_args(a) + a, _ = module.vime_validate_args(a) assert a.use_tis and a.use_score_centering and a.use_rollout_logprobs @@ -681,6 +682,17 @@ def top_p_weight(ratio, a): return ratio.clamp(a.tis_clip_low, a.tis_clip) +def store_top_p_batch(batch, directory): + from straw import SharedFilesystemStore + from straw.tensor import publish_tensors + + with SharedFilesystemStore(directory, "top-p", codecs=("tensor.v1",)) as store: + for key in ("rollout_top_p_token_ids", "rollout_top_p_token_offsets", "rollout_top_p_log_probs"): + batch[key] = list( + publish_tensors(store, {str(i): value for i, value in enumerate(batch[key])}, submission_id=key) + ) + + def top_p_reference(logits, batch, a): result = logits.sum() * 0 position = 0 @@ -718,7 +730,8 @@ def test_request_selects_complete_support(): @pytest.mark.parametrize("mode", ["none", "tis", "mis"]) @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) -def test_exact_loss_gradient(mode, dtype, monkeypatch): +@pytest.mark.parametrize("disk", [False, True]) +def test_exact_loss_gradient(mode, dtype, disk, monkeypatch, tmp_path): top_p = 0.95 from megatron.core import mpu from vime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean @@ -729,13 +742,26 @@ def test_exact_loss_gradient(mode, dtype, monkeypatch): batch = top_p_batch(top_p=top_p) for offsets, mask in zip(batch["rollout_top_p_token_offsets"], batch["loss_masks"], strict=True): assert offsets.diff().tolist() == (3 * mask).tolist() + original = dict(batch) + if disk: + store_top_p_batch(batch, tmp_path) + from vime.data.tensor import TensorRef + + load = TensorRef.load + offsets_refs = batch["rollout_top_p_token_offsets"] + + def load_offsets(ref, **kwargs): + assert ref in offsets_refs, "Loss must slice ids/logprobs instead of loading entire supports" + return load(ref, **kwargs) + + monkeypatch.setattr(TensorRef, "load", load_offsets) a = args(mode=mode, rollout_top_p=top_p) logits = torch.randn(1, 16, 12, dtype=dtype).requires_grad_() reducer = get_sum_of_sample_mean( batch["total_lengths"], batch["response_lengths"], batch["loss_masks"], batch["rollout_mask_sums"] ) loss, metrics = policy_loss_function(a, batch, logits.float(), reducer) - expected = top_p_reference(logits, batch, a) + expected = top_p_reference(logits, original, a) torch.testing.assert_close(loss, expected, atol=2e-6, rtol=2e-6) tol = 0.004 if dtype == torch.bfloat16 else 2e-6 torch.testing.assert_close( @@ -808,7 +834,7 @@ def test_exact_correction_cancels_expected_drift(mode): ) -def top_p_distributed_worker(rank, world_size, port, layout, mode): +def top_p_distributed_worker(rank, world_size, port, layout, mode, directory=None): import torch.distributed as dist from megatron.core import mpu @@ -841,6 +867,10 @@ def top_p_distributed_worker(rank, world_size, port, layout, mode): local = full_logits[row_ids] local = local.clone().requires_grad_() original = dict(batch) + if directory is not None: + from pathlib import Path + + store_top_p_batch(batch, Path(directory) / str(rank)) if not is_tp: for key in ["advantages", "rollout_log_probs"]: batch[key] = [ @@ -867,20 +897,25 @@ def top_p_distributed_worker(rank, world_size, port, layout, mode): @pytest.mark.parametrize("layout", ["tp", "zigzag", "allgather"]) @pytest.mark.parametrize("mode", ["none", "tis", "mis"]) -def test_distributed_exact_gradients(layout, mode): +@pytest.mark.parametrize("disk", [False, True]) +def test_distributed_exact_gradients(layout, mode, disk, tmp_path): torch.multiprocessing.spawn( - top_p_distributed_worker, args=(2, _cp_dist_helpers.free_port(), layout, mode), nprocs=2 + top_p_distributed_worker, + args=(2, _cp_dist_helpers.free_port(), layout, mode, str(tmp_path) if disk else None), + nprocs=2, ) def top_p_meta(): - return dict( + info = dict( score_centering_top_p=( np.asarray([1, 4, 2], dtype=np.int32), np.asarray([0, 2, 3], dtype=np.int32), np.asarray(np.log([0.3, 0.7, 1.0]), dtype=np.float32), ) ) + info["top_p_token_ids"], info["top_p_token_offsets"], _ = info["score_centering_top_p"] + return info def test_top_p_resume_and_masked_environment(): @@ -933,6 +968,7 @@ def test_invalid_support_rejected(corruption, disk, tmp_path): values["logps"] = torch.tensor(q) with SharedFilesystemStore(tmp_path, "invalid-top-p", codecs=("tensor.v1",)) as store: refs = dict(zip(values, publish_tensors(store, values, submission_id="sample"), strict=True)) + # These deliberately malformed captures have not passed sampler validation. refs = {key: replace(value, validated=False) for key, value in refs.items()} ids, offsets, q = refs["ids"], refs["offsets"], refs.get("logps") with pytest.raises(ValueError): diff --git a/tests/test_score_centering_transport.py b/tests/test_score_centering_transport.py index 05beb0c7b..1e0f6b5d8 100644 --- a/tests/test_score_centering_transport.py +++ b/tests/test_score_centering_transport.py @@ -19,6 +19,25 @@ NUM_GPUS = 0 +def test_score_centering_reads_native_tito_integer_token_ids(): + from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens + from vllm.logprobs import Logprob + + from vime.rollout.vllm_rollout import _score_centering_metadata + + serving = object.__new__(ServingTokens) + logprobs = serving._create_tokens_logprobs( + [42], + [{42: Logprob(-3.0, 3), 4: Logprob(-0.1, 1), 7: Logprob(-0.2, 2)}], + num_output_top_logprobs=3, + ) + metadata, sampled = _score_centering_metadata({"logprobs": logprobs.model_dump()}, [42], top_p=1.0, top_k=2) + ids, probabilities = metadata["score_centering_topk"] + np.testing.assert_array_equal(ids, [[4, 7]]) + np.testing.assert_allclose(probabilities, [[-0.1, -0.2]]) + assert sampled is None + + @pytest.mark.parametrize("finish_reason", ["abort", "length"]) @pytest.mark.parametrize("return_token_ids", [False, True]) @pytest.mark.parametrize("terminal_only", [False, True]) @@ -33,6 +52,7 @@ def test_tito_stream_preserves_empty_finish_reason(finish_reason, return_token_i serving.enable_per_request_metrics = False serving.request_logger = None request = SimpleNamespace( + output_mode="tokens", sampling_params=SamplingParams(), stream_options=None, return_token_ids=return_token_ids, @@ -110,10 +130,9 @@ def test_terminal_spec_metrics_survive_output_coalescing(): def manager(**overrides): from vime.data.batch_builder import BatchBuilder - cls = BatchBuilder - result = cls.__new__(cls) - result.args = args(**overrides) - result.custom_convert_samples_to_train_data_func = None + result = BatchBuilder( + args(custom_reward_post_process_path=None, custom_convert_samples_to_train_data_path=None, **overrides) + ) result._post_process_rewards = lambda samples: ([1.0] * len(samples), [1.0] * len(samples)) return result @@ -186,10 +205,10 @@ async def post(url, payload, **kwargs): { "logprob": -0.5, "top_logprobs": [ - {"token": "token_id:9", "logprob": -4.0}, - {"token": "token_id:4", "logprob": -3.0}, - {"token": "token_id:1", "logprob": -2.0}, - {"token": "token_id:3", "logprob": -0.5}, + {"token_id": 9, "logprob": -4.0}, + {"token_id": 4, "logprob": -3.0}, + {"token_id": 1, "logprob": -2.0}, + {"token_id": 3, "logprob": -0.5}, ], } ] @@ -255,7 +274,7 @@ async def post(url, payload, **kwargs): { "logprob": -0.5, "top_logprobs": [ - {"token": f"token_id:{token_id}", "logprob": float(logprob)} + {"token_id": token_id, "logprob": float(logprob)} for token_id, logprob in zip(head_ids[0], head_logprobs[0], strict=True) ], } @@ -376,8 +395,8 @@ def test_training_metrics_ignore_sampler_head_payloads(monkeypatch, tmp_path, di assert "rollout_log_probs" in reported[0] -@pytest.mark.parametrize("transport", ["object-store", "nixl"]) -def test_exact_top_p_transport_and_microbatch(monkeypatch, transport): +@pytest.mark.parametrize("transport", ["object-store", "nixl", "straw"]) +def test_exact_top_p_transport_and_microbatch(monkeypatch, tmp_path, transport): import numpy as np from test_score_centering import top_p_meta @@ -390,8 +409,13 @@ def test_exact_top_p_transport_and_microbatch(monkeypatch, transport): monkeypatch.setitem(sys.modules, "megatron.core.packed_seq_params", packed) monkeypatch.setitem(sys.modules, "megatron.training", training) from vime.backends.megatron_utils.data import DataIterator + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store - mgr = manager(rollout_top_p=0.9, rollout_data_transport=transport, global_batch_size=2) + mgr = manager( + rollout_top_p=0.9, rollout_data_transport=transport, rollout_data_dir=str(tmp_path), global_batch_size=2 + ) + mgr.rollout_id = 0 mgr.train_parallel_config = {"dp_size": 2} monkeypatch.setattr(rollout, "build_dp_schedule", lambda *a, **kw: ([[1], [0]], [[[0]], [[0]]], [1], [2])) monkeypatch.setattr(rollout.ray, "put", lambda data, **kwargs: data) @@ -404,16 +428,27 @@ def test_exact_top_p_transport_and_microbatch(monkeypatch, transport): if i == 1: sample.append_response_tokens(mgr.args, tokens=[8], trainable=False) samples.append(sample) + if transport == "straw": + samples = pack_rollout_payload(samples, mgr.args, 0).load() batch = mgr.convert(samples) refs = mgr.split_by_dp(batch) - assert refs[0].inner["rollout_top_p_token_offsets"][0].tolist() == [0, 2, 3, 3] - for ref in refs: - tensorize_rollout_data_for_training(ref.inner) - iterator = DataIterator(ref.inner, micro_batch_indices=[[0]]) + shards = [ref.inner.load() if transport == "straw" else ref.inner for ref in refs] + offsets = shards[0]["rollout_top_p_token_offsets"][0] + assert (offsets.load() if isinstance(offsets, TensorRef) else offsets).tolist() == [0, 2, 3, 3] + for shard in shards: + tensorize_rollout_data_for_training(shard) + iterator = DataIterator(shard, micro_batch_indices=[[0]]) data = iterator.get_next(["rollout_top_p_log_probs", "rollout_top_p_token_ids", "rollout_top_p_token_offsets"]) - assert data["rollout_top_p_log_probs"][0].dtype == torch.float32 - torch.testing.assert_close(data["rollout_top_p_log_probs"][0].exp(), torch.tensor([0.3, 0.7, 1.0])) - assert data["rollout_top_p_token_ids"][0].tolist() == [1, 4, 2] + logps = data["rollout_top_p_log_probs"][0] + if transport == "straw": + assert isinstance(logps, TensorRef) + logps = logps.load() + assert logps.dtype == torch.float32 + torch.testing.assert_close(logps.exp(), torch.tensor([0.3, 0.7, 1.0])) + ids = data["rollout_top_p_token_ids"][0] + assert (ids.load() if isinstance(ids, TensorRef) else ids).tolist() == [1, 4, 2] + if transport == "straw": + seal_rollout_store(mgr.args) def test_generate_requests_complete_top_p_probabilities(monkeypatch): @@ -448,11 +483,11 @@ async def post(url, payload, **kwargs): "content": [ { "logprob": float(np.log(0.7)), - "top_logprobs": [{"token": "token_id:4", "logprob": float(np.log(0.7))}], + "top_logprobs": [{"token_id": 4, "logprob": float(np.log(0.7))}], }, { "logprob": -2.0, - "top_logprobs": [{"token": "token_id:2", "logprob": -2.0}], + "top_logprobs": [{"token_id": 2, "logprob": -2.0}], }, ] }, @@ -467,8 +502,224 @@ async def post(url, payload, **kwargs): assert sample.rollout_top_p_token_ids.tolist() == [1, 4, 2] -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__])) +def test_straw_heads_survive_buffer_and_debug_dump_lifetimes(tmp_path): + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store + from vime.observability.rollout_data_utils import load_debug_rollout_data, save_debug_rollout_data + from vime.utils.score_centering import validate_sampler_topk + + a = args(rollout_data_transport="straw", rollout_data_dir=str(tmp_path)) + sample = pack_rollout_payload(samples()[0], a, 1).load() + assert isinstance(sample.rollout_topk_token_ids, TensorRef) + sample = pack_rollout_payload(sample, a, 2).load() + path = str(tmp_path / "debug.pt") + save_debug_rollout_data(path, [sample], rollout_id=2, evaluation=False) + seal_rollout_store(a) + validate_sampler_topk(sample, 3) + for pack in tmp_path.rglob("*.pack"): + pack.unlink() + restored = load_debug_rollout_data(path, rollout_id=2)[0] + validate_sampler_topk(restored, 3) + restored.append_response_tokens(a, tokens=[3], log_probs=[-0.5], meta_info=meta()) + assert restored.rollout_topk_token_ids.tolist() == [[3, 1, 4]] * 2 + + +@pytest.mark.parametrize("loss_mask,expected", [(None, 1.5), ([1, 0], 2.0), ([0, 0], None)]) +def test_rollout_metrics_read_only_top_p_offsets_from_straw(tmp_path, monkeypatch, loss_mask, expected): + import numpy as np + from test_score_centering import top_p_meta + + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store + from vime.observability import rollout_metrics + + a = args( + rollout_top_p=0.95, + rollout_data_transport="straw", + rollout_data_dir=str(tmp_path), + rollout_queue_online_gc=True, + rollout_num_gpus=0, + log_reward_category=None, + reward_key=None, + custom_rollout_log_function_path=None, + load_debug_rollout_data=None, + wandb_always_use_train_step=False, + ) + sample = Sample(tokens=[9], reward=1.0, response="answer") + sample.append_response_tokens(a, tokens=[4, 2], log_probs=[float(np.log(0.7)), 0.0], meta_info=top_p_meta()) + sample.loss_mask = loss_mask + sample.status = Sample.Status.COMPLETED + reported = [] + monkeypatch.setattr(rollout_metrics.logging_utils, "log", lambda args, metrics, **kw: reported.append(metrics)) + rollout_metrics.log_rollout_data(0, a, [sample], None, 1.0) + + restored = pack_rollout_payload([sample], a, 0).load() + seal_rollout_store(a) + fields = ("rollout_top_p_token_ids", "rollout_top_p_token_offsets", "rollout_top_p_log_probs") + refs = {key: getattr(restored[0], key) for key in fields} + assert all(isinstance(ref, TensorRef) for ref in refs.values()) + load = TensorRef.load + reads = [] + + def load_offsets(ref, **kwargs): + assert ref.kind == "rollout_top_p_token_offsets", "Logging must not load the full sampler payload" + reads.append(ref) + return load(ref, **kwargs) + + monkeypatch.setattr(TensorRef, "load", load_offsets) + rollout_metrics.log_rollout_data(0, a, restored, None, 1.0) + + assert reported[0] == reported[1] + key = "rollout/top_p_kept_vocab_per_token" + if expected is None: + assert key not in reported[1] + else: + assert reported[1][key] == pytest.approx(expected) + assert reads == [refs["rollout_top_p_token_offsets"]] + assert all(getattr(restored[0], key) is ref for key, ref in refs.items()) + + +@pytest.mark.parametrize("score_centering", [False, True]) +@pytest.mark.parametrize("append", ["model", "tool", "terminal"]) +def test_top_p_resume_from_straw(tmp_path, monkeypatch, score_centering, append): + import numpy as np + from test_score_centering import top_p_meta + + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store + + a = args( + rollout_top_p=0.95, + use_score_centering=score_centering, + rollout_data_transport="straw", + rollout_data_dir=str(tmp_path), + rollout_queue_online_gc=True, + ) + sample = Sample(tokens=[9]) + sample.append_response_tokens(a, tokens=[4, 2], log_probs=[float(np.log(0.7)), 0.0], meta_info=top_p_meta()) + sample.status = Sample.Status.ABORTED + original = pack_rollout_payload(sample, a, 0) + restored = original.load() + assert isinstance(restored.rollout_top_p_token_ids, TensorRef) + kwargs = { + "model": dict(tokens=[4, 2], log_probs=[float(np.log(0.7)), 0.0], meta_info=top_p_meta()), + "tool": dict(tokens=[8], trainable=False), + "terminal": dict(tokens=[], meta_info={"finish_reason": {"type": "stop"}}), + }[append] + sample.append_response_tokens(a, **kwargs) + load = TensorRef.load + prefix_ids, prefix_logps = restored.rollout_top_p_token_ids, restored.rollout_top_p_log_probs + + def load_changed_field(ref, **kwargs): + if append != "model": + assert ref.kind == "rollout_top_p_token_offsets", "Unchanged supports must remain shared" + return load(ref, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(TensorRef, "load", load_changed_field) + restored.append_response_tokens(a, **kwargs) + if append != "model": + assert restored.rollout_top_p_token_ids is prefix_ids + assert restored.rollout_top_p_log_probs is prefix_logps + republished = pack_rollout_payload(restored, a, 1).load() + seal_rollout_store(a) + assert republished.tokens == sample.tokens + assert republished.loss_mask == sample.loss_mask + for key in ("rollout_top_p_token_ids", "rollout_top_p_token_offsets", "rollout_top_p_log_probs"): + expected, actual = getattr(sample, key), getattr(republished, key) + if expected is not None: + torch.testing.assert_close(actual.load(), torch.as_tensor(expected)) + # Continuing one reader must leave the persisted prefix usable by another. + assert original.load().rollout_top_p_token_offsets.load().tolist() == [0, 2, 3] + + +@pytest.mark.parametrize("rank", [0, 1]) +@pytest.mark.parametrize("allgather", [False, True]) +def test_top_p_mask_reads_only_local_cp_support(tmp_path, monkeypatch, rank, allgather): + from megatron.core import mpu + from straw import SharedFilesystemStore + from straw.tensor import TensorRef, publish_tensors + + from vime.backends.megatron_utils.loss import _build_topp_keep_mask + + monkeypatch.setattr(mpu, "get_context_parallel_world_size", lambda: 2) + monkeypatch.setattr(mpu, "get_context_parallel_rank", lambda: rank) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) + with SharedFilesystemStore(tmp_path, "cp-mask", codecs=("tensor.v1",)) as store: + ids, offsets = publish_tensors( + store, + {"ids": torch.arange(6, dtype=torch.int32), "offsets": torch.arange(7, dtype=torch.int32)}, + submission_id="support", + ) + getitem = TensorRef.__getitem__ + reads = [] + + def read_support(ref, rows): + assert ref is ids + reads.append((rows.start, rows.stop)) + return getitem(ref, rows) + + monkeypatch.setattr(TensorRef, "__getitem__", read_support) + mask = _build_topp_keep_mask(4, 8, torch.device("cpu"), [ids], [offsets], [8], [6], allgather) + positions = ( + list(range(rank * 4, rank * 4 + 4)) if allgather else [2 * rank, 2 * rank + 1, 6 - 2 * rank, 7 - 2 * rank] + ) + expected = torch.ones(4, 8, dtype=torch.bool) + for row, position in enumerate(positions): + if 1 <= position <= 6: + expected[row] = False + expected[row, position - 1] = True + torch.testing.assert_close(mask, expected) + expected_reads = ( + ([(0, 3)] if rank == 0 else [(3, 6)]) if allgather else ([(0, 1), (5, 6)] if rank == 0 else [(1, 3), (3, 5)]) + ) + assert reads == expected_reads + + +def test_top_p_validation_checks_unvalidated_support_in_chunks(tmp_path, monkeypatch): + from vime.data.tensor import TensorRef + from vime.data.transport import pack_rollout_payload, seal_rollout_store + from vime.utils.score_centering import validate_sampler_top_p + + count = 4097 + a = args(rollout_top_p=0.95, rollout_data_transport="straw", rollout_data_dir=str(tmp_path)) + sample = Sample( + tokens=[9] + [4] * count, + response_length=count, + rollout_log_probs=[0.0] * count, + rollout_top_p_token_ids=torch.full((count,), 4, dtype=torch.int32), + rollout_top_p_token_offsets=torch.arange(count + 1, dtype=torch.int32), + rollout_top_p_log_probs=torch.zeros(count), + # Interrupted captures remain unvalidated until their continuation is + # ready for training; publishing a completed sample validates it first. + status=Sample.Status.ABORTED, + ) + restored = pack_rollout_payload(sample, a, 0).load() + seal_rollout_store(a) + fields = (restored.rollout_top_p_token_ids, restored.rollout_top_p_token_offsets, restored.rollout_top_p_log_probs) + assert all(not ref.validated for ref in fields) + load, getitem = TensorRef.load, TensorRef.__getitem__ + reads = [] + + def load_offsets(ref, **kwargs): + assert ref.kind == "rollout_top_p_token_offsets" + return load(ref, **kwargs) + + def read_chunk(ref, rows): + reads.append((ref.kind, rows.start, rows.stop)) + return getitem(ref, rows) + + monkeypatch.setattr(TensorRef, "load", load_offsets) + monkeypatch.setattr(TensorRef, "__getitem__", read_chunk) + validate_sampler_top_p(*fields, count, tokens=[4] * count, sampled_logps=[0.0] * count) + assert reads == [ + (key, start, stop) + for start, stop in ((0, 4096), (4096, count)) + for key in ("rollout_top_p_token_ids", "rollout_top_p_log_probs") + ] + # Sharing an unvalidated capture must not skip the sampled-token check. + with pytest.raises(ValueError, match="sampled token"): + validate_sampler_top_p(*fields, count, tokens=[4] * (count - 1) + [5], sampled_logps=[0.0] * count) def test_top_p_published_captures_need_only_metadata_checks(tmp_path, monkeypatch): @@ -511,3 +762,7 @@ def no_payload_read(*args, **kwargs): validate_sampler_top_p(*fields, count, tokens=[4] * count, sampled_logps=[0.0] * count) with pytest.raises(ValueError, match="align"): validate_sampler_top_p(*fields, count, tokens=[4], sampled_logps=[0.0]) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_stateless_adam.py b/tests/test_stateless_adam.py index 359e3c312..d117957d5 100644 --- a/tests/test_stateless_adam.py +++ b/tests/test_stateless_adam.py @@ -1,3 +1,9 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + import pytest import torch @@ -58,5 +64,69 @@ def test_stateless_adam_does_not_persist_moment_tensors(): assert optimizer.state_dict()["state"] == {} +@pytest.fixture +def checkpoint_module(tmp_path, monkeypatch): + # Only Megatron's model/collective boundary is replaced. The Vime adapter + # writes real checkpoint files, and the optimizer/scheduler below are real. + args = SimpleNamespace( + save=str(tmp_path), load=str(tmp_path), use_stateless_adam=True, finetune=False, ckpt_step=0 + ) + + def save_model(iteration, *a, **kw): + (tmp_path / f"iter_{iteration:07d}").mkdir() + (tmp_path / "latest_checkpointed_iteration.txt").write_text(str(iteration)) + + native = SimpleNamespace(save_checkpoint=Mock(side_effect=save_model), load_checkpoint=Mock(return_value=(0, 0))) + monkeypatch.setitem(sys.modules, "megatron.training.checkpointing", native) + monkeypatch.setitem(sys.modules, "megatron.training.global_vars", SimpleNamespace(get_args=lambda: args)) + path = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils/checkpoint.py" + spec = importlib.util.spec_from_file_location("stateless_checkpoint_test", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module.dist, "get_rank", lambda: 0) + return module, args, native + + +@pytest.mark.parametrize("step", [0, 7]) +def test_stateless_checkpoint_restores_scheduler_progress(checkpoint_module, step): + module, args, native = checkpoint_module + args.ckpt_step = step + param = torch.tensor([1.0]) + optimizer = StatelessAdam([param], lr=0.1) + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lambda count: 0.8**count) + for _ in range(3): + param.grad = torch.ones_like(param) + optimizer.step() + scheduler.step() + module.save_checkpoint(step, [], optimizer, scheduler, num_floating_point_operations_so_far=0) + expected = scheduler.state_dict() + param.grad = torch.ones_like(param) + optimizer.step() + scheduler.step() + expected_next_lr = scheduler.get_last_lr() + + restored_optimizer = StatelessAdam([torch.tensor([1.0])], lr=0.1) + restored_scheduler = torch.optim.lr_scheduler.LambdaLR(restored_optimizer, lambda count: 0.8**count) + native.load_checkpoint.return_value = (step, 0) + assert module.load_checkpoint([], restored_optimizer, restored_scheduler, {}) == (step, 0) + assert args.no_load_optim and restored_optimizer.state == {} + assert restored_scheduler.state_dict() == expected + restored_optimizer.step() + restored_scheduler.step() + assert restored_scheduler.get_last_lr() == expected_next_lr + + +def test_stateless_resume_rejects_missing_scheduler_but_finetune_can_reset(checkpoint_module): + module, args, native = checkpoint_module + native.save_checkpoint(0) + scheduler = Mock() + with pytest.raises(FileNotFoundError, match="opt_param_scheduler"): + module.load_checkpoint([], Mock(), scheduler, {}) + scheduler.load_state_dict.assert_not_called() + args.finetune = True + assert module.load_checkpoint([], Mock(), scheduler, {}) == (0, 0) + scheduler.load_state_dict.assert_not_called() + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_training_recovery.py b/tests/test_training_recovery.py new file mode 100644 index 000000000..0a88acc44 --- /dev/null +++ b/tests/test_training_recovery.py @@ -0,0 +1,819 @@ +"""Trainer restart boundaries and parallelism-independent rollout retention.""" + +import copy +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from vime.data.batch_builder import BatchBuilder +from vime.data.checkpoint import RestorePlan +from vime.data.transport import pack_rollout_payload, rollout_store +from vime.ray.serving import ServingCluster +from vime.ray.training_recovery import ( + TrainingRecovery, + configure_recovery_checkpoint, + retained_rollout_configuration, + training_recovery_enabled, + training_session_name, +) +from vime.utils.types import Sample + +NUM_GPUS = 0 + + +@pytest.fixture +def args(tmp_path): + return SimpleNamespace( + use_fault_tolerance=True, + rollout_data_transport="straw", + rollout_data_dir=str(tmp_path / "pool"), + rollout_queue_run_id="test", + save_debug_rollout_data=None, + load_debug_rollout_data=None, + debug_train_only=False, + debug_rollout_only=False, + load="initial-model", + save=str(tmp_path / "model"), + start_rollout_id=0, + ckpt_step=None, + finetune=True, + no_load_optim=True, + no_load_rng=True, + hf_checkpoint="model", + global_batch_size=4, + micro_batch_size=1, + use_dynamic_batch_size=False, + balance_data=False, + balance_by_flops=False, + custom_reward_post_process_path=None, + custom_convert_samples_to_train_data_path=None, + ) + + +@pytest.mark.parametrize( + "transport,dump,enabled", + [ + ("straw", None, True), + ("object-store", "rollout_{rollout_id}.pt", True), + ("object-store", None, False), + ("nixl", "rollout_{rollout_id}.pt", True), + ], +) +def test_replay_persistence_is_independent_of_health_checks(args, transport, dump, enabled): + args.rollout_data_transport, args.save_debug_rollout_data = transport, dump + assert training_recovery_enabled(args) == enabled + args.use_fault_tolerance = False + assert training_recovery_enabled(args) == enabled + + +@pytest.mark.parametrize("mode", ["debug_train_only", "debug_rollout_only", "load_debug_rollout_data"]) +def test_debug_only_modes_do_not_replay_training(args, mode): + setattr(args, mode, True) + assert not training_recovery_enabled(args) + + +def test_session_identity_survives_checkpoint_branch_and_parallelism_changes(args): + name = training_session_name(args) + args.save += "/branches/new" + args.tensor_model_parallel_size = 8 + args.actor_num_gpus_per_node = 16 + assert training_session_name(args) == name + args.rollout_queue_run_id = "another-run" + assert training_session_name(args) != name + + +def test_configuration_allows_trainer_parallelism_and_memory_changes(args): + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.configuration = retained_rollout_configuration(args) + serving.driver_job_id = None + updated = copy.copy(args) + updated.tensor_model_parallel_size = 4 + updated.context_parallel_size = 2 + updated.micro_batch_size = 2 + updated.max_tokens_per_gpu = 1024 + updated.padded_vocab_size = 152064 + updated.distrib_optim_fully_reshardable_mem_efficient = True + updated.overlap_grad_reduce = True + updated.ddp_bucket_size = 40000000 + updated.ddp_num_buckets = None + serving.validate_attachment(updated) + updated.global_batch_size = 8 + with pytest.raises(ValueError, match="global_batch_size"): + serving.validate_attachment(updated) + + +def test_role_yaml_cannot_override_retained_resume_boundary(args): + recovery = TrainingRecovery(args, RestorePlan()) + actor = copy.copy(args) + critic = copy.copy(args) + actor.save += "/actor" + critic.save += "/critic" + critic.load = "critic-initial-model" + assert recovery.resume_role("actor", actor)["load"] == args.load + assert recovery.resume_role("critic", critic)["load"] == critic.load + recovery.initial_load_completed(0) + recovery.checkpoint_committed(2) + for role, initial in (("actor", actor), ("critic", critic)): + edited_yaml = copy.copy(initial) + edited_yaml.load = "wrong-initial-checkpoint" + edited_yaml.save = "wrong-new-save-path" + edited_yaml.tensor_model_parallel_size = 4 + resumed = recovery.resume_role(role, edited_yaml) + assert resumed["load"] == resumed["save"] == initial.save + assert resumed["ckpt_step"] == 2 and resumed["start_rollout_id"] == 3 + assert not resumed["finetune"] and not resumed["no_load_optim"] and not resumed["no_load_rng"] + assert "tensor_model_parallel_size" not in resumed + + +@pytest.mark.parametrize("role", ["actor", "critic"]) +@pytest.mark.parametrize("stateless", [False, True]) +def test_megatron_init_receives_retained_role_checkpoint(args, monkeypatch, role, stateless): + from unittest.mock import Mock + + from vime.ray import actor_group + + args.train_env_vars = {} + args.offload_train = args.use_routing_replay = False + args.ckpt_format = "torch_dist" + args.no_save_optim = args.use_stateless_adam = stateless + args.no_save_rng = False + recovery = TrainingRecovery(args, RestorePlan()) + recovery.resume_role(role, args) + recovery.initial_load_completed(0) + recovery.checkpoint_committed(2) + expected_load = args.save + args.load = "initial-checkpoint-from-yaml" + args.save = "new-path-from-yaml" + args.update_weight_start_version = 0 + args.ckpt_fully_parallel_save = args.dist_ckpt_optim_fully_reshardable = False + worker, manager, remote_class = Mock(), Mock(), Mock() + worker.get_master_addr_and_port.remote.return_value = ("127.0.0.1", 12345) + worker.init.remote.return_value = 3 + remote_class.options.return_value = remote_class + remote_class.remote.return_value = worker + manager.register_training_actors.remote.side_effect = lambda role, actors, config: { + **recovery.resume_role(role, config), + "update_weight_start_version": 12, + } + monkeypatch.setattr(actor_group.ray, "remote", lambda **kw: lambda cls: remote_class) + monkeypatch.setattr(actor_group.ray, "get", lambda value: value) + group = actor_group.RayTrainGroup(args, 1, 1, pg=(object(), [0], [0]), role=role, actor_cls=object) + assert group.create(rollout_manager=manager) == [3] + configuration = worker.init.remote.call_args.args[0] + assert configuration.load == configuration.save == expected_load + assert configuration.ckpt_step == 2 and configuration.start_rollout_id == 3 + assert configuration.update_weight_start_version == group._disk_weight_version == 12 + assert configuration.ckpt_fully_parallel_save and configuration.dist_ckpt_optim_fully_reshardable + assert configuration.no_load_optim == stateless and not configuration.no_load_rng + + +def test_stateless_recovery_keeps_scheduler_and_rng_policy(args): + args.ckpt_format = "torch_dist" + args.no_save_optim = args.use_stateless_adam = True + args.no_save_rng = False + configure_recovery_checkpoint(args) + recovery = TrainingRecovery(args, RestorePlan()) + recovery.resume_role("actor", args) + critic = copy.copy(args) + critic.use_stateless_adam = critic.no_save_optim = False + recovery.resume_role("critic", critic) + recovery.initial_load_completed(0) + recovery.checkpoint_committed(1) + rebuilt = TrainingRecovery(args, RestorePlan()) + assert rebuilt.checkpoint.no_load_optim + assert rebuilt.resume_role("actor", args)["no_load_optim"] + assert not rebuilt.resume_role("critic", critic)["no_load_optim"] + assert not rebuilt.checkpoint.no_load_rng + args.no_save_rng = True + with pytest.raises(ValueError, match="RNG state"): + configure_recovery_checkpoint(args) + + +@pytest.mark.parametrize("previous", [None, {"JobID": "old", "IsDead": False}]) +def test_live_or_unknown_previous_driver_cannot_be_replaced(args, monkeypatch, previous): + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.configuration = retained_rollout_configuration(args) + serving.driver_job_id = "old" + monkeypatch.setattr("ray._private.state.jobs", lambda: [] if previous is None else [previous]) + with pytest.raises(RuntimeError, match="still owned"): + serving.validate_attachment(args) + + +def test_dead_driver_can_be_replaced(args, monkeypatch): + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.configuration = retained_rollout_configuration(args) + serving.driver_job_id = "old" + monkeypatch.setattr("ray._private.state.jobs", lambda: [{"JobID": "old", "IsDead": True}]) + serving.validate_attachment(args) + + +@pytest.mark.parametrize("was_paused", [None, False, True]) +@pytest.mark.parametrize("weight_sync_fails", [False, True]) +def test_initial_weight_sync_controls_retained_producer_resume(args, monkeypatch, was_paused, weight_sync_fails): + import runpy + import sys + from unittest.mock import Mock + + from vime.ray import rollout + + # Argument parsing is outside this CPU test and imports vLLM server args. + monkeypatch.setitem(sys.modules, "vime.utils.arguments", SimpleNamespace(parse_args=None)) + train = runpy.run_path(str(Path(__file__).resolve().parents[1] / "train.py"))["train"] + manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager._recovery_admission_was_paused = was_paused + producer = Mock() + manager.data_source = SimpleNamespace(consumers={"fully_async": producer}) + endpoint, actor = Mock(), Mock() + endpoint.training_ready.remote.side_effect = manager.training_ready + + def publish_weights(): + producer.resume.assert_not_called() + if weight_sync_fails: + raise RuntimeError("weight sync failed") + + actor.update_weights.side_effect = publish_weights + monkeypatch.setitem(train.__globals__, "create_training_models", lambda *a: (actor, None)) + monkeypatch.setattr(rollout.ray, "get", lambda value: value) + args.release_train = args.offload_rollout = args.check_weight_update_equal = False + args.num_rollout, args.eval_interval = 0, None + if weight_sync_fails: + with pytest.raises(RuntimeError, match="weight sync failed"): + train(args, {}, endpoint, 1, RestorePlan()) + endpoint.training_ready.remote.assert_not_called() + endpoint.dispose.remote.assert_not_called() + assert manager._recovery_admission_was_paused is was_paused + else: + train(args, {}, endpoint, 1, RestorePlan()) + endpoint.training_ready.remote.assert_called_once_with() + assert manager._recovery_admission_was_paused is None + manager.training_ready() + assert producer.resume.call_count == int(not weight_sync_fails and was_paused is False) + + +@pytest.mark.parametrize("external_ray,live_head", [(False, True), (False, False), (True, True)]) +@pytest.mark.parametrize( + "options", + [ + "--rollout-data-transport=straw", + "--num-rollout 3", + "--use-fault-tolerance --save-debug-rollout-data 'dir with spaces/rollout_{rollout_id}.pt'", + ], +) +def test_launcher_preserves_serving_and_reuses_ray(monkeypatch, external_ray, live_head, options): + from unittest.mock import Mock + + from vime.utils.external_utils import command_utils + + commands = [] + status = Mock(return_value=SimpleNamespace(returncode=0 if live_head else 1)) + monkeypatch.setattr(command_utils, "exec_command", commands.append) + monkeypatch.setattr(command_utils.subprocess, "run", status) + monkeypatch.setattr(command_utils, "check_has_nvlink", lambda: False) + monkeypatch.setenv("VIME_SCRIPT_EXTERNAL_RAY", str(int(external_ray))) + monkeypatch.setenv("VIME_SCRIPT_ENABLE_RAY_SUBMIT", "1") + command_utils.execute_train(options, num_gpus_per_node=4, megatron_model_type=None) + assert not any("pkill" in command or "ray stop" in command for command in commands) + assert any("ray start" in command for command in commands) == (not external_ray and not live_head) + assert options in commands[-1] + assert status.call_count == int(not external_ray) + + +@pytest.mark.parametrize("external_ray", [False, True]) +@pytest.mark.parametrize( + "filename", ["test_qwen2.5_0.5B_training_recovery.py", "test_qwen3_30B_A3B_training_recovery.py"] +) +def test_recovery_e2e_contacts_dashboard_without_proxy(monkeypatch, tmp_path, external_ray, filename): + import importlib.util + import json + import os + import subprocess + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + import ray + from ray import job_submission + + requests = [] + + class Dashboard(BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + # A proxy receives an absolute URL; direct dashboard requests use + # /api/version. Reproduce the CI proxy's 503 with real Ray HTTP calls. + self.send_response(200 if self.path == "/api/version" else 503) + body = json.dumps({"ray_version": ray.__version__}).encode() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + class Connected(Exception): + pass + + client = job_submission.JobSubmissionClient + + def connect(address): + client(address) + raise Connected # Stop after the real handshake, before GPU job submission. + + started = [] + monkeypatch.setattr(job_submission, "JobSubmissionClient", connect) + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: started.append(dict(os.environ))) + spec = importlib.util.spec_from_file_location("recovery_e2e_proxy_test", Path(__file__).with_name(filename)) + recovery = importlib.util.module_from_spec(spec) + spec.loader.exec_module(recovery) + monkeypatch.setenv("VIME_SCRIPT_EXTERNAL_RAY", str(int(external_ray))) + proxies = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy") + with ThreadingHTTPServer(("127.0.0.1", 0), Dashboard) as server: + url = f"http://127.0.0.1:{server.server_port}" + for name in proxies: + monkeypatch.setenv(name, url) + for name in ("NO_PROXY", "no_proxy"): + monkeypatch.setenv(name, "") + monkeypatch.setenv("VIME_TEST_RAY_DASHBOARD", url) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with pytest.raises(Connected): + recovery.execute(directory=tmp_path) + finally: + server.shutdown() + thread.join(timeout=5) + assert requests and all(path == "/api/version" for path in requests) + assert len(started) == int(not external_ray) + assert all(not env.get(name) for env in started for name in proxies) + + +def test_checkpoint_restores_serving_version_after_manual_restart(args, tmp_path): + import json + + from vime.data.checkpoint import commit_checkpoint, resolve_checkpoint + + root = tmp_path / "checkpoint" + model = root / "iter_0000002" + model.mkdir(parents=True) + (model / "weights.pt").write_bytes(b"model-and-optimizer") + (root / "latest_checkpointed_iteration.txt").write_text("2") + (root / "rollout").mkdir() + for name in ("queue_state", "builder_state"): + (root / "rollout" / f"{name}_2.json").write_text(json.dumps({"test": name})) + args.save = str(root) + commit_checkpoint(args, 2, model_args=[args], weight_version=7) + args.load, args.save = str(root), str(tmp_path / "new") + args.ckpt_step, args.start_rollout_id = 2, None + args.finetune = args.no_load_optim = args.no_load_rng = False + args, _ = resolve_checkpoint(args) + assert args.start_rollout_id == 3 + assert args.update_weight_start_version == 7 + + +def global_train_data(): + return { + "tokens": [[1, 2, 3], [1, 4], [1, 5, 6, 7], [1, 8]], + "sample_indices": list(range(4)), + "rollout_ids": list(range(4)), + "response_lengths": [2, 1, 3, 1], + "rewards": [0.0, 1.0, 0.0, 1.0], + "raw_reward": [0.0, 1.0, 0.0, 1.0], + "loss_masks": [[1, 1], [1], [1, 1, 1], [1]], + } + + +def test_retained_straw_batch_reshards_without_reusing_old_plan(args, monkeypatch): + from vime.data import batch_builder + + monkeypatch.setattr(batch_builder.ray, "put", lambda value: value) + recovery = TrainingRecovery(args, RestorePlan()) + samples = [Sample(index=i, tokens=tokens) for i, tokens in enumerate(global_train_data()["tokens"])] + raw = pack_rollout_payload(samples, args, 0) + recovery.remember_raw(0, raw) + recovery.remember_converted(0, BatchBuilder(args).publish_converted(global_train_data()), "old-dp-batch") + store, _, lock = rollout_store(args) + with lock: + store.release_publications([raw.manifest]) + builder = BatchBuilder(args) + builder.batch_id = "old-dp-batch" + builder.publish_converted = lambda *a: pytest.fail("Resharding must not write another conversion") + for dp_size in (2, 1, 4): + builder.train_parallel_config = dict( + dp_size=dp_size, cp_size=1, vpp_size=1, microbatch_group_size_per_vp_stage=1 + ) + reference = recovery.batches[0].converted + refs = builder.split_by_dp(reference) + assert len(refs) == dp_size + shards = [ref.inner.load() for ref in refs] + assert sorted(index for shard in shards for index in shard["sample_indices"]) == list(range(4)) + assert all(shard["global_batch_sizes"] == [4] for shard in shards) + assert [sample.tokens for sample in recovery.load_raw(0)] == global_train_data()["tokens"] + recovery.release_batches() + + +def test_debug_batch_replay_keeps_converted_rewards_and_original_dump(args, tmp_path): + from vime.observability.rollout_data_utils import save_debug_rollout_data + + args.rollout_data_transport = "object-store" + args.save_debug_rollout_data = str(tmp_path / "rollout_{rollout_id}.pt") + samples = [Sample(index=0, tokens=[1, 2], reward=3.0)] + save_debug_rollout_data(args.save_debug_rollout_data, samples, rollout_id=0, evaluation=False) + recovery = TrainingRecovery(args, RestorePlan()) + recovery.remember_raw(0, args.save_debug_rollout_data) + recovery.remember_converted(0, {"rewards": torch.tensor([7.0])}, None) + assert recovery.load_raw(0)[0].reward == 3.0 + assert recovery.load_converted(0)["rewards"].tolist() == [7.0] + recovery.release_batches() + assert (tmp_path / "rollout_0.pt").exists() + assert not (tmp_path / "rollout_0.pt.train-recovery.pt").exists() + + +def test_only_committed_model_boundary_releases_replay_batches(args): + recovery = TrainingRecovery(args, RestorePlan()) + recovery.initial_load_completed(0) + for step in range(3): + recovery.remember_raw(step, pack_rollout_payload([], args, step)) + recovery.remember_converted(step, global_train_data(), f"batch-{step}") + # Runtime completion alone has no model/optimizer checkpoint to resume. + assert recovery.checkpoint.start_rollout_id == 0 + recovery.checkpoint_committed(1) + assert set(recovery.batches) == {2} + assert recovery.checkpoint.start_rollout_id == 2 + assert recovery.checkpoint.load == args.save + assert recovery.checkpoint.ckpt_step == 1 + assert not recovery.checkpoint.no_load_optim + assert not recovery.checkpoint.no_load_rng + recovery.release_batches() + + +@pytest.mark.parametrize("transport", ["straw", "object-store"]) +def test_new_manager_restores_journal_without_health_checks(args, tmp_path, transport): + args.use_fault_tolerance = False + args.rollout_data_transport = transport + args.save_debug_rollout_data = str(tmp_path / "rollout_{rollout_id}.pt") + recovery = TrainingRecovery(args, RestorePlan()) + recovery.resume_role("actor", args) + recovery.initial_load_completed(0) + recovery.checkpoint_committed(1) + raw = pack_rollout_payload([], args, 2) if transport == "straw" else args.save_debug_rollout_data + source_state = {"sample_offset": 12, "metadata": {"custom": 1}} + recovery.remember_raw(2, raw, source_state=source_state) + # Manager death immediately after raw acceptance must restore both the + # advanced source cursor and the batch that owns those consumed samples. + accepted = TrainingRecovery(args, RestorePlan()) + assert accepted.source_state == source_state and 2 in accepted.batches + assert accepted.batches[2].converted is None + recovery.remember_converted(2, global_train_data(), "batch-2") + if transport == "straw": + store, _, lock = rollout_store(args) + with lock: + store.release_publications([raw.manifest]) + store.seal() + store.collect_garbage() + + rebuilt = TrainingRecovery(args, RestorePlan()) + assert rebuilt.incarnation == recovery.incarnation + assert rebuilt.loaded and rebuilt.checkpoint_step == 1 + assert rebuilt.checkpoint.start_rollout_id == 2 + assert rebuilt.source_state == recovery.source_state + assert rebuilt.load_converted(2) == global_train_data() + assert rebuilt.resume_role("actor", args)["load"] == args.save + rebuilt.checkpoint_committed(2) + assert not TrainingRecovery(args, RestorePlan()).batches + + +def test_manager_restart_before_initial_model_load(args): + args.start_rollout_id = None + recovery = TrainingRecovery(args, RestorePlan()) + recovery.source_state = {"reader_generation": "old", "metadata": {}} + recovery.persist() + + restarted = TrainingRecovery(args, RestorePlan()) + restarted.reconcile_collection(None, "branch") + # The model checkpoint, loaded later, still determines where training starts. + restarted.initial_load_completed(7) + assert restarted.checkpoint.start_rollout_id == 7 + assert not restarted.batches + + +def test_source_snapshot_restores_cursor_and_buffer(args): + from vime.data.data_source import RolloutDataSourceWithBuffer + + source = object.__new__(RolloutDataSourceWithBuffer) + source.args, source.dataset = args, None + source.consumers, source._restored_consumers = {}, {} + args.rollout_shuffle = False + source.sample_offset, source.epoch_id = 12, 2 + source.sample_group_index, source.sample_index = 12, 48 + source.metadata = {"custom": "value"} + source.buffer = [[Sample(index=47, tokens=[1, 2])]] + state = source.state_dict() + source.buffer.clear() + rebuilt = object.__new__(RolloutDataSourceWithBuffer) + rebuilt.args, rebuilt.dataset = args, None + rebuilt.load_state_dict(state) + assert rebuilt.sample_offset == 12 and rebuilt.sample_index == 48 + assert rebuilt.buffer[0][0].index == 47 + assert rebuilt.metadata == {"custom": "value"} + + +def test_plain_serving_identity_does_not_depend_on_driver_or_health_checks(args): + args.rollout_data_transport = "object-store" + args.save = args.save_debug_rollout_data = None + name = training_session_name(args) + args.use_fault_tolerance = False + assert training_session_name(args) == name + args.hf_checkpoint = "another-model" + assert training_session_name(args) != name + + +def test_new_serving_owner_uses_cold_checkpoint_handoff(args): + recovery = TrainingRecovery(args, RestorePlan()) + recovery.initial_load_completed(0) + recovery.remember_raw(0, pack_rollout_payload([], args, 0)) + recovery.remember_converted(0, global_train_data(), "batch-0") + rebuilt = TrainingRecovery(args, RestorePlan(mode="snapshot"), retained_serving=False) + assert not rebuilt.loaded and not rebuilt.batches + assert rebuilt.source_state is None + assert rebuilt.restore_plan.mode == "snapshot" + assert rebuilt.incarnation != recovery.incarnation + + +def test_committed_checkpoint_survives_lost_manager_notification(args, tmp_path): + import json + + from vime.data.checkpoint import commit_checkpoint + + args.save = str(tmp_path / "checkpoint") + recovery = TrainingRecovery(args, RestorePlan()) + recovery.initial_load_completed(0) + recovery.remember_raw(2, pack_rollout_payload([], args, 2)) + root = Path(args.save) + (root / "iter_0000002").mkdir(parents=True) + (root / "iter_0000002/weights.pt").write_bytes(b"optimizer-and-model") + (root / "rollout").mkdir() + for name in ("queue_state", "builder_state"): + (root / "rollout" / f"{name}_2.json").write_text(json.dumps({"version": 1})) + commit_checkpoint(args, 2, model_args=[args]) + rebuilt = TrainingRecovery(args, RestorePlan()) + assert rebuilt.checkpoint_step is None and 2 in rebuilt.batches + rebuilt.reconcile_checkpoint() + assert rebuilt.checkpoint_step == 2 and not rebuilt.batches + assert rebuilt.checkpoint.start_rollout_id == 3 + + +def test_replay_builds_new_layout_without_writing_payloads(args, monkeypatch): + from unittest.mock import Mock + from vime.data import batch_builder + + monkeypatch.setattr(batch_builder.ray, "put", lambda value: value) + builder = BatchBuilder(args) + reference = builder.publish_converted(global_train_data()) + builder.controller = Mock() + builder.publish_converted = lambda *a: pytest.fail("Replay must share the accepted records") + builder.train_parallel_config = dict(dp_size=1, cp_size=1, vpp_size=1, microbatch_group_size_per_vp_stage=1) + one = builder.replay_converted(reference, "batch-0") + builder.train_parallel_config["dp_size"] = 2 + two = builder.replay_converted(reference, "batch-0") + assert one[0].inner.load()["sample_indices"] == [0, 1, 2, 3] + assert {ref.inner.manifest for ref in one + two} == {reference.manifest} + assert one[0].inner.plan_digest != two[0].inner.plan_digest + builder.controller.ready_batch.remote.assert_not_called() + + +@pytest.mark.parametrize( + "field,value", + [ + ("rollout_temperature", 0.25), + ("rollout_top_p", 0.8), + ("rollout_top_k", 20), + ("rollout_max_response_len", 8192), + ("rollout_stop", ["STOP"]), + ("apply_chat_template_kwargs", {"enable_thinking": False}), + ("rm_type", "changed"), + ("dynamic_sampling_filter_path", "custom.filter"), + ("buffer_filter_path", "custom.buffer"), + ("custom_new_argument", {"value": 1}), + ("custom_new_argument", None), + ], +) +def test_retained_session_rejects_new_or_changed_rollout_options(args, field, value): + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.configuration = retained_rollout_configuration(args) + serving.driver_job_id = None + recovery = TrainingRecovery(args, RestorePlan()) + recovery.persist() + setattr(args, field, value) + with pytest.raises(ValueError, match=field): + serving.validate_attachment(args) + with pytest.raises(ValueError, match=field): + TrainingRecovery(args, RestorePlan()) + + +def test_retained_session_rejects_removed_custom_option(args): + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.configuration = retained_rollout_configuration(args) + serving.driver_job_id = None + TrainingRecovery(args, RestorePlan()).persist() + del args.custom_reward_post_process_path + with pytest.raises(ValueError, match="custom_reward_post_process_path"): + serving.validate_attachment(args) + with pytest.raises(ValueError, match="custom_reward_post_process_path"): + TrainingRecovery(args, RestorePlan()) + + +@pytest.mark.parametrize("manager_error", [TimeoutError("wedged"), RuntimeError("pause failed")]) +def test_failed_attempt_releases_trainers_despite_manager_failure(args, monkeypatch, manager_error): + from unittest.mock import Mock + + from vime.ray import placement_group + + args.rollout_cleanup_timeout = 2 + manager, serving = Mock(), Mock() + waits = [] + + def get(ref, *, timeout): + waits.append(timeout) + if ref is manager.detach_training.remote.return_value: + raise manager_error + + monkeypatch.setattr(placement_group.ray, "get", get) + monkeypatch.setattr(placement_group.ray, "kill", Mock()) + monkeypatch.setattr( + placement_group.ray, "get_runtime_context", lambda: SimpleNamespace(get_job_id=lambda: "driver") + ) + startup = placement_group.RolloutStartup(args, manager=manager, serving=serving) + startup.close(failed=True) + assert serving.detach_training.remote.call_args.args[0] == "driver" + serving.dispose.remote.assert_not_called() + placement_group.ray.kill.assert_called_once_with(manager, no_restart=True) + assert len(waits) == 2 and all(0 < wait <= 2 for wait in waits) + + +@pytest.mark.parametrize("broken_owner", ["manager", "serving"]) +def test_successful_attempt_cleans_both_owners_after_dispose_error(args, monkeypatch, broken_owner): + from unittest.mock import Mock + + from vime.ray import placement_group + + args.rollout_cleanup_timeout = 2 + owners = {name: Mock() for name in ("manager", "serving")} + + def get(ref, *, timeout): + assert 0 <= timeout <= 2 + if ref is owners[broken_owner].dispose.remote.return_value: + raise RuntimeError("dispose failed") + + monkeypatch.setattr(placement_group.ray, "get", get) + monkeypatch.setattr(placement_group.ray, "kill", Mock()) + startup = placement_group.RolloutStartup(args, **owners) + with pytest.raises(RuntimeError, match="dispose failed"): + startup.close(failed=False) + for owner in owners.values(): + owner.dispose.remote.assert_called_once() + placement_group.ray.kill.assert_any_call(owner, no_restart=True) + + +def test_startup_failure_detaches_acquired_serving_without_mutating_request(args, monkeypatch): + from unittest.mock import Mock + + from vime.ray import placement_group + + before = copy.deepcopy(vars(args)) + acquired = Mock() + closed = [] + + def attach(startup): + startup.serving = acquired + startup.args.load = "restored-model" + startup.args.custom_options = {"new": True} + raise ValueError("manager initialization failed") + + def close(startup, *, failed): + closed.append((startup.serving, failed)) + raise RuntimeError("cleanup also failed") + + monkeypatch.setattr(placement_group, "_attach_rollout_manager", attach) + monkeypatch.setattr(placement_group.RolloutStartup, "close", close) + with pytest.raises(ValueError, match="manager initialization failed"): + placement_group.create_rollout_manager(args) + assert closed == [(acquired, True)] + assert vars(args) == before + + +def test_training_resume_keeps_checkpoint_and_attempt_configuration_separate(args): + from vime.ray.training_recovery import TrainingCheckpoint, TrainingResume + + saved = TrainingCheckpoint.from_args(args) + args.load = "new-cli-path" + args.tensor_model_parallel_size = 4 + args.custom_options = {"nested": [1]} + before = copy.deepcopy(vars(args)) + resume = TrainingResume(RestorePlan(), saved, 17, True) + resolved = resume.apply(args) + assert resolved.load == "initial-model" + assert resolved.tensor_model_parallel_size == 4 + assert resolved.update_weight_start_version == 17 + resolved.custom_options["nested"].append(2) + assert vars(args) == before + assert saved.load == "initial-model" + + +def test_checkpoint_selection_does_not_rewrite_user_paths(args, tmp_path): + from vime.data.checkpoint import resolve_checkpoint + + root = tmp_path / "checkpoint" + (root / "iter_0000002").mkdir(parents=True) + (root / "iter_0000002/weights.pt").write_bytes(b"model") + (root / "latest_checkpointed_iteration.txt").write_text("2") + args.load = args.save = str(root) + args.ckpt_step = 2 + args.start_rollout_id = None + args.finetune = args.no_load_optim = args.no_load_rng = False + before = copy.deepcopy(vars(args)) + resolved, plan = resolve_checkpoint(args) + assert plan.mode == "empty" + assert resolved.start_rollout_id == 3 + assert resolved.save != args.save + assert vars(args) == before + + +def test_manager_cleanup_closes_remaining_resources_after_custom_source_error(args, monkeypatch): + from unittest.mock import Mock + + from vime.data import transport + from vime.ray import rollout + + args.rollout_cleanup_timeout = 2 + manager = object.__new__(rollout.RolloutManager.__ray_metadata__.modified_class) + manager.args = args + manager.data_source = Mock() + manager.data_source.close.side_effect = ValueError("custom close failed") + manager.recovery = Mock() + manager.serving = Mock() + manager._owns_controller = False + monkeypatch.setattr(transport, "seal_rollout_store", Mock()) + monkeypatch.setattr(rollout.logging_utils, "finish_tracking", Mock()) + with pytest.raises(ValueError, match="custom close failed"): + manager.dispose() + manager.recovery.release_batches.assert_called_once() + manager.recovery.journal.unlink.assert_called_once_with(missing_ok=True) + manager.serving.dispose.remote.assert_not_called() + transport.seal_rollout_store.assert_called_once_with(args) + rollout.logging_utils.finish_tracking.assert_called_once_with(args) + + +def test_serving_cleanup_releases_lock_and_placements_after_controller_timeout(args, monkeypatch): + from unittest.mock import Mock + + from vime.ray import serving as module + + args.rollout_cleanup_timeout = 2 + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.args = args + serving.training_actors = {} + serving._health_monitors = [] + serving.servers = {} + serving.router_processes = [] + serving.controller, serving.lock, pg = Mock(), Mock(), Mock() + serving.placements = {"actor": (pg, [], []), "rollout": (pg, [], [])} + monkeypatch.setattr(module.ray, "get", Mock(side_effect=TimeoutError("controller blocked"))) + monkeypatch.setattr(module.ray, "kill", Mock()) + remove = Mock() + import importlib + + monkeypatch.setattr(importlib.import_module("ray.util.placement_group"), "remove_placement_group", remove) + with pytest.raises(TimeoutError, match="controller blocked"): + serving.dispose() + module.ray.kill.assert_any_call(serving.controller, no_restart=True) + module.ray.kill.assert_any_call(serving.lock, no_restart=True) + remove.assert_called_once_with(pg) + + +@pytest.mark.parametrize("failed", [False, True]) +def test_driver_preserves_training_or_cleanup_failure_and_finishes_tracking(args, monkeypatch, failed): + import runpy + import sys + from unittest.mock import Mock + + monkeypatch.setitem(sys.modules, "vime.utils.arguments", SimpleNamespace(parse_args=None)) + main = runpy.run_path(str(Path(__file__).resolve().parents[1] / "train.py"))["main"] + startup = Mock() + startup.close.side_effect = RuntimeError("cleanup failure") + finish = Mock(side_effect=ValueError("tracking failure")) + monkeypatch.setitem(main.__globals__, "init_tracking", Mock()) + monkeypatch.setitem(main.__globals__, "finish_tracking", finish) + monkeypatch.setitem(main.__globals__, "create_rollout_manager", lambda *a, **kw: startup) + monkeypatch.setitem(main.__globals__, "train", Mock(side_effect=KeyError("training failure") if failed else None)) + with pytest.raises( + KeyError if failed else RuntimeError, match="training failure" if failed else "cleanup failure" + ): + main(args, RestorePlan()) + startup.close.assert_called_once_with(failed=failed) + finish.assert_called_once_with(args) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_vllm_rollout.py b/tests/test_vllm_rollout.py index 02d8536c7..47284c62e 100644 --- a/tests/test_vllm_rollout.py +++ b/tests/test_vllm_rollout.py @@ -99,6 +99,8 @@ def _rollout_args(**overrides) -> Namespace: hf_checkpoint="/tmp/model", vllm_router_ip="127.0.0.1", vllm_router_port=8000, + rollout_external=False, + rollout_health_check_timeout=600, partial_rollout=False, mask_offpolicy_in_partial_rollout=False, group_rm=False, @@ -305,8 +307,8 @@ def test_inference_generate_tokens_and_logprobs_aligns_partial_content(): "logprobs": {"content": [{"logprob": -0.1}, {"logprob": -0.2}]}, } ) - assert token_ids == [11, 12, 13] - assert log_probs == [-0.1, -0.2, 0.0] + assert token_ids == [11, 12] + assert log_probs == [-0.1, -0.2] @pytest.mark.unit @@ -633,7 +635,7 @@ def raise_for_status(self): return None async def aiter_lines(self): - yield 'data: {"choices": [{"token_ids": [50], "finish_reason": null}]}' + yield 'data: {"choices": [{"token_ids": [50], "logprobs": {"content": [{"logprob": -0.1}]}, "finish_reason": null}]}' yield "data: [DONE]" class FakeClient: @@ -718,7 +720,7 @@ def test_generate_applies_routed_experts(patch_generate_state, monkeypatch): "token_ids": [50, 51], "finish_reason": "stop", "routed_experts": _encode_routed(routed_rows), - "logprobs": {"content": [{}, {}]}, + "logprobs": {"content": [{"logprob": 0.0}, {"logprob": 0.0}]}, } ], "usage": {}, @@ -1044,7 +1046,8 @@ async def fake_generate_and_rm(args, sample, sampling_params, evaluation=False): @pytest.mark.unit -def test_abort_deletes_inflight_without_pause_resume(patch_generate_state, monkeypatch): +@pytest.mark.parametrize("rollout_external", [False, True]) +def test_abort_deletes_inflight_without_pause_resume(patch_generate_state, monkeypatch, rollout_external): state = _PatchedGenerateState(_rollout_args()) state.active_server_generations = 1 monkeypatch.setattr(mod, "GenerateState", lambda args: state) @@ -1060,6 +1063,8 @@ async def abort_servers(urls): aborted.set() monkeypatch.setattr(mod, "get", fake_get) + prune_mock = AsyncMock(return_value=[{"url": "http://w0:9000"}]) + monkeypatch.setattr(mod, "get_live_router_workers", prune_mock) monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(index=0, prompt="p") @@ -1072,19 +1077,23 @@ async def pending_group(): async def run_abort(): state.pendings = {asyncio.create_task(pending_group())} - return await asyncio.wait_for(mod.abort(_rollout_args(), rollout_id=0), timeout=5.0) + return await asyncio.wait_for( + mod.abort(_rollout_args(rollout_external=rollout_external), rollout_id=0), timeout=5.0 + ) aborted_samples = asyncio.run(run_abort()) assert aborted_urls == ["http://w0:9000"] + assert prune_mock.await_count == (0 if rollout_external else 1) assert state.pendings == set() # partial_rollout is off by default, so drained groups are discarded, not returned. assert aborted_samples == [] @pytest.mark.unit -def test_abort_collects_partial_samples_when_partial_rollout(patch_generate_state, monkeypatch): - args = _rollout_args(partial_rollout=True) +@pytest.mark.parametrize("rollout_external", [False, True]) +def test_abort_collects_partial_samples_when_partial_rollout(patch_generate_state, monkeypatch, rollout_external): + args = _rollout_args(partial_rollout=True, rollout_external=rollout_external) state = _PatchedGenerateState(args) state.active_server_generations = 1 monkeypatch.setattr(mod, "GenerateState", lambda a: state) @@ -1098,6 +1107,8 @@ async def abort_servers(urls): aborted.set() monkeypatch.setattr(mod, "get", fake_get) + prune_mock = AsyncMock(return_value=[{"url": "http://w0:9000"}]) + monkeypatch.setattr(mod, "get_live_router_workers", prune_mock) monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(index=0, prompt="p") @@ -1116,6 +1127,7 @@ async def run_abort(): aborted_samples = asyncio.run(run_abort()) assert aborted_samples == [[sample]] + assert prune_mock.await_count == (0 if rollout_external else 1) assert sample.metadata["start_rollout_id"] == 7 @@ -1201,6 +1213,8 @@ async def abort_servers(urls): monkeypatch.setattr(mod, "generate", server_generate) get_mock = AsyncMock(return_value={"workers": [{"url": "http://worker:9000"}]}) monkeypatch.setattr(mod, "get", get_mock) + prune_mock = AsyncMock(return_value=[{"url": "http://worker:9000"}]) + monkeypatch.setattr(mod, "get_live_router_workers", prune_mock) monkeypatch.setattr(mod, "abort_servers_until_idle", abort_servers) sample = Sample(prompt="abc", generate_function_path="streaming") @@ -1213,6 +1227,7 @@ async def exercise(): with pytest.raises(asyncio.CancelledError): await request_task get_mock.assert_not_awaited() + prune_mock.assert_not_awaited() else: server_task = asyncio.create_task(mod.generate_and_rm(args, Sample(prompt="abc"), {})) await server_started.wait() @@ -1220,7 +1235,8 @@ async def exercise(): result, server_result = await asyncio.gather(request_task, server_task) assert result is sample assert result.status == server_result.status == Sample.Status.ABORTED - get_mock.assert_awaited_once() + get_mock.assert_not_awaited() + prune_mock.assert_awaited_once() async def run(): await asyncio.wait_for(exercise(), timeout=5) diff --git a/tests/test_vllm_server_control.py b/tests/test_vllm_server_control.py index c6d639ba6..b8e9a3263 100644 --- a/tests/test_vllm_server_control.py +++ b/tests/test_vllm_server_control.py @@ -1,6 +1,8 @@ import asyncio import json +from types import SimpleNamespace +import httpx import pytest from vime.backends.vllm_utils import server_control @@ -9,6 +11,126 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("failure", ["connection", "timeout", "status"]) +def test_rollout_boundary_prunes_failed_router_workers(monkeypatch, failure): + calls = [] + + async def respond(request): + calls.append((request.method, str(request.url))) + if request.url.path == "/workers": + return httpx.Response( + 200, json={"workers": [{"id": "good", "url": "http://good"}, {"id": "bad", "url": "http://bad"}]} + ) + if request.method == "DELETE": + return httpx.Response(200) + if request.url.host == "bad": + if failure == "connection": + raise httpx.ConnectError("server stopped", request=request) + if failure == "timeout": + raise httpx.ReadTimeout("server stuck", request=request) + return httpx.Response(503) + return httpx.Response(200) + + client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + monkeypatch.setattr(server_control.httpx, "AsyncClient", lambda **kwargs: client) + workers = asyncio.run(server_control.get_live_router_workers("http://router", timeout=0.1)) + assert workers == [{"id": "good", "url": "http://good"}] + assert ("DELETE", "http://router/workers/http%3A%2F%2Fbad") in calls + assert ("DELETE", "http://router/workers/http%3A%2F%2Fgood") not in calls + + +@pytest.mark.parametrize("actor_failure", ["dead", "wedged"]) +def test_boundary_health_check_bypasses_grace_and_cleans_dead_actor(monkeypatch, actor_failure): + from vime.backends.vllm_utils import engine_group + from vime.ray.serving import ServingCluster + from vime.utils import health_monitor + + calls = [] + dead = SimpleNamespace( + health_generate=SimpleNamespace(remote=lambda **kw: "dead"), + shutdown=SimpleNamespace(remote=lambda: "shutdown"), + ) + alive = SimpleNamespace(health_generate=SimpleNamespace(remote=lambda **kw: "alive")) + group = engine_group.ServerGroup( + args=SimpleNamespace(num_gpus_per_node=1), + pg=None, + num_gpus_per_engine=1, + num_new_engines=0, + all_engines=[dead, alive], + worker_type="regular", + engine_urls={0: "http://dead", 1: "http://alive"}, + router_ip="router", + router_port=8000, + ) + args = SimpleNamespace( + rollout_health_check_interval=600, rollout_health_check_first_wait=600, rollout_health_check_timeout=0.1 + ) + monitor = health_monitor.RolloutHealthMonitor(group, args) + serving = object.__new__(ServingCluster.__ray_metadata__.modified_class) + serving.args = args + serving._health_monitors = [monitor] + serving.servers = {"model": group} + + def wait(refs, *, num_returns, timeout): + assert timeout == 0.1 + if refs == ["shutdown"]: + return [], refs + assert refs == ["dead", "alive"] and num_returns == 2 + return (["alive"], ["dead"]) if actor_failure == "wedged" else (refs, []) + + def get(ref, *, timeout): + if ref in {"dead", "shutdown"}: + raise TimeoutError(actor_failure) + return True + + def unregister(router_url, worker_url, *, timeout): + calls.append(("unregister", router_url, worker_url)) + + monkeypatch.setattr(health_monitor.ray, "wait", wait) + monkeypatch.setattr(health_monitor.ray, "get", get) + monkeypatch.setattr(health_monitor.ray, "kill", lambda engine, **kw: calls.append(("kill", engine))) + monkeypatch.setattr(engine_group, "unregister_worker", unregister) + monitor.start() + try: + snapshot = serving.finish_rollout() + assert calls == [("unregister", "http://router:8000", "http://dead"), ("kill", dead)] + assert snapshot["model"].all_engines == [None, alive] + finally: + monitor.stop() + + +@pytest.mark.unit +def test_abort_uses_supported_load_sections_and_waits_for_pd_transfers(monkeypatch): + abort_calls = 0 + load_calls = 0 + + async def post(url, payload, **kwargs): + nonlocal abort_calls + assert url == "http://engine/abort_requests" + assert payload == {} + abort_calls += 1 + + async def get(url, **kwargs): + nonlocal load_calls + assert url == "http://engine/load?include_inflight=true&inflight_limit=100" + load_calls += 1 + return { + "server_load": 0, + "inflight": [ + { + "data_parallel_rank": 0, + "has_pending_work": load_calls == 1, + "queues": [{"name": "kv_holding_waiting", "num_requests": int(load_calls == 1)}], + } + ], + } + + monkeypatch.setattr(server_control, "post", post) + monkeypatch.setattr(server_control, "get", get) + asyncio.run(server_control.abort_server_until_idle("http://engine", retry_interval=0, timeout=0.1)) + assert abort_calls == load_calls == 2 + + @pytest.mark.unit def test_num_requests_includes_all_dp_queues(): load = { @@ -42,6 +164,13 @@ def test_num_requests_includes_all_dp_queues(): ] +@pytest.mark.unit +@pytest.mark.parametrize("pending_work", [False, True]) +def test_num_requests_includes_connector_activity(pending_work): + load = {"server_load": 0, "inflight": [{"queues": [], "has_pending_work": pending_work}]} + assert server_control.num_requests_from_load(load) == int(pending_work) + + @pytest.mark.unit def test_abort_retries_load_failure_until_idle(monkeypatch): calls = 0 @@ -153,7 +282,7 @@ async def request(index): for _ in range(2): await asyncio.gather(*(request(index) for index in range(concurrency + 3))) assert peak <= concurrency - assert accepted <= concurrency + assert accepted <= concurrency # The second wave reused live sockets. with pytest.raises(httpx.ReadTimeout): await client.post(url + "/slow", content=b"x", timeout=0.01) assert (await client.post(url, content=b"after-timeout")).content == b"after-timeout" diff --git a/tests/training_recovery_test_helpers.py b/tests/training_recovery_test_helpers.py new file mode 100644 index 000000000..00267ab66 --- /dev/null +++ b/tests/training_recovery_test_helpers.py @@ -0,0 +1,96 @@ +"""Actual CUDA OOM injection and serving/data identity checks for restart e2e.""" + +import hashlib +import json +import os +from pathlib import Path + +import ray +import torch +import torch.distributed as dist + +from vime.ray.training_recovery import RECOVERY_NAMESPACE, training_session_name + + +async def reward(args, sample, **kwargs): + return float(sample.index % args.n_samples_per_prompt) + + +def _engine_identity(engine): + return {"actor_pid": os.getpid(), "server_pid": engine.process.pid, "url": engine.get_url()} + + +def _serving_identity(serving): + return {"serving_pid": os.getpid(), "router_pids": [process.pid for process in serving.router_processes]} + + +def _session_snapshot(manager, rollout_id): + data = manager.recovery.load_converted(rollout_id) + serialized = json.dumps( + {name: data[name] for name in ("tokens", "sample_indices", "rewards")}, + sort_keys=True, + default=lambda value: value.tolist(), + ) + # R3 is part of the replay contract: compare the persisted routing bytes, + # independently of how the restarted trainer partitions samples or layers. + route_digest = hashlib.sha256() + route_elements = 0 + for routes in data.get("rollout_routed_experts", []): + if hasattr(routes, "load"): + routes = routes.load() + route_elements += routes.numel() + route_digest.update(routes.to(dtype=torch.int32).contiguous().numpy().tobytes()) + if getattr(manager.args, "use_rollout_routing_replay", False): + assert route_elements > 0, "R3 recovery must replay nonempty routing tensors" + engines = [engine for engine in manager.rollout_engines if engine is not None] + return { + "manager_pid": os.getpid(), + **ray.get(manager.serving.__ray_call__.remote(_serving_identity)), + "engines": ray.get([engine.__ray_call__.remote(_engine_identity) for engine in engines]), + "engine_actor_ids": [engine._actor_id.hex() for engine in engines], + "router": [manager.args.vllm_router_ip, manager.args.vllm_router_port], + "controller_id": manager.controller._actor_id.hex() if manager.controller else None, + "rollout_pg_id": manager.pg[0].id.hex(), + "sample_digest": hashlib.sha256(serialized.encode()).hexdigest(), + "sample_indices": data["sample_indices"], + "route_digest": route_digest.hexdigest(), + "parallel": manager.batch_builder.train_parallel_config, + "weight_versions": ray.get([engine.get_weight_version.remote() for engine in engines]), + "start_rollout_id": manager.args.start_rollout_id, + "checkpoint_step": manager.recovery.checkpoint_step, + } + + +def before_train_step(args, rollout_id, step_id, model, optimizer, opt_param_scheduler): + failure_rollout = int(os.environ.get("VIME_RECOVERY_TEST_FAILURE_ROLLOUT", "0")) + directory = Path(os.environ["VIME_RECOVERY_TEST_DIR"]) + bad_configuration = args.tensor_model_parallel_size == 1 + if rollout_id > failure_rollout: + if not bad_configuration and args.rollout_data_transport == "object-store" and dist.get_rank() == 0: + manager = ray.get_actor(training_session_name(args), namespace=RECOVERY_NAMESPACE) + snapshot = ray.get(manager.__ray_call__.remote(_session_snapshot, rollout_id)) + (directory / f"continued_{rollout_id}.json").write_text(json.dumps(snapshot)) + return + if dist.get_rank() == 0: + manager = ray.get_actor(training_session_name(args), namespace=RECOVERY_NAMESPACE) + snapshot = ray.get(manager.__ray_call__.remote(_session_snapshot, rollout_id)) + snapshot["trainer"] = { + "pid": os.getpid(), + "job_id": str(ray.get_runtime_context().get_job_id()), + "tp_size": args.tensor_model_parallel_size, + } + snapshot["scheduler_num_steps"] = opt_param_scheduler.num_steps + serialized = json.dumps(snapshot, indent=2) + (directory / f"{'original' if bad_configuration else 'replayed'}_{rollout_id}.json").write_text(serialized) + if rollout_id == failure_rollout: + path = directory / ("before.json" if bad_configuration else "after.json") + path.write_text(serialized) + if bad_configuration and os.environ.get("VIME_RECOVERY_TEST_KILL_MANAGER") == "1": + ray.kill(manager, no_restart=True) + dist.barrier() + if bad_configuration and rollout_id == failure_rollout: + # Exercise the real CUDA allocator's failure and Ray exception handling. + # The corrected TP=2 configuration proceeds through actual GRPO updates. + total_memory = torch.cuda.get_device_properties(torch.cuda.current_device()).total_memory + torch.empty(total_memory * 2, dtype=torch.uint8, device="cuda") + raise AssertionError("CUDA allocation exceeding twice the device capacity unexpectedly succeeded") diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py index e97869721..a1f195f4a 100644 --- a/tests/utils/test_vllm_engine.py +++ b/tests/utils/test_vllm_engine.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import sys from pathlib import Path from types import SimpleNamespace @@ -50,6 +51,7 @@ def vllm_args() -> SimpleNamespace: offload_rollout=False, use_rollout_routing_replay=False, rollout_top_p=1.0, + rollout_health_check_timeout=600.0, vllm_pipeline_parallel_size=1, vllm_prefill_context_parallel_size=1, vllm_data_parallel_size=1, @@ -366,14 +368,20 @@ def test_build_vllm_subprocess_env_colocate(vllm_args, monkeypatch): @pytest.mark.unit -def test_build_vllm_subprocess_env_drops_trainer_allocator_config(vllm_args, monkeypatch): +def test_launch_server_process_drops_trainer_allocator_config(vllm_args, monkeypatch): monkeypatch.setenv("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") monkeypatch.setenv("PYTORCH_ALLOC_CONF", "expandable_segments:True") - env = mod._build_subprocess_env({"_args": vllm_args, "_visible_devices": "0"}) + env = {} + process = SimpleNamespace(start=lambda: None) + monkeypatch.setattr(mod.multiprocessing, "set_start_method", lambda *args, **kwargs: None) + monkeypatch.setattr(mod.multiprocessing, "Process", lambda *, target, args: env.update(args[1]) or process) + mod.launch_server_process({"_args": vllm_args, "_visible_devices": "0", "node_rank": 1}) assert "PYTORCH_CUDA_ALLOC_CONF" not in env assert "PYTORCH_ALLOC_CONF" not in env + assert "PYTORCH_CUDA_ALLOC_CONF" not in os.environ + assert "PYTORCH_ALLOC_CONF" not in os.environ @pytest.mark.unit @@ -853,7 +861,8 @@ def fake_post(url, *, params=None, timeout=30, json=None): def test_pipeline_rl_pause_and_disk_reload(vllm_engine, monkeypatch, flush_cache): calls = [] - def post(url, *, json, params=None): + def post(url, *, json, params=None, timeout=None): + assert timeout is None calls.append((url, json, params)) return _MockResponse(json_data={"success": True}) diff --git a/train.py b/train.py index 0d6b4ab81..39c3172fa 100644 --- a/train.py +++ b/train.py @@ -1,24 +1,18 @@ +import logging + import ray from vime.data.checkpoint import save_checkpoint from vime.observability.logging_utils import configure_logger, finish_tracking, init_tracking -from vime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models +from vime.ray.placement_group import create_rollout_manager, create_training_models from vime.utils.arguments import parse_args +from vime.utils.cleanup import Cleanup from vime.utils.misc import should_run_periodic_action -def train(args, restore_plan=None): - configure_logger() +def train(args, pgs, rollout_manager, num_rollout_per_epoch, restore_plan): release_train = args.release_train - # allocate the GPUs - pgs = create_placement_groups(args) - init_tracking(args) - - # create the rollout manager, with vLLM engines inside. - # need to initialize rollout manager first to calculate num_rollout - rollout_manager, num_rollout_per_epoch = create_rollout_manager(args, pgs["rollout"], restore_plan=restore_plan) - actor_model, critic_model = create_training_models(args, pgs, rollout_manager) if args.offload_rollout and not release_train: @@ -26,6 +20,9 @@ def train(args, restore_plan=None): # Always push actor weights to rollout once weights are loaded. actor_model.update_weights() + # Reattached async producers stay paused until restored weights are installed. + # This startup notification resumes them once, before entering the train loop. + ray.get(rollout_manager.training_ready.remote()) if args.check_weight_update_equal: ray.get(rollout_manager.check_weights.remote(action="compare")) @@ -69,6 +66,8 @@ def offload_train(actor_trains_this_step): else: ray.get(actor_model.async_train(rollout_id, rollout_data_ref)) + # Runtime completion releases queue capacity, without claiming that the + # model/optimizer or this data progress have a durable joint checkpoint. ray.get(rollout_manager.training_completed.remote(rollout_id)) if release_train or should_run_periodic_action( @@ -89,6 +88,8 @@ def offload_train(actor_trains_this_step): ray.get(rollout_manager.onload_weights.remote()) was_paused = ray.get(rollout_manager.pause_rollout_admission.remote()) actor_model.update_weights() + # Final evaluation uses the synchronized engines directly. Keep training + # producers paused when no later rollout will consume their new work. if rollout_id + 1 < args.num_rollout: ray.get(rollout_manager.resume_rollout_admission.remote(was_paused)) @@ -98,10 +99,35 @@ def offload_train(actor_trains_this_step): if should_run_periodic_action(rollout_id, args.eval_interval, num_rollout_per_epoch): ray.get(rollout_manager.eval.remote(rollout_id)) - ray.get(rollout_manager.dispose.remote()) - finish_tracking(args) + +def main(args, restore_plan): + """Own the attempt's exit policy; actors only clean up their own resources.""" + with Cleanup() as cleanup: + init_tracking(args) + startup = None + try: + # Create or reattach rollout resources, then run this training attempt. + startup = create_rollout_manager(args, restore_plan=restore_plan) + train( + startup.args, startup.placements, startup.manager, startup.num_rollout_per_epoch, startup.restore_plan + ) + except BaseException: + # Failures and interruptions retain serving and replay data for a manual + # restart. Detach this driver's trainers instead of disposing the session. + if startup is not None: + try: + startup.close(failed=True) + except Exception: + logging.getLogger(__name__).exception("Failed to clean up training attempt") + raise + else: + startup.close(failed=False) + finally: + # Finish this driver's tracking on success, startup failure, or interruption. + cleanup.run("finish driver tracking", finish_tracking, args) if __name__ == "__main__": args, restore_plan = parse_args(return_restore_plan=True) - train(args, restore_plan) + configure_logger() + main(args, restore_plan) diff --git a/vime/agent/adapters/common.py b/vime/agent/adapters/common.py index afbf22416..83dd45981 100644 --- a/vime/agent/adapters/common.py +++ b/vime/agent/adapters/common.py @@ -488,18 +488,9 @@ def _tokens_and_logprobs_from_choice(choice: dict) -> tuple[list[int], list[floa return [], [] tids = [int(x) for x in tids_raw] lp = choice.get("logprobs") - if not isinstance(lp, dict): - return tids, [0.0] * len(tids) - content = lp.get("content") - if isinstance(content, list) and content: - lps: list[float] = [] - for i in range(len(tids)): - if i < len(content) and isinstance(content[i], dict): - lps.append(float(content[i].get("logprob", 0.0))) - else: - lps.append(0.0) - return tids, lps - return tids, [0.0] * len(tids) + content = (lp.get("content") or []) if isinstance(lp, dict) else [] + pairs = list(zip(tids, content, strict=False)) + return [token_id for token_id, _ in pairs], [float(item["logprob"]) for _, item in pairs] async def call_vllm_generate( @@ -561,8 +552,8 @@ async def call_vllm_generate( fr = choice.get("finish_reason") finish = fr if isinstance(fr, str) and fr else "stop" except (asyncio.CancelledError, aiohttp.ClientError, asyncio.TimeoutError) as e: - # vLLM has no per-request abort endpoint. Closing this router request also - # closes its selected worker request, so vLLM cancels the engine request. + # Close the HTTP request context on cancellation; do not issue a + # separate worker-broadcast abort here. logger.debug("[%s] sid=%s turn aborted: %s", adapter.log_prefix, session_id, type(e).__name__) raise diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 610cc8914..d8824def9 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -610,6 +610,8 @@ def save_model(self, rollout_id: int, force_sync: bool = False) -> None: save(rollout_id, self.model, self.optimizer, self.opt_param_scheduler) if force_sync and self.args.async_save: + # Replay data can be released once this call returns, so the current + # save must be durable rather than merely queued in the background. maybe_finalize_async_save(blocking=True) if self.args.save_hf is not None and self.role == "actor": @@ -623,7 +625,9 @@ def update_weights(self) -> None: if self.args.debug_train_only or self.args.debug_rollout_only: return - if self.args.use_fault_tolerance: + if not self.args.rollout_external or self.args.use_fault_tolerance: + # Recover just before weights can be installed. One rank changes + # serving topology; the barrier lets all ranks see the same engines. if dist.get_rank() == 0: ray.get(self.rollout_manager.recover_updatable_engines.remote()) dist.barrier(group=get_gloo_group()) @@ -641,7 +645,7 @@ def update_weights(self) -> None: if not rollout_engines and not reconnect_rollout_engines: if dist.get_rank() == 0: - logger.info("No updatable VLLM engines are running; skip weight update.") + logger.info("No updatable vLLM engines are running; skip weight update.") return if reconnect_rollout_engines: @@ -650,6 +654,8 @@ def update_weights(self) -> None: reload_process_groups() if num_new_engines > 0 or reconnect_rollout_engines: + # A replacement trainer must reconnect even to surviving engines; + # their previous update groups belonged to the old trainer ranks. self.weight_updater.connect_rollout_engines( rollout_engines, rollout_engine_lock, @@ -659,6 +665,7 @@ def update_weights(self) -> None: ) dist.barrier(group=get_gloo_group()) if dist.get_rank() == 0: + # Clear connection markers only after every rank is connected. ray.get(self.rollout_manager.clear_updatable_num_new_engines.remote()) with torch_memory_saver.disable() if self.args.offload_train else nullcontext(): diff --git a/vime/backends/megatron_utils/checkpoint.py b/vime/backends/megatron_utils/checkpoint.py index 90697d08d..031caf6c6 100644 --- a/vime/backends/megatron_utils/checkpoint.py +++ b/vime/backends/megatron_utils/checkpoint.py @@ -3,9 +3,11 @@ import re from pathlib import Path +import torch + # TODO: may need to copy those 2 functions and do refactoring. from megatron.training.checkpointing import load_checkpoint as _load_checkpoint_megatron -from megatron.training.checkpointing import save_checkpoint +from megatron.training.checkpointing import save_checkpoint as _save_checkpoint_megatron from megatron.training.global_vars import get_args try: @@ -92,6 +94,20 @@ def _init_from_local_shards_and_global_metadata( # type: ignore[override] __all__ = ["save_checkpoint"] +def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, **kwargs): + _save_checkpoint_megatron(iteration, model, optimizer, opt_param_scheduler, **kwargs) + args = get_args() + if getattr(args, "use_stateless_adam", False) and opt_param_scheduler is not None and dist.get_rank() == 0: + # Megatron omits the scheduler together with --no-save-optim. Stateless + # Adam needs no moments, but LR/WD progress must share the model's commit + # boundary. Store this small payload inside the same checkpoint directory; + # the joint checkpoint commit waits for and includes it in its manifest. + path = Path(args.save) / f"iter_{iteration:07d}" / "opt_param_scheduler.pt" + temporary = path.with_suffix(".tmp") + torch.save(opt_param_scheduler.state_dict(), temporary) + temporary.replace(path) + + def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, checkpointing_context): # ref: how megatron `load_checkpoint` gets directory args = get_args() @@ -102,6 +118,10 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, checkpointing_con ), f"{args.load=} does not exist or is an empty directory. Did you specify the wrong folder?" if _is_megatron_checkpoint(load_path): + if getattr(args, "use_stateless_adam", False): + # The master parameters are rebuilt from the saved model. There are + # no Adam moments to load, and Megatron must skip its optimizer keys. + args.no_load_optim = True result = _load_checkpoint_megatron( ddp_model=ddp_model, optimizer=optimizer, @@ -114,6 +134,15 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, checkpointing_con f"Model loaded step {result[0]}, but checkpoint restoration requires step {args.ckpt_step}. " "The Megatron checkpoint loader must honor --ckpt-step (including zero)." ) + if getattr(args, "use_stateless_adam", False) and not args.finetune and opt_param_scheduler is not None: + checkpoint = Path(load_path) + if not re.fullmatch(r"iter_\d{7}", checkpoint.name): + checkpoint /= f"iter_{result[0]:07d}" + # Loading only the model without this file would silently restart + # the learning-rate schedule. A missing payload is an invalid resume. + opt_param_scheduler.load_state_dict( + torch.load(checkpoint / "opt_param_scheduler.pt", map_location="cpu", weights_only=True) + ) return result else: return _load_checkpoint_hf( diff --git a/vime/backends/megatron_utils/hf_checkpoint_saver.py b/vime/backends/megatron_utils/hf_checkpoint_saver.py index c442706ac..538f7634f 100644 --- a/vime/backends/megatron_utils/hf_checkpoint_saver.py +++ b/vime/backends/megatron_utils/hf_checkpoint_saver.py @@ -106,12 +106,10 @@ def save_hf_model_to_path( hf_weight_iterator.get_hf_weight_chunks( megatron_local_weights, progress_desc=progress_desc, - # Megatron-to-HF conversion is stateful for some parameters. For - # example, q_a_proj and kv_a_proj can land in adjacent chunks but - # must be emitted together for VLLM compatibility. Every node - # writer therefore has to observe every chunk so that pairs can - # cross chunk boundaries. Writers still only persist their - # modulo-assigned shards below; non-writer ranks skip conversion. + # Preserve Slime's stateful Megatron-to-HF iterator contract: + # every node writer observes every chunk, including across chunk + # boundaries. Writers still only persist their modulo-assigned + # shards below; non-writer ranks skip conversion. should_convert_chunk=lambda _idx: is_writer_rank, ) ): diff --git a/vime/backends/megatron_utils/initialize.py b/vime/backends/megatron_utils/initialize.py index 6dd2056d8..a86607f92 100644 --- a/vime/backends/megatron_utils/initialize.py +++ b/vime/backends/megatron_utils/initialize.py @@ -62,9 +62,6 @@ def init(args): # Pytorch distributed. _initialize_distributed(args) - # https://github.com/NVIDIA/Megatron-LM/issues/1563 - assert np.__version__.startswith("1."), "Megatron does not support numpy 2.x" - # Random seeds for reproducibility. if args.rank == 0: logger.info(f"> setting random seeds to {args.seed} ...") diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index af8c56325..5efdafc16 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -796,13 +796,15 @@ def train( config.grad_scale_func = optimizer.scale_loss config.timers = None if isinstance(model[0], DDP) and args.overlap_grad_reduce: - assert config.no_sync_func is None, ( - "When overlap_grad_reduce is True, config.no_sync_func must be None; " - "a custom no_sync_func is not supported when overlapping grad-reduce" - ) - config.no_sync_func = [model_chunk.no_sync for model_chunk in model] + no_sync_func = [model_chunk.no_sync for model_chunk in model] if len(model) == 1: - config.no_sync_func = config.no_sync_func[0] + no_sync_func = no_sync_func[0] + # train() runs once per rollout, while the model/config survive across + # rollouts. Reuse our DDP callback instead of rejecting it as custom. + assert ( + config.no_sync_func is None or config.no_sync_func == no_sync_func + ), "A custom no_sync_func is not supported when overlapping grad-reduce" + config.no_sync_func = no_sync_func if args.align_grad_reduce: config.grad_sync_func = [model_chunk.start_grad_sync for model_chunk in model] if len(model) == 1: @@ -953,7 +955,6 @@ def train( if ( accumulated_step_id == 0 and not getattr(args, "use_rollout_routing_replay", False) - and args.rollout_top_p == 1.0 and "train/kl_loss" in log_dict ): assert log_dict["train/kl_loss"] < 1e-8, f"{log_dict=}" diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py index 6bce47ea4..d4d72d7db 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py @@ -4,11 +4,11 @@ import logging import os import queue -import shutil from argparse import Namespace from collections import deque from collections.abc import Callable, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor +from pathlib import Path import numpy as np import ray @@ -19,10 +19,11 @@ from ray.actor import ActorHandle from vime.utils import accelerator -from vime.utils.disk_delta import NUM_WORKERS, checksum, make_tensor_reader, overwrite_encode +from vime.utils.disk_delta import NUM_WORKERS, checksum, overwrite_encode from vime.utils.distributed_utils import get_gloo_group from vime.utils.weight_sync import should_flush_cache +from ..hf_checkpoint_saver import _copy_hf_assets, _finalize_distributed_shards, _SafetensorShardWriter from .update_weight_from_distributed import UpdateWeightFromDistributed logger = logging.getLogger(__name__) @@ -30,11 +31,13 @@ class UpdateWeightFromDiskDelta(UpdateWeightFromDistributed): """ - Delta weight sync over a shared filesystem. PP-src ranks diff each gathered HF tensor against + Delta weight sync over a shared filesystem. The publishing rank diffs each gathered HF tensor against a CPU snapshot of the previous sync and publish the changes as a canonical HF checkpoint dir; - each engine's /pull_weights fans the apply out to every host it spans, then the engine reloads + each engine's pull_weights worker method applies it on every host it spans, then the engine reloads the patched local checkpoint via the ordinary update_weights_from_disk path. vime only ever talks to one endpoint per engine, so multi-node serving and external engines need nothing extra. + Each new trainer or engine topology first publishes its current full weights, establishing + the exact shared baseline before incremental updates begin. """ def __init__( @@ -52,7 +55,7 @@ def __init__( self.delta_encoding = args.update_weight_delta_encoding self.checksum_algorithm = args.update_weight_delta_checksum self._snapshot: dict[str, np.ndarray] = {} - self._baseline_captured = False + self._needs_full_sync = True # Post-write hook: object-store-backed shared filesystems lack cross-host # read-after-write consistency, so written files need an explicit step # (e.g. uploading them to the backing object store) before the engines can see them. @@ -72,6 +75,8 @@ def connect_rollout_engines( ) -> None: # The rollout_engine_lock the NCCL path uses isn't needed — the engine-side apply is # serialized by a per-host flock. + if list(rollout_engines) != list(getattr(self, "rollout_engines", [])): + self._needs_full_sync = True self.rollout_engines = rollout_engines self._is_pp_src_rank = dist.get_rank() == 0 @@ -80,51 +85,54 @@ def disconnect_rollout_engines(self) -> None: @torch.no_grad() def update_weights(self) -> None: - # The first call only captures the baseline snapshot the next sync diffs against. - if not self._baseline_captured: - self._capture_baseline() - self._baseline_captured = True - return - + if self._needs_full_sync: + # A failed update may have reached host-local checkpoints without + # reaching GPU weights. Publish above every attempted disk version, + # so pull() cannot mistake that stale local checkpoint for this one. + version = [self.weight_version] + if dist.get_rank() == 0: + version[0] = max( + [self.weight_version] + + [ + int(path.name[8:]) + for path in Path(self.delta_dir).glob("weight_v*") + if path.name[8:].isdigit() + ] + ) + dist.broadcast_object_list(version, src=0, group=get_gloo_group()) + self.weight_version = version[0] self.weight_version += 1 - self._publish() + self._version_dir = os.path.join(self.delta_dir, f"weight_v{self.weight_version:06d}") + if self._is_pp_src_rank: + os.makedirs(self._version_dir, exist_ok=True) + if self._needs_full_sync: + self._publish_full() + else: + self._publish() self._reload_engines() + self._needs_full_sync = False self._record_metrics() - def _capture_baseline(self) -> None: - """Capture the baseline snapshot the first delta diffs against (no publish), and clear any - stale stream from a prior run. Seeds from hf_checkpoint — what each host materializes its - base from — so the invariant ``snapshot == engine base`` holds even where the megatron->HF - round-trip trims vocab-padding rows (embed/lm_head). A tensor absent there (rare) falls back - to the gathered value. pull_weights(0) makes each host materialize its local base now, - overlapped with the snapshot gather, so the first real sync only pays the delta apply.""" - # a prior run's versions would apply against the wrong base; start the dir clean - pulls = [] + def _publish_full(self) -> None: + """Publish restored weights and seed deltas from those exact HF tensors.""" + path = Path(self._version_dir) if dist.get_rank() == 0: - shutil.rmtree(self.delta_dir, ignore_errors=True) - os.makedirs(self.delta_dir, exist_ok=True) - if self._post_write_hook is not None: - self._post_write_hook(self.args, self.delta_dir, list(self.rollout_engines)) - pulls = [engine.pull_weights.remote(target_version=0) for engine in self.rollout_engines] + _copy_hf_assets(self.args.hf_checkpoint, path) + self._snapshot.clear() + writer = _SafetensorShardWriter(path, enabled=self._is_pp_src_rank) + chunk_index = dist.get_rank() + for chunk in self._source.iterator.get_hf_weight_chunks(self._source.weights_getter()): + tensors = [(name, tensor.detach().to(device="cpu", copy=True).contiguous()) for name, tensor in chunk] + for name, tensor in tensors: + self._snapshot[name] = tensor.view(torch.uint8).numpy().reshape(-1) + writer.write(tensors, chunk_index) + chunk_index += dist.get_world_size() dist.barrier(group=get_gloo_group()) - - read_hf = make_tensor_reader(self.args.hf_checkpoint) # index the HF headers once - for name, tensor in self._iter_hf_tensors(): - try: - self._snapshot[name] = read_hf(name) - except KeyError: - self._snapshot[name] = tensor.detach().cpu().contiguous().view(torch.uint8).numpy().reshape(-1) - logger.warning("seed: %s absent from hf_checkpoint; seeding from current weights", name) - if dist.get_rank() == 0: - ray.get(pulls) - logger.info( - "[disk delta] captured baseline snapshot of %d tensors from %s", - len(self._snapshot), - self.args.hf_checkpoint, - ) + _finalize_distributed_shards(path, writer.state()) + self.total_bytes = self.changed_bytes = self.wire_bytes = writer.total_size def _publish(self) -> None: - """Encode this version's changed tensors (PP-src ranks), then write it as a canonical HF dir.""" + """Encode this version's changed tensors, then write it as a canonical HF dir.""" self._encode_delta() dist.barrier(group=get_gloo_group()) self._write_delta_files() @@ -211,9 +219,6 @@ def _encode_delta(self) -> None: in self._delta with their checksums. The GPU->CPU gather is pipelined into a compute pool: the main loop copies one tensor to a pinned buffer and submits it; pool workers diff and compress in parallel (each is a few big GIL-releasing numpy/zstd calls).""" - self._version_dir = os.path.join(self.delta_dir, f"weight_v{self.weight_version:06d}") - if self._is_pp_src_rank: - os.makedirs(self._version_dir, exist_ok=True) snapshot = self._snapshot self._delta: dict[str, np.ndarray] = {} # changed tensor name -> compressed diff self._checksums: dict[str, str] = {} # changed tensor name -> new-state checksum diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py index 6405535fb..feb628647 100644 --- a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py +++ b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py @@ -222,8 +222,8 @@ def connect_rollout_engines( if not self._expert_transfer_plan: return - # Rank-local expert routing is the one case the generic IPC API cannot - # express: each rollout EP rank receives a different expert subset. + # Expert routing supplies per-worker payloads instead of using the + # generic trainer's shared weight source. if self._ipc_gather_group is None: for index in range(colocate_engine_nums): group_ranks = list( diff --git a/vime/backends/vllm_utils/engine_group.py b/vime/backends/vllm_utils/engine_group.py index 409f72051..195bfe693 100644 --- a/vime/backends/vllm_utils/engine_group.py +++ b/vime/backends/vllm_utils/engine_group.py @@ -8,8 +8,8 @@ import ray from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy +from vime.backends.vllm_utils.server_control import unregister_worker from vime.backends.vllm_utils.vllm_config import ServerGroupConfig -from vime.backends.vllm_utils.vllm_engine import VLLMEngine, _resolve_parallel_sizes from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, add_default_ray_env_vars GPU_MEMORY_TYPE_KV_CACHE = "kv_cache" @@ -19,6 +19,32 @@ logger = logging.getLogger(__name__) +def reset_weights_update_groups(groups, *, timeout): + """Reset every serving peer together, then retire only failed engines. + + Prefill and decode may share one NCCL weight-update group. Destroying that + group can wait for its peers, so sending resets to one server group and + waiting before contacting the next group can deadlock healthy engines. + """ + resets = { + engine.reset_weights_update_groups.remote(): (group_index, index // group.nodes_per_engine) + for group_index, group in enumerate(groups) + for index, engine in enumerate(group.all_engines) + if engine is not None + } + if resets: + ray.wait(list(resets), num_returns=len(resets), timeout=timeout) + failed = set() + for ref, engine in resets.items(): + try: + ray.get(ref, timeout=0) + except Exception as error: + logger.warning("Retiring engine %s after trainer reset failed: %s", engine, error) + failed.add(engine) + for group_index, engine_id in sorted(failed): + groups[group_index].retire_engine(engine_id, timeout=timeout) + + @dataclasses.dataclass class ServerGroup: """A group of homogeneous vLLM engines with the same configuration. @@ -32,6 +58,7 @@ class ServerGroup: pg: Any # (placement_group, reordered_bundle_indices, reordered_gpu_ids) all_engines: list num_gpus_per_engine: int + # Also marks surviving engines that a replacement trainer must reconnect. num_new_engines: int worker_type: str = "regular" # "regular", "prefill", "decode", or "placeholder" rank_offset: int = 0 # cumulative engine count before this group @@ -41,6 +68,9 @@ class ServerGroup: model_path: str | None = None # checkpoint path for update_weights_from_disk router_ip: str | None = None router_port: int | None = None + # Retain addresses outside the actors: deregistration must still work when + # an actor dies or cannot answer get_url(). Keys index all_engines locally. + engine_urls: dict[int, str] = dataclasses.field(default_factory=dict) @property def nodes_per_engine(self): @@ -53,6 +83,8 @@ def engines(self): def parallel_config(self) -> dict[str, Any]: """Return the VLLM parallel args that affect rank-local expert routing.""" + from vime.backends.vllm_utils.vllm_engine import _resolve_parallel_sizes + overrides = {key.replace("-", "_"): value for key, value in self.vllm_overrides.items()} tp_size, pp_size, pcp_size, dp_size = _resolve_parallel_sizes( self.args, @@ -74,6 +106,24 @@ def parallel_config(self) -> dict[str, Any]: "ep_size": tp_size * pcp_size * dp_size if enable_expert_parallel else 1, } + def retire_engine(self, engine_id, *, timeout): + """Remove one serving unit from routing and terminate all of its nodes.""" + first = engine_id * self.nodes_per_engine + if self.worker_type != "encoder": + unregister_worker( + f"http://{self.router_ip or self.args.vllm_router_ip}:{self.router_port or self.args.vllm_router_port}", + self.engine_urls[first], + timeout=timeout, + ) + engines = self.all_engines[first : first + self.nodes_per_engine] + shutdowns = [engine.shutdown.remote() for engine in engines if engine is not None] + if shutdowns: + ray.wait(shutdowns, num_returns=len(shutdowns), timeout=timeout) + for engine in engines: + if engine is not None: + ray.kill(engine, no_restart=True) + self.all_engines[first : first + self.nodes_per_engine] = [None] * self.nodes_per_engine + def start_engines(self, port_cursors: dict[int, int] | None = None) -> tuple[list, dict[int, int]]: """Create Ray actors, allocate ports, and fire ``engine.init()`` without waiting. @@ -114,11 +164,15 @@ def start_engines(self, port_cursors: dict[int, int] | None = None) -> tuple[lis "and --vllm-config server_groups." ) + from vime.backends.vllm_utils.vllm_engine import VLLMEngine + RolloutRayActor = ray.remote(VLLMEngine) rollout_engines = [] for i in range(len(self.all_engines)): if self.all_engines[i] is not None: + # Startup and recovery share this slot map. Reuse live processes; + # only empty slots need new actors. continue global_rank = self.rank_offset + i @@ -178,6 +232,14 @@ def start_engines(self, port_cursors: dict[int, int] | None = None) -> tuple[lis base_port=base_port, ) + # Cache URLs before init can fail. Deregistration must remain possible + # even if the actor dies before answering get_url(). + for rank, _engine in rollout_engines: + address = addr_and_ports[rank] + host = address["host"] + if ":" in host and not host.startswith("["): + host = f"[{host}]" + self.engine_urls[rank - self.rank_offset] = f"http://{host}:{address['port']}" init_handles = [ engine.init.remote( **(addr_and_ports[rank]), @@ -296,6 +358,8 @@ def recover(self): logger.info(f"Recovered {g.num_new_engines} dead rollout engines (worker_type={g.worker_type})") assert g.num_new_engines == len(dead_indices), "num_new_engines does not match dead_indices length" if g.needs_offload and dead_indices: + # Fresh engines allocate all memory at startup. Restore offload + # state before weight sync to avoid conflicts with colocated training. new_engines = [g.all_engines[i] for i in dead_indices] release_handles.extend( engine.release_memory_occupation.remote(level=2 if self.update_weights else 1) @@ -383,7 +447,7 @@ def create( for key, value in overrides_extra.items(): overrides.setdefault(key, value) if self.args.offload_rollout and not needs_offload: - overrides.setdefault("enable_memory_saver", False) + overrides.setdefault("enable_sleep_mode", False) logger.info( f"Engine group '{group_config.worker_type}' gpu_offset={self.gpu_offset} " f"(abs={group_abs_start}): needs_offload={needs_offload}" @@ -422,12 +486,7 @@ def _allocate_rollout_engine_addr_and_ports_normal( rank_offset=0, base_port=15000, ): - # get ports - # there are 4 ports we need to allocate - # 1. server port - # 2. nccl port - # 3. dist_init_addr port - # 4. other ports for dp_attention, which is of size 4 + dp_size + """Allocate rank-local vLLM and distributed-init ports for one group.""" _gpus_per_engine = num_gpus_per_engine or args.rollout_num_gpus_per_engine num_engines_per_node = max(1, args.num_gpus_per_node // _gpus_per_engine) addr_and_ports: dict[int, dict] = {} diff --git a/vime/backends/vllm_utils/server_control.py b/vime/backends/vllm_utils/server_control.py index bde788b34..8c069d720 100644 --- a/vime/backends/vllm_utils/server_control.py +++ b/vime/backends/vllm_utils/server_control.py @@ -1,6 +1,10 @@ import asyncio import logging from typing import Any +from urllib.parse import quote + +import httpx +import requests from vime.utils.http_utils import get, post @@ -11,7 +15,52 @@ DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 10.0 +def unregister_worker(router_url: str, worker_url: str, *, timeout: float) -> None: + """Remove a worker even when its engine actor can no longer answer RPCs.""" + with requests.Session() as client: + client.trust_env = False + response = client.get(f"{router_url}/workers", timeout=timeout) + response.raise_for_status() + for worker in response.json()["workers"]: + if worker["url"] == worker_url: + response = client.delete(f"{router_url}/workers/{quote(worker_url, safe='')}", timeout=timeout) + # Another health check may already have removed this worker. + if response.status_code != 404: + response.raise_for_status() + + +async def get_live_router_workers(router_url: str, *, timeout: float) -> list[dict]: + """Prune failed workers before rollout drains requests through the router. + + This precedes the manager's end-of-rollout check: the rollout function itself + must finish abort/drain before it can return to that manager. Probe HTTP + directly so cleanup never waits for a dead engine actor to answer an RPC. + """ + async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + response = await client.get(f"{router_url}/workers") + response.raise_for_status() + + async def check(worker): + try: + response = await client.get(f"{worker['url']}/health_generate") + response.raise_for_status() + except httpx.HTTPError as error: + # A registered URL is not proof of a live server. Remove it + # before abort/drain can wait on requests the server cannot finish. + logger.warning("Removing failed rollout worker %s: %s", worker["url"], error) + response = await client.delete(f"{router_url}/workers/{quote(worker['url'], safe='')}") + if response.status_code != 404: + response.raise_for_status() + return None + return worker + + # Probe concurrently so multiple failed servers do not multiply the wait. + workers = await asyncio.gather(*(check(worker) for worker in response.json()["workers"])) + return [worker for worker in workers if worker is not None] + + def num_requests_from_load(load: Any) -> int: + """Return request counts or a nonzero busy marker for scheduler-owned work.""" if isinstance(load, list): return sum(num_requests_from_load(item) for item in load) if not isinstance(load, dict): @@ -22,7 +71,7 @@ def num_requests_from_load(load: Any) -> int: core_requests = load.get("server_load", 0) if not isinstance(core_requests, int) or isinstance(core_requests, bool): core_requests = 0 - detailed_counts = [core_requests] + detailed_counts = [core_requests, int(load.get("has_pending_work") is True)] for key in ("inflight", "queues"): if key in load: detailed_counts.append(num_requests_from_load(load[key])) @@ -93,6 +142,8 @@ async def abort_server_until_idle( raise ValueError("abort and control-request timeouts must be positive") loop = asyncio.get_running_loop() + # Abort acknowledgements do not prove the scheduler is idle. Bound retries + # with one overall deadline while checking all outstanding request queues. deadline = loop.time() + timeout attempt = 1 last_error: Exception | None = None @@ -102,7 +153,7 @@ async def abort_server_until_idle( remaining = deadline - loop.time() if remaining <= 0: detail = ( - f"last observed request count={last_num_requests}" + f"last observed pending activity (count/busy marker)={last_num_requests}" if last_num_requests is not None else f"last error={last_error!r}" ) @@ -111,9 +162,12 @@ async def abort_server_until_idle( raise TimeoutError(f"Timed out draining vLLM server {url} after {timeout:.1f}s ({detail})") per_request_timeout = min(request_timeout, remaining) + logger.info(f"Abort request for vLLM server {url}") try: await _abort_server_once(url, per_request_timeout) except Exception as error: + # Retry within the deadline; a transient abort failure does not + # prove that the server is either busy or already idle. last_error = error logger.warning(f"Failed to abort vLLM server at {url}: {error}") @@ -125,6 +179,7 @@ async def abort_server_until_idle( num_requests = num_requests_from_load(load) except Exception as error: last_error = error + # An unavailable load response cannot be treated as zero requests. logger.warning(f"Failed to get vLLM server load from {url}: {error}") else: last_load = load @@ -132,7 +187,7 @@ async def abort_server_until_idle( if num_requests <= 0: return logger.info( - "vLLM server %s still has %d requests after abort attempt %d; " + "vLLM server %s still has pending activity (count/busy marker=%d) after abort attempt %d; " "non-idle queues=%r; retrying in %s seconds.", url, num_requests, @@ -153,6 +208,8 @@ async def abort_servers_until_idle( timeout: float = DEFAULT_ABORT_TIMEOUT_SECONDS, request_timeout: float = DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS, ) -> None: + # Let every server finish its own bounded drain, then report all failures. + # Proceeding after only some servers became idle would make controls unsafe. results = await asyncio.gather( *(abort_server_until_idle(url, timeout=timeout, request_timeout=request_timeout) for url in urls), return_exceptions=True, diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py index c632c951c..9ff50ca24 100644 --- a/vime/backends/vllm_utils/vllm_engine.py +++ b/vime/backends/vllm_utils/vllm_engine.py @@ -5,8 +5,8 @@ import multiprocessing import os import time +from threading import Thread from typing import Any -from urllib.parse import quote import cloudpickle import requests @@ -14,6 +14,7 @@ from vllm.utils.system_utils import kill_process_tree from vime.backends.vllm_utils.external import get_server_info +from vime.backends.vllm_utils.server_control import unregister_worker from vime.ray.ray_actor import RayActor from vime.utils.http_utils import _wrap_ipv6, get_host_info @@ -38,6 +39,8 @@ def get_base_gpu_id(args, rank): def launch_server_process(server_args_dict: dict) -> multiprocessing.Process: + os.environ.pop("PYTORCH_CUDA_ALLOC_CONF", None) + os.environ.pop("PYTORCH_ALLOC_CONF", None) env = _build_subprocess_env(server_args_dict) kwargs = {k: v for k, v in server_args_dict.items() if not k.startswith("_")} host = _wrap_ipv6(kwargs.get("host") or "127.0.0.1") @@ -62,8 +65,6 @@ def launch_server_process(server_args_dict: dict) -> multiprocessing.Process: def _build_subprocess_env(server_args_dict: dict[str, Any]) -> dict[str, str]: args = server_args_dict["_args"] env = os.environ.copy() - env.pop("PYTORCH_CUDA_ALLOC_CONF", None) - env.pop("PYTORCH_ALLOC_CONF", None) env.setdefault("NCCL_CUMEM_ENABLE", "0") env["CUDA_VISIBLE_DEVICES"] = server_args_dict["_visible_devices"] # ROCm: keep HIP visibility in sync with CUDA (no-op on CUDA). @@ -93,6 +94,17 @@ def _build_subprocess_env(server_args_dict: dict[str, Any]) -> dict[str, str]: def _run_vllm_server(kwargs: dict, env: dict) -> None: os.environ.update(env) + parent = multiprocessing.parent_process() + if parent is not None: + + def stop_when_parent_exits(): + parent.join() + kill_process_tree(os.getpid()) + + Thread(target=stop_when_parent_exits, daemon=True).start() + if not parent.is_alive(): + return + from vllm.entrypoints.cli.serve import ServeSubcommand from vllm.entrypoints.launchers.cli_args import make_arg_parser, validate_parsed_serve_args from vllm.utils.argparse_utils import FlexibleArgumentParser @@ -107,7 +119,7 @@ def _run_vllm_server(kwargs: dict, env: dict) -> None: def _wait_server_healthy(base_url, is_process_alive): while True: try: - response = requests.get(f"{base_url}/health") + response = requests.get(f"{base_url}/health_generate", timeout=5) if response.status_code == 200: break except requests.RequestException: @@ -230,7 +242,7 @@ def _register_to_router(self, server_args_dict): ) response.raise_for_status() - def _make_request(self, endpoint: str, payload: dict | None = None): + def _make_request(self, endpoint: str, payload: dict | None = None, *, timeout=None): """Make a POST request to the specified endpoint with the given payload. Args: @@ -244,7 +256,7 @@ def _make_request(self, endpoint: str, payload: dict | None = None): return url = f"http://{self.server_host}:{self.server_port}/{endpoint}" - response = requests.post(url, json=payload or {}) + response = requests.post(url, json=payload or {}, timeout=timeout) try: response.raise_for_status() except requests.exceptions.HTTPError as e: @@ -259,7 +271,7 @@ def health_generate(self, timeout: float = 5.0) -> bool: return True response = requests.get( - f"http://{self.server_host}:{self.server_port}/health", + f"http://{self.server_host}:{self.server_port}/health_generate", timeout=timeout, ) response.raise_for_status() @@ -312,23 +324,21 @@ def get_url(self): def shutdown(self): if self.args.rollout_external: + # The external cluster owns its processes; vime must not kill them. return logger.info(f"Shutdown engine {self.server_host}:{self.server_port}...") if self.worker_type != "encoder" and self.node_rank == 0: worker_url = f"http://{self.server_host}:{self.server_port}" try: - all_workers = requests.get(f"http://{self.router_ip}:{self.router_port}/workers").json()["workers"] - for worker in all_workers: - if worker["url"] == worker_url: - response = requests.delete( - f"http://{self.router_ip}:{self.router_port}/workers/{quote(worker_url, safe='')}", - ) - response.raise_for_status() - break - else: - logger.warning(f"Worker {worker_url} not found in vllm-router during shutdown.") + unregister_worker( + f"http://{self.router_ip}:{self.router_port}", + worker_url, + timeout=self.args.rollout_health_check_timeout, + ) except Exception as e: + # Even if router cleanup fails, terminate the local process. + # Owner-side cleanup keeps the URL outside this actor for retry. logger.warning(f"Failed to fetch workers list or remove worker: {e}") kill_process_tree(self.process.pid) @@ -336,7 +346,9 @@ def shutdown(self): def get_weight_version(self): if self.node_rank != 0: return - response = requests.get(f"http://{self.server_host}:{self.server_port}/weight_info") + response = requests.get( + f"http://{self.server_host}:{self.server_port}/weight_info", timeout=self.args.rollout_health_check_timeout + ) try: response.raise_for_status() except requests.exceptions.HTTPError as error: @@ -446,7 +458,15 @@ def init_weights_update_group(self, master_address, master_port, rank_offset, wo def destroy_weights_update_group(self, group_name): del group_name - return None + return self.reset_weights_update_groups() + + def reset_weights_update_groups(self): + """Remove dead trainer peers while retaining the serving process.""" + return self._make_request( + "collective_rpc", + {"method": "shutdown_weight_transfer_engine"}, + timeout=self.args.rollout_health_check_timeout, + ) def update_weights_from_distributed( self, diff --git a/vime/data/batch_builder.py b/vime/data/batch_builder.py index 780c0ca87..654cc2fd2 100644 --- a/vime/data/batch_builder.py +++ b/vime/data/batch_builder.py @@ -1,7 +1,10 @@ -"""The single rollout-to-training conversion boundary, shared by all producers. +"""Convert an accepted rollout once, then give each trainer rank a data view. -Reward postprocessing, masks, rollout aggregation and DP packing stay in their -established order. The manager retains model/server and data-source lifecycle. +Global reward/conversion hooks still see the complete sample batch in their +established order. The accepted conversion stores token/mask tensors once and +shares workers' existing lazy R3/SC records. DP partitions and microbatches are +small read selections over that conversion; a new trainer can rebuild them +without calling hooks again or rewriting tensors. """ import json @@ -32,7 +35,6 @@ def __init__(self, args, *, controller=None): self.rollout_id = -1 self.raw_ref = None self.batch_id = None - self.consumer_state = None self._plan = None self.custom_reward_post_process_func = None if self.args.custom_reward_post_process_path is not None: @@ -43,10 +45,11 @@ def __init__(self, args, *, controller=None): self.args.custom_convert_samples_to_train_data_path ) - def begin(self, samples): + def begin(self, samples, *, replay=False): """Persist the selection and conversion configuration before invoking hooks.""" if self.raw_ref is None: self.batch_id = None + self._plan = None return None from straw.protocol import Lease, RecordSetRef, digest @@ -80,18 +83,19 @@ def begin(self, samples): "configuration": configuration, "parallel": self.train_parallel_config, } - self.plan_digest = digest(identity) + plan_digest = digest(identity) existing = ray.get(controller.batch.remote(self.batch_id)) if existing: self._plan = DiskPayloadRef(RecordSetRef.from_dict(existing["plan_ref"]), self.args.rollout_data_dir) plan = self._plan.load() - if plan["digest"] != self.plan_digest: + # Normal retries must match the original plan. Trainer replay may + # change parallelism, but still uses the retained plan's identity. + if not replay and plan["digest"] != plan_digest: raise ValueError("An existing batch ID cannot be reused with a different selection or conversion plan") if existing["ready"]: - refs = DiskPayloadRef( - RecordSetRef.from_dict(existing["ready_ref"]), self.args.rollout_data_dir - ).load()["ranks"] - return [Box(ray.put(ref)) for ref in refs] + return self.split_by_dp( + DiskPayloadRef(RecordSetRef.from_dict(existing["ready_ref"]), self.args.rollout_data_dir) + ) else: positions = {self.raw_ref.receipt.position} for sample in samples: @@ -105,32 +109,37 @@ def begin(self, samples): # Samples already carry group receipts/positions; do not reread raw. plan = { **identity, - "digest": self.plan_digest, + "digest": plan_digest, "raw": self.raw_ref, "input_positions": sorted(positions), "producer_processing": "generation/reward/dynamic and batch filters already applied", } self._plan = pack_rollout_payload(plan, self.args, self.rollout_id) - ray.get(controller.plan_batch.remote(self.batch_id, plan["input_positions"], self._plan.manifest)) - self._positions = plan["input_positions"] + batch = ray.get(controller.plan_batch.remote(self.batch_id, plan["input_positions"], self._plan.manifest)) + self._lease = batch["lease"] return None - def _commit_ready(self, ranks, schedule): - controller = self.controller - ready = pack_rollout_payload( - { - "version": 1, - "batch_id": self.batch_id, - "plan": self._plan, - "plan_digest": self.plan_digest, - "schedule": schedule, - "ranks": ranks, - }, - self.args, - self.rollout_id, + def replay_converted(self, data, batch_id): + """Rebuild only the trainer's schedule from the accepted conversion.""" + self.batch_id = batch_id + return self.split_by_dp(data) + + def publish_converted(self, data): + """Write the global conversion once; every DP layout shares its records.""" + data = dict(data) + data["total_lengths"] = [len(tokens) for tokens in data["tokens"]] + tensorize_rollout_data_for_training(data) + # Keep selection/raw dependencies alive with the conversion. This also + # makes a queue commit sufficient to recover a lost manager reply. + if self._plan is not None: + data["_plan"] = self._plan + metadata = ( + {"task_id": self.batch_id, "attempt_id": self._lease["attempt_id"]} if self.batch_id is not None else None ) - state_ref = ray.get(controller.ready_batch.remote(self.batch_id, ready.manifest)) - self.consumer_state = DiskPayloadRef(state_ref, self.args.rollout_data_dir) + reference = pack_rollout_payload(data, self.args, self.rollout_id, metadata=metadata) + if self.batch_id is not None: + ray.get(self.controller.ready_batch.remote(self.batch_id, reference.manifest)) + return reference def training_completed(self, rollout_id): if self.batch_id is not None: @@ -176,7 +185,7 @@ def load(self, rollout_id, *, source_restore=None): # A fork is taken after training completion. Saved pending/ready # groups were reintroduced with new receipts; old positions and # finished batches belong exclusively to the parent queue. - self.consumer_state = self.raw_ref = self.batch_id = self._plan = None + self.raw_ref = self.batch_id = self._plan = None return from straw.protocol import RecordSetRef @@ -189,10 +198,9 @@ def load(self, rollout_id, *, source_restore=None): ): raise ValueError("BatchBuilder checkpoint run/schema differs") state = snapshot["consumer"] - self.consumer_state = DiskPayloadRef(RecordSetRef.from_dict(state["state_ref"]), self.args.rollout_data_dir) ray.get( self.controller.restore_training_state.remote( - self.consumer_state.manifest, + RecordSetRef.from_dict(state["state_ref"]), state["fetch_cursor"], state["processed_cursor"], source_restore.source_ref if source_restore is not None else None, @@ -416,21 +424,17 @@ def convert(self, samples: list[Sample] | list[list[Sample]]): return train_data def split_by_dp(self, data): - """Compute the DP/mbs schedule and package each rank's rollout_data - into a Ray Box. The schedule itself is computed by - :func:`build_dp_schedule` so it stays unit-testable without Ray/vLLM. - - Step split is by rollout id (``samples[i].rollout_id``, falling back - to ``samples[i].index``); each step holds exactly - ``args.global_batch_size`` rollouts so the training-step count per - rollout is fixed at ``rollout_batch_size * n_samples_per_prompt // - global_batch_size`` regardless of how many training samples each - rollout produced. - """ - dp_size = self.train_parallel_config["dp_size"] - total_lengths = [len(t) for t in data["tokens"]] + """Build a schedule over one immutable conversion, without per-rank writes.""" + transport = self.args.rollout_data_transport + if transport == "straw": + # Scheduling needs lengths and rollout identity only. In particular, + # replay must not read the full token or R3 payload on the manager. + reference = data if isinstance(data, DiskPayloadRef) else self.publish_converted(data) + data = reference.load(selection={"total_lengths": None, "rollout_ids": None}) + total_lengths = data.get("total_lengths") + if total_lengths is None: + total_lengths = [len(tokens) for tokens in data["tokens"]] data["total_lengths"] = total_lengths - partitions, micro_batch_indices, num_microbatches, global_batch_sizes = build_dp_schedule( self.args, self.train_parallel_config, @@ -438,69 +442,70 @@ def split_by_dp(self, data): global_batch_size=self.args.global_batch_size, rollout_indices=data["rollout_ids"], ) - - # Package per-rank rollout_data - rollout_data_refs = [] - stored_ranks = [] - for r in range(dp_size): - partition = partitions[r] - rollout_data = {"partition": partition} - for key in [ - "tokens", - "multimodal_train_inputs", - "response_lengths", - "rewards", - "truncated", - "loss_masks", - "round_number", - "sample_indices", - "rollout_ids", - "rollout_mask_sums", - "rollout_log_probs", - "rollout_topk_token_ids", - "rollout_topk_log_probs", - "rollout_top_p_token_ids", - "rollout_top_p_token_offsets", - "rollout_top_p_log_probs", - "rollout_routed_experts", - "source_names", - "prompt", - "teacher_log_probs", - ]: - if key not in data: - continue - rollout_data[key] = [data[key][j] for j in partition] - # keys that need to be splited at train side - for key in ["raw_reward", "total_lengths"]: - if key not in data: - continue - rollout_data[key] = data[key] - rollout_data["global_batch_sizes"] = global_batch_sizes - rollout_data["num_microbatches"] = num_microbatches - rollout_data["micro_batch_indices"] = micro_batch_indices[r] - tensorize_rollout_data_for_training(rollout_data) - transport = self.args.rollout_data_transport + fields = [ + "tokens", + "multimodal_train_inputs", + "response_lengths", + "rewards", + "truncated", + "loss_masks", + "round_number", + "sample_indices", + "rollout_ids", + "rollout_mask_sums", + "rollout_log_probs", + "rollout_topk_token_ids", + "rollout_topk_log_probs", + "rollout_top_p_token_ids", + "rollout_top_p_token_offsets", + "rollout_top_p_log_probs", + "rollout_routed_experts", + "source_names", + "prompt", + "teacher_log_probs", + ] + if transport == "straw": + from straw.protocol import digest + + plan_digest = digest( + { + "conversion": reference.manifest.digest, + "partitions": partitions, + "micro_batch_indices": micro_batch_indices, + "global_batch_sizes": global_batch_sizes, + } + ) + result = [] + for rank, partition in enumerate(partitions): + schedule = { + "partition": partition, + "global_batch_sizes": global_batch_sizes, + "num_microbatches": num_microbatches, + "micro_batch_indices": micro_batch_indices[rank], + } if transport == "straw": - ref = pack_rollout_payload(rollout_data, self.args, self.rollout_id) - if getattr(self, "batch_id", None): - ref = TrainBatchRef(ref.manifest, ref.root, self.batch_id, r, self.plan_digest) - stored_ranks.append(ref) - elif transport == "nixl": - rollout_data_refs.append(Box(ray.put(rollout_data, _tensor_transport="nixl"))) - elif transport == "object-store": - rollout_data_refs.append(Box(ray.put(rollout_data))) + selection = {key: partition for key in fields} + # Training's pass-rate and load metrics use the global arrays; + # all per-sample tensors are selected before storage reads. + selection.update(raw_reward=None, total_lengths=None) + value = TrainBatchRef( + reference.manifest, + reference.root, + self.batch_id, + rank, + plan_digest, + selection=selection, + schedule=schedule, + ) + else: + value = {key: [data[key][index] for index in partition] for key in fields if key in data} + value.update({key: data[key] for key in ("raw_reward", "total_lengths") if key in data}) + value.update(schedule) + tensorize_rollout_data_for_training(value) + if transport == "nixl": + result.append(Box(ray.put(value, _tensor_transport="nixl"))) + elif transport in {"straw", "object-store"}: + result.append(Box(ray.put(value))) else: raise ValueError(f"Unsupported rollout data transport: {transport!r}") - if transport == "straw": - if getattr(self, "batch_id", None): - self._commit_ready( - stored_ranks, - { - "partitions": partitions, - "micro_batch_indices": micro_batch_indices, - "num_microbatches": num_microbatches, - "global_batch_sizes": global_batch_sizes, - }, - ) - rollout_data_refs = [Box(ray.put(ref)) for ref in stored_ranks] - return rollout_data_refs + return result diff --git a/vime/data/checkpoint.py b/vime/data/checkpoint.py index 2e162b441..e8efe7039 100644 --- a/vime/data/checkpoint.py +++ b/vime/data/checkpoint.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import hashlib import json import logging @@ -39,13 +40,18 @@ def save_checkpoint(args, rollout_id, actor_model, critic_model, rollout_manager """Save training and rollout state, publishing a joint boundary for straw.""" import ray + from vime.ray.training_recovery import training_recovery_enabled + straw_checkpoint = ( args.rollout_data_transport == "straw" and actor_trains and not args.debug_train_only and not args.debug_rollout_only ) - force_sync = straw_checkpoint or args.release_train or rollout_id == args.num_rollout - 1 + recoverable = training_recovery_enabled(args) + # A recovery boundary must wait for async model writes to finish before it + # can declare earlier batches safe to discard. + force_sync = straw_checkpoint or recoverable or args.release_train or rollout_id == args.num_rollout - 1 if straw_checkpoint and (Path(args.save) / "rollout" / f"committed_{rollout_id}.json").exists(): raise FileExistsError("Refusing to overwrite a committed straw checkpoint") if actor_trains: @@ -58,10 +64,17 @@ def save_checkpoint(args, rollout_id, actor_model, critic_model, rollout_manager model_args = [actor_model.args] if args.use_critic: model_args.append(critic_model.args) - commit_checkpoint(args, rollout_id, model_args=model_args, restore_plan=restore_plan) + weight_version = ray.get(rollout_manager.get_weight_version.remote()) + commit_checkpoint( + args, rollout_id, model_args=model_args, restore_plan=restore_plan, weight_version=weight_version + ) + if actor_trains and recoverable: + # Notify recovery last. Any model, rollout, or joint-commit failure above + # must leave the previous boundary and its replay batches intact. + ray.get(rollout_manager.checkpoint_committed.remote(rollout_id)) -def commit_checkpoint(args, rollout_id, *, model_args, restore_plan=None): +def commit_checkpoint(args, rollout_id, *, model_args, restore_plan=None, weight_version=None): """Publish the boundary after synchronous model saves and rollout snapshots. The driver waits for those calls; a save exception prevents this call. @@ -76,8 +89,13 @@ def commit_checkpoint(args, rollout_id, *, model_args, restore_plan=None): files = {} resumable = True for config in model_args: - resumable &= not getattr(config, "no_save_optim", False) and not getattr(config, "no_save_rng", False) model = Path(config.save).resolve() / f"iter_{rollout_id:07d}" + stateless = getattr(config, "use_stateless_adam", False) + if stateless and not (model / "opt_param_scheduler.pt").is_file(): + raise ValueError(f"Missing stateless Adam scheduler checkpoint: {model}") + resumable &= (stateless or not getattr(config, "no_save_optim", False)) and not getattr( + config, "no_save_rng", False + ) if not model.is_relative_to(root): raise ValueError("Model checkpoint must be inside --save") payloads = [p for p in model.rglob("*") if p.is_file()] @@ -115,9 +133,9 @@ def commit_checkpoint(args, rollout_id, *, model_args, restore_plan=None): "rollout_id": rollout_id, "files": files, "resumable": bool(resumable), - # train.py syncs once before rollout 0, then after each rollout. - # This save precedes the next sync, so version = rollout_id + 1. - "weight_version": rollout_id + 1, + # Manual restarts publish an extra initial sync, so the serving + # version can advance independently of the training rollout ID. + "weight_version": rollout_id + 1 if weight_version is None else weight_version, "rollout_data_dir": str(Path(args.rollout_data_dir).resolve()), "run_id": getattr(args, "rollout_queue_run_id", None) or "rollout", }, @@ -217,19 +235,21 @@ def _initial_configuration(args): def resolve_checkpoint(args): - """Translate ordinary load/save arguments into an isolated checkpoint branch. + """Return a configuration copy and an isolated checkpoint branch plan. Each checkpoint restore gets fresh queue authority and output files. A small current pointer lets callers keep using the same logical save directory. Before the first checkpoint, the original run can instead recover its WAL. + Resolution never rewrites the caller's requested paths, even on failure. """ + args = copy.deepcopy(args) mode, dataset_cursor = "new", None if getattr(args, "rollout_data_transport", None) != "straw": - return RestorePlan() + return args, RestorePlan() if any(getattr(args, key, False) for key in ("debug_train_only", "debug_rollout_only", "load_debug_rollout_data")): - return RestorePlan() + return args, RestorePlan() if not getattr(args, "save", None): - return RestorePlan() + return args, RestorePlan() save_root = Path(args.save).resolve() current_path = save_root / "rollout/current.json" expected_current = current_path.read_text() if current_path.exists() else None @@ -300,7 +320,7 @@ def resolve_checkpoint(args): raise ValueError("Interrupted run requires its original straw pool") args.load, args.save = branch["initial_load"], str(active) args.rollout_data_dir = branch["pool"] - return RestorePlan("resume", str(save_root), expected_current, branch, branch["queue_id"]) + return args, RestorePlan("resume", str(save_root), expected_current, branch, branch["queue_id"]) selected = ( (root, step, _read_checkpoint(root, step)) if direct_commit @@ -334,8 +354,12 @@ def resolve_checkpoint(args): } if selected is not None: root, step, value = selected - if any(getattr(args, key, False) for key in ("finetune", "no_load_optim", "no_load_rng")): - raise ValueError("Checkpoint restoration requires model, optimizer and RNG state") + if ( + getattr(args, "finetune", False) + or getattr(args, "no_load_rng", False) + or (getattr(args, "no_load_optim", False) and not getattr(args, "use_stateless_adam", False)) + ): + raise ValueError("Checkpoint restoration requires model, scheduler, RNG and any stateful optimizer state") if args.start_rollout_id is not None and args.start_rollout_id != step + 1: raise ValueError("--start-rollout-id differs from the checkpoint") if value is not None: @@ -364,7 +388,7 @@ def resolve_checkpoint(args): ) args.ckpt_step, args.start_rollout_id, args.load = step, step + 1, str(root) # Also seed the counter when an older model has no queue snapshot. - args.update_weight_start_version = step + 1 + args.update_weight_start_version = value["weight_version"] if value is not None else step + 1 branch["parent"] = {"directory": str(root), "step": step, "queue_snapshot": value is not None} args.save = str(destination) - return RestorePlan(mode, str(save_root), expected_current, branch, branch["queue_id"], dataset_cursor) + return args, RestorePlan(mode, str(save_root), expected_current, branch, branch["queue_id"], dataset_cursor) diff --git a/vime/data/codec.py b/vime/data/codec.py index c5255cbc9..353b9777f 100644 --- a/vime/data/codec.py +++ b/vime/data/codec.py @@ -214,6 +214,8 @@ def visit(value): "batch_id": value.batch_id, "rank": value.rank, "plan_digest": value.plan_digest, + "selection": value.selection, + "schedule": value.schedule, } if value.path: extra["path"] = value.path @@ -347,10 +349,10 @@ def visit(value): ) return publications - def load(self, ref, *, reader=None): + def load(self, ref, *, reader=None, selection=None): if reader is None: with self.store.read_session() as reader: - return self.load(ref, reader=reader) + return self.load(ref, reader=reader, selection=selection) if reader.run_id != self.store.run_id or reader.backend.root != self.store.backend.root: raise InvalidReference("Sample reader belongs to a different storage root or run") records = list(reader.read(ref)) @@ -456,4 +458,22 @@ def visit(node): return image raise UnsupportedSchema(f"Unknown Sample codec tag: {tag!r}") - return visit(payload["tree"]) + tree = payload["tree"] + if selection is None: + return visit(tree) + if tree[0] != "dict": + raise ValueError("A column selection requires a dictionary payload") + result = {} + for key, node in tree[1]: + if key not in selection: + continue + indices = selection[key] + if indices is None: + result[key] = visit(node) + elif node[0] in {"list", "tuple"}: + # Select descriptors before opening tensor records. Other DP + # ranks' token/mask/R3 payloads are never read or materialized. + result[key] = [visit(node[1][index]) for index in indices] + else: + result[key] = visit(node)[indices] + return result diff --git a/vime/data/data_source.py b/vime/data/data_source.py index 5dfa2c351..c5114ad2c 100644 --- a/vime/data/data_source.py +++ b/vime/data/data_source.py @@ -1,7 +1,6 @@ import abc import copy import logging -import os from pathlib import Path import torch @@ -15,6 +14,18 @@ class DataSource(abc.ABC): + def register_consumer(self, name, consumer): + if name in self.consumers: + raise ValueError(f"Consumer {name!r} already registered") + restored = getattr(self, "_restored_consumers", {}) + if name in restored: + consumer.load_state_dict(restored.pop(name)) + self.consumers[name] = consumer + + def close(self): + for consumer in getattr(self, "consumers", {}).values(): + consumer.close() + @abc.abstractmethod def get_samples(self, num_samples: int) -> list[list[Sample]]: """ @@ -50,6 +61,8 @@ def __len__(self) -> int: class RolloutDataSource(DataSource): def __init__(self, args): self.args = args + self.consumers = {} + self._restored_consumers = {} self.epoch_id = 0 self.sample_group_index = 0 @@ -120,38 +133,53 @@ def get_samples(self, num_samples): def add_samples(self, samples: list[list[Sample]]): raise RuntimeError(f"Cannot add samples to {self.__class__.__name__}. This is a read-only data source.") + def state_dict(self): + # The cursor includes prompts already borrowed by background workers. + # Drain admission and snapshot their ready queues with that cursor so a + # checkpoint cannot skip prefetched samples when the source is restored. + paused = [] + try: + for consumer in self.consumers.values(): + paused.append((consumer, consumer.pause())) + state = { + name: copy.deepcopy(getattr(self, name)) + for name in ("sample_offset", "epoch_id", "sample_group_index", "sample_index", "metadata") + } + if hasattr(self, "buffer"): + state["buffer"] = copy.deepcopy(self.buffer) + state["consumers"] = { + **self._restored_consumers, + **{name: consumer.state_dict() for name, consumer in self.consumers.items()}, + } + return state + finally: + for consumer, was_paused in paused: + if not was_paused: + consumer.resume() + + def load_state_dict(self, state): + for name, value in state.items(): + if name == "consumers": + self._restored_consumers = value + else: + setattr(self, name, value) + if self.args.rollout_shuffle and self.dataset is not None: + # Rebuild the saved epoch's ordering before reading from its cursor. + self.dataset.shuffle(self.epoch_id) + def save(self, rollout_id): - state_dict = { - "sample_offset": self.sample_offset, - "epoch_id": self.epoch_id, - "sample_group_index": self.sample_group_index, - "sample_index": self.sample_index, - "metadata": self.metadata, - } - path = os.path.join(self.args.save, f"rollout/global_dataset_state_dict_{rollout_id}.pt") - os.makedirs(os.path.dirname(path), exist_ok=True) - torch.save(state_dict, path) + path = Path(self.args.save) / "rollout" / f"global_dataset_state_dict_{rollout_id}.pt" + path.parent.mkdir(parents=True, exist_ok=True) + torch.save(self.state_dict(), path) def load(self, rollout_id=None): if self.args.load is None: return - - path = os.path.join(self.args.load, f"rollout/global_dataset_state_dict_{rollout_id}.pt") - if not os.path.exists(path): - logger.info(f"Checkpoint {path} does not exist.") + path = Path(self.args.load) / "rollout" / f"global_dataset_state_dict_{rollout_id}.pt" + if not path.exists(): + logger.info("Checkpoint %s does not exist.", path) return - - logger.info(f"load metadata from {path}") - logger.info(f"load metadata: {self.metadata}") - state_dict = torch.load(path) - self.sample_offset = state_dict.get("sample_offset", 0) - self.epoch_id = state_dict.get("epoch_id", 0) - self.sample_group_index = state_dict.get("sample_group_index", 0) - self.sample_index = state_dict.get("sample_index", 0) - self.metadata = state_dict.get("metadata", {}) - - if self.args.rollout_shuffle and self.dataset is not None: - self.dataset.shuffle(self.epoch_id) + self.load_state_dict(torch.load(path, weights_only=False)) def __len__(self) -> int: if self.dataset is None: diff --git a/vime/data/queue_data_source.py b/vime/data/queue_data_source.py index 0d96de659..2a086ddae 100644 --- a/vime/data/queue_data_source.py +++ b/vime/data/queue_data_source.py @@ -147,6 +147,7 @@ def __init__(self, args, *, producer=None, restore_plan=None, defer_gc=False): self._producer_lock = threading.Lock() self._reader_tokens = {} self._retired_readers = set() + self._active_readers = set() self.branch_id = uuid.uuid4().hex self._training_token = None self._training_lock = threading.RLock() @@ -164,9 +165,8 @@ def __init__(self, args, *, producer=None, restore_plan=None, defer_gc=False): self.queue.release_task_reads(readers[offset : offset + 128]) # Recovery invalidates leases; unfinished collections from the stopped # manager cannot produce a new branch's batch. Accepted facts stay intact. - for task_id, task in list(self.queue.tasks.items()): - if task_id.startswith("collection:") and task["state"] == "pending": - self.queue.cancel_task(task_id, request_id=f"abandon:{self.branch_id}:{task_id}") + for task in self.queue.pending_tasks(task_prefix="collection:"): + self.queue.cancel_task(task.task_id, request_id=f"abandon:{self.branch_id}:{task.task_id}") # Joint restore can rewind the training cursor. Reconcile its durable # storage ownership before GC inspects restored live result roots. if not fork and not defer_gc: @@ -494,6 +494,7 @@ def take(self, reader_id, count): counts prompt groups, each containing n_samples_per_prompt samples. """ self._activate_fork() + self._active_readers.add(reader_id) if reader_id in self._retired_readers: raise StaleAttempt("Reader was retired by the scheduler") requested = count @@ -608,6 +609,8 @@ def recover_reader_results(self, reader_id, delivered_ref): """Fence a stopped reader and recover accepted results whose reply was lost.""" delivered = set(self.codec.load(delivered_ref)) with self._producer_lock: + # Fence the reader before recovering its results. Late acquire or + # completion calls from that reader must not compete with its successor. self._retired_readers.add(reader_id) accepted = self.queue.retire_worker(reader_id) state = self._training_state() @@ -627,6 +630,19 @@ def recover_reader_results(self, reader_id, delivered_ref): with self._writer_lock: return self.codec.publish(receipts, submission_id=f"recover-reader:{uuid.uuid4().hex}") + def recover_manager_readers(self, generation, excluded): + """Fence one dead manager's readers while keeping this queue authoritative.""" + # Reader IDs are scoped to a manager incarnation. Fence only that + # incarnation, and avoid returning samples already retained for replay. + with self._writer_lock: + delivered = self.codec.publish(excluded, submission_id=f"recover-manager:{uuid.uuid4().hex}") + results = [] + for reader_id in sorted(self._active_readers): + if reader_id.startswith(generation + ":"): + ref = self.recover_reader_results(reader_id, delivered) + results.extend(self.codec.load(ref)) + return results + def recover_pending_rollout(self): """Replay accepted groups after a whole-job failure before the first batch. @@ -638,7 +654,7 @@ def recover_pending_rollout(self): raise RuntimeError("Whole-job replay requires automatic WAL recovery selection") with self._producer_lock, self._training_lock: state = self._training_state() - if self.queue.batches or state["processed_cursor"] or state["processed_positions"]: + if state.get("conversion_started") or state["processed_cursor"] or state["processed_positions"]: raise RuntimeError( "Queue-only recovery is limited to an interrupted first rollout; " "restore matching model/optimizer and rollout checkpoints after batch conversion begins" @@ -679,23 +695,42 @@ def accepted(self, receipt): return actual def batch(self, batch_id): - return self.queue.get_batch(batch_id) + """Conversion is a control task in the same accepted log as generation.""" + task = self.queue.task_status(batch_id) + if task is None: + return None + receipt = self.result(Lease(**task["lease"])) if task["state"] == "completed" else None + return { + "plan_ref": task["spec"]["input_ref"], + "input_positions": task["spec"]["metadata"]["source_positions"], + "lease": task.get("lease"), + "ready": receipt is not None, + "ready_ref": asdict(receipt.result_ref) if receipt else None, + } def plan_batch(self, batch_id, positions, plan_ref): with self._training_lock: - if self._training_token is None: - self._training_token = self.queue.open_consumer( - "training", exclusive_owner="job owns one BatchBuilder" - ) - return self.queue.plan_batch( - "training", - token=self._training_token, - batch_id=batch_id, - input_positions=positions, - plan_ref=plan_ref, + self.queue.submit_tasks( + batch_id, + [ + TaskSpec( + batch_id, plan_ref, control=True, estimated_records=0, metadata={"source_positions": positions} + ) + ], ) + task = self.queue.task_status(batch_id) + if task["state"] == "pending": + self.queue.acquire("manager", 1, task_ids=[batch_id], control=True) + state = self._training_state() + if not state.get("conversion_started"): + state["conversion_started"] = True + self._save_training_state(state) + return self.batch(batch_id) def _training_state(self): + # This is a consumer cursor and its unfinished reads, not a second + # history of batches. Queue control tasks own selection/conversion facts; + # TrainingRecovery separately pins the window after the model checkpoint. previous = self.queue.load_consumer_state("training") return ( self.codec.load(RecordSetRef.from_dict(previous["state_ref"])) @@ -706,7 +741,6 @@ def _training_state(self): "fetch_cursor": 0, "processed_positions": [], "batches": [], - "finished_batches": [], } ) @@ -731,7 +765,7 @@ def _advance(self, state, positions): processed_positions=sorted(position for position in positions if position >= cursor), ) - def _save_training_state(self, state, *, batch_id=None, ready_ref=None): + def _save_training_state(self, state): self._check_gc_error() if self._training_token is None: self._training_token = self.queue.open_consumer("training", exclusive_owner="job owns one BatchBuilder") @@ -745,7 +779,9 @@ def _save_training_state(self, state, *, batch_id=None, ready_ref=None): { "version": 1, "processed_positions": state["processed_positions"], - "finished_batches": state.get("finished_batches", []), + # Native batch plans are unused: conversion is + # an ordinary accepted control-task result. + "finished_batches": [], } ), codec="json.v1", @@ -761,41 +797,49 @@ def _save_training_state(self, state, *, batch_id=None, ready_ref=None): fetch_cursor=state["fetch_cursor"], processed_cursor=state["processed_cursor"], ) - if batch_id is not None: - self.queue.batch_ready("training", batch_id=batch_id, ready_ref=ready_ref, **kwargs) - else: - self.queue.save_consumer_state( - "training", - request_id=f"state:{self.branch_id}:{state_ref.digest}", - **kwargs, - ) + self.queue.save_consumer_state( + "training", + request_id=f"state:{self.branch_id}:{state_ref.digest}", + **kwargs, + ) return state_ref def ready_batch(self, batch_id, ready_ref): + # Accept first, advance the consumer second. Death between these writes + # leaves an unprocessed result that a replacement manager can reconcile; + # reversing the order could let GC erase an uncommitted conversion. with self._training_lock: - batch = self.queue.get_batch(batch_id) + batch = self.batch(batch_id) if batch["ready"]: if batch["ready_ref"] != asdict(ready_ref): raise ValueError("Batch was already published with another ready reference") - return RecordSetRef.from_dict(self.training_state()["state_ref"]) + else: + task = self.queue.task_status(batch_id) + if task["state"] == "leased": + lease = Lease(**task["lease"]) + else: + page = self.queue.acquire("manager", 1, task_ids=[batch_id], control=True) + lease = page.assignments[0].lease + self.complete(lease, ready_ref) state = self._training_state() self._advance(state, batch["input_positions"]) - state["batches"].append(DiskPayloadRef(ready_ref, str(self.store.backend.root))) - return self._save_training_state(state, batch_id=batch_id, ready_ref=ready_ref) + reference = DiskPayloadRef(ready_ref, str(self.store.backend.root)) + if reference not in state["batches"]: + state["batches"].append(reference) + return self._save_training_state(state) def finish_batch(self, batch_id): - """Acknowledge completed reads on every training rank; checkpoints retain their own roots.""" + """Advance one accepted conversion, retaining only unfinished batches.""" with self._training_lock: - batch = self.queue.get_batch(batch_id) + batch = self.batch(batch_id) if not batch or not batch["ready"]: raise ValueError("Cannot finish an unknown or unready training batch") + task = self.queue.task_status(batch_id) + receipt = self.result(Lease(**task["lease"])) state = self._training_state() - finished = state.setdefault("finished_batches", []) - if batch_id not in finished: - finished.append(batch_id) - if getattr(self.args, "rollout_queue_online_gc", False): - state["batches"] = [ref for ref in state["batches"] if asdict(ref.manifest) != batch["ready_ref"]] - self._save_training_state(state) + self._advance(state, [receipt.position]) + state["batches"] = [ref for ref in state["batches"] if asdict(ref.manifest) != batch["ready_ref"]] + self._save_training_state(state) if getattr(self.args, "rollout_queue_online_gc", False): self._collect_storage() @@ -804,7 +848,9 @@ def record_dispositions(self, ref): with self._training_lock: decision = self.codec.load(ref) positions = decision["positions"] - if not decision.get("reason") or any(not 0 <= p < len(self.queue.commits) for p in positions): + if not decision.get("reason") or any( + p < 0 or not self.queue.read_commits(p, 1).commits for p in positions + ): raise ValueError("Invalid accepted-output disposition") state = self._training_state() self._advance(state, positions) @@ -876,18 +922,11 @@ def visit(value): for commit in self.queue.commits[processed_cursor:] if commit["position"] not in retained | processed ] - old_batches = {ref.load()["batch_id"] for ref in state["batches"]} - abandoned_batches = [ - batch_id - for batch_id, batch in self.queue.batches.items() - if batch["ready"] and batch_id not in old_batches - ] with self._writer_lock: decision = self.codec.publish( { "reason": "checkpoint branch excludes outputs absent from its saved source", "positions": abandoned, - "batch_ids": abandoned_batches, "checkpoint_state": asdict(state_ref), "retained_source": asdict(retained_ref), "branch_id": self.branch_id, @@ -895,7 +934,6 @@ def visit(value): submission_id=f"restore-decision:{uuid.uuid4().hex}", ) self._advance(state, abandoned) - state["finished_batches"] = sorted(set(state.get("finished_batches", [])) | set(abandoned_batches)) state["disposition"] = DiskPayloadRef(decision, str(self.store.backend.root)) # Filter dispositions may replace the audit record, but cannot # release old reader prefixes before a complete snapshot exists. @@ -1258,27 +1296,51 @@ class QueueDataSource(QueueReader): workers open separate readers against the same controller. """ - def __init__(self, args, *, controller=None, restore_plan=None): + def __init__(self, args, *, controller=None, restore_plan=None, reader_generation=""): self.restore_plan = restore_plan or RestorePlan() self.restored_source = SourceRestore(new_queue=self.restore_plan.mode == "empty") self._owns_controller = controller is None if controller is None: controller = create_queue_controller(args, restore_plan=self.restore_plan) self.data_config = ray.get(controller.configuration.remote()) - super().__init__(args, controller, "owner", self.data_config["dataset_size"]) + # Give each manager incarnation distinct reader IDs. Reusing old IDs + # would let late worker calls interfere with the replacement manager. + self.reader_generation = reader_generation + reader_id = f"{reader_generation}:owner" if reader_generation else "owner" + super().__init__(args, controller, reader_id, self.data_config["dataset_size"]) + self.manager_restored = False self.consumers, self._restored_consumers = {}, {} def reader_config(self, reader_id): if reader_id == "owner": raise ValueError("Reader ID 'owner' is reserved") + if self.reader_generation: + reader_id = f"{self.reader_generation}:{reader_id}" return QueueReaderConfig(self.args, self.controller, reader_id, len(self), self.branch_id) - def register_consumer(self, name, consumer): - if name in self.consumers: - raise ValueError(f"Consumer {name!r} already registered") - if name in self._restored_consumers: - consumer.load_state_dict(self._restored_consumers.pop(name)) - self.consumers[name] = consumer + def manager_state(self): + # Pending tasks and the producer cursor remain in the live controller. + # Restoring a manager must not rewind that shared queue to an old snapshot. + return {"reader_generation": self.reader_generation, "metadata": self.get_metadata()} + + def restore_manager(self, state, *, excluded): + # Exclude batches already journaled for trainer replay; recover only + # accepted results that the dead manager had not handed off durably. + receipts = ray.get(self.controller.recover_manager_readers.remote(state["reader_generation"], excluded)) + for value in receipts: + from straw.protocol import CommitReceipt + + receipt = CommitReceipt.from_dict(value) + group = DiskPayloadRef(receipt.result_ref, self.args.rollout_data_dir).load() + for sample in iter_samples(group): + sample._queue_receipt = value + # Native deliveries preserve generation/reward and work for both + # synchronous and fully-async consumers after manager replacement. + self.add_samples([group]) + self.update_metadata(state["metadata"]) + # The async scheduler must not also run whole-job queue recovery, which + # would deliver the same accepted results a second time. + self.manager_restored = True def save(self, rollout_id): from straw.reporting import write_report diff --git a/vime/data/transport.py b/vime/data/transport.py index 8d1329473..4ae2591a8 100644 --- a/vime/data/transport.py +++ b/vime/data/transport.py @@ -85,20 +85,22 @@ def rollout_store(args): profile = getattr(args, "rollout_storage_profile", "local") declaration_path = getattr(args, "rollout_storage_declaration", None) declaration = json.loads(Path(declaration_path).read_text()) if declaration_path else None - segment_mib = getattr(args, "rollout_queue_segment_mib", 256) + segment_mib = getattr(args, "rollout_queue_segment_mib", None) online_gc = getattr(args, "rollout_queue_online_gc", False) key = (os.getpid(), root, run_id, profile, segment_mib, online_gc, json.dumps(declaration, sort_keys=True)) with _writers_lock: if key not in _writers: + # Omit the override unless requested, so Straw owns its default. + pack_options = {} if segment_mib is None else {"segment_target_bytes": segment_mib * 1024**2} store = SharedFilesystemStore( root, run_id, online_gc=online_gc, codecs=CODECS, - segment_target_bytes=segment_mib * 1024**2, max_record_bytes=MAX_TENSOR_BYTES, max_buffer_bytes=MAX_PUBLICATION_BYTES, backend=FilesystemBackend(root, profile=profile, declaration=declaration), + **pack_options, ) _writers[key] = (store, threading.RLock()) store, lock = _writers[key] @@ -120,7 +122,7 @@ class DiskPayloadRef: path: tuple = field(default=(), kw_only=True) sample_metadata: list[dict] | None = field(default=None, kw_only=True) - def load(self, *, root=None): + def load(self, *, root=None, selection=None): from straw.store import SharedFilesystemStore from straw.tensor import MAX_PUBLICATION_BYTES, MAX_TENSOR_BYTES @@ -133,7 +135,7 @@ def load(self, *, root=None): max_record_bytes=MAX_TENSOR_BYTES, max_buffer_bytes=MAX_PUBLICATION_BYTES, ) - value = SampleCodec(store).load(self.manifest) + value = SampleCodec(store).load(self.manifest, selection=selection) for part in self.path: value = value[part] if isinstance(part, int) else getattr(value, part) if self.sample_metadata is not None: @@ -170,20 +172,35 @@ class RawRolloutRef(DiskPayloadRef): @dataclass(frozen=True) class TrainBatchRef(DiskPayloadRef): + """One rank's view of shared conversion records, plus its trainer schedule. + + ``plan_digest`` identifies this layout, so ranks from different DP/mbs + schedules cannot accidentally train together even when the data is shared. + """ + batch_id: str rank: int plan_digest: str + selection: dict | None = field(default=None, kw_only=True) + schedule: dict | None = field(default=None, kw_only=True) + def load(self, *, root=None): + value = super().load(root=root, selection=self.selection) + if self.schedule is not None: + value.update(self.schedule) + return value -def group_lease(group): - from straw.protocol import Lease +def group_lease(group): from vime.rollout.base_types import iter_samples samples = list(iter_samples(group)) leases = [getattr(sample, "_queue_lease", None) for sample in samples] if not any(leases): return None + # Ordinary in-memory groups have no queue lease and do not require Straw. + from straw.protocol import Lease + if not all(value == leases[0] for value in leases): raise ValueError("A queue group must preserve one task authorization across all trajectories") return Lease(**leases[0]) diff --git a/vime/ray/actor_group.py b/vime/ray/actor_group.py index 4d910f44a..56bdc9a15 100644 --- a/vime/ray/actor_group.py +++ b/vime/ray/actor_group.py @@ -1,3 +1,4 @@ +import copy import os import shutil import time @@ -7,6 +8,7 @@ from ray.util.placement_group import PlacementGroup from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy +from vime.ray.training_recovery import configure_recovery_checkpoint, training_recovery_enabled from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, add_default_ray_env_vars from vime.utils.weight_sync import should_flush_cache @@ -42,7 +44,9 @@ def __init__( with_opd_teacher: bool = False, actor_cls=None, ) -> None: - self.args = args + # Role initialization/release cycles resolve their own checkpoint + # options; they must not rewrite the driver's attempt configuration. + self.args = copy.deepcopy(args) self._num_nodes = num_nodes self._num_gpus_per_node = num_gpus_per_node self._pg = pg @@ -128,6 +132,18 @@ def _allocate_gpus_for_actor(self, pg, num_gpus_per_actor): if rank == 0: master_addr, master_port = ray.get(actor.get_master_addr_and_port.remote()) self._actor_handlers.append(actor) + if self._rollout_manager is not None: + # Register before model initialization can OOM. The serving owner + # can then release all ranks even if the manager also disappears. + configuration = ray.get( + self._rollout_manager.register_training_actors.remote(self.role, self._actor_handlers, self.args) + ) + configuration["update_weight_start_version"] = max( + configuration["update_weight_start_version"], self._disk_weight_version + ) + for name, value in configuration.items(): + setattr(self.args, name, value) + self._disk_weight_version = configuration["update_weight_start_version"] def async_train(self, rollout_id, rollout_data_ref, external_data=None): """Do one rollout training. Returns a list of Ray refs (one per worker). @@ -189,6 +205,10 @@ def release(self): def create(self, rollout_manager=None): if self._actor_handlers: return None + if training_recovery_enabled(self.args): + # Role-specific YAML overrides are applied after CLI validation. + # Recheck each role's optimizer/RNG checkpoint policy here. + configure_recovery_checkpoint(self.args) if rollout_manager is not None: self._rollout_manager = rollout_manager self.args.update_weight_start_version = self._disk_weight_version diff --git a/vime/ray/placement_group.py b/vime/ray/placement_group.py index ae15779a1..5e1fb059f 100644 --- a/vime/ray/placement_group.py +++ b/vime/ray/placement_group.py @@ -1,11 +1,14 @@ import copy import logging import socket +from dataclasses import dataclass import ray from ray.util.placement_group import placement_group from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy +from vime.utils.cleanup import Cleanup + from .actor_group import RayTrainGroup from .utils import add_default_ray_env_vars @@ -120,6 +123,20 @@ def _get_placement_group_layout(args) -> tuple[int, int]: def create_placement_groups(args): """Create placement groups for actor, critic, and rollout engines.""" + if not args.colocate and not args.rollout_external and not args.debug_train_only and not args.debug_rollout_only: + # Separate allocations let a restarted trainer resize without moving + # the serving engines that still hold rollout state. + actor_pg = _create_placement_group(args.actor_num_nodes * args.actor_num_gpus_per_node) + try: + rollout_pg = _create_placement_group(args.rollout_num_gpus) + except BaseException: + from ray.util.placement_group import remove_placement_group + + if actor_pg[0] is not None: + Cleanup().run("remove partial training placement", remove_placement_group, actor_pg[0]) + raise + return {"actor": actor_pg, "critic": actor_pg if args.use_critic else None, "rollout": rollout_pg} + num_gpus, rollout_offset = _get_placement_group_layout(args) logger.info(f"Creating placement group with {num_gpus} GPUs...") @@ -223,30 +240,137 @@ def create_training_models(args, pgs, rollout_manager, actor_cls=None): return actor_model, critic_model -def create_rollout_manager(args, pg, *, restore_plan=None): +@dataclass +class RolloutStartup: + """One driver's attempt, including resources acquired before startup ends.""" + + args: object + restore_plan: object = None + manager: object = None + serving: object = None + placements: dict | None = None + num_rollout_per_epoch: int | None = None + + def close(self, *, failed): + timeout = getattr(self.args, "rollout_cleanup_timeout", 60) + with Cleanup(timeout) as cleanup: + if failed and self.serving is not None: + # A live but wedged manager must not prevent the independent + # serving owner from releasing this driver's training actors. + if self.manager is not None: + try: + ray.get(self.manager.detach_training.remote(), timeout=cleanup.remaining / 2) + except Exception: + logger.exception("Manager could not pause for restart; replacing it on the next attempt") + cleanup.run("terminate unresponsive manager", ray.kill, self.manager, no_restart=True) + cleanup.run( + "detach training from serving", + lambda: ray.get( + self.serving.detach_training.remote(ray.get_runtime_context().get_job_id(), cleanup.remaining), + timeout=cleanup.remaining, + ), + ) + return + + # Successful completion disposes both owners independently. A + # broken custom source/manager cannot skip engine or PG cleanup. + for name, actor, fraction in (("manager", self.manager, 0.5), ("serving", self.serving, 1)): + if actor is not None: + timeout = cleanup.remaining * fraction + cleanup.run( + f"dispose {name}", + lambda actor=actor, timeout=timeout: ray.get(actor.dispose.remote(timeout), timeout=timeout), + ) + cleanup.run(f"terminate {name}", ray.kill, actor, no_restart=True) + if self.serving is None and self.placements: + from ray.util.placement_group import remove_placement_group + + # External engines are not ours, but driver-created placements are. + for group in {value[0] for value in self.placements.values() if value and value[0]}: + cleanup.run("remove training placement", remove_placement_group, group) + + +def create_rollout_manager(args, *, restore_plan=None): + # Keep the caller's requested configuration intact. Runtime discovery and + # checkpoint selection produce an attempt-local Namespace for old hooks. + startup = RolloutStartup(copy.deepcopy(args), restore_plan) + try: + _attach_rollout_manager(startup) + except BaseException: + # Startup can fail after serving attaches but before the caller receives + # this object. Roll back that partial attempt at the acquisition boundary. + try: + startup.close(failed=True) + except Exception: + logger.exception("Failed to detach partially started training") + raise + return startup + + +def _attach_rollout_manager(startup): from .rollout import RolloutManager + from .serving import ServingCluster + from .training_recovery import RECOVERY_NAMESPACE, training_session_name - rollout_manager_options = { + options = { "num_cpus": 1, "num_gpus": 0, "runtime_env": {"env_vars": add_default_ray_env_vars()}, } - if getattr(args, "rollout_data_transport", "object-store") == "nixl": - rollout_manager_options["enable_tensor_transport"] = True - rollout_manager = RolloutManager.options(**rollout_manager_options).remote(args, pg, restore_plan=restore_plan) - - # calculate num_rollout from num_epoch + args, restore_plan = startup.args, startup.restore_plan + serving = None + deployment = None + if args.rollout_external: + # External serving retains its existing startup and ownership contract. + placements = startup.placements = create_placement_groups(args) + else: + # Ray resolves these stable names in one namespace. Reuse the detached + # owner first, then recreate/attach the manager against its deployment; + # manager death never requires discovering individual router processes. + name = training_session_name(args) + serving = startup.serving = ServingCluster.options( + **options, + name=name + ":serving", + namespace=RECOVERY_NAMESPACE, + lifetime="detached", + get_if_exists=True, + ).remote(args, restore_plan) + # get_if_exists may return an old actor without running its constructor. + # Attachment checks ownership and applies the new training configuration. + deployment = ray.get(serving.attach_training.remote(args, ray.get_runtime_context().get_job_id())) + placements, restore_plan = deployment.placements, deployment.restore_plan + startup.placements = placements + for key, value in deployment.routers.items(): + setattr(args, key, value) + options.update(name=name, namespace=RECOVERY_NAMESPACE, lifetime="detached", get_if_exists=True) + if args.rollout_data_transport == "nixl": + options["enable_tensor_transport"] = True + manager = startup.manager = RolloutManager.options(**options).remote( + args, + placements["rollout"], + restore_plan=restore_plan, + serving=serving, + deployment=deployment, + ) + reused = False + if serving is not None: + # Serving attachment fences the previous driver and resets its NCCL + # connections. Manager attachment restores the data/checkpoint boundary. + resume = ray.get(manager.attach_training.remote(args, deployment)) + restore_plan, reused = resume.restore_plan, resume.reused + args = startup.args = resume.apply(args) + logger.info("%s serving session %s", "Reusing" if reused else "Created", name) num_rollout_per_epoch = None if args.num_rollout is None: - num_rollout_per_epoch = ray.get(rollout_manager.get_num_rollout_per_epoch.remote()) + num_rollout_per_epoch = ray.get(manager.get_num_rollout_per_epoch.remote()) args.num_rollout = num_rollout_per_epoch * args.num_epoch assert args.num_rollout > 0 - - if args.check_weight_update_equal: - ray.get(rollout_manager.check_weights.remote(action="snapshot")) - ray.get(rollout_manager.check_weights.remote(action="reset_tensors")) - + if args.check_weight_update_equal and not reused: + # This diagnostic resets initial weights. Reused engines must retain + # their current weights until the restarted trainer replaces them. + ray.get(manager.check_weights.remote(action="snapshot")) + ray.get(manager.check_weights.remote(action="reset_tensors")) if args.offload_rollout: - ray.get(rollout_manager.offload.remote()) - - return rollout_manager, num_rollout_per_epoch + ray.get(manager.offload.remote()) + startup.num_rollout_per_epoch = num_rollout_per_epoch + startup.restore_plan = restore_plan diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 4bd1e9cc8..7fb055af2 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -1,19 +1,14 @@ import itertools import logging import time +import uuid from pathlib import Path from typing import Any import ray from vime.data.batch_builder import BatchBuilder -from vime.data.transport import ( - DiskPayloadRef, - accept_raw_rollout, - check_rollout_storage, - load_rollout_samples, - seal_rollout_store, -) +from vime.data.transport import DiskPayloadRef, accept_raw_rollout, check_rollout_storage, load_rollout_samples from vime.observability import logging_utils from vime.observability.logging_utils import configure_logger, init_tracking from vime.observability.rollout_data_utils import ( @@ -24,7 +19,7 @@ from vime.observability.rollout_metrics import log_eval_rollout_data, log_rollout_data from vime.rollout.base_types import RolloutFnTrainOutput, call_rollout_fn from vime.rollout.sample_hooks import set_current_rollout_id -from vime.utils.health_monitor import RolloutHealthMonitor +from vime.utils.cleanup import Cleanup from vime.utils.http_utils import init_http_client from vime.utils.misc import load_function from vime.utils.staleness import fully_async_metrics_enabled @@ -39,21 +34,49 @@ @ray.remote class RolloutManager: - """The class to run rollout and convert rollout data to training data.""" + """Generate and convert batches, borrowing internal engines from serving. + + The serving owner retains engines and queue state across manager death. + Recovery retains raw/global converted batches across trainer death. This + manager owns transient readers, conversion and the current trainer's shards. + """ - def __init__(self, args, pg, *, restore_plan=None): + def __init__(self, args, pg, *, restore_plan=None, serving=None, deployment=None): configure_logger() + from vime.ray.training_recovery import TrainingRecovery, TrainingResume, training_recovery_enabled + + self.serving = serving + self.recovery = ( + TrainingRecovery(args, restore_plan, retained_serving=deployment.reused) + if serving is not None and training_recovery_enabled(args) + else None + ) + if self.recovery is not None: + args = TrainingResume( + self.recovery.restore_plan, + self.recovery.checkpoint, + getattr(args, "update_weight_start_version", 0), + deployment.reused, + ).apply(args) + restore_plan = self.recovery.restore_plan + self._recovery_admission_was_paused = None self.pg = pg self.args = args - self.controller = None + self.controller = deployment.controller if deployment else None self._owns_controller = False self.weight_version = None + self.training_weight_version = getattr(args, "update_weight_start_version", 0) if args.rollout_data_transport == "straw": check_rollout_storage(args) rollout_init_handles: list[Any] = [] - if self.args.debug_train_only: + if deployment is not None: + # This is a handle snapshot; the serving owner remains responsible + # for topology changes and survives replacement of this manager. + self.servers = deployment.servers + init_http_client(args) + elif self.args.debug_train_only: self.servers: dict[str, Any] = {} else: from vime.backends.vllm_utils.deployment import start_rollout_servers @@ -65,19 +88,41 @@ def __init__(self, args, pg, *, restore_plan=None): if args.rollout_data_transport == "straw": from vime.data.queue_data_source import QueueDataSource, QueueReader, create_queue_controller - if data_source_cls is QueueDataSource: - self.controller = create_queue_controller(args, restore_plan=restore_plan) - self._owns_controller = True - self.data_source = data_source_cls(args, controller=self.controller, restore_plan=restore_plan) - else: + # Custom sources keep their args-only constructor and may already + # own a controller. Construct them before creating a default one. + if data_source_cls is not QueueDataSource: self.data_source = data_source_cls(args) if isinstance(self.data_source, QueueReader): self.controller = self.data_source.controller + if self.controller is None: + if serving is not None: + self.controller = ray.get(serving.get_queue_controller.remote()) else: self.controller = create_queue_controller(args, restore_plan=restore_plan) self._owns_controller = True + if data_source_cls is QueueDataSource: + self.data_source = data_source_cls( + args, + controller=self.controller, + restore_plan=restore_plan, + reader_generation=uuid.uuid4().hex if serving is not None else "", + ) + if self.recovery is not None and self.recovery.source_state is not None: + self.recovery.reconcile_collection(self.controller, self.data_source.branch_id) + # A dead manager's readers may have delivered these samples + # already. Exclude retained batches before fencing/replaying + # its readers, so each accepted sample is delivered once. + excluded = set() + for rollout_id in self.recovery.batches: + for sample in self.recovery.load_raw(rollout_id): + excluded.update(getattr(sample, "_queue_source_positions", [])) + if receipt := getattr(sample, "_queue_receipt", None): + excluded.add(receipt["position"]) + self.data_source.restore_manager(self.recovery.source_state, excluded=sorted(excluded)) else: self.data_source = data_source_cls(args) + if self.recovery is not None and self.recovery.source_state is not None: + self.data_source.load_state_dict(self.recovery.source_state) self.generate_rollout = load_function(self.args.rollout_function_path) self.eval_generate_rollout = load_function(self.args.eval_function_path) @@ -89,21 +134,19 @@ def __init__(self, args, pg, *, restore_plan=None): ray.get(rollout_init_handles) init_tracking(args, primary=False) - self.rollout_engine_lock = Lock.options( - num_cpus=1, - num_gpus=0, - runtime_env={"env_vars": add_default_ray_env_vars()}, - ).remote() + self.rollout_engine_lock = ( + deployment.engine_lock + if deployment is not None + else Lock.options( + num_cpus=1, + num_gpus=0, + runtime_env={"env_vars": add_default_ray_env_vars()}, + ).remote() + ) self.rollout_id = -1 - - self._health_monitors = [] - if not self.args.debug_train_only and self.args.use_fault_tolerance: - for srv in self.servers.values(): - for group in srv.server_groups: - monitor = RolloutHealthMonitor(group, args) - monitor.start() - self._health_monitors.append(monitor) - self._ci_fault_injection_pending = self.args.ci_test # Flag for CI fault injection + if self.recovery is not None: + self.recovery.source_state = self._source_state() + self.recovery.persist() def _get_metrics_router_addr(self) -> str | None: """Return the full Prometheus scrape URL for the rollout router. @@ -124,32 +167,6 @@ def get_metrics_router_addr(self) -> str | None: """Public wrapper for remote calls from the driver process.""" return self._get_metrics_router_addr() - def _try_ci_fault_injection(self): - """Try to inject fault during generate (when health monitor is running).""" - if not self._ci_fault_injection_pending: - return - - # Only inject fault once - self._ci_fault_injection_pending = False - - if ( - self.server - and self.server.server_groups - and self.server.server_groups[0].all_engines - and self.server.server_groups[0].all_engines[0] - ): - logger.info("CI Fault Injection: Simulating crash on engine 0 during generate") - try: - # This will cause the ray actor to exit - self.server.server_groups[0].all_engines[0].simulate_crash.remote() - # Wait for health monitor to detect the crash and mark engine as None - # health_check_interval + health_check_timeout + buffer - wait_time = self.args.rollout_health_check_interval + self.args.rollout_health_check_timeout + 5 - logger.info(f"CI Fault Injection: Waiting {wait_time}s for health monitor to detect crash") - time.sleep(wait_time) - except Exception as e: - logger.warning(f"CI Fault Injection failed: {e}") - def pause_rollout_admission(self): """Stop the distributed producer before engines drain for a weight update.""" worker = getattr(self.data_source, "consumers", {}).get("fully_async") @@ -159,22 +176,108 @@ def resume_rollout_admission(self, was_paused): if not was_paused: self.data_source.consumers["fully_async"].resume() - def dispose(self): - for monitor in self._health_monitors: - monitor.stop() - if close := getattr(self.data_source, "close", None): - close() - if self._owns_controller: - controller = self.controller - ray.get(controller.close.remote()) - ray.kill(controller, no_restart=True) - self.controller = None - self._owns_controller = False - seal_rollout_store(self.args) - engines = [engine for server in self.servers.values() for engine in server.all_engines if engine is not None] - if engines: - ray.get([engine.shutdown.remote() for engine in engines]) - logging_utils.finish_tracking(self.args) + def dispose(self, timeout=None): + from vime.data.transport import seal_rollout_store + + # The driver disposes serving separately, even when a custom source + # fails here. Managers close only resources they own, never borrowed ones. + with Cleanup(getattr(self.args, "rollout_cleanup_timeout", 60) if timeout is None else timeout) as cleanup: + if close := getattr(self.data_source, "close", None): + cleanup.run("close rollout data source", close) + if self.recovery is not None: + cleanup.run("release replay history", self.recovery.release_batches) + cleanup.run("remove recovery journal", self.recovery.journal.unlink, missing_ok=True) + if self._owns_controller: + cleanup.run( + "close queue controller", + lambda: ray.get(self.controller.close.remote(), timeout=cleanup.remaining), + ) + cleanup.run("terminate queue controller", ray.kill, self.controller, no_restart=True) + cleanup.run("seal rollout storage", seal_rollout_store, self.args) + cleanup.run("finish manager tracking", logging_utils.finish_tracking, self.args) + + def attach_training(self, args, deployment): + """Bind a driver to the same serving owner, including after manager death.""" + from vime.data.queue_data_source import QueueDataSource + from vime.ray.training_recovery import TrainingResume + + was_paused = self.pause_rollout_admission() + if self._recovery_admission_was_paused is None: + # Remember the original state across repeated attachments; a + # restart must not resume a producer that was already paused. + self._recovery_admission_was_paused = was_paused + self.pg = deployment.placements["rollout"] + self.servers = deployment.servers + if self.recovery is not None: + self.recovery.reconcile_checkpoint() + if isinstance(self.data_source, QueueDataSource): + # A queue commit can succeed while its RPC fails. Reconcile on + # live-manager retries too, before training can consume and GC + # a conversion missing from the checkpoint replay window. + self.recovery.reconcile_collection(self.controller, self.data_source.branch_id) + # The saved load boundary wins over new CLI settings, while trainer + # parallelism and memory limits still come from the new attempt. + weight_version = getattr(args, "update_weight_start_version", 0) + if deployment.reused: + weight_version = ray.get(self.serving.get_weight_version.remote(allow_inconsistent=True)) or 0 + resume = TrainingResume( + self.recovery.restore_plan if self.recovery is not None else deployment.restore_plan, + self.recovery.checkpoint if self.recovery is not None else None, + weight_version, + deployment.reused, + ) + self.args = resume.apply(args) + # Trainer layout affects batch splitting. Sources and their long-lived + # workers keep their original rollout configuration; recovery keeps its + # own storage configuration and checkpoint boundary. + self.batch_builder.args = self.args + self.rollout_engine_lock = deployment.engine_lock + self.training_weight_version = weight_version + return resume + + def register_training_actors(self, role, actors, configuration): + if self.serving is not None: + ray.get(self.serving.register_trainers.remote(role, actors)) + values = self.recovery.resume_role(role, configuration) if self.recovery is not None else {} + values["update_weight_start_version"] = self.training_weight_version + return values + + def detach_training(self): + self._recovery_admission_was_paused = self.pause_rollout_admission() + logger.warning("Training stopped; preserving serving and available replay batches for manual restart") + + def _source_state(self): + """Snapshot the cursor, leaving live queue tasks with their controller.""" + from vime.data.queue_data_source import QueueDataSource + + if isinstance(self.data_source, QueueDataSource): + return self.data_source.manager_state() + if state_dict := getattr(self.data_source, "state_dict", None): + return state_dict() + return None + + def training_ready(self): + # train.py calls this only after publishing restored weights. Starting + # producers earlier could generate with the failed attempt's weights. + if self._recovery_admission_was_paused is not None: + self.resume_rollout_admission(self._recovery_admission_was_paused) + self._recovery_admission_was_paused = None + + def checkpoint_committed(self, rollout_id): + if self.recovery is not None: + self.recovery.checkpoint_committed(rollout_id) + + def get_weight_version(self): + if self.serving is not None: + return ray.get(self.serving.get_weight_version.remote()) + server = self._get_updatable_server() + engines = [engine for engine in server.engines if engine is not None] if server else [] + versions = ray.get([engine.get_weight_version.remote() for engine in engines]) + if not versions: + return None + if len(set(versions)) != 1 or not str(versions[0]).isdigit(): + raise RuntimeError(f"Cannot checkpoint inconsistent serving weight versions: {versions}") + return int(versions[0]) @property def server(self) -> Any | None: @@ -189,10 +292,7 @@ def _get_updatable_server(self) -> Any | None: When multiple updatable servers exist, returns the first one (multi-model weight update is not yet supported). """ - for srv in self.servers.values(): - if srv.update_weights: - return srv - return None + return next((server for server in self.servers.values() if server.update_weights), None) @property def rollout_engines(self): @@ -206,6 +306,8 @@ def get_updatable_engines_and_lock(self): ``update_weights=True``. Frozen models (reference, reward, etc.) are automatically excluded. """ + if self.serving is not None: + return ray.get(self.serving.get_updatable_engines_and_lock.remote()) srv = self._get_updatable_server() engines = srv.engines if srv else [] gpu_counts = srv.engine_gpu_counts if srv else [] @@ -223,27 +325,73 @@ def generate(self, rollout_id): self.batch_builder.rollout_id = rollout_id set_current_rollout_id(rollout_id) self.health_monitoring_resume() - if self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: - self._try_ci_fault_injection() - data, metrics = self._get_rollout_data(rollout_id=rollout_id) - save_debug_rollout_data( - self.args.save_debug_rollout_data, - data, - rollout_id=rollout_id, - evaluation=False, - args=self.args, - reference=self.batch_builder.raw_ref if self.args.rollout_data_transport == "straw" else None, - ) + # The legacy flag still selects deliberate CI crash injection. It no + # longer gates internal health checks, and external servers are never + # eligible for this internal-engine test. + if self.serving is not None and self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: + self.servers = ray.get(self.serving.try_ci_fault_injection.remote()) + result = self._generate(rollout_id, start_time) + if self.serving is not None: + # Refresh the local snapshot before offload/pause can target a dead + # actor. Replacement engines are created only before weight update. + self.servers = ray.get(self.serving.finish_rollout.remote()) + return result + + def _generate(self, rollout_id, start_time): + if self.recovery is not None and rollout_id in self.recovery.batches: + batch = self.recovery.batches[rollout_id] + logger.info("Replaying retained rollout %s with the current trainer parallelism", rollout_id) + if batch.converted is not None: + # Conversion already succeeded: reuse rewards and tokens and + # build only the partitions for this trainer's DP layout. + return self.batch_builder.replay_converted( + ( + batch.converted + if isinstance(batch.converted, DiskPayloadRef) + else self.recovery.load_converted(rollout_id) + ), + batch.batch_id, + ) + # A crash before conversion still leaves the accepted raw batch. + data, metrics = self.recovery.load_raw(rollout_id), None + if self.args.rollout_data_transport == "straw": + self.batch_builder.raw_ref = batch.raw + else: + data, metrics = self._get_rollout_data(rollout_id=rollout_id) + save_debug_rollout_data( + self.args.save_debug_rollout_data, + data, + rollout_id=rollout_id, + evaluation=False, + args=self.args, + reference=self.batch_builder.raw_ref if self.args.rollout_data_transport == "straw" else None, + ) + if self.recovery is not None: + self.recovery.remember_raw( + rollout_id, + ( + self.batch_builder.raw_ref + if self.args.rollout_data_transport == "straw" + else self.args.save_debug_rollout_data + ), + source_state=self._source_state(), + ) log_rollout_data( rollout_id, self.args, data, metrics, time.time() - start_time, weight_version=self.weight_version ) if self.args.debug_rollout_only: # if debug rollout only, we don't convert samples to train data and directly return return - cached = self.batch_builder.begin(data) + cached = self.batch_builder.begin( + data, replay=self.recovery is not None and rollout_id in self.recovery.batches + ) if cached is not None: return cached data = self.batch_builder.convert(data) + if self.args.rollout_data_transport == "straw": + data = self.batch_builder.publish_converted(data) + if self.recovery is not None: + self.recovery.remember_converted(rollout_id, data, self.batch_builder.batch_id) return self.batch_builder.split_by_dp(data) def eval(self, rollout_id): @@ -254,6 +402,10 @@ def eval(self, rollout_id): self.health_monitoring_resume() result = call_rollout_fn(self.eval_generate_rollout, self.args, rollout_id, self.data_source, evaluation=True) + if self.serving is not None: + # Evaluation shares the engines, so it needs the same cleanup before + # subsequent training controls can use the local handle snapshot. + self.servers = ray.get(self.serving.finish_rollout.remote()) data = result.data save_debug_rollout_data( self.args.save_debug_rollout_data, @@ -274,6 +426,8 @@ def save(self, rollout_id): self.data_source.save(rollout_id) self.batch_builder.save(rollout_id) finally: + # Resume only consumers that this save paused, even if saving fails. + # Recovery may have deliberately left other consumers paused. for consumer, was_paused in paused: if not was_paused: consumer.resume() @@ -284,14 +438,25 @@ def training_completed(self, rollout_id): def load(self, rollout_id=None): from vime.data.checkpoint import SourceRestore + if self.recovery is not None and self.recovery.loaded: + # The live source may be ahead of the model checkpoint. Keep that + # progress and replay retained batches rather than rereading prompts. + return + source_restore = self.data_source.load(rollout_id) # Custom sources keep their existing load() contract; only the built-in # queue returns a source/builder restoration handoff. self.batch_builder.load( rollout_id, source_restore=source_restore if isinstance(source_restore, SourceRestore) else None ) + if self.recovery is not None: + self.recovery.source_state = self._source_state() + self.recovery.initial_load_completed(rollout_id + 1) def offload(self): + # These controls do not change topology; both internal and external + # serving use the current handle snapshot. The owner serializes health + # checks with this pause before we change memory residency. self.health_monitoring_pause() for srv in self.servers.values(): srv.offload() @@ -309,34 +474,36 @@ def onload_kv(self): srv.onload_kv() def recover_updatable_engines(self): - """Restart dead updatable rollout engines before the next weight update. - - Recovers the updatable model (the one that receives weight - updates from training). - """ - self.health_monitoring_pause() - srv = self._get_updatable_server() - if self.rollout_id == -1 or srv is None: + if self.serving is not None: + self.servers = ray.get(self.serving.recover_updatable_engines.remote()) return - - srv.recover() + # Keep the existing external-serving recovery policy. + if self.rollout_id == -1: + return + server = self._get_updatable_server() + if server is not None: + server.recover() def clear_updatable_num_new_engines(self): - # when fault tolerance is not enabled, we need to manually clear num_new_engines after update_weights + if self.serving is not None: + return ray.get(self.serving.clear_updatable_num_new_engines.remote()) srv = self._get_updatable_server() if srv: srv.num_new_engines = 0 def health_monitoring_pause(self) -> None: - for monitor in self._health_monitors: - monitor.pause() + if self.serving is not None: + return ray.get(self.serving.health_monitoring_pause.remote()) def health_monitoring_resume(self) -> None: - for monitor in self._health_monitors: - monitor.resume() + if self.serving is not None: + self.servers = ray.get(self.serving.health_monitoring_resume.remote()) + return def check_weights(self, action: str): - return ray.get([engine.check_weights.remote(action=action) for engine in self.rollout_engines]) + return ray.get( + [engine.check_weights.remote(action=action) for engine in self.rollout_engines if engine is not None] + ) def _get_rollout_data(self, rollout_id): if self.args.load_debug_rollout_data: diff --git a/vime/ray/serving.py b/vime/ray/serving.py new file mode 100644 index 000000000..397926b44 --- /dev/null +++ b/vime/ray/serving.py @@ -0,0 +1,334 @@ +"""Keep serving alive independently of trainers and rollout managers. + +This actor owns persistent topology: placements, routers, engines, the queue +controller and the weight-update lock. Managers receive snapshots of engine +handles; health checks and recovery mutate only this owner's topology. +""" + +import copy +import logging +import multiprocessing +from dataclasses import dataclass + +import ray + +from vime.ray.training_recovery import retained_rollout_configuration +from vime.ray.utils import Lock +from vime.utils.cleanup import Cleanup +from vime.utils.health_monitor import RolloutHealthMonitor + + +@dataclass +class ServingDeployment: + """Startup handoff; ordinary health checks return only the engine snapshot.""" + + placements: dict + servers: dict + controller: object + restore_plan: object + routers: dict + engine_lock: object + reused: bool = False + + +@ray.remote +class ServingCluster: + """A named, detached owner for routers, engines and their GPU placements.""" + + def __init__(self, args, restore_plan): + from vime.backends.vllm_utils.deployment import start_rollout_servers + from vime.observability.logging_utils import configure_logger + from vime.ray.placement_group import create_placement_groups + + configure_logger() + self.args = args + self.restore_plan = restore_plan + self.configuration = retained_rollout_configuration(args) + self.placements = {} + self.servers = {} + self.router_processes = [] + self.controller = None + self.lock = None + self.driver_job_id = None + self.training_actors = {} + self.attachments = 0 + self._health_monitors = [] + self._ci_fault_injection_pending = args.ci_test + existing_children = {process.pid for process in multiprocessing.active_children()} + try: + # Constructor failure must release partial topology too. A detached + # actor can fail before any driver obtains a usable deployment. + self.placements = create_placement_groups(args) + try: + self.servers, handles = ( + ({}, []) if args.debug_train_only else start_rollout_servers(args, self.placements["rollout"]) + ) + finally: + self.router_processes = [ + process for process in multiprocessing.active_children() if process.pid not in existing_children + ] + ray.get(handles) + self.lock = Lock.options(num_cpus=0, num_gpus=0).remote() + except BaseException: + try: + self.dispose() + except Exception: + logging.getLogger(__name__).exception("Failed to dispose partial serving deployment") + raise + + def get_queue_controller(self): + """Create only when the built-in source or batch builder needs a queue.""" + if self.controller is None: + from vime.data.queue_data_source import create_queue_controller + + # Managers borrow this controller; recreating a manager must not + # create a second queue or lose accepted generation results. + self.controller = create_queue_controller(self.args, restore_plan=self.restore_plan) + return self.controller + + def deployment(self, reused=False): + routers = { + name: getattr(self.args, name) + for name in ("vllm_router_ip", "vllm_router_port", "vllm_model_routers") + if hasattr(self.args, name) + } + return ServingDeployment( + placements=self.placements, + servers=self.servers, + controller=self.controller, + restore_plan=self.restore_plan, + routers=routers, + engine_lock=self.lock, + reused=reused, + ) + + def validate_attachment(self, args): + configuration = retained_rollout_configuration(args) + changed = [ + name + for name in self.configuration.keys() | configuration.keys() + if name not in self.configuration + or name not in configuration + or self.configuration[name] != configuration[name] + ] + if changed: + raise ValueError("Retained serving requires unchanged rollout/model configuration: " + ", ".join(changed)) + if self.driver_job_id is not None: + # Actor existence identifies the session, not whether its previous + # trainer is dead. Fence live/unknown drivers before taking ownership. + from ray._private.state import jobs + + previous = next((job for job in jobs() if job["JobID"] == self.driver_job_id), None) + if previous is None or not previous["IsDead"]: + raise RuntimeError(f"Serving session is still owned by training job {self.driver_job_id}") + + def attach_training(self, args, job_id): + from ray.util.placement_group import remove_placement_group + + from vime.backends.vllm_utils.engine_group import reset_weights_update_groups + from vime.ray.placement_group import _create_placement_group + + self.validate_attachment(args) + args = copy.deepcopy(args) + # Only a validated successor may touch the old trainer's resources. + # Stop health checks before replacing connections or GPU allocations. + self.health_monitoring_pause() + self.release_trainers() + reused = self.attachments > 0 + count = args.actor_num_nodes * args.actor_num_gpus_per_node + actor_pg = self.placements["actor"] + if args.colocate: + # This placement is shared with live serving, so it cannot be resized. + if count > len(actor_pg[1]): + raise ValueError("Restarted colocated trainer exceeds the retained GPU placement") + elif not args.debug_rollout_only and count != len(actor_pg[1]): + if actor_pg[0] is not None: + remove_placement_group(actor_pg[0]) + self.placements["actor"] = _create_placement_group(count) + self.placements["critic"] = self.placements["actor"] if args.use_critic else None + if reused: + # NCCL groups and a possibly held lock belong to the old trainer. + # Engines survive; their next update reconnects to the new trainer. + reset_weights_update_groups( + [group for server in self.servers.values() for group in server.server_groups], + timeout=args.rollout_health_check_timeout, + ) + ray.kill(self.lock, no_restart=True) + self.lock = Lock.options(num_cpus=0, num_gpus=0).remote() + server = self._updatable_server() + if server: + # Healthy engines are also new peers from the trainer's point + # of view; mark them for connection on the next weight update. + for group in server.server_groups: + group.num_new_engines = len([engine for engine in group.engines if engine is not None]) + # Internal serving is always monitored, independently of the legacy flag. + # Rebuild paused monitors using this attempt's health-check settings. The + # manager resumes them only when generation can safely use the engines. + for monitor in self._health_monitors: + monitor.stop() + self._health_monitors = [] + for server in self.servers.values(): + for group in server.server_groups: + monitor = RolloutHealthMonitor(group, args) + monitor.start() + self._health_monitors.append(monitor) + for name, value in self.deployment().routers.items(): + setattr(args, name, value) + self.args = args + self.driver_job_id = job_id + self.attachments += 1 + return self.deployment(reused) + + def try_ci_fault_injection(self): + server = self._updatable_server() + if not self._ci_fault_injection_pending or server is None: + return self.servers + self._ci_fault_injection_pending = False + engines = [engine for engine in server.all_engines if engine is not None] + if engines: + ray.get(engines[0].simulate_crash.remote(), timeout=self.args.rollout_health_check_timeout) + for monitor in self._health_monitors: + monitor.check_once() + return self.servers + + def register_trainers(self, role, actors): + # Keep cleanup handles outside the manager, which may itself crash. + self.training_actors[role] = actors + + def release_trainers(self): + with Cleanup() as cleanup: + for actors in self.training_actors.values(): + for actor in actors[:]: + # Retain failed handles so a repeated detach can retry them. + def release(actor=actor, actors=actors): + ray.kill(actor, no_restart=True) + actors.remove(actor) + + cleanup.run("terminate training actor", release) + + def detach_training(self, job_id, timeout=None): + # A late cleanup from an old driver must not detach its successor. + if self.driver_job_id is None: + return # A repeated cleanup after a lost RPC reply is harmless. + if self.driver_job_id != job_id: + raise RuntimeError("Only the owning driver can detach its serving session") + with Cleanup(getattr(self.args, "rollout_cleanup_timeout", 60) if timeout is None else timeout) as cleanup: + cleanup.run("pause serving health checks", self.health_monitoring_pause, timeout=cleanup.remaining) + cleanup.run("release trainers", self.release_trainers) + # Keep engines, routers, placements and the queue for the next attempt. + self.driver_job_id = None + + def _updatable_server(self): + return next((server for server in self.servers.values() if server.update_weights), None) + + def get_updatable_engines_and_lock(self): + server = self._updatable_server() + if server is None: + return [], self.lock, 0, [], [], [] + return ( + server.engines, + self.lock, + server.num_new_engines, + server.engine_gpu_counts, + server.engine_gpu_offsets, + server.engine_parallel_configs, + ) + + def get_weight_version(self, *, allow_inconsistent=False): + server = self._updatable_server() + engines = [engine for engine in server.engines if engine is not None] if server else [] + versions = ray.get( + [engine.get_weight_version.remote() for engine in engines], timeout=self.args.rollout_health_check_timeout + ) + if not versions: + return None + if allow_inconsistent: + # A partial update or a replacement engine may have no version yet. + # Seed the restart from the highest installed version so publishing + # restored checkpoint weights does not move the version backwards. + return max((int(version) for version in versions if str(version).isdigit()), default=0) + if any(not str(version).isdigit() for version in versions): + raise RuntimeError(f"Cannot resume nonnumeric serving weight versions: {versions}") + if len(set(versions)) != 1: + raise RuntimeError(f"Cannot checkpoint inconsistent serving weight versions: {versions}") + return int(versions[0]) + + def recover_updatable_engines(self): + # Recovery waits until the trainer can immediately install its weights. + # Rollout completion only prunes failed engines; it never starts them. + self.health_monitoring_pause() + server = self._updatable_server() + # Fresh and replacement trainers must connect to every retained engine. + # A no-op recover() would clear num_new_engines and lose those markers, + # regardless of whether the rollout manager was replaced or survived. + if server and any(engine is None for engine in server.all_engines): + server.recover() + return self.servers + + def clear_updatable_num_new_engines(self): + server = self._updatable_server() + if server: + server.num_new_engines = 0 + + def health_monitoring_pause(self, timeout=None): + if not self._health_monitors: + return + with Cleanup(self.args.rollout_health_check_timeout if timeout is None else timeout) as cleanup: + for monitor in self._health_monitors: + cleanup.run("pause health monitor", monitor.pause, timeout=cleanup.remaining) + + def finish_rollout(self): + """Remove failed engines before the trainer sends control requests.""" + self.health_monitoring_pause() + # A short rollout may finish before any periodic check. Check once even + # during warmup grace, while weights/KV are still resident for probing. + for monitor in self._health_monitors: + monitor.check_once() + return self.servers + + def health_monitoring_resume(self): + for monitor in self._health_monitors: + monitor.resume() + return self.servers + + def dispose(self, timeout=None): + from ray.util.placement_group import remove_placement_group + + # Full teardown is for successful completion. Failure cleanup uses + # detach_training instead, preserving the resources needed to resume. + with Cleanup(getattr(self.args, "rollout_cleanup_timeout", 60) if timeout is None else timeout) as cleanup: + cleanup.run("release trainers", self.release_trainers) + for monitor in self._health_monitors: + cleanup.run("stop health monitor", monitor.stop, timeout=cleanup.remaining) + engines = [ + engine for server in self.servers.values() for engine in server.all_engines if engine is not None + ] + shutdowns = [] + for engine in engines: + ref = cleanup.run("request engine shutdown", engine.shutdown.remote) + if ref is not None: + shutdowns.append(ref) + if shutdowns: + cleanup.run( + "wait for engines", ray.wait, shutdowns, num_returns=len(shutdowns), timeout=cleanup.remaining / 2 + ) + for engine in engines: + cleanup.run("terminate engine", ray.kill, engine, no_restart=True) + for router in self.router_processes: + if router.is_alive(): + cleanup.run("terminate router", router.terminate) + cleanup.run("join router", router.join, timeout=min(10, cleanup.remaining / 2)) + if router.is_alive(): + cleanup.run("kill router", router.kill) + cleanup.run("reap router", router.join, timeout=cleanup.remaining) + if self.controller is not None: + cleanup.run( + "close queue controller", + lambda: ray.get(self.controller.close.remote(), timeout=cleanup.remaining), + ) + cleanup.run("terminate queue controller", ray.kill, self.controller, no_restart=True) + if self.lock is not None: + cleanup.run("terminate weight lock", ray.kill, self.lock, no_restart=True) + # Colocated actor/critic/rollout entries can share the same placement. + for group in {placement[0] for placement in self.placements.values() if placement and placement[0]}: + cleanup.run("remove serving placement", remove_placement_group, group) diff --git a/vime/ray/training_recovery.py b/vime/ray/training_recovery.py new file mode 100644 index 000000000..1067f4ef3 --- /dev/null +++ b/vime/ray/training_recovery.py @@ -0,0 +1,450 @@ +"""Persist checkpoint boundaries and replay batches across trainer/manager restarts. + +ServingCluster owns engines and the live queue. Queue receipts are the authority +for accepted raw/converted data; this journal owns the model/optimizer rollback +boundary and pins its replay window. Training completion advances queue reads, +but only a committed model checkpoint can release those replay pins. +""" + +import copy +import hashlib +import itertools +import json +import os +import uuid +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +import ray +import torch + +from vime.data.checkpoint import RestorePlan +from vime.data.tensor import materialize_tensor_refs +from vime.data.transport import DiskPayloadRef, RawRolloutRef, pack_rollout_payload, rollout_store +from vime.observability.rollout_data_utils import load_debug_rollout_data + +RECOVERY_NAMESPACE = "vime-training-recovery" + + +def training_recovery_enabled(args): + # Serving is always retained internally. Trainer replay additionally needs + # durable batches, regardless of the legacy --use-fault-tolerance flag. + replay_storage = ( + getattr(args, "rollout_data_transport", "object-store") == "straw" + or getattr(args, "save_debug_rollout_data", None) is not None + ) + return ( + replay_storage + and getattr(args, "train_backend", "megatron") == "megatron" + and not getattr(args, "rollout_external", False) + and not any( + getattr(args, name, False) + for name in ("debug_train_only", "debug_rollout_only", "load_debug_rollout_data") + ) + ) + + +def configure_recovery_checkpoint(args): + """Apply the same recovery policy before and after role-specific overrides.""" + stateless = getattr(args, "use_stateless_adam", False) + if getattr(args, "no_save_rng", False) or (getattr(args, "no_save_optim", False) and not stateless): + raise ValueError( + "Trainer fault tolerance requires optimizer and RNG state; only --use-stateless-adam may omit optimizer state" + ) + if args.ckpt_format != "torch_dist": + raise ValueError("Trainer fault tolerance requires --ckpt-format torch_dist for parallelism changes") + args.ckpt_fully_parallel_save = True + args.dist_ckpt_optim_fully_reshardable = True + + +def retained_rollout_configuration(args): + """Freeze rollout and custom-hook inputs, allowing explicit trainer controls. + + Unknown/custom fields are immutable too: workers keep their original args. + Adding a new rollout option therefore cannot silently bypass this check. + """ + mutable = { + "load", + "save", + "ckpt_step", + "start_rollout_id", + "finetune", + "no_load_optim", + "no_load_rng", + "save_interval", + "num_rollout", + "num_epoch", + "eval_interval", + "update_weight_start_version", + "tensor_model_parallel_size", + "pipeline_model_parallel_size", + "context_parallel_size", + "expert_model_parallel_size", + "expert_tensor_parallel_size", + "virtual_pipeline_model_parallel_size", + "num_layers_per_virtual_pipeline_stage", + "pipeline_model_parallel_layout", + "decoder_first_pipeline_num_layers", + "decoder_last_pipeline_num_layers", + "micro_batch_size", + "use_dynamic_batch_size", + "max_tokens_per_gpu", + "log_probs_max_tokens_per_gpu", + "balance_data", + "balance_by_flops", + "recompute_granularity", + "recompute_method", + "recompute_num_layers", + "recompute_modules", + "distribute_saved_activations", + "sequence_parallel", + "offload_train", + "use_distributed_optimizer", + "optimizer_cpu_offload", + "optimizer_offload_fraction", + "use_precision_aware_optimizer", + "overlap_cpu_optimizer_d2h_h2d", + "overlap_grad_reduce", + "overlap_param_gather", + "ddp_bucket_size", + "ddp_num_buckets", + "use_fault_tolerance", + "distributed_backend", + "distributed_timeout_minutes", + "rank", + "local_rank", + "world_size", + "data_parallel_size", + # Megatron pads the same tokenizer vocabulary to the current TP size. + "padded_vocab_size", + "num_layers_per_pipeline_rank", + "vllm_router_ip", + "vllm_router_port", + "vllm_model_routers", + "distributed_init_method", + "master_addr", + "master_port", + "megatron_config", + "megatron_config_path", + "rollout_health_check_interval", + "rollout_health_check_timeout", + "rollout_health_check_first_wait", + "rollout_cleanup_timeout", + "train_env_vars", + "actor_config", + "critic_config", + "ckpt_fully_parallel_save", + "dist_ckpt_optim_fully_reshardable", + "distrib_optim_fully_reshardable_mem_efficient", + "enable_gloo_process_groups", + "exit_interval", + "exit_duration_in_mins", + } + mutable_prefixes = ("actor_num_", "critic_num_", "wandb_", "tensorboard_", "profiling_", "profile_", "ci_") + return { + name: copy.deepcopy(value) + for name, value in sorted(vars(args).items()) + if name not in mutable and not name.startswith(mutable_prefixes) + } + + +def training_session_name(args): + """Find the same named actors across drivers without depending on trainer layout. + + Prefer an explicit ID or persistent run path. The configuration fallback + lets runs without replay storage retain their serving cluster as well. + """ + if identity := getattr(args, "rollout_session_id", None): + identity = "explicit:" + identity + elif args.rollout_data_transport == "straw": + identity = str(Path(args.rollout_data_dir).expanduser().resolve()) + ":" + args.rollout_queue_run_id + elif path := getattr(args, "save_debug_rollout_data", None) or getattr(args, "save", None): + identity = str(Path(path).expanduser().resolve()) + else: + identity = "configuration:" + json.dumps(retained_rollout_configuration(args), sort_keys=True, default=str) + return "rollout:" + hashlib.sha256(identity.encode()).hexdigest() + + +@dataclass(frozen=True) +class TrainingCheckpoint: + """The saved training boundary, independent of this attempt's CLI options.""" + + load: str | None + save: str | None + ckpt_step: int | None + start_rollout_id: int | None + finetune: bool + no_load_optim: bool + no_load_rng: bool + + @classmethod + def from_args(cls, args): + return cls(**{name: getattr(args, name, None) for name in cls.__dataclass_fields__}) + + +@dataclass(frozen=True) +class TrainingResume: + restore_plan: RestorePlan + checkpoint: TrainingCheckpoint | None + weight_version: int + reused: bool + + def apply(self, args): + """Build a fresh configuration for existing args-based custom hooks. + + Only checkpoint fields override the requested trainer settings. Neither + the caller's configuration nor another component's snapshot is mutated. + """ + args = copy.deepcopy(args) + if self.checkpoint is not None: + vars(args).update(asdict(self.checkpoint)) + args.update_weight_start_version = self.weight_version + return args + + +@dataclass +class ReplayBatch: + raw: object + converted: object = None + batch_id: str | None = None + + +class TrainingRecovery: + """Persist the rollback boundary and batches independently of manager lifetime.""" + + def __init__(self, args, restore_plan, *, retained_serving=True): + self.args = args + self.restore_plan = restore_plan or RestorePlan() + self.role_configuration = {} + self.batches = {} + self.incarnation = uuid.uuid4().hex + self.loaded = False + self.checkpoint_step = None + self.checkpoint = TrainingCheckpoint.from_args(args) + self.configuration = retained_rollout_configuration(args) + self.source_state = None + # The journal path follows the session identity, not the manager PID, + # so a replacement manager can find the same checkpoint and batches. + if args.rollout_data_transport == "straw": + directory = Path(args.rollout_data_dir) / "training-recovery" + else: + directory = Path(args.save_debug_rollout_data.format(rollout_id=0)).parent / ".vime-recovery" + self.journal = directory / (training_session_name(args).removeprefix("rollout:") + ".pt") + if self.journal.exists(): + state = torch.load(self.journal, weights_only=False) + changed = [ + name + for name in state["configuration"].keys() | self.configuration.keys() + if name not in state["configuration"] + or name not in self.configuration + or state["configuration"][name] != self.configuration[name] + ] + if changed: + raise ValueError("Retained rollout session requires unchanged configuration: " + ", ".join(changed)) + if retained_serving: + # Reuse the journal only with its live serving/queue owner. + # Cold startup takes its progress from the checkpoint instead. + self.checkpoint = TrainingCheckpoint(**state.pop("resume_configuration")) + for name, value in state.items(): + setattr(self, name, value) + else: + # A new serving owner must load the checkpoint source/builder + # handoff instead of treating an old journal as a live queue. + self._release_batch_storage(state["batches"], state["incarnation"]) + + def persist(self): + self.journal.parent.mkdir(parents=True, exist_ok=True) + state = { + name: getattr(self, name) + for name in ( + "configuration", + "restore_plan", + "role_configuration", + "batches", + "incarnation", + "loaded", + "checkpoint_step", + "source_state", + ) + } + # Keep the durable record as plain fields; the typed boundary is an + # in-memory handoff, not a Python class dependency in the journal format. + state["resume_configuration"] = asdict(self.checkpoint) + # Flush the complete new record before atomically replacing the old + # one; a crash must not expose a half-written recovery journal. + temporary = self.journal.with_suffix(".tmp") + with temporary.open("wb") as stream: + torch.save(state, stream) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(self.journal) + # Persist the rename as well as the file contents before acknowledging. + descriptor = os.open(self.journal.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + def reconcile_checkpoint(self): + """A joint commit may have succeeded just before its manager RPC was lost.""" + if self.args.rollout_data_transport != "straw" or not self.checkpoint.save: + return + from vime.data.checkpoint import _read_checkpoint + + root = Path(self.checkpoint.save) + steps = [int(path.stem.removeprefix("committed_")) for path in (root / "rollout").glob("committed_*.json")] + if steps and (self.checkpoint_step is None or max(steps) > self.checkpoint_step): + step = max(steps) + _read_checkpoint(root, step) + self.checkpoint_committed(step) + + def reconcile_collection(self, controller, branch_id): + """Recover a collection accepted just before the manager journal write.""" + if not self.loaded: + # Model loading has not selected the first rollout yet, so this + # manager cannot have accepted any training data. + return + from straw.protocol import Lease, RecordSetRef + + # Generation is sequential: at most the next rollout can have been + # accepted without reaching remember_raw(). The queue owns that fact; + # reuse its original receipt before fencing/replaying the old readers. + rollout_id = max(self.batches, default=self.checkpoint.start_rollout_id - 1) + 1 + task = ray.get(controller.status.remote(f"collection:{branch_id}:{rollout_id}")) + if task is not None and task["state"] == "completed": + receipt = ray.get(controller.result.remote(Lease(**task["lease"]))) + self.remember_raw(rollout_id, RawRolloutRef(receipt.result_ref, self.args.rollout_data_dir, receipt)) + # Conversion acceptance is owned by the queue too. A manager can die + # after that commit and before updating its checkpoint replay window. + for rollout_id, retained in self.batches.items(): + if retained.converted is not None: + continue + batch_id = f"batch:{retained.raw.receipt.commit_id}" + batch = ray.get(controller.batch.remote(batch_id)) + if batch is not None and batch["ready"]: + reference = DiskPayloadRef(RecordSetRef.from_dict(batch["ready_ref"]), self.args.rollout_data_dir) + self.remember_converted(rollout_id, reference, batch_id) + + def resume_role(self, role, configuration): + if role not in self.role_configuration: + self.role_configuration[role] = { + name: getattr(configuration, name, None) + for name in ("load", "save", "ckpt_step", "finetune", "no_load_optim", "no_load_rng") + } + # Actor and critic may use different optimizers. Keep each role's + # checkpoint load policy even if a later YAML override changes it. + self.role_configuration[role]["use_stateless_adam"] = getattr(configuration, "use_stateless_adam", False) + self.persist() + values = dict(self.role_configuration[role]) + if self.checkpoint_step is not None: + # Actor/critic YAML may name different paths, but new overrides + # cannot move a role away from the retained checkpoint boundary. + values.update( + load=values["save"], + ckpt_step=self.checkpoint_step, + finetune=False, + no_load_optim=values["use_stateless_adam"], + no_load_rng=False, + ) + values["start_rollout_id"] = self.checkpoint.start_rollout_id + return values + + def remember_raw(self, rollout_id, reference, *, source_state=None): + # Retain accepted generation before conversion: hooks may fail or the + # manager may die before it can persist the converted batch. + if rollout_id in self.batches: + raise RuntimeError(f"Rollout {rollout_id} is already retained for training recovery") + self.batches[rollout_id] = ReplayBatch(reference) + if isinstance(reference, DiskPayloadRef): + self._retain(rollout_id, reference) + if source_state is not None: + self.source_state = source_state + # Commit the advanced cursor and its accepted batch in one journal + # write. A cursor-only write could skip this batch after manager death. + self.persist() + + def remember_converted(self, rollout_id, data, batch_id): + # Store the global batch, before DP sharding, so a restarted trainer can + # change parallelism without rerunning reward/conversion hooks. + if self.args.rollout_data_transport == "straw": + reference = pack_rollout_payload(data, self.args, rollout_id) + self._retain(rollout_id, reference) + store, _, lock = rollout_store(self.args) + with lock: + store.release_publications([reference.manifest]) + else: + path = Path(self.args.save_debug_rollout_data.format(rollout_id=rollout_id) + ".train-recovery.pt") + temporary = path.with_name(path.name + ".tmp") + torch.save(materialize_tensor_refs(data), temporary) + temporary.replace(path) + reference = str(path) + batch = self.batches[rollout_id] + batch.converted, batch.batch_id = reference, batch_id + self.persist() + + def _retain(self, rollout_id, reference): + # Pin raw and converted data independently of queue consumption. An + # acknowledged batch may still need replay until its model is saved. + store, _, lock = rollout_store(self.args) + raw = self.batches[rollout_id].raw + roots = [reference.manifest] + if isinstance(raw, DiskPayloadRef) and raw.manifest not in roots: + roots.append(raw.manifest) + with lock: + store.retain(f"trainer-recovery:{self.incarnation}:{rollout_id}", roots) + + def load_raw(self, rollout_id): + reference = self.batches[rollout_id].raw + if isinstance(reference, DiskPayloadRef): + from vime.data.transport import load_rollout_samples + + data = load_rollout_samples(reference) + while data and isinstance(data[0], list): + data = list(itertools.chain.from_iterable(data)) + return data + return load_debug_rollout_data(reference, rollout_id=rollout_id) + + def load_converted(self, rollout_id): + reference = self.batches[rollout_id].converted + return reference.load() if isinstance(reference, DiskPayloadRef) else torch.load(reference, weights_only=False) + + def initial_load_completed(self, start_rollout_id): + if not self.loaded: + self.checkpoint = replace(self.checkpoint, start_rollout_id=start_rollout_id) + self.loaded = True + self.persist() + + def checkpoint_committed(self, rollout_id): + # Runtime training completion is insufficient: only a durable training + # checkpoint lets us release batches needed for replay. + self.checkpoint_step = rollout_id + self.checkpoint = replace( + self.checkpoint, + load=self.checkpoint.save, + ckpt_step=rollout_id, + start_rollout_id=rollout_id + 1, + finetune=False, + no_load_optim=getattr(self.args, "use_stateless_adam", False), + no_load_rng=False, + ) + self.release_batches(through=rollout_id) + + def release_batches(self, through=None): + released = { + rollout_id: batch for rollout_id, batch in self.batches.items() if through is None or rollout_id <= through + } + for rollout_id in released: + del self.batches[rollout_id] + # Persist the new boundary before releasing storage. A crash may leave + # extra retained bytes, but must not leave a journal pointing to GC'd data. + self.persist() + self._release_batch_storage(released, self.incarnation) + + def _release_batch_storage(self, batches, incarnation): + for rollout_id, batch in batches.items(): + if self.args.rollout_data_transport == "straw": + store, _, lock = rollout_store(self.args) + with lock: + store.release(f"trainer-recovery:{incarnation}:{rollout_id}") + elif batch.converted: + Path(batch.converted).unlink(missing_ok=True) diff --git a/vime/rollout/fully_async_distributed.py b/vime/rollout/fully_async_distributed.py index 364b6aca2..97a8c58b5 100644 --- a/vime/rollout/fully_async_distributed.py +++ b/vime/rollout/fully_async_distributed.py @@ -47,7 +47,7 @@ def __init__(self, args, workers, capacities, *, controller=None): self.capacities = capacities self.capacity = sum(capacities) self.ready = deque() - self.delivered = set() + self.collecting = set() self.pending = {} self.controls = {} self.configurations = {} @@ -144,9 +144,6 @@ def _run(self): self.running_workers.remove(worker) else: self.ready.append(output) - group, _ = output - if isinstance(group, RolloutGroupRef) and group.receipt: - self.delivered.add(group.receipt.position) self.condition.notify_all() except Exception as error: with self.condition: @@ -170,6 +167,8 @@ def generate(self, rollout_id, *, prefetch): raise self.error if self.demand: raise RuntimeError("A rollout batch is already being collected") + # The previous batch is durable before the next collection starts. + self.collecting.clear() self.rollout_id = rollout_id self.demand = self.args.rollout_batch_size self.prefetch = min(prefetch, self.capacity) @@ -193,6 +192,8 @@ def generate(self, rollout_id, *, prefetch): drop_reasons[reason] += 1 else: groups.append(group) + if isinstance(group, RolloutGroupRef) and group.receipt: + self.collecting.add(group.receipt.position) self.demand -= 1 self.condition.notify_all() @@ -237,11 +238,6 @@ def load_state_dict(self, state): if self.thread is not None: raise RuntimeError("Restore rollout state before starting generation") self.ready = deque(state["ready"]) - self.delivered = { - group.receipt.position - for group, _ in self.ready - if isinstance(group, RolloutGroupRef) and group.receipt - } def close(self): with self.condition: @@ -461,23 +457,34 @@ class DistributedRollout(RolloutScheduler): def _recover_worker_results(self, worker): if self.args.rollout_data_transport != "straw": return - delivered = pack_rollout_payload(sorted(self.delivered), self.args, self.rollout_id) - ref = ray.get(self.controller.recover_reader_results.remote(f"fully_async_{worker}", delivered.manifest)) + # A worker can publish a result and die before its reply reaches us. + # Recover queue receipts, excluding positions already delivered locally. + positions = self.collecting | { + group.receipt.position for group, _ in self.ready if isinstance(group, RolloutGroupRef) and group.receipt + } + delivered = pack_rollout_payload(sorted(positions), self.args, self.rollout_id) + ref = ray.get(self.controller.recover_reader_results.remote(self.reader_ids[worker], delivered.manifest)) for value in DiskPayloadRef(ref, self.args.rollout_data_dir).load(): receipt = CommitReceipt.from_dict(value) payload = DiskPayloadRef(receipt.result_ref, self.args.rollout_data_dir) index = next(iter_samples(payload.load())).index group = RolloutGroupRef(receipt.result_ref, payload.root, index, receipt) self.ready.append((group, DynamicFilterOutput(keep=True))) - self.delivered.add(receipt.position) def __init__(self, args, data_source): if args.rollout_all_samples_process_path is not None: raise ValueError("--rollout-all-samples-process-path is not supported by distributed fully-async rollout") recovered = [] - if data_source.restore_plan.mode == "resume" and "fully_async" not in data_source._restored_consumers: + # A new queue restores whole-job progress. A replacement manager keeps + # the live queue and has already recovered old readers through the source. + if ( + not data_source.manager_restored + and data_source.restore_plan.mode == "resume" + and "fully_async" not in data_source._restored_consumers + ): ref = ray.get(data_source.controller.recover_pending_rollout.remote()) - for value in DiskPayloadRef(ref, args.rollout_data_dir).load(): + values = DiskPayloadRef(ref, args.rollout_data_dir).load() + for value in values: receipt = CommitReceipt.from_dict(value) payload = DiskPayloadRef(receipt.result_ref, args.rollout_data_dir) index = next(iter_samples(payload.load())).index @@ -499,12 +506,13 @@ def __init__(self, args, data_source): if not nodes or total_concurrency < len(nodes) * args.n_samples_per_prompt: raise ValueError("Distributed rollout needs concurrency for at least one prompt group per node") groups_per_worker, remainder = divmod(total_concurrency // args.n_samples_per_prompt, len(nodes)) - workers, capacities = [], [] + workers, capacities, reader_ids = [], [], [] try: for i, node in enumerate(nodes): capacity = groups_per_worker + (i < remainder) capacities.append(capacity) reader = data_source.reader_config(f"fully_async_{i}") + reader_ids.append(reader.reader_id) workers.append( ray.remote(_GenerationActor) .options( @@ -521,12 +529,14 @@ def __init__(self, args, data_source): if any(size != len(data_source) for size in sizes): raise ValueError(f"Replicated datasets differ in size: owner={len(data_source)}, workers={sizes}") except Exception: + # A partially started worker set is not usable. Release its actors + # while leaving accepted data in the independently owned queue. for worker in workers: ray.kill(worker) raise super().__init__(args, workers, capacities, controller=data_source.controller) + self.reader_ids = reader_ids self.ready.extend(recovered) - self.delivered.update(group.receipt.position for group, _ in recovered) logging.getLogger(__name__).info( "Fully-async rollout: %d nodes, dataset=%d, concurrency=%d", len(nodes), diff --git a/vime/rollout/fully_async_rollout.py b/vime/rollout/fully_async_rollout.py index cf9c34282..607c488c7 100644 --- a/vime/rollout/fully_async_rollout.py +++ b/vime/rollout/fully_async_rollout.py @@ -29,7 +29,6 @@ from __future__ import annotations import asyncio -import atexit import logging import queue import threading @@ -52,32 +51,28 @@ logger = logging.getLogger("vime.rollout.fully_async") -# Global worker, shared across rollout calls so the queue stays warm. -_global_worker: AsyncRolloutWorker | None = None -_worker_lock = threading.Lock() - +def _get_worker(args, data_buffer): + # The data source owns execution across calls and checkpoint boundaries. + # Storage only decides how results are published. + if not hasattr(data_buffer, "consumers"): + data_buffer.consumers = {} + worker = data_buffer.consumers.get("fully_async") + if worker is None: + if hasattr(data_buffer, "reader_config"): + from vime.rollout.fully_async_distributed import DistributedRollout -def _get_global_worker(args, data_buffer) -> AsyncRolloutWorker: - global _global_worker - with _worker_lock: - if _global_worker is None or not _global_worker.worker_thread.is_alive(): - logger.info("starting fully-async rollout worker") - _global_worker = AsyncRolloutWorker( + worker = DistributedRollout(args, data_buffer) + else: + worker = AsyncRolloutWorker( args, data_buffer, concurrency=args.vllm_server_concurrency * get_rollout_num_engines(args) ) - _global_worker.start() - return _global_worker - - -def _stop_global_worker() -> None: - global _global_worker - with _worker_lock: - if _global_worker is not None: - _global_worker.stop() - _global_worker = None - - -atexit.register(_stop_global_worker) + if hasattr(data_buffer, "register_consumer"): + data_buffer.register_consumer("fully_async", worker) + else: + data_buffer.consumers["fully_async"] = worker + if isinstance(worker, AsyncRolloutWorker): + worker.start() + return worker class AsyncRolloutWorker: @@ -89,13 +84,18 @@ def __init__(self, args, data_buffer, concurrency: int = 10): self.data_buffer = data_buffer self.concurrency = max(1, concurrency // getattr(args, "n_samples_per_prompt", 1)) self.running = True + self.paused = False + self.condition = threading.Condition() + self.active = 0 + self.admission_paused = False + self.error = None # Unbounded on purpose: put() runs inside the event-loop thread (task # done-callback), so a bounded queue that fills up would block the loop # and freeze every in-flight generation. Backpressure lives in _loop() # instead, which stops topping up while a full pool of completed groups # is already waiting to be consumed. self.output_queue: queue.Queue[tuple[int, list[Sample]]] = queue.Queue() - self.poll_interval = 1.0 + self.poll_interval = 0.01 self.worker_thread: threading.Thread | None = None self.state = GenerateState(args) @@ -106,10 +106,38 @@ def start(self) -> None: self.worker_thread = threading.Thread(target=self._thread_main, name="fully-async-rollout", daemon=True) self.worker_thread.start() - def stop(self) -> None: + def pause(self, *, drain=True): + with self.condition: + previous = self.paused + self.paused = True + while self.running and self.error is None and (not self.admission_paused or (drain and self.active)): + self.condition.wait() + if self.error is not None: + raise self.error + return previous + + def resume(self): + with self.condition: + self.paused = False + + def state_dict(self): + if not self.admission_paused or self.active: + raise RuntimeError("Pause generation before saving worker state") + with self.output_queue.mutex: + return {"ready": list(self.output_queue.queue)} + + def load_state_dict(self, state): + if self.worker_thread is not None: + raise RuntimeError("Restore worker state before starting generation") + for result in state["ready"]: + self.output_queue.put(result) + + def close(self): self.running = False - if self.worker_thread and self.worker_thread.is_alive(): - self.worker_thread.join(timeout=5) + if self.worker_thread is not None: + self.worker_thread.join(timeout=35) + if self.worker_thread.is_alive(): + raise TimeoutError("Generation did not stop within 35 seconds") def get_completed_groups(self, limit: int | None = None) -> list[tuple[int, list[Sample]]]: """Pop up to ``limit`` completed groups (all of them when ``None``). @@ -133,7 +161,14 @@ def queue_size(self) -> int: # -- internals ----------------------------------------------------------- def _thread_main(self) -> None: - asyncio.run(self._loop()) + try: + asyncio.run(self._loop()) + except Exception as error: + self.error = error + finally: + with self.condition: + self.running = False + self.condition.notify_all() async def _loop(self) -> None: active_tasks: set[asyncio.Task] = set() @@ -160,7 +195,10 @@ async def _loop(self) -> None: # full pool of completed groups is waiting, stop pulling new # prompts until the training side drains some. while ( - len(active_tasks) < max_concurrent and self.output_queue.qsize() < max_concurrent and self.running + len(active_tasks) < max_concurrent + and self.output_queue.qsize() < max_concurrent + and self.running + and not self.paused ): groups = self.data_buffer.get_samples(1) if not groups: @@ -179,10 +217,16 @@ async def _loop(self) -> None: task.add_done_callback(self._make_done_cb(gid)) active_tasks.add(task) + with self.condition: + self.active = len(active_tasks) + self.admission_paused = self.paused + self.condition.notify_all() + if self.error is not None: + raise self.error await asyncio.sleep(self.poll_interval) except Exception as e: # noqa: BLE001 - logger.exception("fully-async loop iteration error: %s", e) - await asyncio.sleep(self.poll_interval) + self.error = e + raise if active_tasks: logger.info( @@ -198,8 +242,10 @@ def _make_done_cb(self, gid: int): def _cb(done_task: asyncio.Task) -> None: try: result = done_task.result() - except Exception: # noqa: BLE001 - logger.exception("fully-async: process task raised") + except asyncio.CancelledError: + return + except Exception as error: + self.error = error return if not isinstance(result, list): logger.warning( @@ -223,7 +269,8 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout filters_enabled = bool( getattr(args, "dynamic_sampling_filter_path", None) or getattr(args, "rollout_sample_filter_path", None) ) - worker = _get_global_worker(args, data_buffer) + worker = _get_worker(args, data_buffer) + worker.resume() target = args.rollout_batch_size logger.info( @@ -246,10 +293,12 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout LOG_EVERY = 30.0 while len(collected) < target: + if worker.error is not None: + raise worker.error # Pull only what this rollout still needs; the surplus stays queued for # the next rollout (that is the "queue stays warm" contract). drained = 0 - for _group_id, group in worker.get_completed_groups(limit=target - len(collected)): + for _gid, group in worker.get_completed_groups(limit=target - len(collected)): drained += 1 verdict = call_dynamic_filter(dynamic_filter, args, group) if verdict.keep: @@ -317,19 +366,11 @@ async def _generate_rollout_async(args, rollout_id: int, data_buffer) -> Rollout def generate_rollout_fully_async(args, rollout_id, data_buffer, evaluation: bool = False): - """vime ``--rollout-function-path`` entrypoint.""" + """Vime ``--rollout-function-path`` entrypoint.""" if evaluation: raise ValueError("fully-async rollout doesn't support evaluation mode") - if getattr(args, "rollout_data_transport", "object-store") == "straw": - from vime.data.queue_data_source import QueueDataSource - - if isinstance(data_buffer, QueueDataSource): - from vime.rollout.fully_async_distributed import DistributedRollout - - worker = data_buffer.consumers.get("fully_async") - if worker is None: - worker = DistributedRollout(args, data_buffer) - data_buffer.register_consumer("fully_async", worker) - return worker.generate(rollout_id, prefetch=worker.capacity) + worker = _get_worker(args, data_buffer) + if not isinstance(worker, AsyncRolloutWorker): + return worker.generate(rollout_id, prefetch=worker.capacity) return run(_generate_rollout_async(args, rollout_id, data_buffer)) diff --git a/vime/rollout/vllm_rollout.py b/vime/rollout/vllm_rollout.py index 7cd8e2348..0a28e1f90 100644 --- a/vime/rollout/vllm_rollout.py +++ b/vime/rollout/vllm_rollout.py @@ -14,7 +14,7 @@ import numpy as np from tqdm import tqdm -from vime.backends.vllm_utils.server_control import abort_servers_until_idle +from vime.backends.vllm_utils.server_control import abort_servers_until_idle, get_live_router_workers from vime.data.transport import discard_rollout_group, inherit_queue_context, publish_rollout_async from vime.observability.trace_utils import build_vllm_meta_trace_attrs, trace_function, trace_span from vime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput, finalize_rollout_groups @@ -249,11 +249,8 @@ def _inference_generate_tokens_and_logprobs(choice: dict[str, Any]) -> tuple[lis logprobs = choice.get("logprobs") content = logprobs.get("content") if isinstance(logprobs, dict) else [] content = content or [] - log_probs = [ - float(content[index].get("logprob", 0.0)) if index < len(content) and isinstance(content[index], dict) else 0.0 - for index in range(len(token_ids)) - ] - return token_ids, log_probs + pairs = list(zip(token_ids, content, strict=False)) + return [token_id for token_id, _ in pairs], [float(item["logprob"]) for _, item in pairs] def _score_centering_metadata( @@ -266,11 +263,7 @@ def _score_centering_metadata( if top_p == 1.0: rows = [] for item in content: - row = {} - for entry in item.get("top_logprobs") or []: - token = entry.get("token", "") - if token.startswith("token_id:"): - row[int(token.removeprefix("token_id:"))] = float(entry["logprob"]) + row = {entry["token_id"]: float(entry["logprob"]) for entry in item.get("top_logprobs") or []} rows.append(row) heads = [sorted(row.items(), key=lambda item: item[1], reverse=True)[:top_k] for row in rows] if any(len(head) != top_k for head in heads): @@ -623,7 +616,7 @@ async def generate_and_rm( "generate_function": sample.generate_function_path or args.custom_generate_function_path, "input_index": sample.index, "input_group_index": sample.group_index, - "branch": getattr(args, "_rollout_queue_branch", None), + "branch": getattr(sample, "_queue_branch", None), } ] @@ -737,10 +730,15 @@ async def abort(args: Namespace, rollout_id: int) -> list[list[Sample]]: for task in cancellable_tasks: task.cancel() - if state.active_server_generations > 0: - base = f"http://{args.vllm_router_ip}:{args.vllm_router_port}" - response = await get(f"{base}/workers") - urls = [worker["url"] for worker in response["workers"]] + if state.active_server_generations: + router_url = f"http://{args.vllm_router_ip}:{args.vllm_router_port}" + if args.rollout_external: + workers = (await get(f"{router_url}/workers"))["workers"] + else: + # The manager's final health check runs only after this function + # returns. Prune dead URLs now so abort/drain itself can finish. + workers = await get_live_router_workers(router_url, timeout=args.rollout_health_check_timeout) + urls = [worker["url"] for worker in workers] await abort_servers_until_idle(urls) await asyncio.gather(*cancellable_tasks, return_exceptions=True) @@ -787,7 +785,8 @@ async def generate_rollout_async( """ state = GenerateState(args) - controller = getattr(getattr(data_source, "__self__", None), "controller", None) + reader = getattr(data_source, "__self__", None) + controller = getattr(reader, "controller", None) # instantiate data filters dynamic_filter = ( @@ -801,13 +800,14 @@ async def generate_rollout_async( data = [] all_data = [] + # The legacy all-samples hook can mutate selected and rejected groups. + # Preserve those objects until it runs; otherwise keep only stored refs. keep_all_samples = args.rollout_all_samples_process_path is not None do_print = True pbar = tqdm(total=target_data_size * args.n_samples_per_prompt, desc="Rollout generation") while len(data) < target_data_size: while state.remaining_batch_size < target_data_size: # get samples from the buffer and submit the generation requests. - reader = getattr(data_source, "__self__", None) if hasattr(reader, "get_samples_async"): samples = await reader.get_samples_async(args.over_sampling_batch_size) else: @@ -883,9 +883,12 @@ async def generate_rollout_async( data = await publish_rollout_async(data, args, rollout_id) output = RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()) elif args.rollout_sample_filter_path is not None: - output = finalize_rollout_groups(args, rollout_id, data, metric_gatherer.collect()) + # Preserve the calling thread/context of custom batch hooks. + output = finalize_rollout_groups(args, rollout_id, data, metric_gatherer.collect(), controller=controller) else: - output = await asyncio.to_thread(finalize_rollout_groups, args, rollout_id, data, metric_gatherer.collect()) + output = await asyncio.to_thread( + finalize_rollout_groups, args, rollout_id, data, metric_gatherer.collect(), controller=controller + ) return output, aborted_samples diff --git a/vime/rollout/vllm_streaming_rollout.py b/vime/rollout/vllm_streaming_rollout.py index 90cb1170e..56dce7f5a 100644 --- a/vime/rollout/vllm_streaming_rollout.py +++ b/vime/rollout/vllm_streaming_rollout.py @@ -48,6 +48,7 @@ _coerce_flat_int_token_ids, _inference_generate_meta_info, _inference_generate_metrics, + _inference_generate_tokens_and_logprobs, _mm_render_response_to_generate_body, _prepare_prompt_ids, prime_encoder, @@ -196,15 +197,10 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d # Each chunk carries only its delta tokens + logprobs; accumulate. delta_tokens = choice.get("token_ids") or [] - delta_log_probs = [] - lp = choice.get("logprobs") - if isinstance(lp, dict): - content_items = lp.get("content") or [] - delta_log_probs = [ - float(it.get("logprob", 0.0)) if isinstance(it, dict) else 0.0 for it in content_items - ] - if len(delta_log_probs) != len(delta_tokens): - delta_log_probs = (delta_log_probs + [0.0] * len(delta_tokens))[: len(delta_tokens)] + content = (choice.get("logprobs") or {}).get("content") or [] + if len(content) != len(delta_tokens): + raise ValueError("Streaming token IDs and logprob pairs must have matching lengths.") + delta_tokens, delta_log_probs = _inference_generate_tokens_and_logprobs(choice) if delta_tokens: call_tokens += delta_tokens call_log_probs += delta_log_probs @@ -236,12 +232,7 @@ async def generate_streaming(args: Namespace, sample: Sample, sampling_params: d if finish_reason and last_choice is not None: new_response_tokens = call_tokens - if len(call_log_probs) == len(call_tokens): - new_response_log_probs = [float(x) for x in call_log_probs] - else: - new_response_log_probs = ([float(x) for x in call_log_probs] + [0.0] * len(call_tokens))[ - : len(call_tokens) - ] + new_response_log_probs = [float(x) for x in call_log_probs] # Build meta_info from the terminal choice + usage, mirroring generate(). fr = last_choice.get("finish_reason") or "stop" diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index af82a657f..8bb7eb184 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -616,8 +616,11 @@ def add_rollout_arguments(parser): parser.add_argument( "--rollout-queue-segment-mib", type=int, - default=256, - help="Append queue publications to a pack file until this target size; a single larger publication is kept intact.", + default=None, + help=( + "Override Straw's pack target size in MiB (Straw defaults to 1 GiB). " + "A single larger publication is kept intact." + ), ) parser.add_argument( "--rollout-io-concurrency", @@ -635,30 +638,45 @@ def add_rollout_arguments(parser): return parser def add_fault_tolerance_arguments(parser): + parser.add_argument( + "--rollout-session-id", + type=str, + default=None, + help="Identity for retained internal serving. Defaults to the Straw pool/run, debug dump, save directory, or model/rollout configuration.", + ) parser.add_argument( "--use-fault-tolerance", action="store_true", default=False, - help="Whether to enable the fault tolerance function during rollout.", + help=( + "Compatibility flag: internal serving always checks engine health and recovers failed engines. " + "Enable the existing health-check policy for external serving." + ), ) parser.add_argument( "--rollout-health-check-interval", type=float, - default=30.0, - help="Interval in seconds between rollout engine /health checks during generate/eval.", + default=600.0, + help="Interval in seconds between rollout engine /health_generate checks during generate/eval.", ) parser.add_argument( "--rollout-health-check-timeout", type=float, - default=30.0, - help="Timeout in seconds to wait for a rollout engine /health response before killing it.", + default=600.0, + help="Timeout in seconds to wait for a rollout engine /health_generate response before killing it.", ) parser.add_argument( "--rollout-health-check-first-wait", type=float, - default=0, + default=600.0, help="Initial grace period (in seconds) before starting health checks. This allows time for model compilation and initialization. Increase this value significantly when using deepgemm.", ) + parser.add_argument( + "--rollout-cleanup-timeout", + type=float, + default=60.0, + help="Total time budget in seconds for detaching or disposing a training attempt.", + ) return parser # data @@ -1535,7 +1553,8 @@ def add_rollout_buffer_arguments(parser): default=None, help=( "Path to the rollout all samples process function that " - "can process all samples including filtered ones." + "can process all samples including filtered ones. " + "Not supported by distributed fully-async rollout." ), ) return parser @@ -1836,7 +1855,7 @@ def parse_args(add_custom_arguments=None, *, return_restore_plan=False): for key, value in vars(vllm_ns).items(): setattr(args, key, value) - restore_plan = vime_validate_args(args) + args, restore_plan = vime_validate_args(args) if not args.debug_rollout_only: megatron_validate_args(args) @@ -1972,6 +1991,9 @@ def vime_validate_args(args): from vime.utils.ppo_utils import get_pg_loss_type from vime.utils.score_centering import validate_score_centering_args + args = copy.deepcopy(args) + if getattr(args, "rollout_cleanup_timeout", 60) <= 0: + raise ValueError("--rollout-cleanup-timeout must be positive") get_pg_loss_type(args) validate_score_centering_args(args) args.eval_datasets = _resolve_eval_datasets(args) @@ -2028,7 +2050,7 @@ def vime_validate_args(args): # HuggingFace/finetune fallback can replace --load or disable optimizer load. from vime.data.checkpoint import resolve_checkpoint - restore_plan = resolve_checkpoint(args) + args, restore_plan = resolve_checkpoint(args) load_is_megatron = ( args.load is not None and os.path.exists(args.load) @@ -2084,7 +2106,10 @@ def vime_validate_args(args): "rollout_queue_segment_mib", "rollout_io_concurrency", ): - if getattr(args, name) <= 0: + value = getattr(args, name) + if name == "rollout_queue_segment_mib" and value is None: + continue + if value <= 0: raise ValueError(f"--{name.replace('_', '-')} must be positive") if args.rollout_data_transport == "straw": @@ -2235,6 +2260,9 @@ def vime_validate_args(args): args.disable_grad_buffers_cpu_backup = True args.disable_param_buffers_cpu_backup = True + if args.flush_cache_interval == 1 and args.eval_function_path is None: + args.eval_function_path = args.rollout_function_path + if args.num_steps_per_rollout is not None: global_batch_size = args.rollout_batch_size * args.n_samples_per_prompt // args.num_steps_per_rollout if args.global_batch_size is not None: @@ -2350,4 +2378,13 @@ def vime_validate_args(args): if args.eval_function_path is None: args.eval_function_path = args.rollout_function_path - return restore_plan + from vime.ray.training_recovery import configure_recovery_checkpoint, training_recovery_enabled + + if training_recovery_enabled(args): + configure_recovery_checkpoint(args) + if args.save_debug_rollout_data is not None and "{rollout_id" not in args.save_debug_rollout_data: + raise ValueError( + "Trainer fault tolerance requires a unique --save-debug-rollout-data path containing {rollout_id}" + ) + + return args, restore_plan diff --git a/vime/utils/cleanup.py b/vime/utils/cleanup.py new file mode 100644 index 000000000..df057f530 --- /dev/null +++ b/vime/utils/cleanup.py @@ -0,0 +1,39 @@ +"""Cleanup policy shared by resource owners and the training driver.""" + +import logging +import time + +logger = logging.getLogger(__name__) + + +class Cleanup: + """Try every cleanup step, keeping the original failure and one deadline. + + Owners must pass ``remaining`` to blocking operations. Arbitrary custom + hooks cannot be interrupted safely in a thread; the driver's bounded RPC + and actor termination provide the outer limit for those hooks. + """ + + def __init__(self, timeout=60): + self.deadline = time.monotonic() + timeout + self.errors = [] + + @property + def remaining(self): + return max(0, self.deadline - time.monotonic()) + + def run(self, description, function, *args, **kwargs): + try: + return function(*args, **kwargs) + except Exception as error: + self.errors.append(error) + logger.exception("Failed to %s", description) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + # During error unwinding, report cleanup failures without replacing + # the training/startup error. Otherwise cleanup failure is observable. + if exc is None and self.errors: + raise self.errors[0] diff --git a/vime/utils/disk_delta.py b/vime/utils/disk_delta.py index c3abc65b1..43258818a 100644 --- a/vime/utils/disk_delta.py +++ b/vime/utils/disk_delta.py @@ -1,9 +1,6 @@ from __future__ import annotations -import glob -import json import os -import struct import zlib import numpy as np @@ -12,9 +9,10 @@ # so a thread pool over tensors recovers the bandwidth one thread leaves idle. NUM_WORKERS = min(32, (os.cpu_count() or 8)) -# Trainer-side helpers for disk-level delta weight sync. The receive side — materializing the -# host-local checkpoint and applying published deltas in place — lives in vLLM behind its -# /pull_weights endpoint, so it runs on every host while Vime only talks to one endpoint per engine. +# Trainer-side (publish) helpers for disk-level delta weight sync. The receive side — +# materializing the host-local checkpoint and applying published deltas in place — lives in +# the engine's pull_weights worker method (vllm.utils.local_checkpoint), so it +# runs on every host of a multi-node engine while Vime only talks to one endpoint. def overwrite_encode(new: np.ndarray, changed_mask: np.ndarray) -> np.ndarray: @@ -55,32 +53,3 @@ def checksum(algorithm: str, buf) -> str: hasher = _new_hasher(algorithm) hasher.update(buf) return hasher.hexdigest() - - -def _tensor_locations(ckpt_dir: str) -> dict[str, tuple[str, int, int]]: - """Map each tensor name to (file, byte offset, nbytes) by reading every safetensors header.""" - locations: dict[str, tuple[str, int, int]] = {} - for path in glob.glob(os.path.join(ckpt_dir, "*.safetensors")): - with open(path, "rb") as f: - (header_len,) = struct.unpack(" uint8 bytes`` that seeks straight to the - tensor — for reading many tensors without rescanning every header. KeyError if absent.""" - locations = _tensor_locations(ckpt_dir) - - def read(name: str) -> np.ndarray: - path, offset, nbytes = locations[name] - with open(path, "rb") as f: - f.seek(offset) - return np.frombuffer(f.read(nbytes), dtype=np.uint8) - - return read diff --git a/vime/utils/external_utils/command_utils.py b/vime/utils/external_utils/command_utils.py index 85d2e1833..58051b939 100644 --- a/vime/utils/external_utils/command_utils.py +++ b/vime/utils/external_utils/command_utils.py @@ -6,6 +6,7 @@ import json import os import random +import subprocess import time from dataclasses import dataclass from pathlib import Path @@ -106,29 +107,24 @@ def execute_train( external_ray = get_bool_env_var("VIME_SCRIPT_EXTERNAL_RAY") master_addr = os.environ.get("MASTER_ADDR", "127.0.0.1") - exec_command( - "pkill -9 -f '[v]llm serve|VLL[M]::'; " - "sleep 3; " - f"{'' if external_ray else 'ray stop --force; '}" - f"{'' if external_ray else 'pkill -9 ray; '}" - # cannot be run in CI, o/w kill the parent script - # TODO: do we really need this kill? (or can we instead kill vime) - # "pkill -9 python; " - "pkill -9 vime; " - "sleep 3; " - f"{'' if external_ray else 'pkill -9 ray; '}" - # "pkill -9 python; " - "pkill -9 vime; " - "pkill -9 redis; " - "true; " - ) - + # Launching another trainer must preserve independently owned serving. + # Successful training cleans up its own resources; failures retain them. if not external_ray: - exec_command( - # will prevent ray from buffering stdout/stderr - f"export PYTHONUNBUFFERED=1 && " - f"ray start --head --node-ip-address {master_addr} --num-gpus {num_gpus_per_node} --disable-usage-stats" + # A manual trainer restart must leave the Ray head and its detached + # rollout session alive. A stale address file alone is not proof of a + # running cluster, so query the head before deciding to start one. + ray_running = ( + subprocess.run( + ["ray", "status"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30 + ).returncode + == 0 ) + if not ray_running: + exec_command( + # will prevent ray from buffering stdout/stderr + f"export PYTHONUNBUFFERED=1 && " + f"ray start --head --node-ip-address {master_addr} --num-gpus {num_gpus_per_node} --disable-usage-stats" + ) if (f := before_ray_job_submit) is not None: f() diff --git a/vime/utils/health_monitor.py b/vime/utils/health_monitor.py index d7da45c2c..3e4af2d77 100644 --- a/vime/utils/health_monitor.py +++ b/vime/utils/health_monitor.py @@ -2,6 +2,7 @@ import threading import ray +import requests logger = logging.getLogger(__name__) @@ -23,12 +24,14 @@ def __init__(self, server_group, args): self._server_group = server_group self._thread = None - self._stop_event = None - self._pause_event = None # When set, health checking is paused + self._stop_event = threading.Event() + self._pause_event = threading.Event() + self._pause_event.set() # Engines may be offloaded before the first rollout. self._check_interval = args.rollout_health_check_interval self._check_timeout = args.rollout_health_check_timeout self._check_first_wait = args.rollout_health_check_first_wait self._need_first_wait = True # Need to wait after each resume + self._check_lock = threading.Lock() def start(self) -> bool: """Start the health monitor thread. Called once during initialization. @@ -44,9 +47,9 @@ def start(self) -> bool: return True logger.info("Starting RolloutHealthMonitor...") - self._stop_event = threading.Event() - self._pause_event = threading.Event() - self._pause_event.set() # Start in paused state until resume() is called + self._stop_event.clear() + self._pause_event.set() + self._need_first_wait = True self._thread = threading.Thread( target=self._health_monitor_loop, name="RolloutHealthMonitor", @@ -56,47 +59,39 @@ def start(self) -> bool: logger.info("RolloutHealthMonitor started (in paused state).") return True - def stop(self) -> None: + def stop(self, *, timeout=None) -> None: """Stop the health monitor thread completely. Called during dispose.""" if not self._thread: return logger.info("Stopping RolloutHealthMonitor...") - assert self._stop_event is not None self._stop_event.set() - # Also clear pause to let the thread exit - if self._pause_event: - self._pause_event.clear() - timeout = self._check_timeout + self._check_interval + 5 + if timeout is None: + timeout = self._check_timeout + 5 self._thread.join(timeout=timeout) if self._thread.is_alive(): - logging.warning("Rollout health monitor thread did not terminate within %.1fs", timeout) + raise TimeoutError(f"Rollout health monitor did not terminate within {timeout:.1f}s") else: logger.info("RolloutHealthMonitor stopped.") self._thread = None - self._stop_event = None - self._pause_event = None - def pause(self) -> None: + def pause(self, *, timeout=None) -> None: """Pause health checking. Called when engines are offloaded.""" - if self._pause_event is None: - return logger.info("Pausing health monitor...") self._pause_event.set() + # Finish an in-flight check before weights or memory ownership change. + if not self._check_lock.acquire(timeout=self._check_timeout if timeout is None else timeout): + raise TimeoutError("Health check did not finish before pause deadline") + self._check_lock.release() def resume(self) -> None: """Resume health checking. Called when engines are onloaded.""" - if self._pause_event is None: - return logger.info("Resuming health monitor...") self._need_first_wait = True # Need to wait after each resume self._pause_event.clear() def _health_monitor_loop(self) -> None: - assert self._stop_event is not None - assert self._pause_event is not None - while not self._stop_event.is_set(): # Wait while paused while self._pause_event.is_set() and not self._stop_event.is_set(): @@ -119,50 +114,41 @@ def _health_monitor_loop(self) -> None: # Run health checks if not self._pause_event.is_set() and not self._stop_event.is_set(): - self._run_health_checks() + try: + self._run_health_checks() + except requests.RequestException: + logger.exception("Failed to unregister an unhealthy worker; retaining it for the next check") # Wait for next check interval if self._stop_event.wait(self._check_interval): break - def _run_health_checks(self) -> None: - for rollout_engine_id, engine in enumerate(self._server_group.engines): - if self._stop_event is not None and self._stop_event.is_set(): - break - if self._pause_event is not None and self._pause_event.is_set(): - break - self._check_engine_health(rollout_engine_id, engine) + def check_once(self) -> None: + """Check at the rollout boundary, regardless of interval or warmup grace.""" + self._run_health_checks(force=True) - def _check_engine_health(self, rollout_engine_id, engine) -> None: - if engine is None: - logger.info(f"Skipping health check for engine {rollout_engine_id} (None)") - return - - try: - ray.get(engine.health_generate.remote(timeout=self._check_timeout)) - except Exception as e: - logger.error( - f"Health check failed for rollout engine {rollout_engine_id} (ray timeout or error). Killing actor. Exception: {e}" - ) - self._kill_engine(rollout_engine_id=rollout_engine_id) - else: - logger.debug(f"Health check passed for rollout engine {rollout_engine_id}") - - def _kill_engine(self, rollout_engine_id: int): - logger.info(f"Killing server group {rollout_engine_id}...") - for i in range( - rollout_engine_id * self._server_group.nodes_per_engine, - (rollout_engine_id + 1) * self._server_group.nodes_per_engine, - ): - engine = self._server_group.all_engines[i] - if engine: - logger.info(f"Shutting down and killing engine at index {i}") + def _run_health_checks(self, *, force=False) -> None: + # The background thread and rollout-completion RPC share this lock, so + # they cannot retire the same engine while another check uses its handle. + with self._check_lock: + if self._stop_event.is_set(): + return + if not force and self._pause_event.is_set(): + return + checks = { + engine.health_generate.remote(timeout=self._check_timeout): rollout_engine_id + for rollout_engine_id, engine in enumerate(self._server_group.engines) + if engine is not None + } + if checks: + # Bound queued/wedged actor RPCs as well as the underlying HTTP + # requests. All engines are probed concurrently. + ray.wait(list(checks), num_returns=len(checks), timeout=self._check_timeout) + for handle, rollout_engine_id in checks.items(): try: - ray.get(engine.shutdown.remote()) - ray.kill(engine) - logger.info(f"Successfully killed engine at index {i}") - except Exception as e: - logger.warning(f"Fail to kill engine at index {i} (e: {e})") - else: - logger.info(f"Engine at index {i} is already None") - self._server_group.all_engines[i] = None + ray.get(handle, timeout=0) + except Exception as error: + # Both HTTP failures and unresponsive/dead actor RPCs retire + # the engine; HTTP timeout alone cannot detect a wedged actor. + logger.error("Health check failed for engine %s: %s", rollout_engine_id, error) + self._server_group.retire_engine(rollout_engine_id, timeout=self._check_timeout) diff --git a/vime_plugins/models/hf_attention.py b/vime_plugins/models/hf_attention.py index d5cf61972..2daba2a3f 100644 --- a/vime_plugins/models/hf_attention.py +++ b/vime_plugins/models/hf_attention.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod import torch -import torch.distributed as dist +import torch.distributed.nn.functional as dist_nn from megatron.core import mpu, tensor_parallel from megatron.core.inference.contexts import BaseInferenceContext from megatron.core.packed_seq_params import PackedSeqParams @@ -37,29 +37,6 @@ def _fix_dtype(d): return ns -class _AllGatherForDuplicatedComputation(torch.autograd.Function): - """All-gather whose backward just returns the local gradient slice (no reduce). - - Use this instead of ``dist.nn.all_gather`` when the computation after the - gather is *duplicated* across ranks (same weights, same full input → - identical gradients). The default ``all_gather`` backward performs a - reduce-scatter, which incorrectly sums ``world_size`` identical copies of - the gradient. - """ - - @staticmethod - def forward(ctx, x, group): - ctx.group = group - ctx.rank = dist.get_rank(group=group) - out = [torch.empty_like(x) for _ in range(dist.get_world_size(group=group))] - dist.all_gather(out, x.contiguous(), group=group) - return tuple(out) - - @staticmethod - def backward(ctx, *grads): - return grads[ctx.rank], None - - class HuggingfaceAttention(MegatronModule, ABC): """Attention layer abstract class. @@ -116,11 +93,13 @@ def forward( if mpu.get_context_parallel_world_size() > 1: cp_size = mpu.get_context_parallel_world_size() - # Use custom all-gather whose backward returns local gradient - # instead of reduce-scatter, since the computation is duplicated. - hidden_states_list = _AllGatherForDuplicatedComputation.apply( + # Unlike the SP gather above, the gradients here are not identical across ranks: every CP rank runs the + # layer on the whole sequence but keeps only its own output chunks (see the slicing after hf_forward), + # so each rank backpropagates a different part of the output gradient. The input gradient of a chunk is + # the sum over all CP ranks, which is what the reduce-scatter in all_gather's backward computes. + hidden_states_list = dist_nn.all_gather( hidden_states, - mpu.get_context_parallel_group(), + group=mpu.get_context_parallel_group(), ) # TODO: preprocess this for each batch to prevent tolist in the training step From e66fd6ba130699178c1d468d4aee4f2e9e632be3 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 8 Oct 2026 04:40:27 +0000 Subject: [PATCH 37/58] [Sync] Mirror Slime external PD delta artifact assertion Signed-off-by: aoshen02 --- tests/test_qwen3_4B_external_pd.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_qwen3_4B_external_pd.py b/tests/test_qwen3_4B_external_pd.py index 8e743edc8..647b04f6a 100644 --- a/tests/test_qwen3_4B_external_pd.py +++ b/tests/test_qwen3_4B_external_pd.py @@ -345,13 +345,14 @@ def launch_external_engines(): }, ) - checkpoint_dirs = sorted(Path(disk_dir).glob("weight_v*")) - assert checkpoint_dirs, f"No disk checkpoint directories were written under {disk_dir}" - assert any((path / "model.safetensors.index.json").exists() for path in checkpoint_dirs) - assert any(list(path.glob("*.safetensors")) for path in checkpoint_dirs) if update_mode == "delta": - indexes = [json.loads((path / "model.safetensors.index.json").read_text()) for path in checkpoint_dirs] - assert all("delta_encoding" in index["metadata"] for index in indexes) + delta_files = list(Path(disk_dir).glob("weight_v*/*.safetensors")) + assert delta_files, f"No disk delta safetensors were written under {disk_dir}" + else: + checkpoint_dirs = sorted(Path(disk_dir).glob("weight_v*")) + assert checkpoint_dirs, f"No disk checkpoint directories were written under {disk_dir}" + assert any((path / "model.safetensors.index.json").exists() for path in checkpoint_dirs) + assert any(list(path.glob("*.safetensors")) for path in checkpoint_dirs) finally: for p in processes: if p.poll() is None: From a432c7e3ac8e4d6adc9447e3b3cc10425e627e6b Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 8 Oct 2026 15:11:01 +0000 Subject: [PATCH 38/58] fix: repair verified recovery and initial-policy CI contracts Preserve training loss, numerical thresholds and checkpoint strategy. Add indexed precision dumps, offline-first manager setup, scoped reference load, same-distribution CI KL diagnostic, canonical optimizer masters and NIXL peer retirement. Signed-off-by: aoshen02 --- docker/Dockerfile | 5 +- docker/patch/latest/megatron.patch | 71 +++++ .../latest/vllm-nixl-peer-retirement.patch | 265 ++++++++++++++++++ tests/test_qwen3_0.6B_parallel_check.py | 5 +- tests/test_reference_top_p_replay.py | 53 +++- vime/backends/megatron_utils/actor.py | 27 +- vime/backends/megatron_utils/loss.py | 15 + vime/backends/megatron_utils/model.py | 3 +- vime/ray/rollout.py | 6 +- 9 files changed, 427 insertions(+), 23 deletions(-) create mode 100644 docker/patch/latest/vllm-nixl-peer-retirement.patch diff --git a/docker/Dockerfile b/docker/Dockerfile index 8630df709..2cd802ed6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -178,6 +178,7 @@ COPY docker/patch/${PATCH_VERSION}/vllm-retained-serving.patch /tmp/vllm-retaine COPY docker/patch/${PATCH_VERSION}/vllm-generation-health.patch /tmp/vllm-generation-health.patch COPY docker/patch/${PATCH_VERSION}/vllm-tensor-dump.patch /tmp/vllm-tensor-dump.patch COPY docker/patch/${PATCH_VERSION}/vllm-fine-grained-tracing.patch /tmp/vllm-fine-grained-tracing.patch +COPY docker/patch/${PATCH_VERSION}/vllm-nixl-peer-retirement.patch /tmp/vllm-nixl-peer-retirement.patch RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirname(vllm.__file__)))')" && \ cd "$VLLM_SITE" && \ git apply -v /tmp/vllm-pull_weights.patch && \ @@ -189,6 +190,7 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn git apply -v /tmp/vllm-generation-health.patch && \ git apply -v /tmp/vllm-tensor-dump.patch && \ git apply -v /tmp/vllm-fine-grained-tracing.patch && \ + git apply -v /tmp/vllm-nixl-peer-retirement.patch && \ rm /tmp/vllm-pull_weights.patch /tmp/vllm.patch \ /tmp/vllm-score-centering.patch \ /tmp/vllm-pd-request-metrics.patch \ @@ -196,7 +198,8 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn /tmp/vllm-retained-serving.patch \ /tmp/vllm-generation-health.patch \ /tmp/vllm-tensor-dump.patch \ - /tmp/vllm-fine-grained-tracing.patch + /tmp/vllm-fine-grained-tracing.patch \ + /tmp/vllm-nixl-peer-retirement.patch # ====================================== Install main package ============================================ COPY --from=vllm-rust-frontend /build/rust/target/release/vllm-rs /usr/local/lib/python3.12/dist-packages/vllm/vllm-rs diff --git a/docker/patch/latest/megatron.patch b/docker/patch/latest/megatron.patch index 2e21b9751..883c80499 100644 --- a/docker/patch/latest/megatron.patch +++ b/docker/patch/latest/megatron.patch @@ -1038,3 +1038,74 @@ index 7c87eca191a525915502a1deb7622f03990c89de..bdb62df91b9ca2fe6e13d166a21f38e1 if non_persistent_iteration >= iteration: return _load_non_persistent_base_checkpoint( non_persistent_global_dir, +diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py +index dd8590a79..2a6d8fbc1 100644 +--- a/megatron/core/optimizer/distrib_optimizer.py ++++ b/megatron/core/optimizer/distrib_optimizer.py +@@ -641,6 +641,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + """ + inner_state_dict = self.optimizer.state_dict() + state_dict = {} ++ if self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8: ++ state_dict["master_param_format"] = "fp32" + + # Extract 'step', for non-Apex/TE support. + if not HAVE_APEX_OR_TE: +@@ -733,6 +735,13 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + self.optimizer.load_state_dict(state_dict) + return + ++ if ( ++ self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8 ++ and getattr(self.optimizer, "store_param_remainders", False) ++ and state_dict.get("master_param_format") != "fp32" ++ ): ++ raise ValueError("Checkpoint master parameter representation is not identified as FP32.") ++ + if len(self.optimizer.state) == 0: + if isinstance(self.optimizer, HybridDeviceOptimizer): + self.optimizer.dummy_step() +@@ -887,6 +896,9 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + self.load_parameter_state_from_fs_model_space(param_state) + else: + raise NotImplementedError(f'Unknown sharding_type: {sharding_type}') ++ if getattr(self.optimizer, "store_param_remainders", False): ++ for model_chunk in self.model_chunks: ++ model_chunk.start_param_sync(force_sync=True) + + def _get_main_param_and_optimizer_states(self, model_param): + """Return a dict containing the main param and optimizer states corresponding to the input +@@ -910,6 +922,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + + tensors[k] = self.optimizer.get_unscaled_state(sharded_model_param, k) + tensors["param"] = tensors.pop("master_param") ++ if tensors["param"].dtype == torch.int16: ++ remainder = tensors["param"] ++ high = sharded_model_param.view(torch.int16).to(torch.int32) ++ high = high - (remainder < 0).to(torch.int32) ++ tensors["param"] = ((high << 16) | (remainder.to(torch.int32) & 0xFFFF)).view(torch.float32) + else: + main_param = self.optimizer.param_groups[group_index]["params"][group_order] + optim_state = self.optimizer.state[main_param] +@@ -937,6 +954,12 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + continue + + if k == "param": ++ if self.optimizer.store_param_remainders and sharded_model_param.dtype == torch.bfloat16: ++ assert v.dtype == torch.float32 ++ bits = v.contiguous().view(torch.int32) ++ v = bits.to(torch.int16) ++ high = ((bits >> 16) + (v < 0).to(torch.int32)).to(torch.int16) ++ sharded_model_param.data.copy_(high.view(torch.bfloat16)) + self.optimizer.set_scaled_state(sharded_model_param, "master_param", v) + else: + self.optimizer.set_scaled_state(sharded_model_param, k, v) +@@ -2276,6 +2299,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): + """ + if self.is_stub_optimizer: + return ++ if getattr(self.optimizer, "store_param_remainders", False): ++ raise ValueError("Master remainders require a torch_dist checkpoint with representation metadata.") + state_dict = None + if self.data_parallel_group.rank() == 0: + state_dict = torch.load(filename) diff --git a/docker/patch/latest/vllm-nixl-peer-retirement.patch b/docker/patch/latest/vllm-nixl-peer-retirement.patch new file mode 100644 index 000000000..8e8e4c769 --- /dev/null +++ b/docker/patch/latest/vllm-nixl-peer-retirement.patch @@ -0,0 +1,265 @@ +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/base.py b/vllm/distributed/kv_transfer/kv_connector/v1/base.py +index 96ce5daa3..7ed5fac0f 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/base.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/base.py +@@ -774,6 +774,12 @@ class KVConnectorBase_V1(ABC): + """ + return None + ++ def get_idle_timeout(self) -> float | None: ++ return None ++ ++ def on_idle(self) -> None: ++ return ++ + def reset_cache(self) -> bool | None: + """Reset the connector's internal cache. + +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py +index 1ded62add..0729a1f17 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py +@@ -734,6 +734,18 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA): + prom_metrics, + ) + ++ def get_idle_timeout(self) -> float | None: ++ intervals = [ ++ interval ++ for connector in self._connectors ++ if (interval := connector.get_idle_timeout()) is not None ++ ] ++ return min(intervals, default=None) ++ ++ def on_idle(self) -> None: ++ for connector in self._connectors: ++ connector.on_idle() ++ + def reset_cache(self) -> bool: + results = [c.reset_cache() is not False for c in self._connectors] + return all(results) +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py +index b28fef659..2d188e661 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py +@@ -163,6 +163,10 @@ def _uses_dense_virtual_transfer_pages( + ) + + ++def _is_remote_disconnect(error: Exception) -> bool: ++ return type(error).__name__ == "nixlRemoteDisconnectError" ++ ++ + class NixlBaseConnectorWorker: + """Base implementation of Worker side methods shared by pull and push.""" + +@@ -685,6 +689,8 @@ class NixlBaseConnectorWorker: + else nixl_agent_config(num_threads=num_threads, capture_telemetry=True) + ) + ++ if "UCX" in self.nixl_backends: ++ os.environ.setdefault("UCX_CUDA_IPC_CACHE", "n") + self.nixl_wrapper = nixl_wrapper_cls(str(uuid.uuid4()), config) + # Map of engine_id -> {(pp_rank, tp_rank): agent_name, ...}. + # non-PP remote uses pp_rank 0, i.e. (0, tp_rank). +@@ -885,6 +891,14 @@ class NixlBaseConnectorWorker: + ) + # Map of pull peer (host, port) -> engine_id currently serving it. + self._engine_by_address: dict[tuple[str, int], EngineId] = {} ++ # Peers whose NIXL endpoints failed UCX keepalive. ++ self._unreachable_remote_engines: set[EngineId] = set() ++ self._engine_heartbeat_interval: float = float( ++ vllm_config.kv_transfer_config.get_from_extra_config( ++ "engine_heartbeat_interval", 5.0 ++ ) ++ ) ++ self._next_engine_heartbeat: float = 0.0 + + self.model_config = vllm_config.model_config + +@@ -3046,6 +3060,7 @@ class NixlBaseConnectorWorker: + self._reap_expired_send_leases(done_sending) + + self._cleanup_remote_engines() ++ self._release_unreachable_remote_engines() + return KVConnectorTransferResults( + finished_sending=done_sending, + finished_recving=done_recving, +@@ -3275,7 +3290,10 @@ class NixlBaseConnectorWorker: + for agent_name in self._remote_agents[engine_id].values(): + try: + self.nixl_wrapper.send_notif(agent_name, notif_msg=hb_msg) +- except Exception: ++ except Exception as e: ++ if _is_remote_disconnect(e): ++ self._mark_remote_engine_unreachable(engine_id, agent_name) ++ break + logger.debug( + "Failed to send heartbeat to engine %s", + engine_id, +@@ -3751,6 +3769,58 @@ class NixlBaseConnectorWorker: + f" and a {backoff:.0f}s backoff" if backoff else "", + ) + ++ def _release_unreachable_remote_engines(self) -> None: ++ """Release peers that failed heartbeat, once no reads are in flight.""" ++ if self._TRANSFER_MODE != "pull": ++ return ++ self._heartbeat_remote_engines() ++ with self._handshake_lock: ++ if self._handshake_futures: ++ return ++ unreachable = self._unreachable_remote_engines & self._remote_agents.keys() ++ self._unreachable_remote_engines = set(unreachable) ++ for engine_id in unreachable - self._engines_with_inflight_transfers(): ++ self._cleanup_remote_engine(engine_id, log_eviction=False) ++ self._unreachable_remote_engines.discard(engine_id) ++ logger.info( ++ "Released NIXL state for unreachable remote engine %s.", engine_id ++ ) ++ ++ def _heartbeat_remote_engines(self) -> None: ++ """Send an empty heartbeat to every peer; it fails once UCX keepalive ++ has failed the peer's endpoints.""" ++ if self._engine_heartbeat_interval <= 0: ++ return ++ now = time.perf_counter() ++ if now < self._next_engine_heartbeat: ++ return ++ self._next_engine_heartbeat = now + self._engine_heartbeat_interval ++ with self._handshake_lock: ++ engines = { ++ engine_id: list(agents.values()) ++ for engine_id, agents in self._remote_agents.items() ++ } ++ for engine_id, agent_names in engines.items(): ++ for agent_name in agent_names: ++ try: ++ self.nixl_wrapper.send_notif(agent_name, notif_msg=b"HB:") ++ except Exception as e: ++ if _is_remote_disconnect(e): ++ self._mark_remote_engine_unreachable(engine_id, agent_name) ++ break ++ ++ def _mark_remote_engine_unreachable( ++ self, engine_id: EngineId, agent_name: str ++ ) -> None: ++ if engine_id in self._unreachable_remote_engines: ++ return ++ logger.warning( ++ "Remote engine %s (agent %s) failed heartbeat; releasing its NIXL state.", ++ engine_id, ++ agent_name, ++ ) ++ self._unreachable_remote_engines.add(engine_id) ++ + def _cleanup_remote_engine( + self, engine_id: EngineId, *, log_eviction: bool = True + ) -> None: +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py +index 4bb9c78e8..d4eb62247 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py +@@ -370,6 +370,18 @@ class NixlPullConnector(NixlBaseConnector): + vllm_config, self.engine_id, kv_cache_config + ) + ++ def get_idle_timeout(self) -> float | None: ++ interval = float( ++ self.kv_transfer_config.get_from_extra_config( ++ "engine_heartbeat_interval", 5.0 ++ ) ++ ) ++ return interval if interval > 0 else None ++ ++ def on_idle(self) -> None: ++ assert self.connector_worker is not None ++ self.connector_worker._release_unreachable_remote_engines() ++ + def start_load_kv(self, forward_context: "ForwardContext", **kwargs) -> None: + assert self.connector_worker is not None + assert isinstance(self.connector_worker, NixlPullConnectorWorker) +diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py +index 7a3e271c0..47bca456b 100644 +--- a/vllm/v1/engine/core.py ++++ b/vllm/v1/engine/core.py +@@ -1119,6 +1119,7 @@ class EngineCoreProc(EngineCore): + engine_index: int = 0, + ): + self.input_queue = queue.Queue[tuple[EngineCoreRequestType, Any]]() ++ self._kv_idle_deadline = 0.0 + self.output_queue = queue.Queue[tuple[int, EngineCoreOutputs] | bytes]() + executor_fail_callback = lambda: self.input_queue.put_nowait( + (EngineCoreRequestType.EXECUTOR_FAILED, b"") +@@ -1510,10 +1511,24 @@ class EngineCoreProc(EngineCore): + ) + self.output_queue.put_nowait((-1, EngineCoreOutputs(scheduler_stats=stats))) + ++ def _progress_idle_kv_connector(self, interval: float | None) -> None: ++ if ( ++ interval is not None ++ and not self.batch_queue ++ and time.monotonic() >= self._kv_idle_deadline ++ ): ++ self.model_executor.collective_rpc("on_kv_connector_idle") ++ self._kv_idle_deadline = time.monotonic() + interval ++ + def _process_input_queue(self): + """Exits when an engine step needs to be performed.""" ++ connector = self.scheduler.get_kv_connector() ++ idle_timeout = connector.get_idle_timeout() if connector is not None else None ++ if self.scheduler.pause_state == PauseState.PAUSED_ALL: ++ self._progress_idle_kv_connector(idle_timeout) + waited = False + while not self.has_work() and self.is_running(): ++ self._progress_idle_kv_connector(idle_timeout) + # Notify callbacks waiting for engine to become idle. + self._notify_idle_state_callbacks() + if self.input_queue.empty(): +@@ -1525,9 +1540,16 @@ class EngineCoreProc(EngineCore): + waited = True + block = self.process_input_queue_block + try: +- req = self.input_queue.get(block=block) ++ timeout = ( ++ max(0.0, self._kv_idle_deadline - time.monotonic()) ++ if block and idle_timeout is not None ++ else None ++ ) ++ req = self.input_queue.get(block=block, timeout=timeout) + self._handle_client_request(*req) + except queue.Empty: ++ if block and idle_timeout is not None: ++ continue + break + if not block: + break +diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py +index e49e0141d..fe0986b3e 100644 +--- a/vllm/v1/worker/gpu_worker.py ++++ b/vllm/v1/worker/gpu_worker.py +@@ -791,6 +791,10 @@ class Worker(WorkerBase): + getattr(self.parallel_config, "_api_process_count", 1), + ) + ++ def on_kv_connector_idle(self) -> None: ++ if has_kv_transfer_group(): ++ get_kv_transfer_group().on_idle() ++ + def get_kv_connector_handshake_metadata( + self, + ) -> dict[tuple[int, int], KVConnectorHandshakeMetadata] | None: +diff --git a/vllm/v1/worker/worker_base.py b/vllm/v1/worker/worker_base.py +index 95a0c3475..4cbca4592 100644 +--- a/vllm/v1/worker/worker_base.py ++++ b/vllm/v1/worker/worker_base.py +@@ -122,6 +122,9 @@ class WorkerBase: + """ + raise NotImplementedError + ++ def on_kv_connector_idle(self) -> None: ++ return ++ + def check_health(self) -> None: + """Basic health check (override for device-specific checks).""" + return diff --git a/tests/test_qwen3_0.6B_parallel_check.py b/tests/test_qwen3_0.6B_parallel_check.py index 2e3c8cfda..166fb2ab6 100644 --- a/tests/test_qwen3_0.6B_parallel_check.py +++ b/tests/test_qwen3_0.6B_parallel_check.py @@ -102,11 +102,12 @@ def execute(): f"{misc_args} " ) - rollout_data_path = "parallel-check-rollout-data.pt" + rollout_data_pattern = "parallel-check-rollout-data-{rollout_id}.pt" + rollout_data_path = rollout_data_pattern.format(rollout_id=0) for i, calculate_per_token_loss in enumerate((False, True)): loss_args = "--calculate-per-token-loss " if calculate_per_token_loss else "" rollout_data_args = ( - f"--save-debug-rollout-data {rollout_data_path} " + f"--save-debug-rollout-data {rollout_data_pattern} " if i == 0 else f"--load-debug-rollout-data {rollout_data_path} " ) diff --git a/tests/test_reference_top_p_replay.py b/tests/test_reference_top_p_replay.py index 5938cd25f..8a38f5c2a 100644 --- a/tests/test_reference_top_p_replay.py +++ b/tests/test_reference_top_p_replay.py @@ -12,7 +12,7 @@ import torch from megatron.core import mpu -from vime.backends.megatron_utils.loss import get_log_probs_and_entropy +from vime.backends.megatron_utils.loss import get_log_probs_and_entropy, policy_loss_function NUM_GPUS = 0 @@ -115,5 +115,56 @@ def log_prob(probs, *, replay, prefix=""): ) +@pytest.mark.unit +def test_initial_model_kl_preserves_masked_training_loss(monkeypatch): + monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) + monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) + args = Namespace( + rollout_temperature=1.0, + rollout_top_p=0.95, + allgather_cp=False, + log_probs_chunk_size=-1, + entropy_coef=0.0, + use_rollout_logprobs=False, + use_opsm=False, + advantage_estimator="grpo", + pg_loss_type="reinforce", + get_mismatch_metrics=False, + use_tis=False, + use_kl_loss=True, + use_unbiased_kl=False, + kl_loss_type="low_var_kl", + kl_loss_coef=1.0, + ci_test=True, + ) + logits = torch.tensor([[[0.2, 0.2, 0.6], [0.2, 0.2, 0.6]]]).log().requires_grad_() + batch = { + "advantages": [torch.zeros(1)], + "unconcat_tokens": [torch.tensor([0, 0])], + "total_lengths": [2], + "response_lengths": [1], + "loss_masks": [torch.ones(1)], + "ref_log_probs": [torch.tensor([math.log(0.2)])], + "rollout_top_p_token_ids": [[0, 1]], + "rollout_top_p_token_offsets": [[0, 2]], + } + loss, metrics = policy_loss_function(args, batch, logits, torch.mean) + assert metrics["model_kl"].item() == pytest.approx(0.0, abs=1e-8) + assert metrics["kl_loss"].item() > 0.1 + assert not metrics["model_kl"].requires_grad + args.ci_test = False + regular_loss, regular_metrics = policy_loss_function(args, batch, logits, torch.mean) + assert "model_kl" not in regular_metrics + torch.testing.assert_close(loss, regular_loss) + torch.testing.assert_close( + torch.autograd.grad(loss, logits, retain_graph=True)[0], + torch.autograd.grad(regular_loss, logits)[0], + ) + args.ci_test = True + batch["ref_log_probs"] = [torch.tensor([math.log(0.1)])] + _, metrics = policy_loss_function(args, batch, logits, torch.mean) + assert metrics["model_kl"].item() > 1e-8 + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index d8824def9..5a3f5eb03 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -696,24 +696,21 @@ def load_other_checkpoint(self, model_tag: str, path: str) -> None: self.args.no_load_rng = True self.args.finetune = True - if model_tag == "ref" and self.args.ref_ckpt_step is not None: + if model_tag == "ref": self.args.ckpt_step = self.args.ref_ckpt_step - elif model_tag == "teacher" and self.args.opd_teacher_ckpt_step is not None: + elif model_tag == "teacher": self.args.ckpt_step = self.args.opd_teacher_ckpt_step - _, _ = load_checkpoint( - self.model, - None, - None, - checkpointing_context={}, - ) - ( - self.args.load, - self.args.no_load_optim, - self.args.no_load_rng, - self.args.finetune, - self.args.ckpt_step, - ) = old_args + try: + load_checkpoint(self.model, None, None, checkpointing_context={}) + finally: + ( + self.args.load, + self.args.no_load_optim, + self.args.no_load_rng, + self.args.finetune, + self.args.ckpt_step, + ) = old_args self.weights_backuper.backup(model_tag) self._active_model_tag = model_tag diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 8eb1e7a56..bab208deb 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -1346,6 +1346,21 @@ def policy_loss_function( if args.use_kl_loss: reported_loss["kl_loss"] = kl_loss.clone().detach() + if args.ci_test and args.rollout_top_p != 1.0: + with torch.no_grad(): + _, unmasked = get_log_probs_and_entropy( + logits, + args=args, + unconcat_tokens=batch["unconcat_tokens"], + total_lengths=total_lengths, + response_lengths=response_lengths, + ) + model_kl = compute_approx_kl( + torch.cat(unmasked["log_probs"]), + ref_log_probs, + kl_loss_type=args.kl_loss_type, + ) + reported_loss["model_kl"] = sum_of_sample_mean(model_kl) if args.get_mismatch_metrics or args.use_tis: # Aggregate mismatch/TIS/RS related metrics with the *pre-RS* masks. diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index 5efdafc16..d3c6d77f8 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -957,7 +957,8 @@ def train( and not getattr(args, "use_rollout_routing_replay", False) and "train/kl_loss" in log_dict ): - assert log_dict["train/kl_loss"] < 1e-8, f"{log_dict=}" + initial_kl = log_dict.get("train/model_kl", log_dict["train/kl_loss"]) + assert initial_kl < 1e-8, f"{log_dict=}" logger.info(f"{role_tag}step {accumulated_step_id}: {log_dict}") diff --git a/vime/ray/rollout.py b/vime/ray/rollout.py index 7fb055af2..580095adb 100644 --- a/vime/ray/rollout.py +++ b/vime/ray/rollout.py @@ -71,13 +71,13 @@ def __init__(self, args, pg, *, restore_plan=None, serving=None, deployment=None check_rollout_storage(args) rollout_init_handles: list[Any] = [] - if deployment is not None: + if self.args.debug_train_only: + self.servers: dict[str, Any] = {} + elif deployment is not None: # This is a handle snapshot; the serving owner remains responsible # for topology changes and survives replacement of this manager. self.servers = deployment.servers init_http_client(args) - elif self.args.debug_train_only: - self.servers: dict[str, Any] = {} else: from vime.backends.vllm_utils.deployment import start_rollout_servers From c18ca8451e8e97d921ecd53c4b2762ed287a45d4 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 8 Oct 2026 16:32:18 +0000 Subject: [PATCH 39/58] fix: keep initial-model diagnostic optional for direct loss callers Signed-off-by: aoshen02 --- vime/backends/megatron_utils/loss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index bab208deb..4f01d8810 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -1346,7 +1346,7 @@ def policy_loss_function( if args.use_kl_loss: reported_loss["kl_loss"] = kl_loss.clone().detach() - if args.ci_test and args.rollout_top_p != 1.0: + if getattr(args, "ci_test", False) and args.rollout_top_p != 1.0: with torch.no_grad(): _, unmasked = get_log_probs_and_entropy( logits, From 16b6c39a7ed335211e91543a1fae548573956e02 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Thu, 8 Oct 2026 20:27:33 +0000 Subject: [PATCH 40/58] fix: give the PD rollout debug dump a unique per-rollout path Signed-off-by: aoshen02 --- tests/test_qwen3.6_35B_A3B_pd_mooncake.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py index 3e19b4abc..e1d30b51d 100644 --- a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py +++ b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py @@ -27,7 +27,7 @@ def prepare(): def execute(): os.environ.setdefault("VLLM_SSM_CONV_STATE_LAYOUT", "DS") debug_data_path = os.environ.get("DEBUG_ROLLOUT_DATA") or tempfile.mktemp( - prefix="qwen3_6_35b_a3b_pd_rollout_", suffix=".pt" + prefix="qwen3_6_35b_a3b_pd_rollout_", suffix="_{rollout_id}.pt" ) try: os.remove(debug_data_path) From 101ced6918c459ffcda99f527ae6d7882eb0d330 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Fri, 9 Oct 2026 08:30:53 +0000 Subject: [PATCH 41/58] [Sync] Mirror Slime v0.4.0 training stack and release updates Signed-off-by: aoshen02 --- .buildkite/README.md | 3 +- .buildkite/pipeline.yml | 3 + .dockerignore | 16 + .github/workflows/release-docs.yaml | 12 +- README.md | 2 + README_zh.md | 2 + docker/Dockerfile | 34 +- docker/patch/latest/megatron.patch | 979 +++--------------- docker/version.txt | 2 +- docs/README.md | 54 +- docs/_static/js/lang-toggle.js | 16 +- docs/build.sh | 16 +- docs/build_all.sh | 6 +- docs/conf.py | 124 +-- .../advanced/arch-support-beyond-megatron.md | 33 +- docs/en/advanced/low-precision.md | 6 +- docs/en/developer_guide/install_flashqla.md | 28 + docs/en/examples/qwen3-next-80B-A3B.md | 54 + docs/en/get_started/agent.md | 4 +- docs/en/get_started/customization.md | 4 +- docs/en/get_started/quick_start.md | 2 +- docs/en/get_started/usage.md | 9 +- docs/en/index.rst | 32 +- docs/en/platform_support/amd_tutorial.md | 8 +- docs/requirements.txt | 2 +- docs/serve.sh | 16 +- .../advanced/arch-support-beyond-megatron.md | 33 +- docs/zh/advanced/low-precision.md | 6 +- docs/zh/developer_guide/install_flashqla.md | 17 +- docs/zh/examples/qwen3-next-80B-A3B.md | 96 +- docs/zh/get_started/agent.md | 4 +- docs/zh/get_started/customization.md | 4 +- docs/zh/get_started/quick_start.md | 2 +- docs/zh/get_started/usage.md | 9 +- docs/zh/index.rst | 54 +- docs/zh/platform_support/amd_tutorial.md | 63 ++ examples/coding_agent_rl/README.md | 2 +- setup.py | 2 +- tests/glm52_layerwise_comparator.py | 17 + tests/pipeline_rl_test_helpers.py | 9 +- tests/test_disk_delta_checkpoint_index.py | 36 +- tests/test_glm52_6layer_deterministic_e2e.py | 23 + tests/test_hf_to_megatron.py | 44 + tests/test_layerwise_alignment.py | 97 ++ tests/test_megatron_argument_validation.py | 133 +++ tests/test_megatron_checkpoint.py | 190 ++++ tests/test_megatron_tokenizer_init.py | 115 ++ tests/test_model_provider_freeze.py | 88 ++ tests/test_published_payload_retry.py | 8 +- tests/test_qwen3.6_35B_A3B_pd_mooncake.py | 6 +- tests/test_qwen3_0.6B_parallel_check.py | 5 +- tests/test_reference_top_p_replay.py | 119 ++- tests/test_rollout_manager_recovery.py | 30 +- tests/test_rollout_metadata_index.py | 6 + tests/test_rollout_straw_transport.py | 6 + tests/test_stateless_adam.py | 8 +- tests/test_training_recovery.py | 4 + tests/test_update_weight_factory.py | 4 + tests/utils/test_vllm_config.py | 5 + tools/convert_hf_to_torch_dist.py | 3 + vime/backends/megatron_utils/actor.py | 10 +- vime/backends/megatron_utils/arguments.py | 8 + vime/backends/megatron_utils/checkpoint.py | 27 +- .../megatron_utils/hf_to_megatron/common.py | 2 +- vime/backends/megatron_utils/initialize.py | 13 +- vime/backends/megatron_utils/loss.py | 15 - .../megatron_utils/megatron_to_hf/__init__.py | 3 + vime/backends/megatron_utils/memory.py | 35 + vime/backends/megatron_utils/model.py | 32 +- .../backends/megatron_utils/model_provider.py | 58 +- .../megatron_utils/transformer_engine.py | 107 ++ vime/backends/vllm_utils/engine_group.py | 20 + vime/data/transport.py | 8 +- vime/ray/training_recovery.py | 4 + vime/utils/arguments.py | 15 + vime/utils/routing_replay.py | 31 +- vime_plugins/models/glm4.py | 53 +- 77 files changed, 1907 insertions(+), 1249 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/en/developer_guide/install_flashqla.md create mode 100644 docs/en/examples/qwen3-next-80B-A3B.md create mode 100644 docs/zh/platform_support/amd_tutorial.md create mode 100644 tests/test_megatron_checkpoint.py create mode 100644 tests/test_megatron_tokenizer_init.py create mode 100644 vime/backends/megatron_utils/memory.py create mode 100644 vime/backends/megatron_utils/transformer_engine.py diff --git a/.buildkite/README.md b/.buildkite/README.md index bf2a45327..173463c0d 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -62,8 +62,7 @@ The block uses `blocked_state: passed`, so a build whose CPU steps are green reports a passing commit status even if nobody unblocks the GPU gate. For sync validation, always select all six suites. The REST unblock payload -passes the selection directly, for example `{"gpu-suites": "short\nvllm-config\nmegatron\nvime-customized\nprecision\nckpt"}`; -do not wrap it in a second `fields` object. +wraps the selection in `fields`, for example `{"fields": {"gpu-suites": "short\nvllm-config\nmegatron\nvime-customized\nprecision\nckpt"}}`. GPU jobs run on the shared **`mithril-h100-pool`** queue, following the same pattern vllm-omni uses for it: each job is a Kubernetes pod (agent-stack-k8s diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 99c6bd93a..12f7d6045 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -67,6 +67,8 @@ steps: pip install -q pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors transformers cloudpickle blake3 xxhash zstandard psutil wandb "straw-queue>=0.1.2" pip install -q -e . --no-deps python tests/test_megatron_argument_validation.py + python tests/test_megatron_tokenizer_init.py + python tests/test_megatron_checkpoint.py python tests/test_optional_straw.py python tests/test_value_temperature.py python tests/test_docs_consistency.py @@ -153,6 +155,7 @@ steps: tests/test_deep_ep_tms_patch.py \ tests/test_distributed_rollout.py \ tests/test_disk_delta_recovery.py \ + tests/test_disk_delta_checkpoint_index.py \ tests/test_discounted_returns.py \ tests/test_eval_config.py \ tests/test_filter_long_prompt.py \ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..937a820df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.env +.venv* +.cache +.pytest_cache +.ruff_cache +**/__pycache__ +**/*.py[cod] +**/*.egg-info +**/_examples_synced +build +dist +docs/build +docs/_build +outputs +wandb diff --git a/.github/workflows/release-docs.yaml b/.github/workflows/release-docs.yaml index 36ef99607..93fad67a7 100644 --- a/.github/workflows/release-docs.yaml +++ b/.github/workflows/release-docs.yaml @@ -7,7 +7,8 @@ on: paths: - "docs/**" - "examples/**" - - "version.txt" + - "README.md" + - "README_zh.md" workflow_dispatch: concurrency: @@ -23,18 +24,16 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 - - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.13' - - name: Install dependencies run: | - apt-get update && apt-get install -y pandoc parallel retry + sudo apt-get update + sudo apt-get install -y pandoc pip install -r docs/requirements.txt - - name: Build documentation run: | cd docs @@ -45,9 +44,8 @@ jobs: LC_ALL: "en_US.UTF-8" LC_CTYPE: "en_US.UTF-8" - - name: Deploy uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/build/en \ No newline at end of file + publish_dir: ./docs/build/en diff --git a/README.md b/README.md index 144d7fd3f..d577dabec 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 1. **High-performance training**: Efficient training in various modes by connecting Megatron with vLLM; 2. **Flexible data generation**: Arbitrary training data generation workflows through custom data generation interfaces and server-based engines. +Training, rollout, the data buffer, and environment feedback share one dataflow. This supports math, code, search, tool use, and long-horizon agent workflows within the same training loop. + Vime inherits broad model support from slime, including: - Qwen series (Qwen3.6, Qwen3.5, Qwen3Next, Qwen3MoE, Qwen3, Qwen2.5); diff --git a/README_zh.md b/README_zh.md index ae9bcdfb3..dde6efac7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,6 +10,8 @@ 1. **高性能训练**:通过连接 Megatron 与 vLLM,支持各种模式的高效训练; 2. **灵活的数据生成**:通过自定义数据生成接口以及 server based engine,实现任意的训练数据生成流程。 +训练、rollout、数据 buffer 和环境反馈共享同一条数据流,在同一个训练循环中支持数学、代码、搜索、工具调用和长程 agent 工作流。 + Vime 继承了 slime 广泛的模型支持,包括: - Qwen 系列(Qwen3.6、Qwen3.5、Qwen3Next、Qwen3MoE、Qwen3、Qwen2.5); diff --git a/docker/Dockerfile b/docker/Dockerfile index 2cd802ed6..139f37edc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,15 +21,20 @@ FROM ${BASE_IMAGE} ARG PATCH_VERSION=latest ARG VLLM_COMMIT=43b4aaea3e40e20ef53ce2bfecfa1b72060bf2b5 -ARG MEGATRON_COMMIT=1dcf0dafa884ad52ffb243625717a3471643e087 +ARG MEGATRON_COMMIT=4b4acac9a1d28ea6829c8d4f566d75698a21249d ARG DEEPGEMM_COMMIT=b38a77cd193cf38f670caae192310521d24343be ARG DEEPEP_COMMIT=6845ffd9d59126ec0030c13e0e155935a61e5b5a ARG DEEPEP_CUDA_ARCH_LIST=9.0;10.0;10.3 ARG FLASH_QLA_COMMIT=821fd9d37ede18fdc2a4e707fefe3770bfc32e58 -ARG TRANSFORMER_ENGINE_COMMIT=c9877beb87ad7e711e1869dd0b5062167ede447a +ARG TRANSFORMER_ENGINE_VERSION=2.18.0 +ARG TRANSFORMER_ENGINE_COMMIT=27486e03cfc1fa41f6932dcecdc47c71c47eac3e ARG TRANSFORMER_ENGINE_CUDA_ARCHS=90;100a;103a -ARG TMS_COMMIT=8d30c59ca12a68d9deccbc9c6599076a1218cbc5 +ARG TMS_COMMIT=4d525cf378fdbfe7eb044909b8e66e112a508624 ARG TMS_CUDA_MAJOR= +ARG NCCL_VERSION=2.30.7 +ARG MAX_JOBS=16 +ENV MAX_JOBS=${MAX_JOBS} CMAKE_BUILD_PARALLEL_LEVEL=${MAX_JOBS} +ENV LD_LIBRARY_PATH=/usr/local/lib/python3.12/dist-packages/nvidia/nccl/lib:${LD_LIBRARY_PATH} ARG ENABLE_CUDA_13=1 ARG FA2_MAX_JOBS=64 @@ -65,18 +70,18 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1 # ====================================== Python dependencies ============================================ -# The validated TransformerEngine 2.16 context-parallel stack uses FA2 + FA3. +# The training context-parallel stack uses FA2 + FA3. RUN pip uninstall -y flash-attn-4 flash_attn_4 || true RUN MAX_JOBS=${FA2_MAX_JOBS} pip -v install flash-attn==2.8.3 --no-build-isolation -# This FA3 commit provides the window_size_left/window_size_right API used by TE 2.16. +# This FA3 commit provides the window_size_left/window_size_right API used by TE. RUN git clone https://github.com/Dao-AILab/flash-attention.git && \ cd flash-attention/ && git checkout 002cce0a1068f8c07dfccb5a1d232b9a3276947c && git submodule update --init && \ cd hopper/ && \ FLASH_ATTENTION_FORCE_BUILD=TRUE MAX_JOBS=96 pip -v install . --no-build-isolation && \ cd /root/ && rm -rf flash-attention/ -RUN pip install flash-linear-attention==0.4.2 +RUN pip install flash-linear-attention==0.5.2 # FlashQLA: optional GDN backend for Qwen3.5/Qwen3-Next (--qwen-gdn-backend flashqla; requires SM90+) ARG INSTALL_FLASHQLA=0 RUN if [ "${INSTALL_FLASHQLA}" = "1" ]; then \ @@ -100,17 +105,17 @@ RUN if [ "${ENABLE_CUDA_13}" = "1" ]; then \ pip install pybind11 && \ NCCL_INCLUDE_DIR="$(python -c 'import importlib.util; print(next(iter(importlib.util.find_spec("nvidia.nccl").submodule_search_locations)) + "/include")')" && \ export CPATH="${NCCL_INCLUDE_DIR}${CPATH:+:${CPATH}}" && \ - NVTE_CUDA_ARCHS="${TRANSFORMER_ENGINE_CUDA_ARCHS}" MAX_JOBS=64 \ + NVTE_WITH_NCCL_EP=0 NVTE_CUDA_ARCHS="${TRANSFORMER_ENGINE_CUDA_ARCHS}" \ pip -v install --no-build-isolation \ git+https://github.com/NVIDIA/TransformerEngine.git@${TRANSFORMER_ENGINE_COMMIT}; \ else \ - pip -v install --no-build-isolation "transformer_engine[pytorch]==2.16.1"; \ + NVTE_WITH_NCCL_EP=0 pip -v install --no-build-isolation "transformer_engine[pytorch]==${TRANSFORMER_ENGINE_VERSION}"; \ fi -RUN NVCC_APPEND_FLAGS="--threads 4" \ +RUN NVCC_APPEND_FLAGS="--threads 1" \ pip -v install --disable-pip-version-check --no-cache-dir \ --no-build-isolation \ - --config-settings "--build-option=--cpp_ext --cuda_ext --parallel 8" git+https://github.com/NVIDIA/apex.git@10417aceddd7d5d05d7cbf7b0fc2daad1105f8b4 + --config-settings "--build-option=--cpp_ext --cuda_ext --parallel ${MAX_JOBS}" git+https://github.com/NVIDIA/apex.git@10417aceddd7d5d05d7cbf7b0fc2daad1105f8b4 RUN git clone https://github.com/NVIDIA/Megatron-LM.git --recursive && \ cd Megatron-LM && git checkout ${MEGATRON_COMMIT} @@ -119,7 +124,7 @@ RUN git clone https://github.com/NVIDIA/Megatron-LM.git --recursive && \ # torch_memory_saver, plus the GLM-5 train/rollout alignment kernels. RUN TMS_CUDA_MAJOR="${TMS_CUDA_MAJOR:-$(python -c 'import torch; print(torch.version.cuda.split(".")[0])')}" && \ export TMS_CUDA_MAJOR && \ - pip install git+https://github.com/zhuzilin/torch_memory_saver.git@${TMS_COMMIT} --no-cache-dir --force-reinstall + pip install git+https://github.com/zhuzilin/torch_memory_saver.git@${TMS_COMMIT} --no-cache-dir --force-reinstall --no-build-isolation RUN git clone https://github.com/zhuzilin/DeepGEMM.git --recursive && \ cd DeepGEMM && git checkout ${DEEPGEMM_COMMIT} && \ @@ -135,7 +140,7 @@ RUN git clone https://github.com/zhuzilin/DeepEP.git /root/DeepEP && \ export CPATH="/usr/local/cuda/include/cccl${CPATH:+:$CPATH}"; \ export NVCC_PREPEND_FLAGS="-I/usr/local/cuda/include/cccl ${NVCC_PREPEND_FLAGS:-}"; \ fi && \ - TORCH_CUDA_ARCH_LIST="${CUDA_ARCHS}" MAX_JOBS=64 python setup.py bdist_wheel && \ + TORCH_CUDA_ARCH_LIST="${CUDA_ARCHS}" python setup.py bdist_wheel && \ pip install --force-reinstall --no-deps dist/deep_ep-*.whl && \ cd /root/ && rm -rf DeepEP RUN pip install "nvidia-modelopt[torch]>=0.37.0" --no-build-isolation @@ -150,7 +155,7 @@ RUN if [ "${ENABLE_CUDA_13}" != "1" ]; then \ pip install nvidia-cudnn-cu12==9.16.0.29; \ fi -RUN pip install "scipy==1.17.1" +RUN pip install "scipy==1.17.1" "kernels<0.15.0" RUN rm -rf /root/.cache/pip /root/flash-attention @@ -218,4 +223,7 @@ RUN cd /root/vime/vime/backends/megatron_utils/kernels/int4_qat && \ # image is a plain bash/ray environment. Without this, `docker run ... bash -c ...` # and `ray job submit` append to `vllm serve` and break. ENTRYPOINT [] + +RUN CUDA_MAJOR="$(python -c 'import torch; print(torch.version.cuda.split(".")[0])')" && \ + pip install --no-deps --force-reinstall "nvidia-nccl-cu${CUDA_MAJOR}==${NCCL_VERSION}" CMD ["/bin/bash"] diff --git a/docker/patch/latest/megatron.patch b/docker/patch/latest/megatron.patch index 883c80499..35ec53d83 100644 --- a/docker/patch/latest/megatron.patch +++ b/docker/patch/latest/megatron.patch @@ -1,21 +1,21 @@ diff --git a/megatron/core/dist_checkpointing/strategies/common.py b/megatron/core/dist_checkpointing/strategies/common.py -index 41c21d93d..ef80f72d6 100644 +index 3e9685e2b..cffd9af2a 100644 --- a/megatron/core/dist_checkpointing/strategies/common.py +++ b/megatron/core/dist_checkpointing/strategies/common.py -@@ -86,7 +86,7 @@ class TorchCommonLoadStrategy(LoadCommonStrategy): - msc = MultiStorageClientFeature.import_package() - return msc.torch.load(load_path, map_location='cpu') - else: -- return torch.load(load_path, map_location='cpu') -+ return torch.load(load_path, map_location='cpu', weights_only=False) - except FileNotFoundError as e: - err_msg = f'Common file {load_path} does not exist' - if MultiStorageClientFeature.is_enabled(): +@@ -45,7 +45,7 @@ def load_common(checkpoint_dir: str): + + load_path = os.path.join(checkpoint_dir, COMMON_STATE_FNAME) + try: +- return maybe_msc.torch.load(load_path, map_location='cpu') ++ return maybe_msc.torch.load(load_path, map_location='cpu', weights_only=False) + except FileNotFoundError as e: + err_msg = f'Common file {load_path} does not exist' + ckpt_files = [f.name for f in maybe_msc.Path(checkpoint_dir).iterdir()] diff --git a/megatron/core/dist_checkpointing/strategies/torch.py b/megatron/core/dist_checkpointing/strategies/torch.py -index a5b6c009b..22794d7e6 100644 +index d8487c4bb..c9fc61838 100644 --- a/megatron/core/dist_checkpointing/strategies/torch.py +++ b/megatron/core/dist_checkpointing/strategies/torch.py -@@ -503,10 +503,12 @@ class MCoreLoadPlanner(DefaultLoadPlanner): +@@ -519,10 +519,8 @@ class MCoreLoadPlanner(DefaultLoadPlanner): def _validate_global_shapes(self, metadata, sharded_tensors): for sh_ten in sharded_tensors: if sh_ten.key not in metadata.state_dict_metadata: @@ -23,270 +23,80 @@ index a5b6c009b..22794d7e6 100644 - f"{sh_ten.key} from model not in state dict:" - f" {sorted(metadata.state_dict_metadata.keys())}" - ) -+ # raise KeyError( -+ # f"{sh_ten.key} from model not in state dict:" -+ # f" {sorted(metadata.state_dict_metadata.keys())}" -+ # ) -+ print(f"{sh_ten.key} from model not in state dict, will skip") ++ logger.warning('%s from model not in state dict, will skip', sh_ten.key) + continue loaded_shape = metadata.state_dict_metadata[sh_ten.key].size expected_shape = sh_ten.global_shape if loaded_shape != expected_shape: -@@ -530,7 +532,7 @@ class MCoreLoadPlanner(DefaultLoadPlanner): - tensor_metadata = self.metadata.state_dict_metadata +@@ -547,6 +545,7 @@ class MCoreLoadPlanner(DefaultLoadPlanner): metadata_with_sizes = [ (tensor_metadata[key], tensor_metadata[key].size, sharded_tensor) -- for key, sharded_tensor in self.allow_shape_mismatch_sharded_tensors.items() -+ for key, sharded_tensor in self.allow_shape_mismatch_sharded_tensors.items() if key in tensor_metadata + for key, sharded_tensor in self.allow_shape_mismatch_sharded_tensors.items() ++ if key in tensor_metadata ] try: # Temporarily set sizes to expected shapes -@@ -802,6 +804,7 @@ class TorchDistLoadShardedStrategy(LoadShardedStrategy): - planner=MCoreLoadPlanner( +@@ -983,6 +982,7 @@ class TorchDistLoadShardedStrategy: shapes_validation_sharded_tensors=flexible_shape_sharded_tensors, allow_shape_mismatch_sharded_tensors=allow_shape_mismatch_sharded_tensors, + stream_ckpt_dequant=self.stream_ckpt_dequant, + allow_partial_load=True, + flatten_state_dict=False, + flatten_sharded_tensors=False, ), - ) - -diff --git a/megatron/core/distributed/distributed_data_parallel.py b/megatron/core/distributed/distributed_data_parallel.py -index 55179ff30..6629f41a6 100644 ---- a/megatron/core/distributed/distributed_data_parallel.py -+++ b/megatron/core/distributed/distributed_data_parallel.py -@@ -45,6 +45,8 @@ class DistributedDataParallel(_BaseDataParallel): - module: torch.nn.Module, - disable_bucketing: bool = False, - pg_collection: Optional[ProcessGroupCollection] = None, -+ disable_grad_buffers_cpu_backup: bool = False, -+ disable_param_buffers_cpu_backup: bool = False, - ): - super().__init__(config=config, module=module) - if has_config_logger_enabled(config): -@@ -209,6 +211,8 @@ class DistributedDataParallel(_BaseDataParallel): - param_and_grad_dtype_to_indices[(param_dtype, grad_dtype)], - self.ddp_config.nccl_ub, - pg_collection, -+ disable_grad_buffers_cpu_backup=disable_grad_buffers_cpu_backup, -+ disable_param_buffers_cpu_backup=disable_param_buffers_cpu_backup, - ) - ) - diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py -index 088374fbf..a9982e176 100644 +index 50fa566d1..92e1b3267 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py -@@ -599,6 +599,8 @@ class _ParamAndGradBuffer: - param_indices: List[int], - nccl_ub: bool, - pg_collection: Optional[ProcessGroupCollection] = None, -+ disable_grad_buffers_cpu_backup: bool = False, -+ disable_param_buffers_cpu_backup: bool = False, - ): - - if pg_collection is None: -@@ -629,6 +631,9 @@ class _ParamAndGradBuffer: - self.data_parallel_world_size = self.data_parallel_group.size() - self.gradient_scaling_factor = gradient_scaling_factor - self.nccl_ub = nccl_ub -+ disable_param_buffers_cpu_backup = ( -+ disable_param_buffers_cpu_backup and self.ddp_config.use_distributed_optimizer -+ ) - - # Data structures to store underlying buckets and relevant indexing data. - self.buckets = [] -@@ -755,6 +760,12 @@ class _ParamAndGradBuffer: - - if self.nccl_ub: - # If nccl_ub is True, use nccl_allocator to allocate memory for param_data/grad_data. -+ assert not disable_grad_buffers_cpu_backup, ( -+ "disable_grad_buffers_cpu_backup is not supported with nccl_ub=True" -+ ) -+ assert not disable_param_buffers_cpu_backup, ( -+ "disable_param_buffers_cpu_backup is not supported with nccl_ub=True" -+ ) - nccl_allocator.init() - pool = nccl_allocator.create_nccl_mem_pool( - symmetric=not self.ddp_config.disable_symmetric_registration -@@ -773,19 +784,48 @@ class _ParamAndGradBuffer: - torch.distributed.barrier() - else: - # If nccl_ub is False, mem_alloc_context is nullcontext. -+ # Individual param/grad contexts below handle TMS regions separately. - mem_alloc_context = nullcontext - -+ def _make_no_backup_context(tag, disable, flag_name="disable_grad_buffers_cpu_backup"): -+ if disable: -+ try: -+ from torch_memory_saver import torch_memory_saver -+ except ImportError as e: -+ raise ImportError( -+ f"{flag_name}=True requires torch_memory_saver. " -+ "Install with: pip install torch-memory-saver" -+ ) from e -+ return partial( -+ torch_memory_saver.region, -+ tag=tag, -+ enable_cpu_backup=False, -+ ) -+ return nullcontext -+ grad_mem_alloc_context = _make_no_backup_context( -+ "grad_buffer", disable_grad_buffers_cpu_backup -+ ) -+ param_mem_alloc_context = _make_no_backup_context( -+ "param_buffer", disable_param_buffers_cpu_backup, "disable_param_buffers_cpu_backup" -+ ) -+ - with mem_alloc_context(): - # For MXFP8 param: Create a shared buffer for param AG and grad RS for memory efficiency - # The buffer is mapped to weight gradients whose dtype is either bf16 or FP32. - # It can be temporarily reused by param AG. - if self.ddp_config.use_distributed_optimizer and any(is_mxfp8tensor(p) for p in params): +@@ -1156,12 +1156,7 @@ class _ParamAndGradBuffer: + if self.ddp_config.use_distributed_optimizer and any( + is_mxfp8tensor(p) or is_grouped_mxfp8tensor(p) for p in self.params + ): - self.shared_buffer = torch.zeros( - self.numel, - dtype=self.grad_dtype, - device=torch.cuda.current_device(), - requires_grad=False, -+ shared_mem_alloc_context = ( -+ param_mem_alloc_context -+ if disable_param_buffers_cpu_backup -+ else grad_mem_alloc_context - ) -+ with shared_mem_alloc_context(): -+ self.shared_buffer = torch.zeros( -+ self.numel, -+ dtype=self.grad_dtype, -+ device=torch.cuda.current_device(), -+ requires_grad=False, -+ ) +- ) ++ self.shared_buffer = self._allocate_buffer("shared", self.numel, self.grad_dtype) # For FP32 weight grads, only half of the buffer is used to store params in bf16. if self.grad_dtype == torch.float32: self.param_data = self.shared_buffer[: math.ceil(self.numel / 2)].view( -@@ -797,18 +837,20 @@ class _ParamAndGradBuffer: - else: +@@ -1174,18 +1169,8 @@ class _ParamAndGradBuffer: # Only re-map param tensors if using distributed optimizer. if self.ddp_config.use_distributed_optimizer: + numel = self.nvfp4_packed_numel if self.has_nvfp4_params else self.numel - self.param_data = torch.zeros( -+ with param_mem_alloc_context(): -+ self.param_data = torch.zeros( -+ self.numel, -+ dtype=self.param_dtype, -+ device=torch.cuda.current_device(), -+ requires_grad=False, -+ ) -+ with grad_mem_alloc_context(): -+ self.grad_data = torch.zeros( - self.numel, +- numel, - dtype=self.param_dtype, -+ dtype=self.grad_dtype, - device=torch.cuda.current_device(), - requires_grad=False, - ) +- device=torch.cuda.current_device(), +- requires_grad=False, +- ) - self.grad_data = torch.zeros( - self.numel, - dtype=self.grad_dtype, - device=torch.cuda.current_device(), - requires_grad=False, - ) ++ self.param_data = self._allocate_buffer("param", numel, self.param_dtype) ++ self.grad_data = self._allocate_buffer("grad", self.numel, self.grad_dtype) self.grad_data_size = 0 self.param_data_size = 0 -diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py -index ef8527e9e..57fbe5bd7 100644 ---- a/megatron/core/extensions/transformer_engine.py -+++ b/megatron/core/extensions/transformer_engine.py -@@ -639,6 +639,7 @@ class TELinear(te.pytorch.Linear): - self.te_quant_params: Optional[TEQuantizationParams] = None - - for param in self.parameters(): -+ setattr(param, "parallel_mode", parallel_mode) - if is_expert: - # Reduce the gradient on the expert_data_parallel group for expert linear layers - setattr(param, "allreduce", not self.expert_parallel) -@@ -1455,6 +1456,61 @@ class TEDotProductAttention(te.pytorch.DotProductAttention): - +@@ -1428,6 +1413,11 @@ class _ParamAndGradBuffer: + dp_cp_group=self.dp_cp_group, + ) - if HAVE_TE and is_te_min_version("1.9.0.dev0"): -+ def ceil_div(x: int, y: int) -> int: -+ return (x + y - 1) // y -+ -+ class _FakeInt4QuantizationSTE(torch.autograd.Function): -+ @staticmethod -+ def forward(ctx, x, group_size): -+ m, n = x.shape -+ block_size_m, block_size_n = 1, group_size -+ -+ -+ m_padded = ceil_div(m, block_size_m) * block_size_m -+ n_padded = ceil_div(n, block_size_n) * block_size_n -+ -+ x_padded = torch.zeros( -+ (m_padded, n_padded), -+ dtype=x.dtype, device=x.device -+ ) -+ x_padded[:m, :n] = x -+ -+ x_view = x_padded.view( -+ m_padded // block_size_m, -+ block_size_m, -+ n_padded // block_size_n, -+ block_size_n -+ ) -+ -+ x_max = x_view.abs().float().amax(dim=(1, 3), keepdim=True) -+ q_max = 7 -+ x_scale = x_max / q_max -+ -+ x_scale = x_scale.clamp(min=1e-5) -+ -+ x_div = x_view / x_scale -+ x_round = torch.round(x_div) -+ -+ x_q_clamped = x_round.clamp(-q_max, q_max) -+ -+ x_dequant_view = x_q_clamped * x_scale -+ -+ x_dequant_full = x_dequant_view.view_as(x_padded) -+ x_out = x_dequant_full[:m, :n].contiguous().to(x.dtype) -+ -+ return x_out -+ -+ @staticmethod -+ def backward(ctx, grad_output): -+ return grad_output, None -+ -+ def fake_int4_quantization_ste(x, group_size): -+ x_out = _FakeInt4QuantizationSTE.apply(x, group_size) -+ -+ if hasattr(x, 'main_grad'): -+ x_out.main_grad = x.main_grad ++ def _allocate_buffer(self, buffer_type, numel, dtype): ++ return torch.zeros( ++ numel, dtype=dtype, device=torch.cuda.current_device(), requires_grad=False ++ ) + -+ return x_out - - class TEGroupedLinear(te.pytorch.GroupedLinear): - """ -@@ -1671,6 +1727,20 @@ if HAVE_TE and is_te_min_version("1.9.0.dev0"): - return out - return out, None + def _compute_nvfp4_packed_layout(self, params_with_names): + """Derive packed NVFP4 index map and bucket indices from the primary layout. -+ def _get_weight_tensors(self): -+ """Get the weight tensors of the module.""" -+ weight_tensors = super()._get_weight_tensors() -+ -+ if os.getenv("OPEN_TRAINING_INT4_FAKE_QAT_FLAG", "0") == "1": -+ group_size = int(os.getenv("OPEN_TRAINING_INT4_GROUP_SIZE", "128")) -+ -+ weight_tensors = [ -+ fake_int4_quantization_ste(w, group_size) -+ for w in weight_tensors -+ ] -+ -+ return weight_tensors -+ - def _encode_extra_state(self, state): - # TE 2.0 changed the format of extra_state to be a byte tensor - if is_te_min_version("2.0.0"): diff --git a/megatron/core/fusions/fused_mla_yarn_rope_apply.py b/megatron/core/fusions/fused_mla_yarn_rope_apply.py -index 1fd5dcfae..75e1072d5 100644 +index 1fd5dcfae..5938fc04e 100644 --- a/megatron/core/fusions/fused_mla_yarn_rope_apply.py +++ b/megatron/core/fusions/fused_mla_yarn_rope_apply.py @@ -385,6 +385,7 @@ def rotary_fwd_kv_kernel( @@ -431,6 +241,24 @@ index 1fd5dcfae..75e1072d5 100644 v_dim, nheads, batch_size, +@@ -668,7 +685,7 @@ class ApplyMLARotaryEmbKV(torch.autograd.Function): + ctx.cp_size = cp_size + if cu_seqlens_kv is None: + o_key = o_key.view(max_seqlen, -1, nheads, emb_dim + k_dim) +- o_value = o_value.view(max_seqlen, -1, nheads, v_dim) ++ o_value = o_value.view(max_seqlen, batch_size, nheads, v_dim) + return o_key, o_value + + @staticmethod +@@ -689,7 +706,7 @@ class ApplyMLARotaryEmbKV(torch.autograd.Function): + # sbhd + max_seqlen, batch_size, nheads, _ = dk.shape + dk = dk.contiguous().view(-1, nheads, ctx.emb_dim + ctx.k_dim) +- dv = dv.contiguous().view(-1, nheads, ctx.v_dim) ++ dv = dv.contiguous().view(max_seqlen * batch_size, nheads, ctx.v_dim) + total_seqlen = dk.shape[0] + else: + # thd @@ -700,6 +717,7 @@ class ApplyMLARotaryEmbKV(torch.autograd.Function): d_kv = dk.new_empty(total_seqlen, nheads, ctx.k_dim + ctx.v_dim) @@ -448,201 +276,85 @@ index 1fd5dcfae..75e1072d5 100644 nheads, batch_size, diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py -index 5dc2d5030..2b241c86d 100644 +index 102a5e5e5..d6978a437 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py -@@ -61,8 +61,8 @@ except ImportError: +@@ -67,8 +67,9 @@ except ImportError: + HAVE_FLASHINFER = False + try: - from torch_memory_saver import torch_memory_saver +- from torch_memory_saver import torch_memory_saver ++ from torch_memory_saver import TorchMemorySaver -- torch_memory_saver.hook_mode = "torch" -- HAVE_TORCH_MEMORY_SAVER = True -+ # torch_memory_saver.hook_mode = "torch" -+ HAVE_TORCH_MEMORY_SAVER = False ++ torch_memory_saver = TorchMemorySaver() + torch_memory_saver.hook_mode = "torch" + HAVE_TORCH_MEMORY_SAVER = True except ImportError: - HAVE_TORCH_MEMORY_SAVER = False - -diff --git a/megatron/core/models/common/embeddings/rotary_pos_embedding.py b/megatron/core/models/common/embeddings/rotary_pos_embedding.py -index 05a7e8f60..881cfbcaa 100644 ---- a/megatron/core/models/common/embeddings/rotary_pos_embedding.py -+++ b/megatron/core/models/common/embeddings/rotary_pos_embedding.py -@@ -308,6 +308,7 @@ class MultimodalRotaryEmbedding(nn.Module): - self, - position_ids: torch.Tensor, - mrope_section: List[int], -+ packed_seq: bool = False, - cp_group: Optional[torch.distributed.ProcessGroup] = None, - ) -> Tensor: - """Forward pass of multimodal RoPE embedding. -@@ -352,7 +353,9 @@ class MultimodalRotaryEmbedding(nn.Module): - emb = emb[..., None, :].transpose(0, 1).contiguous() - if cp_group is None: - cp_group = self.cp_group -- if cp_group is not None and cp_group.size() > 1: -+ # For THD (packed sequence) format, skip CP slicing here — it is handled -+ # per-sequence inside _apply_rotary_pos_emb_thd instead (same as RotaryEmbedding). -+ if cp_group is not None and cp_group.size() > 1 and not packed_seq: - # slice rotary_pos_emb along sequence dimension and select the parition of the current - # CP rank - emb = get_pos_emb_on_this_cp_rank(emb, 0, cp_group) -diff --git a/megatron/core/models/gpt/gpt_layer_specs.py b/megatron/core/models/gpt/gpt_layer_specs.py -index 5bb479ad3..a9d3583e5 100755 ---- a/megatron/core/models/gpt/gpt_layer_specs.py -+++ b/megatron/core/models/gpt/gpt_layer_specs.py -@@ -182,6 +182,8 @@ def get_gpt_layer_with_transformer_engine_spec( - fallback_to_eager_attn: bool = False, - use_kitchen_attention: bool = False, - kitchen_attention_backend: str = "sdpa", -+ post_self_attn_layernorm: bool = False, -+ post_mlp_layernorm: bool = False, - ) -> ModuleSpec: - """Use this spec to use lower-level Transformer Engine modules (required for fp8 training). - -@@ -263,9 +265,11 @@ def get_gpt_layer_with_transformer_engine_spec( - ), - ), - self_attn_bda=get_bias_dropout_add, -+ post_self_attn_layernorm=TENorm if post_self_attn_layernorm else IdentityOp, - pre_mlp_layernorm=backend.layer_norm() if num_experts else IdentityOp, - mlp=mlp, - mlp_bda=get_bias_dropout_add, -+ post_mlp_layernorm=TENorm if post_mlp_layernorm else IdentityOp, - ), - ) - else: -@@ -289,9 +293,11 @@ def get_gpt_layer_with_transformer_engine_spec( - ), - ), - self_attn_bda=get_bias_dropout_add, -+ post_self_attn_layernorm=TENorm if post_self_attn_layernorm else IdentityOp, - pre_mlp_layernorm=backend.layer_norm() if num_experts else IdentityOp, - mlp=mlp, - mlp_bda=get_bias_dropout_add, -+ post_mlp_layernorm=TENorm if post_mlp_layernorm else IdentityOp, - sharded_state_dict_keys_map={ - "mlp.0.weight": "mlp.linear_fc1.layer_norm_weight", - "mlp.0.bias": "mlp.linear_fc1.layer_norm_bias", -@@ -537,6 +543,8 @@ def get_gpt_decoder_layer_specs( - qk_l2_norm=qk_l2_norm, - use_kitchen=config.use_kitchen, - use_te_activation_func=config.use_te_activation_func, -+ post_self_attn_layernorm=config.post_self_attn_layernorm, -+ post_mlp_layernorm=config.post_mlp_layernorm, - ) - moe_layer_spec = get_gpt_layer_with_transformer_engine_spec( - num_experts=config.num_moe_experts, -@@ -547,6 +555,8 @@ def get_gpt_decoder_layer_specs( - qk_l2_norm=qk_l2_norm, - use_kitchen=config.use_kitchen, - use_te_activation_func=config.use_te_activation_func, -+ post_self_attn_layernorm=config.post_self_attn_layernorm, -+ post_mlp_layernorm=config.post_mlp_layernorm, - ) - else: - dense_layer_spec = get_gpt_layer_local_spec( diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py -index 5b31ddedf..ead60f2dd 100644 +index ff3514b74..c6bd29fd2 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py -@@ -394,6 +394,8 @@ class GPTModel(LanguageModule): - rotary_pos_emb = self.rotary_pos_emb( - position_ids, - self.mrope_section, -+ packed_seq=packed_seq_params is not None -+ and packed_seq_params.qkv_format == 'thd', - cp_group=packed_seq_params.cp_group if packed_seq_params is not None else None, - ) - else: -@@ -488,6 +490,7 @@ class GPTModel(LanguageModule): - inference_params: Optional[BaseInferenceContext] = None, - loss_mask: Optional[Tensor] = None, +@@ -533,6 +533,7 @@ class GPTModel(LanguageModule): padding_mask: Optional[Tensor] = None, + output_processor: Optional[Callable[..., Tensor]] = None, + output_processor_context: Optional[Any] = None, + mtp_kwargs: Optional[dict] = None, ) -> Tensor: """Forward function of the GPT Model This function passes the input tensors through the embedding layer, and then the decoder and finally into the post -@@ -560,6 +563,7 @@ class GPTModel(LanguageModule): - runtime_gather_output=runtime_gather_output, - extra_block_kwargs=extra_block_kwargs, +@@ -615,6 +616,7 @@ class GPTModel(LanguageModule): inference_context=inference_context, + output_processor=output_processor, + output_processor_context=output_processor_context, + mtp_kwargs=mtp_kwargs, ) def _postprocess( -@@ -581,6 +585,7 @@ class GPTModel(LanguageModule): - runtime_gather_output=None, - extra_block_kwargs=None, +@@ -639,6 +641,7 @@ class GPTModel(LanguageModule): inference_context=None, + output_processor=None, + output_processor_context=None, + mtp_kwargs=None, ): """Postprocesses decoder hidden states to generate logits or compute loss. -@@ -592,10 +597,12 @@ class GPTModel(LanguageModule): - assert runtime_gather_output, "Inference must always gather TP logits" +@@ -660,10 +663,12 @@ class GPTModel(LanguageModule): + ) # logits and loss -+ mtp_kwargs = mtp_kwargs or {} -+ mtp_labels = mtp_kwargs.get('mtp_labels') ++ mtp_labels = (mtp_kwargs or {}).get("mtp_labels") ++ compute_mtp_loss = labels is not None or mtp_labels is not None output_weight = None if self.share_embeddings_and_output_weights: output_weight = self.shared_embedding_or_output_weight() -- if mtp_in_postprocess: -+ if mtp_in_postprocess and mtp_labels is not None: +- if mtp_in_postprocess and not (in_inference_mode or is_spec_decode): ++ if mtp_in_postprocess and compute_mtp_loss and not (in_inference_mode or is_spec_decode): hidden_states = self.mtp( input_ids=input_ids, position_ids=position_ids, -@@ -614,13 +621,35 @@ class GPTModel(LanguageModule): - if not self.post_process: - return hidden_states - -- if self.config.mtp_num_layers is not None: -- mtp_labels = labels.clone() -+ if self.config.mtp_num_layers and mtp_labels is not None: -+ mtp_labels = mtp_labels.clone() -+ mtp_labels, _ = roll_tensor( -+ mtp_labels, -+ shifts=-1, -+ dims=-1, -+ cp_group=self.cp_group, -+ packed_seq_params=packed_seq_params, -+ ) - hidden_states_list = torch.chunk(hidden_states, 1 + self.config.mtp_num_layers, dim=0) - hidden_states = hidden_states_list[0] - if loss_mask is None: - # if loss_mask is not provided, use all ones as loss_mask - loss_mask = torch.ones_like(mtp_labels) -+ else: -+ loss_mask, _ = roll_tensor( -+ loss_mask, -+ shifts=-1, -+ dims=-1, -+ cp_group=self.cp_group, -+ packed_seq_params=packed_seq_params, -+ ) -+ -+ mtp_output_weight = output_weight -+ if mtp_output_weight is None and self.output_layer.weight is not None: -+ mtp_output_weight = self.output_layer.weight -+ if mtp_output_weight is not None: -+ mtp_output_weight = mtp_output_weight.detach() -+ - for mtp_layer_number in range(self.config.mtp_num_layers): - # Calc loss for the current Multi-Token Prediction (MTP) layers. - mtp_labels, _ = roll_tensor( -@@ -641,7 +670,7 @@ class GPTModel(LanguageModule): - # Compute mtp loss without storing logits to save memory. - output_layer_kwargs = dict( - input_=hidden_states_list[mtp_layer_number + 1], -- weight=output_weight, -+ weight=mtp_output_weight, - runtime_gather_output=runtime_gather_output, +@@ -696,7 +701,7 @@ class GPTModel(LanguageModule): + ) + else: + inference_context.mtp_decoder_hidden_states = hidden_states +- elif not in_inference_mode: ++ elif not in_inference_mode and compute_mtp_loss: + # In training/eval, use the utility function for processing MTP loss/scaling. + hidden_states = process_mtp_loss( + hidden_states=hidden_states, +@@ -712,7 +717,7 @@ class GPTModel(LanguageModule): + tp_group=self.tp_group, + packed_seq_params=packed_seq_params, + scale_logits_fn=self._scale_logits if self.config.use_mup else None, +- input_ids=input_ids, ++ input_ids=mtp_labels, ) - if self.fuse_linear_cross_entropy: + sequence_parallel_override = False + diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py -index a4364f5e9..c76f6daac 100644 +index 782d32635..4de315ed0 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py -@@ -686,6 +686,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): +@@ -877,6 +877,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): # TE FusedAdam will not accumulate step for empty param groups, so we need to # align the step across param groups. param_group["step"] = int(step) @@ -651,8 +363,7 @@ index a4364f5e9..c76f6daac 100644 # Grad scaler state. if self.grad_scaler: -@@ -969,7 +971,12 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - for bucket_idx, gbuf_range_map in enumerate(gbuf_range_map_for_all_buckets): +@@ -1247,6 +1249,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): bucket_state = [] for model_param, param_range_map in gbuf_range_map["param_map"].items(): tensors = self._get_main_param_and_optimizer_states(model_param) @@ -664,19 +375,7 @@ index a4364f5e9..c76f6daac 100644 tensors.update( { "gbuf_local_start": param_range_map["gbuf_local"].start, -@@ -1667,6 +1669,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - if key == 'padding': - tensors[key] = LocalNonpersistentObject(tensors[key]) - continue -+ if key == 'step': -+ # The optimizer state of STEP is a 0-dim tensor and is handled -+ # separately via param_groups, not as part of the gradient buffer. -+ tensors[key] = LocalNonpersistentObject(tensors[key]) -+ continue - assert tensors[key].shape == (gbuf_local_end - gbuf_local_start,), ( - tensors[key].shape, - gbuf_local_start, -@@ -1808,6 +1815,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): +@@ -2089,6 +2096,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): for src_tensors, (model_param, param_range_map) in zip( bucket_state, gbuf_range_map["param_map"].items() ): @@ -688,424 +387,90 @@ index a4364f5e9..c76f6daac 100644 # Main param & optimizer states. self._set_main_param_and_optimizer_states(model_param, src_tensors) -diff --git a/megatron/core/parallel_state.py b/megatron/core/parallel_state.py -index 7bb964078..2fe9a8cdc 100644 ---- a/megatron/core/parallel_state.py -+++ b/megatron/core/parallel_state.py -@@ -11,6 +11,7 @@ from typing import Callable, List, Optional - - import numpy as np - import torch -+import torch.distributed as dist - - from .utils import GlobalMemoryBuffer, GlobalSymmetricMemoryBuffer, is_torch_min_version - -diff --git a/megatron/core/pipeline_parallel/p2p_communication.py b/megatron/core/pipeline_parallel/p2p_communication.py -index ac839c21f..f18309217 100644 ---- a/megatron/core/pipeline_parallel/p2p_communication.py -+++ b/megatron/core/pipeline_parallel/p2p_communication.py -@@ -26,22 +26,22 @@ def _batched_p2p_ops( - ops = [] - if tensor_send_prev is not None: - send_prev_op = torch.distributed.P2POp( -- torch.distributed.isend, tensor_send_prev, prev_pipeline_rank, group -+ torch.distributed.isend, tensor_send_prev, prev_pipeline_rank, - ) - ops.append(send_prev_op) - if tensor_recv_prev is not None: - recv_prev_op = torch.distributed.P2POp( -- torch.distributed.irecv, tensor_recv_prev, prev_pipeline_rank, group -+ torch.distributed.irecv, tensor_recv_prev, prev_pipeline_rank, - ) - ops.append(recv_prev_op) - if tensor_send_next is not None: - send_next_op = torch.distributed.P2POp( -- torch.distributed.isend, tensor_send_next, next_pipeline_rank, group -+ torch.distributed.isend, tensor_send_next, next_pipeline_rank, - ) - ops.append(send_next_op) - if tensor_recv_next is not None: - recv_next_op = torch.distributed.P2POp( -- torch.distributed.irecv, tensor_recv_next, next_pipeline_rank, group -+ torch.distributed.irecv, tensor_recv_next, next_pipeline_rank, - ) - ops.append(recv_next_op) - if len(ops) > 0: -diff --git a/megatron/core/transformer/moe/moe_utils.py b/megatron/core/transformer/moe/moe_utils.py -index 75825cd37..445b3fb84 100644 ---- a/megatron/core/transformer/moe/moe_utils.py -+++ b/megatron/core/transformer/moe/moe_utils.py -@@ -711,6 +711,9 @@ def topk_routing_with_score_function( - scores, topk, num_groups, group_topk, _compute_topk - ) - -+ from vime.utils.routing_replay import get_routing_replay_compute_topk -+ compute_topk = get_routing_replay_compute_topk(compute_topk) -+ - if score_function == "softmax": - if use_pre_softmax: - scores = torch.softmax(logits, dim=-1, dtype=torch.float32).type_as(logits) -diff --git a/megatron/core/transformer/moe/router.py b/megatron/core/transformer/moe/router.py -index a2f3e90bd..b6f732561 100644 ---- a/megatron/core/transformer/moe/router.py -+++ b/megatron/core/transformer/moe/router.py -@@ -207,6 +207,9 @@ class TopKRouter(Router): - if self.config.moe_enable_routing_replay: - self.router_replay = RouterReplay() - -+ from vime.utils.routing_replay import register_routing_replay -+ register_routing_replay(self) -+ - def _maintain_float32_expert_bias(self): - """ - Maintain the expert bias in float32. -diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py -index b0476155a..63f81465d 100755 ---- a/megatron/core/transformer/multi_token_prediction.py -+++ b/megatron/core/transformer/multi_token_prediction.py -@@ -709,17 +709,21 @@ class MultiTokenPredictionLayer(MegatronModule): - cp_group=self.cp_group, - packed_seq_params=packed_seq_params, - ) -- position_ids, _ = roll_tensor( -- position_ids, -- shifts=-1, -- dims=-1, -- cp_group=self.cp_group, -- packed_seq_params=packed_seq_params, -- ) -+ if position_ids is not None: -+ position_ids, _ = roll_tensor( -+ position_ids, -+ shifts=-1, -+ dims=-1, -+ cp_group=self.cp_group, -+ packed_seq_params=packed_seq_params, -+ ) - # embedding - decoder_input = embedding(input_ids=input_ids, position_ids=position_ids) -+ decoder_input = decoder_input.detach() - -- hidden_states = make_viewless_tensor(inp=hidden_states, requires_grad=True, keep_graph=True) -+ hidden_states = make_viewless_tensor( -+ inp=hidden_states, requires_grad=True, keep_graph=False -+ ) - - return input_ids, position_ids, decoder_input, hidden_states - -@@ -821,22 +825,60 @@ class MultiTokenPredictionLayer(MegatronModule): - return hidden_states - - def _checkpointed_forward(self, forward_func, *args, **kwargs): -+ """Wrap forward_func with activation checkpointing while only passing tensors.""" -+ -+ positional_specs = [] -+ keyword_specs = [] -+ tensor_args: List[torch.Tensor] = [] -+ -+ for arg in args: -+ if torch.is_tensor(arg): -+ positional_specs.append(('tensor', len(tensor_args))) -+ tensor_args.append(arg) -+ else: -+ positional_specs.append(('const', arg)) -+ -+ for key, value in kwargs.items(): -+ if torch.is_tensor(value): -+ keyword_specs.append((key, ('tensor', len(tensor_args)))) -+ tensor_args.append(value) -+ else: -+ keyword_specs.append((key, ('const', value))) -+ -+ def run(*flat_tensor_args): -+ rebuilt_args = [] -+ for spec_type, payload in positional_specs: -+ if spec_type == 'tensor': -+ rebuilt_args.append(flat_tensor_args[payload]) -+ else: -+ rebuilt_args.append(payload) -+ -+ rebuilt_kwargs = {} -+ for key, (spec_type, payload) in keyword_specs: -+ if spec_type == 'tensor': -+ rebuilt_kwargs[key] = flat_tensor_args[payload] -+ else: -+ rebuilt_kwargs[key] = payload -+ -+ return forward_func(*rebuilt_args, **rebuilt_kwargs) -+ -+ tensor_args_tuple = tuple(tensor_args) -+ - def checkpoint_handler(): - """Determines whether to use the `te_checkpoint` or `tensor_parallel.checkpoint`""" - if self.config.fp8: - from megatron.core.extensions.transformer_engine import te_checkpoint - - return te_checkpoint( -- forward_func, -+ run, - self.config.distribute_saved_activations, - tensor_parallel.random.get_cuda_rng_tracker, - parallel_state.get_tensor_model_parallel_group(), -- *args, -- **kwargs, -+ *tensor_args_tuple, - ) - else: - return tensor_parallel.checkpoint( -- forward_func, self.config.distribute_saved_activations, *args, *kwargs.values() -+ run, self.config.distribute_saved_activations, *tensor_args_tuple - ) - - if self.config.recompute_method == 'uniform': -diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py -index dce438520..de51edaf3 100644 ---- a/megatron/core/transformer/transformer_config.py -+++ b/megatron/core/transformer/transformer_config.py -@@ -229,6 +229,9 @@ class TransformerConfig(ModelParallelConfig): - attention_output_gate: bool = False - """Whether to apply output gate to the attention layers.""" - -+ post_self_attn_layernorm: bool = False -+ post_mlp_layernorm: bool = False -+ - test_mode: bool = False - """Whether to run real-time tests.""" - -diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py -index 12c248684..227e95862 100644 ---- a/megatron/core/transformer/transformer_layer.py -+++ b/megatron/core/transformer/transformer_layer.py -@@ -224,6 +224,7 @@ class TransformerLayerSubmodules: - input_layernorm: Union[ModuleSpec, type] = IdentityOp - self_attention: Union[ModuleSpec, type] = IdentityOp - self_attn_bda: Union[ModuleSpec, type] = IdentityFuncOp -+ post_self_attn_layernorm: Union[ModuleSpec, type] = IdentityOp - - pre_cross_attn_layernorm: Union[ModuleSpec, type] = IdentityOp - cross_attention: Union[ModuleSpec, type] = IdentityOp -@@ -232,6 +233,7 @@ class TransformerLayerSubmodules: - pre_mlp_layernorm: Union[ModuleSpec, type] = IdentityOp - mlp: Union[ModuleSpec, type] = IdentityOp - mlp_bda: Union[ModuleSpec, type] = IdentityFuncOp -+ post_mlp_layernorm: Union[ModuleSpec, type] = IdentityOp - - # Mapping for sharded tensor keys to be applied in `sharded_state_dict` method - sharded_state_dict_keys_map: Dict[str, str] = field(default_factory=dict) -@@ -311,6 +313,13 @@ class TransformerLayer(GraphableMegatronModule, BaseTransformerLayer): - # [Module 3: BiasDropoutFusion] - self.self_attn_bda = build_module(submodules.self_attn_bda) - -+ self.post_self_attn_layernorm = build_module( -+ submodules.post_self_attn_layernorm, -+ config=self.config, -+ hidden_size=self.config.hidden_size, -+ eps=self.config.layernorm_epsilon, -+ ) -+ - # [Module 4: Post SelfAttention] Optional Layernorm after self-attn - self.pre_cross_attn_layernorm = build_module( - submodules.pre_cross_attn_layernorm, -@@ -376,6 +385,13 @@ class TransformerLayer(GraphableMegatronModule, BaseTransformerLayer): - - self.is_moe_layer = isinstance(self.mlp, MoELayer) - -+ self.post_mlp_layernorm = build_module( -+ submodules.post_mlp_layernorm, -+ config=self.config, -+ hidden_size=self.config.hidden_size, -+ eps=self.config.layernorm_epsilon -+ ) -+ - self.recompute_input_layernorm = False - self.recompute_pre_mlp_layernorm = False - self.recompute_mlp = False -@@ -615,6 +631,10 @@ class TransformerLayer(GraphableMegatronModule, BaseTransformerLayer): - attention_output_with_bias[0] - ) - -+ attention_output, attention_output_bias = attention_output_with_bias -+ attention_output = self.post_self_attn_layernorm(attention_output) -+ attention_output_with_bias = (attention_output, attention_output_bias) -+ - # TODO: could we move `bias_dropout_add_exec_handler` itself - # inside the module provided in the `bias_dropout_add_spec` module? - nvtx_range_push(suffix="self_attn_bda") -@@ -794,6 +814,10 @@ class TransformerLayer(GraphableMegatronModule, BaseTransformerLayer): - self.config.inference_fuse_tp_communication - ) - -+ mlp_output, mlp_output_bias = mlp_output_with_bias -+ mlp_output = self.post_mlp_layernorm(mlp_output) -+ mlp_output_with_bias = (mlp_output, mlp_output_bias) -+ - if self.recompute_pre_mlp_layernorm: - # discard the output of the pre-mlp layernorm and register the recompute - # as a gradient hook of mlp_output_with_bias[0] -diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py -index 1af066a82..8c7acadb6 100644 ---- a/megatron/training/arguments.py -+++ b/megatron/training/arguments.py -@@ -1388,6 +1388,9 @@ def core_transformer_config_from_args(args, config_class=None): - - kw_args['inference_sampling_seed'] = args.seed - -+ kw_args['post_self_attn_layernorm'] = args.post_self_attn_layernorm -+ kw_args['post_mlp_layernorm'] = args.post_mlp_layernorm -+ - # handle quantization config - # NOTE: Kitchen arguments are only added to the namespace when - # Kitchen library is available. -@@ -1701,6 +1704,8 @@ def _add_network_size_args(parser): - group.add_argument('--make-vocab-size-divisible-by', type=int, default=128, - help='Pad the vocab size to be divisible by this value.' - 'This is added for computational efficieny reasons.') -+ group.add_argument('--use-gated-attention', action='store_true', -+ help='If set, use gated attention as in Qwen3Next') - group.add_argument('--openai-gelu', action='store_true', - help='Use OpenAIs GeLU implementation. This option' - 'should not be used unless for backward compatibility' -diff --git a/megatron/training/tokenizer/tokenizer.py b/megatron/training/tokenizer/tokenizer.py -index 17df57dda..260a5f6c8 100644 ---- a/megatron/training/tokenizer/tokenizer.py -+++ b/megatron/training/tokenizer/tokenizer.py -@@ -136,7 +136,7 @@ class _HuggingFaceTokenizer(MegatronLegacyTokenizer): - # TODO(bnorick): download tokenizer once to lustre and use force offline to make sure all tasks read it from there - self._tokenizer = transformers.AutoTokenizer.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, -- trust_remote_code=trust_remote_code, -+ trust_remote_code=True, - **kwargs, - ) - self._vocab = self._tokenizer.get_vocab() -diff --git a/megatron/training/training.py b/megatron/training/training.py -index e9736ac08..6567ed426 100644 ---- a/megatron/training/training.py -+++ b/megatron/training/training.py -@@ -1327,6 +1327,14 @@ def get_model(model_provider_func, model_type=ModelType.encoder_or_decoder, wrap - # Wait for the default stream to complete before starting ddp_stream - ddp_stream.wait_stream(torch.cuda.current_stream()) - # Make ddp_stream start after whatever the default stream already queued -+ dp_extra_kwargs = {} -+ if DP is DDP: -+ dp_extra_kwargs['disable_grad_buffers_cpu_backup'] = getattr( -+ args, 'disable_grad_buffers_cpu_backup', False -+ ) -+ dp_extra_kwargs['disable_param_buffers_cpu_backup'] = getattr( -+ args, 'disable_param_buffers_cpu_backup', False -+ ) - with torch.cuda.stream(ddp_stream): - model = [ - DP( -@@ -1337,6 +1345,7 @@ def get_model(model_provider_func, model_type=ModelType.encoder_or_decoder, wrap - # model chunks is overlapped with compute anyway. - disable_bucketing=(model_chunk_idx > 0) - or args.overlap_param_gather_with_optimizer_step, -+ **dp_extra_kwargs, - ) - for (model_chunk_idx, model_chunk) in enumerate(model) - ] +diff --git a/megatron/training/argument_utils.py b/megatron/training/argument_utils.py +index 70f26c64d..6b35b88c8 100644 +--- a/megatron/training/argument_utils.py ++++ b/megatron/training/argument_utils.py +@@ -166,7 +166,7 @@ class ArgumentGroupFactory: + argparse_kwargs = {} + argparse_kwargs["arg_names"] = [self._format_arg_name(attribute.name)] + argparse_kwargs["dest"] = attribute.name +- argparse_kwargs["help"] = self.field_docstrings[attribute.name] if attribute.name in self.field_docstrings else "" ++ argparse_kwargs["help"] = self.field_docstrings.get(attribute.name, "").replace("%", "%%") + + # dataclasses specifies that both should not be set + if isinstance(attribute.default, type(dataclasses.MISSING)): diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py -index 7c87eca191a525915502a1deb7622f03990c89de..bdb62df91b9ca2fe6e13d166a21f38e1b1e90e64 100644 +index eee57040d..3503d96bf 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py -@@ -212,8 +212,8 @@ +@@ -252,8 +252,8 @@ def get_load_checkpoint_path_by_args(args, load_arg='load'): iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. -- if getattr(args, "ckpt_step", None): +- if getattr(args, 'ckpt_step', None): - iteration = args.ckpt_step + if getattr(args, "ckpt_step", None) is not None: + iteration, release = args.ckpt_step, False return get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) -@@ -1185,14 +1185,14 @@ +@@ -1650,8 +1650,10 @@ def _load_base_checkpoint( + if args.non_persistent_global_ckpt_dir or load_dir is None + else os.path.join(load_dir, _NON_PERSISTENT_CKPT_SUBDIR) + ) +- non_persistent_iteration = _get_non_persistent_iteration( +- non_persistent_global_dir, args, checkpointing_context ++ non_persistent_iteration = ( ++ _get_non_persistent_iteration(non_persistent_global_dir, args, checkpointing_context) ++ if getattr(args, "ckpt_step", None) is None ++ else -1 + ) + iteration, release = -1, False + tracker_filename = 'because load directory is not defined' +@@ -1661,8 +1663,8 @@ def _load_base_checkpoint( iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. -- if getattr(args, "ckpt_step", None): +- if getattr(args, 'ckpt_step', None): - iteration = args.ckpt_step + if getattr(args, "ckpt_step", None) is not None: + iteration, release = args.ckpt_step, False # Record the iteration loaded (stored separately from args to avoid # polluting checkpoints, since args is saved in checkpoints). - set_loaded_iteration(iteration) - -- if non_persistent_iteration != -1: # there is a non-persistent checkpoint -+ if non_persistent_iteration != -1 and getattr(args, "ckpt_step", None) is None: - if non_persistent_iteration >= iteration: - return _load_non_persistent_base_checkpoint( - non_persistent_global_dir, -diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py -index dd8590a79..2a6d8fbc1 100644 ---- a/megatron/core/optimizer/distrib_optimizer.py -+++ b/megatron/core/optimizer/distrib_optimizer.py -@@ -641,6 +641,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - """ - inner_state_dict = self.optimizer.state_dict() - state_dict = {} -+ if self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8: -+ state_dict["master_param_format"] = "fp32" - - # Extract 'step', for non-Apex/TE support. - if not HAVE_APEX_OR_TE: -@@ -733,6 +735,13 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - self.optimizer.load_state_dict(state_dict) - return - -+ if ( -+ self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8 -+ and getattr(self.optimizer, "store_param_remainders", False) -+ and state_dict.get("master_param_format") != "fp32" -+ ): -+ raise ValueError("Checkpoint master parameter representation is not identified as FP32.") -+ - if len(self.optimizer.state) == 0: - if isinstance(self.optimizer, HybridDeviceOptimizer): - self.optimizer.dummy_step() -@@ -887,6 +896,9 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - self.load_parameter_state_from_fs_model_space(param_state) - else: - raise NotImplementedError(f'Unknown sharding_type: {sharding_type}') -+ if getattr(self.optimizer, "store_param_remainders", False): -+ for model_chunk in self.model_chunks: -+ model_chunk.start_param_sync(force_sync=True) +diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py +--- a/megatron/core/transformer/attention.py ++++ b/megatron/core/transformer/attention.py +@@ -91,7 +91,6 @@ try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _get_dist_version + +- from flash_attn.cute import flash_attn_varlen_func as flash_attn4_varlen_func + from packaging.version import Version as _Version + + try: +@@ -99,6 +98,8 @@ try: + HAVE_FA4 = _Version(_get_dist_version("flash-attn-4")) >= _Version(_MIN_FA4_VERSION) + except PackageNotFoundError: + HAVE_FA4 = False ++ if HAVE_FA4: ++ from flash_attn.cute import flash_attn_varlen_func as flash_attn4_varlen_func + except ImportError: + HAVE_FA4 = False - def _get_main_param_and_optimizer_states(self, model_param): - """Return a dict containing the main param and optimizer states corresponding to the input -@@ -910,6 +922,11 @@ class DistributedOptimizer(MixedPrecisionOptimizer): +diff --git a/megatron/training/training.py b/megatron/training/training.py +index 57b8efe93..e7376a7f8 100644 +--- a/megatron/training/training.py ++++ b/megatron/training/training.py +@@ -222,8 +222,9 @@ except ImportError: + HAVE_FSDP2 = False - tensors[k] = self.optimizer.get_unscaled_state(sharded_model_param, k) - tensors["param"] = tensors.pop("master_param") -+ if tensors["param"].dtype == torch.int16: -+ remainder = tensors["param"] -+ high = sharded_model_param.view(torch.int16).to(torch.int32) -+ high = high - (remainder < 0).to(torch.int32) -+ tensors["param"] = ((high << 16) | (remainder.to(torch.int32) & 0xFFFF)).view(torch.float32) - else: - main_param = self.optimizer.param_groups[group_index]["params"][group_order] - optim_state = self.optimizer.state[main_param] -@@ -937,6 +954,12 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - continue + try: +- from torch_memory_saver import torch_memory_saver ++ from torch_memory_saver import TorchMemorySaver - if k == "param": -+ if self.optimizer.store_param_remainders and sharded_model_param.dtype == torch.bfloat16: -+ assert v.dtype == torch.float32 -+ bits = v.contiguous().view(torch.int32) -+ v = bits.to(torch.int16) -+ high = ((bits >> 16) + (v < 0).to(torch.int32)).to(torch.int16) -+ sharded_model_param.data.copy_(high.view(torch.bfloat16)) - self.optimizer.set_scaled_state(sharded_model_param, "master_param", v) - else: - self.optimizer.set_scaled_state(sharded_model_param, k, v) -@@ -2276,6 +2299,8 @@ class DistributedOptimizer(MixedPrecisionOptimizer): - """ - if self.is_stub_optimizer: - return -+ if getattr(self.optimizer, "store_param_remainders", False): -+ raise ValueError("Master remainders require a torch_dist checkpoint with representation metadata.") - state_dict = None - if self.data_parallel_group.rank() == 0: - state_dict = torch.load(filename) ++ torch_memory_saver = TorchMemorySaver() + torch_memory_saver.hook_mode = "torch" + HAVE_TORCH_MEMORY_SAVER = True + except ImportError: diff --git a/docker/version.txt b/docker/version.txt index bb25b0b56..a7225e5e9 100644 --- a/docker/version.txt +++ b/docker/version.txt @@ -1 +1 @@ -nightly-dev-20260930a +nightly-dev-20261007a diff --git a/docs/README.md b/docs/README.md index 781a5c262..08b978a0d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,31 +1,51 @@ -# vime Documentation +# Building the Documentation -We recommend new contributors start from writing documentation, which helps you quickly understand vime codebase. -Most documentation files are located under the `docs/` folder. +Documentation lives in `docs/en/` and `docs/zh/`. Update both languages when changing shared behavior, and add new pages to each language's `index.rst`. -## Docs Workflow +Example READMEs are copied into the documentation during the build. Chinese builds prefer `README_zh.md` and fall back to `README.md`. Edit the original files in `examples/`; `_examples_synced/` is generated and ignored by Git. -### Install Dependency +## Install Dependencies + +Run from the repository root: ```bash -apt-get update && apt-get install -y pandoc parallel retry -pip install -r requirements.txt +sudo apt-get update +sudo apt-get install -y pandoc +pip install -r docs/requirements.txt ``` -### Update Documentation +## Build and Preview + +Build both languages and serve them from a common landing page: + +```bash +bash docs/build_all.sh +bash docs/serve.sh all +``` -You can update the documentation in the en and zh folders by adding Markdown or Jupyter Notebook files to the appropriate subdirectories. If you create new files, make sure to update index.rst (or any other relevant .rst files) accordingly. +Open `http://localhost:8000` and select a language. Set `PORT` to change the preview port. -## Build and Render +To build and serve one language: ```bash -# build english version -bash ./build.sh en -bash ./serve.sh en +bash docs/build.sh en +bash docs/serve.sh en +# Use zh for Chinese. +``` -# build chinese version -bash ./build.sh zh -bash ./serve.sh zh +For strict validation, pass Sphinx options through the build script: + +```bash +bash docs/build.sh en -W --keep-going +bash docs/build.sh zh -W --keep-going ``` -You can then visit `http://localhost:8000` to view the documentation. \ No newline at end of file +The documentation workflow publishes English at the site root and Chinese under `zh/`. Local builds made by `build_all.sh` use `en/` and `zh/`; the language toggle supports both layouts. + +--- + +文档源文件位于 `docs/en/` 和 `docs/zh/`。修改共同功能时,请同步更新两种语言,并将新页面加入各自的 `index.rst`。 + +构建时会复制 `examples/` 中的 README。中文构建优先使用 `README_zh.md`,缺少时使用英文 README。请编辑原始文件,不要修改自动生成的 `_examples_synced/`。 + +在仓库根目录安装依赖后,运行 `bash docs/build_all.sh` 和 `bash docs/serve.sh all`,即可在 `http://localhost:8000` 预览两种语言。单独构建中文可使用 `bash docs/build.sh zh`;严格检查可追加 `-W --keep-going`。 diff --git a/docs/_static/js/lang-toggle.js b/docs/_static/js/lang-toggle.js index 7dcc13301..52045ec4e 100644 --- a/docs/_static/js/lang-toggle.js +++ b/docs/_static/js/lang-toggle.js @@ -1,8 +1,10 @@ // Inject a language toggle button into the topbar (sphinx-book-theme compatible) (function(){ const STORAGE_KEY = 'vime-doc-lang'; - const ZH_SEGMENT = 'zh-cn'; - // Default language EN has no URL prefix; Chinese uses 'zh-cn' inserted after optional repo root. + const readTheDocs = window.location.host.endsWith('readthedocs.io'); + const ZH_SEGMENT = readTheDocs ? 'zh-cn' : 'zh'; + const prefixedLayout = document.currentScript?.dataset.layout === 'prefix' || readTheDocs; + // Default language EN has no URL prefix; Chinese follows the deployment's language segment. function detectCurrent(){ const { langIndex, parts } = analyzePath(); if(langIndex === -1) return 'en'; @@ -15,7 +17,7 @@ * /en/… (language as first segment) * /vime/en/… (GitHub Pages project site repo root, language second) * /projects/vime/en/… (Read the Docs project prefix, language third) - * /vime/ (no lang yet) -> insert /vime/zh-cn/ + * /vime/ (no lang yet) -> insert /vime/zh/ * /projects/vime/ (no lang yet) -> insert /projects/vime/zh-cn/ * / (no lang) -> insert /zh/ */ @@ -25,7 +27,7 @@ let repoRootLen = 0; if(parts[0] === 'projects' && parts[1] === 'vime'){ repoRootLen = 2; - } else if(parts.length > 0 && (window.location.host.endsWith('github.io') || parts[0] === 'vime')){ + } else if(parts[0] === 'vime'){ repoRootLen = 1; } let langIndex = -1; @@ -45,12 +47,10 @@ if(langIndex !== -1){ parts.splice(langIndex, 1); } - if(repoRootLen > 0){ + if(prefixedLayout){ parts.splice(repoRootLen, 0, target === 'zh' ? ZH_SEGMENT : 'en'); } else if(target === 'zh'){ - parts.unshift(ZH_SEGMENT); - } else if(parts[0] !== 'en'){ - parts.unshift('en'); + parts.splice(repoRootLen, 0, ZH_SEGMENT); } let newPath = '/' + parts.join('/'); if(newPath === '/') { diff --git a/docs/build.sh b/docs/build.sh index 64eb675fc..4e5bbd510 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash +set -euo pipefail -SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -LANG=$1 +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DOC_LANG="${1:-}" -# make sure language is only en or zh -if [ "$LANG" != "en" ] && [ "$LANG" != "zh" ]; then - echo "Language must be en or zh" +if [ "$DOC_LANG" != "en" ] && [ "$DOC_LANG" != "zh" ]; then + echo "Usage: $0 [en|zh] [sphinx-build options...]" >&2 exit 1 fi +shift -cd $SCRIPT_DIR -VIME_DOC_LANG=$LANG sphinx-build -b html -D language=$LANG --conf-dir ./ ./$LANG ./build/$LANG +cd "$SCRIPT_DIR" +VIME_DOC_LANG="$DOC_LANG" sphinx-build -b html -D language="$DOC_LANG" --conf-dir . \ + "$@" "./$DOC_LANG" "./build/$DOC_LANG" diff --git a/docs/build_all.sh b/docs/build_all.sh index 5651a7f1e..fece56deb 100755 --- a/docs/build_all.sh +++ b/docs/build_all.sh @@ -4,9 +4,9 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) cd "$SCRIPT_DIR" echo "[vime-docs] Building EN..." -./build.sh en +VIME_DOC_LAYOUT=prefix ./build.sh en echo "[vime-docs] Building ZH..." -./build.sh zh +VIME_DOC_LAYOUT=prefix ./build.sh zh # Create a lightweight root index with auto redirect based on localStorage (done client side) ROOT_INDEX=build/index.html @@ -41,4 +41,4 @@ cat > "$ROOT_INDEX" <<'EOF' EOF -echo "[vime-docs] Done. Root landing page at build/index.html" \ No newline at end of file +echo "[vime-docs] Done. Root landing page at build/index.html" diff --git a/docs/conf.py b/docs/conf.py index 0e1fcd83c..e888588eb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,7 +6,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -__version__ = "0.3.2" +__version__ = "0.4.0" project = "Vime" copyright = f"2025-{datetime.now().year}, Vime" @@ -47,7 +47,7 @@ "substitution", ] -myst_heading_anchors = 3 +myst_heading_anchors = 5 nbsphinx_kernel_name = "python3" nbsphinx_execute_arguments = [ @@ -70,20 +70,9 @@ ) } -myst_enable_extensions = [ - "dollarmath", - "amsmath", - "deflist", - "colon_fence", - "html_image", - "linkify", - "substitution", -] - -myst_heading_anchors = 3 myst_ref_domains = ["std", "py"] -templates_path = ["_templates"] +templates_path = [] source_suffix = { ".rst": "restructuredtext", @@ -109,6 +98,7 @@ html_theme_options = { "repository_url": "https://github.com/vllm-project/vime", "repository_branch": "main", + "path_to_docs": f"docs/{language}", "show_navbar_depth": 3, "max_navbar_depth": 4, "collapse_navbar": True, @@ -126,82 +116,69 @@ "github_user": "vllm-project", "github_repo": "vime", "github_version": "main", - "conf_py_path": "/docs/", + "conf_py_path": f"/docs/{language}/", } html_static_path = ["_static"] html_css_files = ["css/custom_log.css"] # Add custom javascript for language toggle (en <-> zh) html_js_files = [ - "js/lang-toggle.js", + ("js/lang-toggle.js", {"data-layout": os.environ.get("VIME_DOC_LAYOUT", "root")}), ] def _sync_examples(app): - """Sync top-level examples into language-specific doc trees. + """Copy example docs and assets into the current language's source tree. - Policy: - - README.md -> English docs/en/_examples_synced//README.md - - README_zh.md -> Chinese docs/zh/_examples_synced//README_zh.md - - If a language-specific README missing, that example is simply skipped for that language. + Chinese builds prefer README_zh.md and fall back to README.md. + The generated copy is always named README.md to keep navigation consistent. """ - docs_root = Path(__file__).resolve().parent - src_dir = docs_root.parent / "examples" - if not src_dir.exists(): - return - - lang_cfgs = { - "en": { - "dir": docs_root / "en", - "readme_name": "README.md", - }, - "zh": { - "dir": docs_root / "zh", - # primary preferred name; will fallback to README.md - "readme_name": "README_zh.md", - }, - } - - for lang, cfg in lang_cfgs.items(): - lang_dir = cfg["dir"] - if not lang_dir.exists(): + src_dir = Path(__file__).resolve().parent.parent / "examples" + out_dir = Path(app.srcdir) / "_examples_synced" + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + for example_dir in sorted(src_dir.iterdir()): + if not example_dir.is_dir(): + continue + readme = example_dir / "README.md" + if app.config.language == "zh" and (example_dir / "README_zh.md").exists(): + readme = example_dir / "README_zh.md" + if not readme.exists(): continue - out_dir = lang_dir / "_examples_synced" - if out_dir.exists(): - shutil.rmtree(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - entries = [] # (example_name, readme_rel_path) - for d in sorted(src_dir.iterdir()): - if not d.is_dir(): - continue - # language-specific selection with fallback for zh - if lang == "zh": - primary = d / cfg["readme_name"] # README_zh.md - fallback = d / "README.md" - candidate = primary if primary.exists() else fallback - else: - candidate = d / cfg["readme_name"] - if not candidate.exists(): - continue # skip entirely if nothing suitable - target_dir = out_dir / d.name - shutil.copytree(d, target_dir, ignore=shutil.ignore_patterns("README.md", "README_zh.md")) - shutil.copy2(candidate, target_dir / "README.md") - entries.append((d.name, f"_examples_synced/{d.name}/README.md")) + target_dir = out_dir / example_dir.name + shutil.copytree( + example_dir, + target_dir, + ignore=shutil.ignore_patterns("README.md", "README_zh.md", "__pycache__", ".git"), + ) + content = readme.read_text().replace("](#", "](README.md#") + (target_dir / "README.md").write_text(content) def setup(app): # ensure examples are synced before reading source files app.connect("builder-inited", _sync_examples) + app.connect("html-page-context", _example_source_context, priority=499) -myst_enable_extensions = [ - "dollarmath", - "amsmath", - "deflist", - "colon_fence", -] -myst_heading_anchors = 5 +def _example_source_context(app, pagename, templatename, context, doctree): + """Point copied example pages back to their original repository source.""" + if not pagename.startswith("_examples_synced/"): + return + example_name = pagename.split("/")[1] + source_dir = Path(__file__).resolve().parent.parent / "examples" / example_name + readme_name = "README.md" + if app.config.language == "zh" and (source_dir / "README_zh.md").exists(): + readme_name = "README_zh.md" + context["original_readme"] = readme_name + context["edit_page_provider_name"] = "GitHub" + context["edit_page_url_template"] = ( + "{{ github_url }}/{{ github_user }}/{{ github_repo }}/edit/{{ github_version }}/" + "examples/{{ file_name.split('/')[1] }}/{{ original_readme }}" + ) + htmlhelp_basename = "vimedoc" @@ -220,7 +197,7 @@ def setup(app): "Vime Documentation", author, "vime", - "One line description of project.", + "An LLM post-training framework for RL scaling.", "Miscellaneous", ), ] @@ -246,12 +223,9 @@ def setup(app): "typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest", None), "pillow": ("https://pillow.readthedocs.io/en/stable", None), "numpy": ("https://numpy.org/doc/stable", None), - "torch": ("https://pytorch.org/docs/stable", None), + "torch": ("https://docs.pytorch.org/docs/stable", None), } -html_theme = "sphinx_book_theme" - - nbsphinx_prolog = """ .. raw:: html diff --git a/docs/en/advanced/arch-support-beyond-megatron.md b/docs/en/advanced/arch-support-beyond-megatron.md index aca455cc9..e6391fb80 100644 --- a/docs/en/advanced/arch-support-beyond-megatron.md +++ b/docs/en/advanced/arch-support-beyond-megatron.md @@ -1,34 +1,19 @@ # Supporting Model Architectures Beyond Megatron-LM -While the Megatron-LM framework is highly efficient for parallel training, it can lack the flexibility to support rapidly evolving model architectures like Qwen3Next. Natively supporting the unique structures of these models, such as Gated-Delta-Net, often requires invasive and time-consuming modifications to Megatron's core codebase. - -To accelerate the adoption of these cutting-edge models, vime inherits slime's more agile approach: **instead of deeply re-engineering Megatron, we directly import and wrap the model's official HuggingFace implementation**, embedding it as a "black-box" module into Megatron's parallel training pipeline. - -This document uses Qwen3Next 80B-A3B as an example to illustrate this concept. +vime can extend a Megatron model by replacing selected layer specifications (`ModuleSpec`) with a custom implementation. Qwen3-Next 80B-A3B uses this approach for its Gated DeltaNet (GDN) layers, while retaining Megatron's full-attention layers, MoE blocks, and pipeline scheduling. ## Principle and Core Components -Megatron's model instantiation is a two-step process: first, it generates a "layer specification" (`ModuleSpec`) based on the configuration, and then it instantiates the actual PyTorch modules according to that spec. - -vime leverages this mechanism by **hijacking the spec generation stage to replace Megatron's native modules** with an external implementation (in this case, from HuggingFace). This process involves the coordination of three core components: +The Qwen3-Next integration has three parts: -1. **Replacing the Megatron Module Spec** - This is the entry point for our solution. We use a custom function (e.g., `get_qwen3_next_spec`) to modify the standard `ModuleSpec`, swapping out Megatron's native Attention layer with our custom wrapper. - * **Implementation**: It retrieves the standard Decoder Block Spec, points its `self_attention` field to our custom module, and enables model-specific configurations like `qk_layernorm` as needed. - * **Corresponding File**: `vime_plugins/models/qwen3_next.py` +1. **Select the layers to replace.** `get_qwen3_next_spec` starts from Megatron's decoder block specification and replaces only the layers marked `linear_attention` in the Hugging Face configuration. It accounts for the layer offset of each pipeline stage. See [qwen3_next.py](https://github.com/vllm-project/vime/blob/main/vime_plugins/models/qwen3_next.py). +2. **Run GDN within Megatron.** The GDN implementation adapts the Hugging Face model layout and uses FLA or the optional FlashQLA backend for the recurrent attention kernel. `HuggingfaceAttention` gathers sequence and context partitions before the replicated computation, then returns the local output partition. See [hf_attention.py](https://github.com/vllm-project/vime/blob/main/vime_plugins/models/hf_attention.py). +3. **Load the model weights.** The checkpoint loader maps Hugging Face parameter names and tensor layouts to the custom modules and the retained Megatron layers. See [the Qwen3-Next weight loader](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/hf_to_megatron/qwen3_next.py). -2. **Wrapping the HuggingFace Implementation** - The spec modified in the previous step now points to a wrapper layer, such as `HuggingfaceAttention`. This layer inherits from Megatron's `MegatronModule`. Its core responsibility is to act as a bridge, handling the data alignment required by parallelism strategies (like sequence parallelism), and then internally calling the native `Qwen3NextAttention` module loaded from HuggingFace. - * **Corresponding File**: `vime_plugins/models/hf_attention.py` - -3. **Aligning Model Weights** - Once the model architecture is integrated, we must ensure that the weights can be loaded correctly. vime keeps the HuggingFace-to-Megatron name mapping and tensor transforms next to its checkpoint loader. - * **Corresponding File**: `vime/backends/megatron_utils/hf_to_megatron/qwen3_next.py` - -Through the coordination of these three components, we can successfully run a complex model architecture not natively supported by Megatron—using its HuggingFace implementation as the vehicle—on top of Megatron's parallel framework. This is achieved while fully retaining all key capabilities like model parallelism, MoE acceleration, and pipeline scheduling. +The [Qwen3-Next example](../examples/qwen3-next-80B-A3B.md) contains the launch configuration and backend options. ## Current Limitations -* This approach does not currently support Tensor Parallelism (TP) within the replaced module itself (e.g., the Attention layer in this case). -* **Impact**: In most large-scale MoE models, the parameter count of the Attention layer is relatively small, so this limitation typically has a minimal effect on memory footprint and training throughput. -* **Alternative**: If TP for the module is critical, the only alternative is to revert to the more invasive approach of modifying Megatron's native implementation. +The custom GDN module supports jobs using TP and CP, but its parameters and gathered computation are replicated across those ranks. Its projections are not sharded by TP, so increasing TP does not reduce that module's parameter memory or computation in the same way as a native tensor-parallel layer. + +The current spec supports pipeline stage offsets, but rejects a custom `pipeline_model_parallel_layout`. A native parallel GDN implementation would be needed to remove the replication cost. diff --git a/docs/en/advanced/low-precision.md b/docs/en/advanced/low-precision.md index e91ca0d6a..08aba5d8b 100644 --- a/docs/en/advanced/low-precision.md +++ b/docs/en/advanced/low-precision.md @@ -55,7 +55,7 @@ This is a rollout-side setting. It does not change Megatron training precision; vime also supports experimental FP8 training paths. We observed that FP8 training plus FP8 inference can improve inference throughput and reduce training/inference mismatch in some settings. More details are available in [this blog](https://lmsys.org/blog/2025-11-25-fp8-rl/). -### Quick Start +### FP8 Quick Start 1. Convert your Hugging Face model weights to FP8 format using `tools/convert_hf_to_fp8.py`. @@ -96,7 +96,7 @@ Only `Linear` and `GroupLinear` layers in TransformerEngine use FP8. `embedding` INT4 STE (Straight-Through Estimator) training and INT4 inference can further reduce rollout memory and improve throughput. Treat this path as beta unless you have validated the target model and reward setup. -### Quick Start +### INT4 Quick Start 1. Convert Hugging Face weights to INT4: @@ -110,7 +110,7 @@ If you only need INT4 rollout, set `--hf-checkpoint` to the converted INT4 check 2. Enable INT4 fake QAT: -```json +```bash RUNTIME_ENV_JSON="{ \"env_vars\": { \"OPEN_TRAINING_INT4_FAKE_QAT_FLAG\": \"1\", diff --git a/docs/en/developer_guide/install_flashqla.md b/docs/en/developer_guide/install_flashqla.md new file mode 100644 index 000000000..f2006c85b --- /dev/null +++ b/docs/en/developer_guide/install_flashqla.md @@ -0,0 +1,28 @@ +# Installing FlashQLA + +FlashQLA is an optional Gated Delta Net (GDN) backend for Qwen3-Next and Qwen3.5. To select it, add this argument to the training command: + +```bash +--qwen-gdn-backend flashqla +``` + +The default backend is FLA. + +## Requirements + +- PyTorch 2.8 or newer. +- CUDA 12.8 or newer. +- NVIDIA SM90 or newer GPUs. +- The same FlashQLA installation on every training node. + +## Docker Images + +Vime omits FlashQLA by default. To request its installation, use the existing Docker build option: + +```bash +docker build -f docker/Dockerfile . \ + --build-arg INSTALL_FLASHQLA=1 \ + -t vime:flashqla +``` + +FlashQLA currently requires TileLang 0.1.9. Do not downgrade a serving environment's incompatible TileLang dependency; use the default FLA backend or a separate compatible training environment. Validate compilation and runtime behavior before using FlashQLA in training. diff --git a/docs/en/examples/qwen3-next-80B-A3B.md b/docs/en/examples/qwen3-next-80B-A3B.md new file mode 100644 index 000000000..af26a1138 --- /dev/null +++ b/docs/en/examples/qwen3-next-80B-A3B.md @@ -0,0 +1,54 @@ +# Qwen3-Next-80B-A3B + +## Environment and Data + +Follow the [quick start](../get_started/quick_start.md) to choose a Docker image and download the DAPO training and AIME evaluation datasets. + +The release environment includes FLA for Gated Delta Net. The optional [FlashQLA backend](../developer_guide/install_flashqla.md) requires explicit installation; select it with `--qwen-gdn-backend flashqla` when adapting the launcher. CUDA 13 uses FLA. + +Download the model to the same base directory as the datasets: + +```bash +export BASE_FOLDER=/root +hf download Qwen/Qwen3-Next-80B-A3B-Thinking \ + --local-dir "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking" +``` + +## Checkpoint Conversion + +```bash +cd /root/vime +source scripts/models/qwen3-next-80B-A3B.sh +PYTHONPATH=/root/Megatron-LM torchrun --nproc-per-node 8 \ + tools/convert_hf_to_torch_dist.py \ + "${MODEL_ARGS[@]}" \ + --hf-checkpoint "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking" \ + --save "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking_torch_dist" +``` + +The model uses a custom Megatron layer specification. See [model architectures beyond Megatron](../advanced/arch-support-beyond-megatron.md) for how the GDN and attention layers integrate with training. + +## Training + +For one node with eight GPUs: + +```bash +cd /root/vime +export BASE_FOLDER=/root +export MASTER_ADDR=127.0.0.1 +ACTOR_NUM_NODES=1 CP_SIZE=1 bash scripts/run-qwen3-next-80B-A3B.sh +``` + +For four nodes with eight GPUs each, place the model and datasets at the same path on every node and prepare a hostfile: + +```bash +cd /root/vime +export BASE_FOLDER=/root +export MASTER_ADDR=your_master_addr +export HOSTFILE=/path/to/hostfile +bash scripts/run-qwen3-next-80B-A3B.sh +``` + +The launcher's defaults are `ACTOR_NUM_NODES=4`, `ACTOR_NUM_GPUS_PER_NODE=8`, and `CP_SIZE=4`. Set them explicitly when changing the node count or parallelism. The H100/H200 recipe is the reference setup; validate the selected kernels and parallel configuration when adapting it to Blackwell. + +If gradient accumulation consumes too much memory, replace `--accumulate-allreduce-grads-in-fp32` with `--grad-reduce-in-bf16` in the launch script. diff --git a/docs/en/get_started/agent.md b/docs/en/get_started/agent.md index e4e01c536..5ad5a59b8 100644 --- a/docs/en/get_started/agent.md +++ b/docs/en/get_started/agent.md @@ -8,8 +8,8 @@ This page is a roadmap: use it to decide which docs and examples to read when pl | Goal | Recommended entry point | | :--- | :--- | -| Run a custom agent loop, tool calls, RAG, browser/terminal/sandbox interaction for each sample | [`--custom-generate-function-path`](customization.md#2-custom-generate-function---custom-generate-function-path), [writing a custom generation function](quick_start.md#writing-custom-generation-function) | -| Implement verifier rewards, test-based rewards, environment success checks, or an external reward service | [`--custom-rm-path`](customization.md#3-reward-model---custom-rm-path), [writing a custom reward function](quick_start.md#writing-custom-reward-function) | +| Run a custom agent loop, tool calls, RAG, browser/terminal/sandbox interaction for each sample | [`--custom-generate-function-path`](customization.md#--custom-generate-function-path), [writing a custom generation function](quick_start.md#writing-custom-generation-function) | +| Implement verifier rewards, test-based rewards, environment success checks, or an external reward service | [`--custom-rm-path`](customization.md#--custom-rm-path), [writing a custom reward function](quick_start.md#writing-custom-reward-function) | | Return multiple training samples from one prompt, such as subagent, multi-agent, or context-compaction segments | [fan-out return from custom generate](customization.md#returning-multiple-training-samples-for-one-prompt), [`examples/multi_agent`](../_examples_synced/multi_agent/README.md) | | Avoid blocking training on long-tail agent rollouts | [`examples/fully_async`](../_examples_synced/fully_async/README.md) | | Study a full end-to-end agent example with sandboxing, real code edits, and test-based grading | [`examples/coding_agent_rl`](../_examples_synced/coding_agent_rl/README.md) | diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index bd4a8b66b..b9c8b65f6 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -482,7 +482,7 @@ def custom_hook(args, rollout_id, step_id, model, optimizer, opt_param_scheduler --- -### 18. MoE Routing Replay +### MoE Routing Replay Stabilize MoE RL training by recording and replaying expert routing decisions to ensure consistency. @@ -493,7 +493,7 @@ Stabilize MoE RL training by recording and replaying expert routing decisions to --- -### 19. Disk Weight-Sync Post-Write Hook (`--custom-update-weight-post-write-path`) +### Disk Weight-Sync Post-Write Hook (`--custom-update-weight-post-write-path`) **Signature**: ```python diff --git a/docs/en/get_started/quick_start.md b/docs/en/get_started/quick_start.md index 5cc23c4bc..c28de3301 100644 --- a/docs/en/get_started/quick_start.md +++ b/docs/en/get_started/quick_start.md @@ -78,7 +78,7 @@ hf download --repo-type dataset zhuzilin/aime-2024 \ When using Megatron as the training backend, you need to first convert Hugging Face format model weights to Megatron `torch_dist` format. -First, load the configuration file of the target model. The `vime/scripts/models` directory contains configuration files for supported models. You need to `source` the corresponding model script to load the configuration parameters into the current environment. Here we use Qwen3-4B model as an example. +First, load the configuration file of the target model. The `scripts/models` directory contains configuration files for supported models. You need to `source` the corresponding model script to load the configuration parameters into the current environment. Here we use Qwen3-4B model as an example. ```bash cd /root/vime diff --git a/docs/en/get_started/usage.md b/docs/en/get_started/usage.md index f97722ace..7b652ddfd 100644 --- a/docs/en/get_started/usage.md +++ b/docs/en/get_started/usage.md @@ -20,6 +20,7 @@ There are four main parameters for cluster resource allocation: - `--actor-num-gpus-per-node`: The number of GPUs per node for RL actor training. - `--rollout-num-gpus`: The total number of GPUs required for rollout (inference). Set it to `0` to still parse vLLM arguments and launch the router without launching local vLLM servers. - `--rollout-num-gpus-per-engine`: The total worker GPU count for one inference engine. It equals vLLM's `tensor_parallel_size` only when data, pipeline, and prefill context parallelism are all 1. For example, if one model is served across 2 nodes and 16 GPUs, this value should be 16. + vLLM receives data, pipeline, and prefill context parallel sizes separately. By default, its TP size is the engine GPU count divided by these sizes; a vLLM config can override the parallel settings for a server group. With the default configuration, we use these parameters to allocate `actor_num_nodes * actor_num_gpus_per_node` GPUs for training and `rollout_num_gpus` GPUs for inference via Ray, thus achieving a separation of training and inference resources. @@ -73,7 +74,7 @@ MODEL_ARGS=( ) ``` -We provide configurations for common models in [scripts/models](../../../scripts/models), which you can reuse directly. If you are also using Megatron for pre-training/SFT, you can directly reuse the model configurations from your pre-training/SFT setup. +We provide configurations for common models in [scripts/models](https://github.com/vllm-project/vime/tree/main/scripts/models), which you can reuse directly. If you are also using Megatron for pre-training/SFT, you can directly reuse the model configurations from your pre-training/SFT setup. Note: @@ -105,7 +106,7 @@ Megatron supports several of its custom checkpoint formats. Here are two of the The `torch` format is Megatron's older storage format. Its structure consists of directories like `mp_rank_xxx`, where each directory corresponds to the checkpoint stored by each rank under a specific parallel partitioning. Because of this, when loading a `torch` format checkpoint, you must ensure that the checkpoint's parallelism strategy matches that of the training task. -We recommend using the `torch_dist` format because it supports automatic parallel sharding, meaning that training tasks with different parallelism settings can share the same checkpoint, which is much more convenient. `torch_dist` is also the default format in the open-source Megatron. A `torch_dist` format checkpoint typically contains a set of `.distcp` files. When using `torch_dist`, you can convert from Hugging Face to `torch_dist` and vice versa using the checkpoint conversion method described in the [README](../../../README.md). +We recommend using the `torch_dist` format because it supports automatic parallel sharding, meaning that training tasks with different parallelism settings can share the same checkpoint, which is much more convenient. `torch_dist` is also the default format in the open-source Megatron. A `torch_dist` format checkpoint typically contains a set of `.distcp` files. When using `torch_dist`, you can convert from Hugging Face to `torch_dist` and vice versa using the checkpoint conversion method described in the [README](https://github.com/vllm-project/vime/blob/main/README.md). In terms of storage structure, a Megatron checkpoint typically looks like this, assuming the storage path is `/ckpt/`: @@ -460,7 +461,7 @@ bounds the lifetime of shared KV without introducing weight-version cache namesp vime runs vLLM in server mode and talks to it over HTTP. -### Parameter Configuration +### vLLM Arguments vime incorporates almost all vLLM parameters by forwarding vLLM's `EngineArgs` CLI flags. When setting a vLLM parameter, you need to add the `--vllm-` prefix. For example: @@ -517,7 +518,7 @@ Each model gets its own router. The per-model router info is accessible via `arg vime supports different and lightly modified versions of Megatron by reusing common functions from the `megatron.training` directory, such as `parse_args`, `save_checkpoint`, and `load_checkpoint`. Therefore, when using it, you must ensure that Megatron is accessible in the `PYTHONPATH`, for example, by adding `export PYTHONPATH=/root/Megatron-LM` at runtime. -### Parameter Configuration +### Megatron Arguments vime directly imports all parameters of the Megatron in the current environment by using `from megatron.training.arguments import parse_args`. If the version of Megatron you are using has parameters defined outside of `parse_args`, you can configure them by passing them in, similar to how it's done in [train.py](https://github.com/vllm-project/vime/blob/main/train.py), for example: diff --git a/docs/en/index.rst b/docs/en/index.rst index e97ea9210..916d5e90b 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -1,16 +1,20 @@ -vime Documentation -==================== +Vime Documentation +=================== -vime is an LLM post-training framework for RL scaling, providing two core capabilities: +Vime is built on `slime `_, retaining its training stack and data-generation design while using vLLM rollout. It provides custom interfaces for data generation and rewards. -- High-Performance Training: Supports efficient training in various modes by connecting Megatron with vLLM; -- Flexible Data Generation: Enables arbitrary training data generation workflows through custom data generation interfaces and server-based engines. +Training, rollout, the data buffer, and environment feedback share one dataflow, supporting math, code, tools, sandboxes, and long-horizon agent workflows. -vime is built on `slime `_, the RL framework behind GLM-4.7, GLM-4.6 and GLM-4.5. vime keeps slime's training stack and data-generation design while using vLLM as the default rollout backend, and inherits broad model support from slime, including: +Design and Production Experience +-------------------------------- -- Qwen3 series (Qwen3Next, Qwen3MoE, Qwen3), Qwen2.5 series; -- DeepSeek V3 series (DeepSeek V3, V3.1, DeepSeek R1); -- Llama 3. +- slime is the RL framework behind `GLM-5.3-Flash `_, `GLM-5.3 `_, `GLM-5.2 `_, `GLM-5.1 `_, `GLM-5 `_, `GLM-4.7 `_, `GLM-4.6 `_, `GLM-4.5 `_. +- Megatron arguments are available directly; installed vLLM arguments use the ``--vllm-`` prefix. +- Generation functions, reward functions, verifiers, and environments connect through documented customization interfaces. +- CPU correctness tests and GPU end-to-end tests cover training, rollout, checkpointing, precision, asynchronous generation, and debug replay. See :doc:`developer_guide/ci`. +- Large MoE recipes combine BF16 training with FP8 rollout; ``--vllm-kv-cache-dtype fp8`` can increase effective KV cache capacity. + +Alongside GLM, supported model families include Qwen (Qwen3.6, Qwen3.5, Qwen3-Next, Qwen3 MoE, Qwen3, Qwen2.5), DeepSeek (V3, V3.1, R1), and Llama 3. Start with the recipes below and the model configurations in ``scripts/models/``. Start by Use Case ----------------- @@ -51,6 +55,7 @@ Start by Use Case examples/glm4.7-30B-A3B.md examples/qwen3-30B-A3B.md + examples/qwen3-next-80B-A3B.md examples/glm5.2-744B-A40B.md examples/glm4.7-355B-A32B.md examples/deepseek-r1.md @@ -81,6 +86,14 @@ Start by Use Case _examples_synced/fully_async/README.md _examples_synced/multi_agent/README.md _examples_synced/coding_agent_rl/README.md + _examples_synced/delta_weight_sync/README.md + _examples_synced/eval_multi_task/README.md + _examples_synced/geo3k_vlm/README.md + _examples_synced/geo3k_vlm_multi_turn/README.md + _examples_synced/on_policy_distillation/README.md + _examples_synced/strands_vllm/README.md + _examples_synced/tau-bench/README.md + _examples_synced/train_infer_mismatch_helper/README.md .. toctree:: :maxdepth: 1 @@ -90,6 +103,7 @@ Start by Use Case developer_guide/debug.md developer_guide/trace.md developer_guide/profiling.md + developer_guide/install_flashqla.md .. toctree:: :maxdepth: 1 diff --git a/docs/en/platform_support/amd_tutorial.md b/docs/en/platform_support/amd_tutorial.md index 5ddc1a0d6..6b141a421 100644 --- a/docs/en/platform_support/amd_tutorial.md +++ b/docs/en/platform_support/amd_tutorial.md @@ -1,4 +1,4 @@ -# Quick Start +# AMD ROCm This document will guide you through setting up the environment and getting started with vime on AMD ROCm, covering environment configuration, data preparation, weight conversion, and training startup. @@ -39,18 +39,18 @@ hf download zhuzilin/dapo-math-17k --repo-type dataset --local-dir /root/dapo-ma ### HF → Megatron torch_dist ckpt -Use Vime's built-in Hugging Face-to-Megatron loader for conversion. Load the model configuration for Qwen3-8B, then run the conversion. Two ROCm-specific flags are required: `--no-gradient-accumulation-fusion` and `--attention-backend flash`. +Use Vime's built-in Hugging Face-to-Megatron loader for conversion. Load the model configuration for Qwen3-8B, then run the conversion with `--use-cpu-initialization` to initialize and save model weights on CPU. Retain the ROCm-specific flags `--no-gradient-accumulation-fusion` and `--attention-backend flash`. ```bash cd /root/vime && source scripts/models/qwen3-8B.sh HIP_VISIBLE_DEVICES=0 PYTHONPATH=/root/vime:/root/Megatron-LM \ torchrun --nproc-per-node=1 tools/convert_hf_to_torch_dist.py "${MODEL_ARGS[@]}" \ - --no-gradient-accumulation-fusion --attention-backend flash \ + --no-gradient-accumulation-fusion --use-cpu-initialization --attention-backend flash \ --hf-checkpoint /root/Qwen3-8B --save /root/Qwen3-8B_torch_dist ``` -> **Note**: On ROCm, use `HIP_VISIBLE_DEVICES` in place of `CUDA_VISIBLE_DEVICES` to select GPUs. The `--attention-backend flash` and `--no-gradient-accumulation-fusion` flags are required to avoid issues during conversion. +> **Note**: On ROCm, use `HIP_VISIBLE_DEVICES` in place of `CUDA_VISIBLE_DEVICES` to select GPUs. The shared conversion script supports CPU initialization through `--use-cpu-initialization`; retain `--attention-backend flash` and `--no-gradient-accumulation-fusion` when adapting this recipe. ## Training diff --git a/docs/requirements.txt b/docs/requirements.txt index 146749f66..76ec912a2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ ipywidgets jupyter_client markdown>=3.4.0 matplotlib -myst-parser +myst-parser[linkify] nbconvert nbsphinx nbstripout diff --git a/docs/serve.sh b/docs/serve.sh index 6a7232010..ae7398528 100755 --- a/docs/serve.sh +++ b/docs/serve.sh @@ -1,29 +1,31 @@ #!/usr/bin/env bash +set -euo pipefail SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -LANG="${1:-all}" +DOC_LANG="${1:-all}" PORT="${PORT:-8000}" cd "$SCRIPT_DIR" -if [ "$LANG" = "all" ]; then +if [ "$DOC_LANG" = "all" ]; then # Expect both builds present if [ ! -d build/en ] || [ ! -d build/zh ]; then echo "[serve] Missing build/en or build/zh. Run ./build_all.sh first." >&2 + exit 1 fi echo "[serve] Serving multi-language docs root on http://localhost:$PORT (en/, zh/)" python -m http.server -d ./build "$PORT" exit $? fi -if [ "$LANG" != "en" ] && [ "$LANG" != "zh" ]; then +if [ "$DOC_LANG" != "en" ] && [ "$DOC_LANG" != "zh" ]; then echo "Usage: $0 [en|zh|all]" >&2 exit 1 fi -if [ ! -d "build/$LANG" ]; then - echo "[serve] build/$LANG not found. Run ./build.sh $LANG first." >&2 +if [ ! -d "build/$DOC_LANG" ]; then + echo "[serve] build/$DOC_LANG not found. Run ./build.sh $DOC_LANG first." >&2 exit 1 fi -echo "[serve] Serving $LANG docs on http://localhost:$PORT" -python -m http.server -d ./build/$LANG "$PORT" \ No newline at end of file +echo "[serve] Serving $DOC_LANG docs on http://localhost:$PORT" +python -m http.server -d "./build/$DOC_LANG" "$PORT" diff --git a/docs/zh/advanced/arch-support-beyond-megatron.md b/docs/zh/advanced/arch-support-beyond-megatron.md index f7e93b0a7..19fd872d3 100644 --- a/docs/zh/advanced/arch-support-beyond-megatron.md +++ b/docs/zh/advanced/arch-support-beyond-megatron.md @@ -1,34 +1,19 @@ # 在 Megatron-LM 中快速支持新模型架构 -Megatron-LM 框架虽然并行效率高,但在支持日新月异的新模型架构(如 Qwen3Next)时,其灵活性有所欠缺。若要原生支持这些模型的特殊结构(例如 Gated-Delta-Net),往往需要对 Megatron 的核心代码进行侵入性较大、开发周期较长的改造。 - -为了能快速跟进这些前沿模型,`vime` 沿用了 slime 提出的一种更敏捷的方案:**与其深度改造 Megatron,不如直接引入并封装模型官方的 HuggingFace 实现**,将其作为一个“黑盒模块”无缝嵌入到 Megatron 的并行训练流程中。 - -本文以 Qwen3Next 80B-A3B 为例,介绍这一实现思路。 +vime 可以通过替换部分层的规格(`ModuleSpec`)来扩展 Megatron 模型。Qwen3-Next 80B-A3B 的集成使用这一方式实现 Gated DeltaNet(GDN)层,同时保留 Megatron 的 full-attention 层、MoE 模块和流水线调度。 ## 实现原理与核心组件 -Megatron 的模型实例化分为两步:首先根据配置生成“层规格”(`ModuleSpec`),再依据该规格实例化具体的 PyTorch 模块。 - -`vime` 正是利用这一机制,在**生成 Spec 的阶段“劫持”并替换掉 Megatron 的原生模块**,从而将外部实现(此处为 HuggingFace 模块)无缝嵌入。这一过程主要涉及三个核心组件的协同: +Qwen3-Next 的集成包含三个部分: -1. **替换 Megatron 模块规格 (Spec)** - 这是整个方案的入口。我们通过一个自定义函数(例如 `get_qwen3_next_spec`)来修改标准的 `ModuleSpec`,用我们自己的封装层换掉 Megatron 的原生 Attention 层。 - * **具体操作**:获取标准的 Decoder Block Spec,将其 `self_attention` 字段指向我们的自定义模块,并按需开启 `qk_layernorm` 等模型特有配置。 - * **对应文件**: `vime_plugins/models/qwen3_next.py` +1. **选择需要替换的层。** `get_qwen3_next_spec` 从 Megatron 的 decoder block 规格开始,只替换 Hugging Face 配置中标记为 `linear_attention` 的层,并根据流水线阶段的层偏移选择对应的配置。参见 [qwen3_next.py](https://github.com/vllm-project/vime/blob/main/vime_plugins/models/qwen3_next.py)。 +2. **在 Megatron 中执行 GDN。** GDN 实现沿用 Hugging Face 模型的参数布局,使用 FLA 或可选的 FlashQLA 后端执行递归注意力内核。`HuggingfaceAttention` 先收集序列并行和上下文并行的输入分片,在各 rank 上执行同一计算,再返回本地输出分片。参见 [hf_attention.py](https://github.com/vllm-project/vime/blob/main/vime_plugins/models/hf_attention.py)。 +3. **加载模型权重。** checkpoint loader 将 Hugging Face 的参数名称和 tensor 布局映射到自定义模块及保留的 Megatron 层。参见 [Qwen3-Next 权重加载器](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/hf_to_megatron/qwen3_next.py)。 -2. **封装 HuggingFace 实现** - 上一步的 Spec 会指向一个封装层,例如 `HuggingfaceAttention`。它继承了 Megatron 的 `MegatronModule`,核心职责是作为桥梁,处理好并行策略所需的数据对齐(如序列并行),然后在内部直接调用从 HuggingFace 加载的原生 `Qwen3NextAttention` 模块。 - * **对应文件**: `vime_plugins/models/hf_attention.py` - -3. **对齐模型权重** - 模型结构跑通后,还需要确保权重能正确加载。vime 将 HuggingFace 到 Megatron 的名称映射和 tensor 变换直接放在 checkpoint loader 旁。 - * **对应文件**: `vime/backends/megatron_utils/hf_to_megatron/qwen3_next.py` - -通过这三层协同,我们成功地将一个 Megatron 原本不支持的复杂模型结构(以其 HuggingFace 实现为载体),运行在了 Megatron 的并行框架之上,并完整保留了模型并行、MoE 加速、流水线调度等全部关键能力。 +启动配置和后端选项见 [Qwen3-Next 示例](../examples/qwen3-next-80B-A3B.md)。 ## 当前限制 -* 本方案暂不支持被替换模块(如此处的 Attention 层)自身的张量并行(TP)。 -* **影响**:在大多数大规模 MoE 模型中,Attention 层的参数量占比较小,因此该限制对显存占用和训练吞吐的影响通常有限。 -* **替代方案**:如果该模块的 TP 至关重要,则需要回归到侵入式修改 Megatron 的原生实现方案。 +自定义 GDN 模块可以运行在使用 TP 和 CP 的任务中,但参数和收集输入后的计算会在这些 rank 上复制。其投影层尚未按 TP 切分,因此增加 TP 不会像原生张量并行层那样降低该模块的参数显存和计算量。 + +当前规格支持流水线阶段的层偏移,但不支持自定义 `pipeline_model_parallel_layout`。若要消除复制计算的成本,需要实现原生的并行 GDN 模块。 diff --git a/docs/zh/advanced/low-precision.md b/docs/zh/advanced/low-precision.md index bf18621c1..552ec57e8 100644 --- a/docs/zh/advanced/low-precision.md +++ b/docs/zh/advanced/low-precision.md @@ -55,7 +55,7 @@ python tools/convert_hf_to_fp8.py \ vime 也支持 experimental 的 FP8 training 路径。我们观察到,在一些设置下同时使用 FP8 training 和 FP8 inference,可以提升推理吞吐并降低训推不一致。更多细节请参考 [这篇博客](https://lmsys.org/blog/2025-11-25-fp8-rl/)。 -### 快速开始 +### FP8 快速开始 1. 使用 `tools/convert_hf_to_fp8.py` 将 Hugging Face 模型权重转换为 FP8 格式。 @@ -96,7 +96,7 @@ bash scripts/low_precision/run-qwen3-30b-a3b-fp8.sh INT4 STE(Straight-Through Estimator)训练和 INT4 inference 可以进一步降低 rollout 显存并提升吞吐。在目标模型和 reward setup 验证前,请把这条路径视作 beta。 -### 快速开始 +### INT4 快速开始 1. 将 Hugging Face 权重转换为 INT4: @@ -110,7 +110,7 @@ python tools/convert_hf_to_int4_direct.py \ 2. 开启 INT4 fake QAT: -```json +```bash RUNTIME_ENV_JSON="{ \"env_vars\": { \"OPEN_TRAINING_INT4_FAKE_QAT_FLAG\": \"1\", diff --git a/docs/zh/developer_guide/install_flashqla.md b/docs/zh/developer_guide/install_flashqla.md index 1010af476..38855b05f 100644 --- a/docs/zh/developer_guide/install_flashqla.md +++ b/docs/zh/developer_guide/install_flashqla.md @@ -1,29 +1,28 @@ # 安装 FlashQLA -FlashQLA 是 Qwen GDN kernel 的可选运行后端。安装 FlashQLA 后,仍需要在训练命令中显式加入: +FlashQLA 是 Qwen3-Next 和 Qwen3.5 的可选 Gated Delta Net(GDN)后端。安装后仍需要在训练命令中显式加入: ```bash --qwen-gdn-backend flashqla ``` -如果不传该参数,Qwen GDN 仍使用默认的 FLA 后端。 +默认后端是 FLA。 ## 环境要求 -使用 `--qwen-gdn-backend flashqla` 前,请确认训练节点满足: - - PyTorch 2.8 或更新版本。 - CUDA 12.8 或更新版本。 - NVIDIA SM90 或更新架构 GPU。 -- 所有训练节点都安装了同一套 FlashQLA Python 包。 +- 所有训练节点都安装相同的 FlashQLA Python 包。 ## Docker 镜像 -直接构建 Dockerfile 时默认不安装 FlashQLA;发布 target 会显式开启。手动构建时请传入相同参数: +Vime 默认不安装 FlashQLA。需要安装时,使用现有 Docker 构建选项: ```bash -docker build \ - -f docker/Dockerfile \ +docker build -f docker/Dockerfile . \ --build-arg INSTALL_FLASHQLA=1 \ - -t vime:flashqla . + -t vime:flashqla ``` + +FlashQLA 当前要求 TileLang 0.1.9。不要降级 serving 环境中与之不兼容的 TileLang 依赖;请使用默认 FLA 后端,或独立的兼容训练环境。用于训练前需要验证编译和运行行为。 diff --git a/docs/zh/examples/qwen3-next-80B-A3B.md b/docs/zh/examples/qwen3-next-80B-A3B.md index 127bea966..5023c8c1c 100644 --- a/docs/zh/examples/qwen3-next-80B-A3B.md +++ b/docs/zh/examples/qwen3-next-80B-A3B.md @@ -1,83 +1,36 @@ -# Qwen3-Next-80B-A3B 训练示例 +# Qwen3-Next-80B-A3B -## 环境准备 +## 环境与数据 -搭建环境、下载模型、数据与 ckpt 转换均与 Qwen3-4B 模型相同,可以参考 [示例:Qwen3-4B](./qwen3-4B.md),将文中 Qwen3-4B 的部分转换为 -Qwen3-next-80B-A3B-Instruct 即可。 +按照[快速使用指南](../get_started/quick_start.md)选择 Docker 镜像,并下载 DAPO 训练集和 AIME 评估集。 -可以用如下完整方法把 huggingface checkpoint 转化为 torch_dist 格式: +预置环境包含用于 Gated Delta Net 的 FLA。可选的 [FlashQLA 后端](../developer_guide/install_flashqla.md) 需要显式安装,修改启动脚本时可通过 `--qwen-gdn-backend flashqla` 选择。CUDA 13 环境使用 FLA。 -```bash -export BASE_FOLDER=./models/ -# 下载模型权重 (Qwen3-Next-80B-A3B-Thinking) -hf download Qwen/Qwen3-Next-80B-A3B-Thinking --local-dir ${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking -``` - -```shell -cd vime/ -pip install -e . --no-deps - -# (for acceleration) -cd .. # and find a proper folder -git clone https://github.com/fla-org/flash-linear-attention -cd flash-linear-attention -git checkout 9714c595 -pip install -e . --no-deps - -wget https://github.com/Dao-AILab/causal-conv1d/releases/download/v1.5.4/causal_conv1d-1.5.4+cu12torch2.8cxx11abiTRUE-cp312-cp312-linux_x86_64.whl -pip install ./causal_conv1d-1.5.4+cu12torch2.8cxx11abiTRUE-cp312-cp312-linux_x86_64.whl -``` - -## [Optional] Fix a bug in triton compilation on Blackwell (sm100) - -see discussion here https://github.com/triton-lang/triton/issues/8695 -and https://github.com/fla-org/flash-linear-attention/issues/638 - -We need to apply a patch to fix the bug. -Go to the flash-linear-attention folder you just installed, and apply the following patch: - -```diff -diff --git a/fla/ops/gated_delta_rule/wy_fast.py b/fla/ops/gated_delta_rule/wy_fast.py -index c5119dcf..838f5e4e 100644 ---- a/fla/ops/gated_delta_rule/wy_fast.py -+++ b/fla/ops/gated_delta_rule/wy_fast.py -@@ -198,7 +198,14 @@ def prepare_wy_repr_bwd_kernel( - b_A += tl.dot(b_kb, tl.trans(b_k)) - b_dkb = tl.dot(b_dA, b_k) - b_db += tl.sum(b_dkb * b_k, 1) -- b_dk += tl.dot(tl.trans(b_dA), b_kb) -+ b_dk += tl.inline_asm_elementwise( -+ asm="mov.f32 $0, $1;", -+ constraints="=r,r", -+ args=[tl.dot(tl.trans(b_dA), b_kb)], -+ dtype=tl.float32, -+ is_pure=True, -+ pack=1, -+ ) - b_dk += b_dkb * b_b[:, None] - tl.store(p_dk, b_dk.to(p_dk.dtype.element_ty), boundary_check=(0, 1)) - tl.store(p_db, b_db.to(p_db.dtype.element_ty), boundary_check=(0,)) +将模型下载到与数据集相同的基础目录: +```bash +export BASE_FOLDER=/root +hf download Qwen/Qwen3-Next-80B-A3B-Thinking \ + --local-dir "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking" ``` -save it as `patch.diff` (Please remember to copy the last empty line to the file!) and do `git apply patch.diff` - -## 执行训练 (Megatron) - -**当前暂不支持Blackwell** - -转换模型权重: +## 权重转换 ```bash +cd /root/vime source scripts/models/qwen3-next-80B-A3B.sh -PYTHONPATH=/root/Megatron-LM/ torchrun --nproc-per-node 8 \ - tools/convert_hf_to_torch_dist.py \ - ${MODEL_ARGS[@]} \ - --hf-checkpoint /root/Qwen3-Next-80B-A3B-Thinking/ \ - --save /root/Qwen3-Next-80B-A3B-Thinking_torch_dist/ +PYTHONPATH=/root/Megatron-LM torchrun --nproc-per-node 8 \ + tools/convert_hf_to_torch_dist.py \ + "${MODEL_ARGS[@]}" \ + --hf-checkpoint "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking" \ + --save "${BASE_FOLDER}/Qwen3-Next-80B-A3B-Thinking_torch_dist" ``` -单机8卡 +模型使用自定义的 Megatron 层配置。GDN 和注意力层的集成方式见[支持 Megatron 之外的模型架构](../advanced/arch-support-beyond-megatron.md)。 + +## 执行训练 + +单机八卡: ```bash cd /root/vime @@ -85,9 +38,8 @@ export BASE_FOLDER=/root export MASTER_ADDR=127.0.0.1 ACTOR_NUM_NODES=1 CP_SIZE=1 bash scripts/run-qwen3-next-80B-A3B.sh ``` -如果显存不够,考虑disable `--accumulate-allreduce-grads-in-fp32`,enable `--grad-reduce-in-bf16` -多机(4x8) +四机、每机八卡时,请确保所有节点使用相同的模型和数据路径,并准备 hostfile: ```bash cd /root/vime @@ -96,3 +48,7 @@ export MASTER_ADDR=your_master_addr export HOSTFILE=/path/to/hostfile bash scripts/run-qwen3-next-80B-A3B.sh ``` + +启动脚本默认使用 `ACTOR_NUM_NODES=4`、`ACTOR_NUM_GPUS_PER_NODE=8` 和 `CP_SIZE=4`。修改节点数或并行方式时,请显式设置这些值。H100/H200 配方是参考配置;适配 Blackwell 时,需要验证所选 kernel 和并行配置。 + +如果梯度累积占用过多显存,可在启动脚本中用 `--grad-reduce-in-bf16` 替换 `--accumulate-allreduce-grads-in-fp32`。 diff --git a/docs/zh/get_started/agent.md b/docs/zh/get_started/agent.md index e5510d534..5452a3cbf 100644 --- a/docs/zh/get_started/agent.md +++ b/docs/zh/get_started/agent.md @@ -8,8 +8,8 @@ vime 的核心定位并不只是跑单轮 RL,而是把高性能训练、vLLM r | 目标 | 推荐入口 | | :--- | :--- | -| 给每条 sample 跑自定义 agent loop、tool call、RAG、browser/terminal/sandbox 交互 | [`--custom-generate-function-path`](customization.md#2-自定义生成函数---custom-generate-function-path)、[编写自定义生成函数](quick_start.md#编写自定义生成函数) | -| 做 verifier reward、test-based reward、环境成功判定或外部 reward 服务 | [`--custom-rm-path`](customization.md#3-奖励模型---custom-rm-path)、[编写自定义奖励函数](quick_start.md#编写自定义奖励函数) | +| 给每条 sample 跑自定义 agent loop、tool call、RAG、browser/terminal/sandbox 交互 | [`--custom-generate-function-path`](customization.md#--custom-generate-function-path)、[编写自定义生成函数](quick_start.md#编写自定义生成函数) | +| 做 verifier reward、test-based reward、环境成功判定或外部 reward 服务 | [`--custom-rm-path`](customization.md#--custom-rm-path)、[编写自定义奖励函数](quick_start.md#编写自定义奖励函数) | | 一个 prompt 会产生多个训练样本,例如 subagent、multi-agent、context compact | [custom generate 的 fan-out 返回](customization.md#一个-prompt-产生多个训练样本)、[`examples/multi_agent`](../_examples_synced/multi_agent/README.md) | | agent rollout 有长尾耗时,希望训练不要被最慢样本卡住 | [`examples/fully_async`](../_examples_synced/fully_async/README.md) | | agent 需要 sandbox、真实代码修改、测试验证和完整端到端样例 | [`examples/coding_agent_rl`](../_examples_synced/coding_agent_rl/README.md) | diff --git a/docs/zh/get_started/customization.md b/docs/zh/get_started/customization.md index 2b2d4a15e..2817a8bba 100644 --- a/docs/zh/get_started/customization.md +++ b/docs/zh/get_started/customization.md @@ -469,7 +469,7 @@ def custom_hook(args, rollout_id, step_id, model, optimizer, opt_param_scheduler --- -### 18. MoE 路由重放 +### MoE 路由重放 通过记录和重放专家路由决策来稳定 MoE RL 训练。 @@ -480,7 +480,7 @@ def custom_hook(args, rollout_id, step_id, model, optimizer, opt_param_scheduler --- -### 19. Disk 权重同步 Post-Write Hook(`--custom-update-weight-post-write-path`) +### Disk 权重同步 Post-Write Hook(`--custom-update-weight-post-write-path`) **签名**: ```python diff --git a/docs/zh/get_started/quick_start.md b/docs/zh/get_started/quick_start.md index 8cff774a3..3f3d15580 100644 --- a/docs/zh/get_started/quick_start.md +++ b/docs/zh/get_started/quick_start.md @@ -77,7 +77,7 @@ hf download --repo-type dataset zhuzilin/aime-2024 \ 当使用 Megatron 作为训练后端时,需要先将 Hugging Face 格式的模型权重转换为 Megatron `torch_dist` 格式。 -首先,加载目标模型的配置文件。`vime/scripts/models` 目录下包含了支持模型的配置文件。需要 `source` 对应模型的脚本,将配置参数加载到当前环境中。此处我们以 Qwen3-4B 模型为例子。 +首先,加载目标模型的配置文件。`scripts/models` 目录下包含了支持模型的配置文件。需要 `source` 对应模型的脚本,将配置参数加载到当前环境中。此处我们以 Qwen3-4B 模型为例子。 ```bash cd /root/vime diff --git a/docs/zh/get_started/usage.md b/docs/zh/get_started/usage.md index 2a53f320c..4a99d402e 100644 --- a/docs/zh/get_started/usage.md +++ b/docs/zh/get_started/usage.md @@ -22,6 +22,7 @@ - `--rollout-num-gpus`:rollout (inference)一共需要多少卡。设置为 `0` 时,vime 仍会解析 vLLM 参数并启动 router,但不会启动本地 vLLM server; - `--rollout-num-gpus-per-engine`:单个 inference engine 使用的 worker GPU 总数;只有 data parallel、pipeline parallel 和 prefill context parallel 都为 1 时,它才等于 vLLM 的 `tensor_parallel_size`。例如用 2 机 16 卡 serving 一个模型时,这里的值应为 16。 + vLLM 分别接收 data、pipeline 和 prefill context parallel 大小。默认 TP 大小为 engine GPU 总数除以这些并行大小;vLLM config 可以为 server group 覆盖并行配置。 在默认的配置下,我们会根据这些参数,通过 ray 给训练部分分配 `actor_num_nodes * actor_num_gpus_per_node` 张 GPU,给推理分配 `rollout_num_gpus` 张 GPU,也就是实现了训推分离。 @@ -75,7 +76,7 @@ MODEL_ARGS=( ) ``` -我们在 [scripts/models](../../../scripts/models) 提供了常用模型的配置,可以直接复用。如果你也在使用 megatron 进行 pretrain/sft 的话,可以直接复用 pretrain/sft 中的模型配置。 +我们在 [scripts/models](https://github.com/vllm-project/vime/tree/main/scripts/models) 提供了常用模型的配置,可以直接复用。如果你也在使用 megatron 进行 pretrain/sft 的话,可以直接复用 pretrain/sft 中的模型配置。 注意: @@ -108,7 +109,7 @@ megatron 支持多种其自定义的 ckpt 格式,这里介绍 2 种比较主 torch 格式是 megatron 的老存储格式,里面的结构大约是一些 `mp_rank_xxx` 的文件夹,每个文件夹对应了在对应的并行划分下,每个 rank 存储的 ckpt。也是因为如此,在加载 torch 格式的 ckpt 的时候,需要保证 ckpt 的并行策略和训练任务的并行策略是相同的。 -我们推荐使用 torch_dist 格式 ckpt,因为 torch_dist 格式可以支持自动并行切分,也就是不同并行的训练任务都可以共用同一个 ckpt,会方便很多。torch_dist 这也是开源 megatron 目前的默认格式。torch_dist 格式的 ckpt 中一般是一堆 `.distcp` 文件。在使用 torch_dist 时,可以使用 [README](../../../README_zh.md) 中介绍的 ckpt 转化方法从 huggingface 转化为 torch_dist,反之亦然。 +我们推荐使用 torch_dist 格式 ckpt,因为 torch_dist 格式可以支持自动并行切分,也就是不同并行的训练任务都可以共用同一个 ckpt,会方便很多。torch_dist 这也是开源 megatron 目前的默认格式。torch_dist 格式的 ckpt 中一般是一堆 `.distcp` 文件。在使用 torch_dist 时,可以使用 [README](https://github.com/vllm-project/vime/blob/main/README_zh.md) 中介绍的 ckpt 转化方法从 huggingface 转化为 torch_dist,反之亦然。 在存储结构上,megatron 的 ckpt 一般是这样的结构,这里假设存储的路径为 `/ckpt/`: @@ -442,7 +443,7 @@ rollout offload 或 `--release-train`。默认评估仍使用标准 vLLM rollout vime 以 server 模式运行 vLLM,通过 HTTP 与之通信。 -### 参数配置 +### vLLM 参数 vime 通过转发 vLLM 的 `EngineArgs` CLI 参数,引入了几乎所有的 vLLM 参数。在设置一个 vLLM 参数的时候,需要在参数前加上 `--vllm-` 的前缀,例如: @@ -499,7 +500,7 @@ vllm: vime 通过复用 `megatron.training` 目录下的常规函数,如 `parse_args`, `save_checkpoint`,`load_checkpoint`,从而实现对不同版本以及轻度魔改的 megatron 的支持。所以在使用时,需要保证 `PYTHONPATH` 中能访问到 megatron,例如在运行时加入 `export PYTHONPATH=/root/Megatron-LM`。 -### 参数配置 +### Megatron 参数 vime 通过直接引入 `from megatron.training.arguments import parse_args` 引入了当前环境中 megatron 的所有参数。如果当前使用的 megatron 有在 `parse_args` 之外的参数,可以通过像 [train.py](https://github.com/vllm-project/vime/blob/main/train.py) 中传入参数来进行配置,例如: diff --git a/docs/zh/index.rst b/docs/zh/index.rst index 4dc9e3454..c03e74e46 100644 --- a/docs/zh/index.rst +++ b/docs/zh/index.rst @@ -1,32 +1,36 @@ -vime 文档 -==================== +Vime 文档 +=================== -vime 是一个面向 RL Scaling 的 LLM 后训练框架,提供两大核心能力: +Vime 基于 `slime `_,保留其训练栈和数据生成设计,采用 vLLM rollout,并提供自定义数据生成和奖励接口。 -- 高性能训练:通过连接 Megatron 与 vLLM,支持多种模式下的高效训练; -- 灵活的数据生成:通过自定义数据生成接口与基于服务器的引擎,实现任意训练数据生成流程。 +训练、rollout、数据缓冲区和环境反馈共享同一条数据流,可以接入数学、代码、工具调用、沙盒和长程智能体任务。 -vime 构建于 `slime `_ 之上,slime 正是 GLM-4.7、GLM-4.6、GLM-4.5 背后的 RL 训练框架。vime 沿用了 slime 的训练栈与数据生成设计,并默认采用 vLLM 作为 rollout 后端,同时继承了 slime 广泛的模型支持,包括: +设计与生产实践 +------------------- -- Qwen3 系列 (Qwen3Next, Qwen3MoE, Qwen3), Qwen2.5 系列; -- DeepSeek V3 系列 (DeepSeek V3, V3.1, DeepSeek R1); -- Llama 3。 +- slime 是 `GLM-5.3-Flash `_、`GLM-5.3 `_、`GLM-5.2 `_、`GLM-5.1 `_、`GLM-5 `_、`GLM-4.7 `_、`GLM-4.6 `_、`GLM-4.5 `_ 背后的 RL 训练框架。 +- 直接使用 Megatron 参数,通过 ``--vllm-`` 前缀传入当前安装版本的 vLLM 参数。 +- 通过自定义接口接入生成函数、奖励函数、验证器和交互环境。 +- CPU 正确性测试和 GPU 端到端测试覆盖训练、rollout、checkpoint、精度、异步生成与调试回放。详见 :doc:`developer_guide/ci`。 +- 大规模 MoE 配方结合 BF16 训练与 FP8 rollout;``--vllm-kv-cache-dtype fp8`` 可提升有效 KV cache 容量。 + +除 GLM 外,支持的模型系列还包括 Qwen(Qwen3.6、Qwen3.5、Qwen3-Next、Qwen3 MoE、Qwen3、Qwen2.5)、DeepSeek(V3、V3.1、R1)和 Llama 3。可以从下方训练示例和 ``scripts/models/`` 中的模型配置开始。 按使用场景开始 -------------- -- 第一次使用 vime::doc:`get_started/quick_start` -- 配置 training 和 rollout 参数::doc:`get_started/usage` -- 添加 custom generation、reward 或 rollout function::doc:`get_started/customization` -- 构建 agentic RL workflow::doc:`get_started/agent` -- 配置生产级 vLLM rollout topology::doc:`advanced/vllm-config` -- 接入 external rollout engines::doc:`advanced/external-rollout-engines` +- 第一次使用 slime::doc:`get_started/quick_start` +- 配置训练与 rollout 参数::doc:`get_started/usage` +- 添加自定义生成、奖励或 rollout 函数::doc:`get_started/customization` +- 构建智能体 RL 工作流::doc:`get_started/agent` +- 配置 SGLang rollout 部署拓扑::doc:`advanced/vllm-config` +- 接入外部 rollout 引擎::doc:`advanced/external-rollout-engines` - 使用 straw 持久化分布式 rollout 与共享张量::doc:`advanced/straw` -- 以字节级 delta 同步权重::doc:`advanced/delta-weight-sync` +- 以字节增量同步权重::doc:`advanced/delta-weight-sync` - 使用 PD disaggregation::doc:`advanced/pd-disaggregation` - 使用 BF16 训练 + FP8 rollout 或 FP8 KV cache::doc:`advanced/low-precision` - 了解 CI 和可靠性覆盖::doc:`developer_guide/ci` -- 调试、trace 和 profiling 长时间任务::doc:`developer_guide/debug`、:doc:`developer_guide/trace`、:doc:`developer_guide/profiling` +- 调试、追踪和分析长时间运行的任务::doc:`developer_guide/debug`、:doc:`developer_guide/trace`、:doc:`developer_guide/profiling` .. toctree:: :maxdepth: 1 @@ -51,6 +55,7 @@ vime 构建于 `slime `_ 之上,slime 正是 G examples/glm4.7-30B-A3B.md examples/qwen3-30B-A3B.md + examples/qwen3-next-80B-A3B.md examples/glm5.2-744B-A40B.md examples/glm4.7-355B-A32B.md examples/deepseek-r1.md @@ -81,6 +86,14 @@ vime 构建于 `slime `_ 之上,slime 正是 G _examples_synced/fully_async/README.md _examples_synced/multi_agent/README.md _examples_synced/coding_agent_rl/README.md + _examples_synced/delta_weight_sync/README.md + _examples_synced/eval_multi_task/README.md + _examples_synced/geo3k_vlm/README.md + _examples_synced/geo3k_vlm_multi_turn/README.md + _examples_synced/on_policy_distillation/README.md + _examples_synced/strands_vllm/README.md + _examples_synced/tau-bench/README.md + _examples_synced/train_infer_mismatch_helper/README.md .. toctree:: :maxdepth: 1 @@ -90,6 +103,13 @@ vime 构建于 `slime `_ 之上,slime 正是 G developer_guide/debug.md developer_guide/trace.md developer_guide/profiling.md + developer_guide/install_flashqla.md + +.. toctree:: + :maxdepth: 1 + :caption: 硬件平台 + + platform_support/amd_tutorial.md .. toctree:: :maxdepth: 1 diff --git a/docs/zh/platform_support/amd_tutorial.md b/docs/zh/platform_support/amd_tutorial.md new file mode 100644 index 000000000..4c12eab09 --- /dev/null +++ b/docs/zh/platform_support/amd_tutorial.md @@ -0,0 +1,63 @@ +# AMD ROCm + +本教程使用 ROCm 镜像。完整的启动脚本说明见[英文教程](../../en/platform_support/amd_tutorial.md)。 + +## 环境准备 + +拉取镜像并启动容器: + +```bash +docker pull vllm/vime-rocm +docker run --rm -it \ + --device /dev/dri --device /dev/kfd \ + --group-add video --cap-add SYS_PTRACE \ + --security-opt seccomp=unconfined \ + --ipc=host --shm-size=128g \ + --ulimit memlock=-1 --ulimit stack=67108864 \ + vllm/vime-rocm /bin/bash +``` + +容器中安装当前 Vime checkout: + +```bash +git clone https://github.com/vllm-project/vime.git /root/vime +cd /root/vime +pip install -e . --no-deps +``` + +也可以使用仓库中的 `docker/Dockerfile.rocm` 构建镜像。 + +## 模型与数据 + +```bash +hf download Qwen/Qwen3-8B --local-dir /root/Qwen3-8B +hf download --repo-type dataset zhuzilin/dapo-math-17k \ + --local-dir /root/dapo-math-17k +``` + +## 权重转换 + +共享的转换脚本在 ROCm 环境下要求 `--use-cpu-initialization`,以便在 CPU 上初始化和保存模型权重。 + +```bash +cd /root/vime +source scripts/models/qwen3-8B.sh +PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ + "${MODEL_ARGS[@]}" \ + --no-gradient-accumulation-fusion \ + --use-cpu-initialization \ + --attention-backend flash \ + --hf-checkpoint /root/Qwen3-8B \ + --save /root/Qwen3-8B_torch_dist +``` + +请将 `PYTHONPATH` 替换为镜像中实际的 Megatron-LM 安装路径。 + +## 训练 + +```bash +cd /root/vime +NUM_ROLLOUT=100 VISIBLE_GPUS=0,1 bash scripts/run-qwen3-8B-amd.sh +``` + +该配方通过 `--no-gradient-accumulation-fusion` 禁用梯度累积融合。Ray 的 GPU 可见性由 `RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES` 和 `HIP_VISIBLE_DEVICES` 控制;调整启动脚本时请保留这些设置。 diff --git a/examples/coding_agent_rl/README.md b/examples/coding_agent_rl/README.md index fd50152ec..258f699ed 100644 --- a/examples/coding_agent_rl/README.md +++ b/examples/coding_agent_rl/README.md @@ -29,7 +29,7 @@ The vime training stack itself follows the standard setup. On top of that you ne Standard vime JSONL with three keys: -```jsonc +```javascript { "prompt": "", "label": "", diff --git a/setup.py b/setup.py index bf4e46f73..c626ebf5b 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def get_tag(self): setup( author="vime Team", name="vime", - version="0.3.2", + version="0.4.0", packages=find_packages(include=["vime*", "vime_plugins*"]), include_package_data=True, install_requires=_fetch_requirements("requirements.txt"), diff --git a/tests/glm52_layerwise_comparator.py b/tests/glm52_layerwise_comparator.py index 015020cd3..6852f575f 100644 --- a/tests/glm52_layerwise_comparator.py +++ b/tests/glm52_layerwise_comparator.py @@ -195,6 +195,11 @@ def map_requests_to_train_sequences(dump_files: list[Path], train_sequences: lis mapping[rid] = candidates[0] if not mapping: raise RuntimeError("vLLM dumps contained no request observations") + expected_sequences = {tuple(sequence.tokens.tolist()) for sequence in train_sequences} + observed_sequences = {tuple(train_sequences[index].tokens.tolist()) for index in mapping.values()} + missing_sequences = expected_sequences - observed_sequences + if missing_sequences: + raise RuntimeError(f"vLLM dumps are missing {len(missing_sequences)} Megatron token sequences") return mapping @@ -251,6 +256,8 @@ def compare_layer_outputs( raise IndexError(f"vLLM request {rid} contains positions outside its " "Megatron sequence") rollout_value = rollout_rows[layer_id][token_slice][keep] train_value = train_sequence.layers[layer_id][kept_positions] + if not torch.isfinite(rollout_value).all() or not torch.isfinite(train_value).all(): + raise ValueError(f"Non-finite hidden states for request {rid}, layer {layer_id}") difference = (rollout_value.float() - train_value.float()).abs() layer_stats = stats[layer_id] layer_stats["max_abs"] = max(layer_stats["max_abs"], float(difference.max().item())) @@ -260,6 +267,16 @@ def compare_layer_outputs( for position in kept_positions.tolist(): compared.add((rid, int(position), layer_id)) + for rid, sequence_id in request_mapping.items(): + expected_positions = set(range(train_sequences[sequence_id].tokens.numel() - 1)) + for layer_id in selected_layers: + observed_positions = { + position for request, position, layer in compared if request == rid and layer == layer_id + } + missing = expected_positions - observed_positions + if missing: + raise ValueError(f"Missing hidden states for request {rid}, layer {layer_id}: {sorted(missing)}") + for layer_stats in stats.values(): layer_stats["mean_abs"] = ( layer_stats.pop("sum_abs") / layer_stats["numel"] if layer_stats["numel"] else float("nan") diff --git a/tests/pipeline_rl_test_helpers.py b/tests/pipeline_rl_test_helpers.py index 8da49f720..7b5210138 100644 --- a/tests/pipeline_rl_test_helpers.py +++ b/tests/pipeline_rl_test_helpers.py @@ -136,7 +136,7 @@ def verify_flush(self): } -def start_probes(urls, model): +def start_probes(urls, model, *, pause=False): _probes.clear() for url in urls: probe = GenerationProbe(url, model) @@ -144,6 +144,11 @@ def start_probes(urls, model): probe.thread.start() for probe in _probes: probe.wait(lambda events: bool(events) and events[-1]["tokens"] >= 16) + if pause: + # Keep the live request across slow first-step compilation. The + # first weight update resumes it without flushing the cache. + response = requests.post(f"{probe.url}/pause", params={"mode": "keep", "clear_cache": "false"}, timeout=10) + response.raise_for_status() def before_train_step(args, rollout_id, step_id, model, optimizer, opt_param_scheduler): @@ -157,7 +162,7 @@ def before_train_step(args, rollout_id, step_id, model, optimizer, opt_param_sch path = Path(os.environ["VIME_PIPELINE_RL_PROBE_FILE"]).with_suffix(".workers.json") urls = json.loads(path.read_text()) assert urls - start_probes(urls, args.hf_checkpoint) + start_probes(urls, args.hf_checkpoint, pause=True) elif rollout_id == 1 and step_id == 0: assert _probes name, initial = _initial_weights diff --git a/tests/test_disk_delta_checkpoint_index.py b/tests/test_disk_delta_checkpoint_index.py index 17097b4b1..40cee3dff 100644 --- a/tests/test_disk_delta_checkpoint_index.py +++ b/tests/test_disk_delta_checkpoint_index.py @@ -10,13 +10,17 @@ import safetensors.numpy import zstandard +from vime.utils.disk_delta import checksum, overwrite_encode + NUM_GPUS = 0 ROOT = Path(__file__).resolve().parents[1] PATCHES = sorted((ROOT / "docker" / "patch").glob("*/vllm-pull_weights.patch")) def _load_trainer(): - spec = importlib.util.spec_from_file_location("disk_delta", ROOT / "vime/utils/disk_delta.py") + spec = importlib.util.spec_from_file_location( + "hf_checkpoint_reader", ROOT / "vime/backends/megatron_utils/hf_to_megatron/common.py" + ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module @@ -42,18 +46,25 @@ def _write_checkpoint(path): @pytest.fixture(params=[None, *PATCHES], ids=["trainer", *(p.parent.name for p in PATCHES)]) def reader(request, monkeypatch): - module = _load_trainer() if request.param is None else _load_receiver(request.param) + if request.param is None: + module = _load_trainer() + return lambda path: module.SafetensorReader(path).get_tensor("weight").numpy() + module = _load_receiver(request.param) # scandir/glob order is unspecified; force the stale file to be visited last. original_glob = module.glob.glob monkeypatch.setattr(module.glob, "glob", lambda pattern: sorted(original_glob(pattern))) - return module + + def read_weight(path): + filename, _, _ = module._tensor_locations(str(path))["weight"] + return safetensors.numpy.load_file(filename)["weight"] + + return read_weight def test_index_excludes_stale_shards(reader, tmp_path): checkpoint = tmp_path / "checkpoint" _write_checkpoint(checkpoint) - locations = reader._tensor_locations(str(checkpoint)) - assert Path(locations["weight"][0]).name == "model.safetensors" + np.testing.assert_array_equal(reader(checkpoint), np.ones((2, 2), dtype=np.float32)) def test_missing_indexed_shard_does_not_fall_back_to_stale_weights(reader, tmp_path): @@ -61,33 +72,32 @@ def test_missing_indexed_shard_does_not_fall_back_to_stale_weights(reader, tmp_p _write_checkpoint(checkpoint) (checkpoint / "model.safetensors").unlink() with pytest.raises(FileNotFoundError): - reader._tensor_locations(str(checkpoint)) + reader(checkpoint) def test_unindexed_checkpoint_remains_supported(reader, tmp_path): safetensors.numpy.save_file({"weight": np.ones(4, dtype=np.float32)}, tmp_path / "model.safetensors") - assert set(reader._tensor_locations(str(tmp_path))) == {"weight"} + np.testing.assert_array_equal(reader(tmp_path), np.ones(4, dtype=np.float32)) @pytest.mark.parametrize("patch", PATCHES, ids=[p.parent.name for p in PATCHES]) @pytest.mark.parametrize("encoding", ["xor", "overwrite"]) def test_delta_updates_indexed_weights(patch, encoding, tmp_path, monkeypatch): trainer, receiver = _load_trainer(), _load_receiver(patch) - original_glob = trainer.glob.glob - monkeypatch.setattr(trainer.glob, "glob", lambda pattern: sorted(original_glob(pattern))) + original_glob = receiver.glob.glob + monkeypatch.setattr(receiver.glob, "glob", lambda pattern: sorted(original_glob(pattern))) base, local, stream = tmp_path / "base", tmp_path / "local", tmp_path / "stream" _write_checkpoint(base) stream.mkdir() receiver.pull_checkpoint(str(local), str(base), str(stream), 0) - read = trainer.make_tensor_reader(str(base)) - old = read("weight") + old = trainer.SafetensorReader(base).get_tensor("weight").numpy().view(np.uint8).reshape(-1) new = np.full((2, 2), 2, dtype=np.float32).view(np.uint8).reshape(-1) - diff = new ^ old if encoding == "xor" else trainer.overwrite_encode(new, new != old) + diff = new ^ old if encoding == "xor" else overwrite_encode(new, new != old) version = stream / "weight_v000001" version.mkdir() compressed = np.frombuffer(zstandard.ZstdCompressor().compress(diff), dtype=np.uint8) safetensors.numpy.save_file( - {"weight": compressed}, version / "model.safetensors", metadata={"weight": trainer.checksum("adler32", new)} + {"weight": compressed}, version / "model.safetensors", metadata={"weight": checksum("adler32", new)} ) (version / "model.safetensors.index.json").write_text( json.dumps( diff --git a/tests/test_glm52_6layer_deterministic_e2e.py b/tests/test_glm52_6layer_deterministic_e2e.py index 5878e6634..8273458ec 100644 --- a/tests/test_glm52_6layer_deterministic_e2e.py +++ b/tests/test_glm52_6layer_deterministic_e2e.py @@ -1,8 +1,31 @@ """GLM-5.2 deterministic train/rollout alignment gate.""" +import math +import re + NUM_GPUS = 8 +def _assert_alignment_result(output: str, rollout_path: str, threshold: float) -> None: + import torch + + values = [float(value) for value in re.findall(r"'train/train_rollout_logprob_abs_diff':\s*([^,}\s]+)", output)] + assert values, "Training did not report the train/rollout alignment metric" + assert all(math.isfinite(value) and 0 <= value <= threshold for value in values), values + samples = torch.load(rollout_path, map_location="cpu", weights_only=False)["samples"] + assert len(samples) == 8, f"Expected 8 rollout samples, got {len(samples)}" + for sample in samples: + length = sample["response_length"] + assert length > 0, "Alignment requires generated response tokens" + log_probs = sample["rollout_log_probs"] + assert len(log_probs) == length and all(math.isfinite(value) for value in log_probs) + mask = sample.get("loss_mask") + assert mask is None or (len(mask) == length and sum(mask) > 0), "Response is entirely masked" + assert sample["weight_versions"] and set(map(str, sample["weight_versions"])) == { + "1" + }, f"Rollout did not use the initial synchronized weights: {sample['weight_versions']}" + + def run_gate(*, layerwise_zero: bool = False, rollout_max_response_len: int = 4096) -> None: del layerwise_zero, rollout_max_response_len # vLLM sparse MLA does not support batch-invariant inference. diff --git a/tests/test_hf_to_megatron.py b/tests/test_hf_to_megatron.py index 07c0d8a52..68616e713 100644 --- a/tests/test_hf_to_megatron.py +++ b/tests/test_hf_to_megatron.py @@ -77,6 +77,7 @@ def _config(model_type="qwen3"): num_query_groups=2, num_layers=2, q_lora_rank=None, + vocab_size=1024, ) @@ -221,6 +222,49 @@ def test_qwen3_omni_encoder_mapping_is_replicated(): assert loaded is parameter +@pytest.mark.unit +@pytest.mark.parametrize("mtp_module", ["transformer_layer", "mtp_model_layer"]) +@pytest.mark.parametrize( + ("component", "shape", "hf_suffixes"), + [ + ("mlp.linear_fc1.layer_norm_weight", (8,), ["post_attention_layernorm.weight"]), + ("mlp.linear_fc1.weight", (12, 8), ["mlp.gate_proj.weight", "mlp.up_proj.weight"]), + ("mlp.linear_fc2.weight", (8, 6), ["mlp.down_proj.weight"]), + ], +) +@pytest.mark.parametrize( + ("model_type", "hf_prefix"), + [ + ("mimo", "model.mtp_layers.1"), + ("deepseek_v3", "model.layers.3"), + ("glm4_moe", "model.layers.3"), + ("qwen3_next", "mtp.layers.1"), + ("qwen3_5_moe", "mtp.layers.1"), + ], +) +def test_mtp_live_module_names_export_and_load(model_type, hf_prefix, mtp_module, component, shape, hf_suffixes): + if model_type == "qwen3_next" and component.endswith(".weight"): + component = component.replace("mlp.", "mlp.experts.") + "3" + hf_suffixes = [suffix.replace("mlp.", "mlp.experts.3.") for suffix in hf_suffixes] + parameter = torch.arange(torch.tensor(shape).prod()).reshape(shape) + name = f"module.module.mtp.layers.1.{mtp_module}.{component}" + + converted = convert_to_hf(_EXPORT_ARGS, model_type, name, parameter) + + assert [key for key, _ in converted] == [f"{hf_prefix}.{suffix}" for suffix in hf_suffixes] + config = types.SimpleNamespace( + model_type=model_type, + hidden_size=8, + num_attention_heads=4, + num_key_value_heads=2, + head_dim=2, + num_hidden_layers=2, + tie_word_embeddings=False, + ) + loaded = _LOADERS[model_type](name, Reader(**dict(converted)), config) + assert torch.equal(loaded, parameter) + + @pytest.mark.unit @pytest.mark.parametrize("model_name", ["deepseekv32config", "kimik2config"]) def test_deepseek_family_parameter_updates_use_the_direct_exporter(model_name): diff --git a/tests/test_layerwise_alignment.py b/tests/test_layerwise_alignment.py index ec452577e..8197617aa 100644 --- a/tests/test_layerwise_alignment.py +++ b/tests/test_layerwise_alignment.py @@ -75,5 +75,102 @@ def test_megatron_layerwise_dump_is_enabled_on_nonzero_rank(monkeypatch, tmp_pat assert "decoder.layers.0" in values["modules"] +def _comparison_fixture(tmp_path, *, positions=(0, 1)): + from glm52_layerwise_comparator import TrainSequence + + rows = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + sequence = TrainSequence(tokens=torch.tensor([7, 8, 9]), layers={0: rows}, source="train") + record = { + "model.forward_batch_info.input_ids": sequence.tokens[list(positions)], + "model.forward_batch_info.positions": torch.tensor(positions), + "model.forward_batch_info.rids": ["request"], + "model.forward_batch_info.extend_seq_lens": torch.tensor([len(positions)]), + "model.layers.0": (rows[list(positions)].clone(), torch.zeros(len(positions), 2)), + } + path = tmp_path / "Pass00000.pt" + torch.save(record, path) + return sequence, record, path + + +def test_layerwise_comparison_covers_all_score_producing_positions(tmp_path): + from glm52_layerwise_comparator import compare_layer_outputs + + sequence, _, path = _comparison_fixture(tmp_path) + stats = compare_layer_outputs([path], [sequence], {"request": 0}, {0}) + assert stats[0]["tokens"] == 2 + assert stats[0]["max_abs"] == 0 + + +@pytest.mark.parametrize("source", ["train", "rollout"]) +@pytest.mark.parametrize("invalid", [float("nan"), float("inf")]) +def test_layerwise_comparison_rejects_nonfinite_values(tmp_path, source, invalid): + from glm52_layerwise_comparator import compare_layer_outputs + + sequence, record, path = _comparison_fixture(tmp_path) + if source == "train": + sequence.layers[0][0, 0] = invalid + else: + record["model.layers.0"][0][0, 0] = invalid + torch.save(record, path) + with pytest.raises(ValueError, match="Non-finite"): + compare_layer_outputs([path], [sequence], {"request": 0}, {0}) + + +def test_layerwise_comparison_rejects_missing_response_positions(tmp_path): + from glm52_layerwise_comparator import compare_layer_outputs + + sequence, _, path = _comparison_fixture(tmp_path, positions=(0,)) + with pytest.raises(ValueError, match="Missing hidden states"): + compare_layer_outputs([path], [sequence], {"request": 0}, {0}) + + +def test_layerwise_request_mapping_rejects_missing_sequences(tmp_path): + from glm52_layerwise_comparator import TrainSequence, map_requests_to_train_sequences + + sequence, _, path = _comparison_fixture(tmp_path) + other = TrainSequence(tokens=torch.tensor([10, 11, 12]), layers=sequence.layers, source="other") + with pytest.raises(RuntimeError, match="missing 1 Megatron token sequences"): + map_requests_to_train_sequences([path], [sequence, other]) + + +def _alignment_rollout(tmp_path): + sample = {"response_length": 1, "rollout_log_probs": [-0.5], "loss_mask": [1], "weight_versions": ["1"]} + path = tmp_path / "rollout.pt" + torch.save({"samples": [dict(sample) for _ in range(8)]}, path) + return path + + +def test_alignment_gate_accepts_finite_metric_and_synchronized_rollout(tmp_path): + from test_glm52_6layer_deterministic_e2e import _assert_alignment_result + + path = _alignment_rollout(tmp_path) + _assert_alignment_result("{'train/train_rollout_logprob_abs_diff': 2e-7}", str(path), 1e-6) + + +@pytest.mark.parametrize("metric", [None, "nan", "inf", "1e-5"]) +def test_alignment_gate_rejects_missing_or_invalid_metric(tmp_path, metric): + from test_glm52_6layer_deterministic_e2e import _assert_alignment_result + + path = _alignment_rollout(tmp_path) + output = "{}" if metric is None else "{'train/train_rollout_logprob_abs_diff': " + metric + "}" + with pytest.raises(AssertionError): + _assert_alignment_result(output, str(path), 1e-6) + + +@pytest.mark.parametrize( + "key,value", + [("response_length", 0), ("rollout_log_probs", [float("nan")]), ("loss_mask", [0]), ("weight_versions", ["0"])], +) +def test_alignment_gate_rejects_empty_masked_or_stale_rollout(tmp_path, key, value): + from test_glm52_6layer_deterministic_e2e import _assert_alignment_result + + path = _alignment_rollout(tmp_path) + data = torch.load(path, weights_only=False) + data["samples"][0][key] = value + torch.save(data, path) + with pytest.raises(AssertionError): + _assert_alignment_result("{'train/train_rollout_logprob_abs_diff': 0}", str(path), 1e-6) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index 64e692b10..fc77ed45b 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -1,4 +1,5 @@ import argparse +import ast import importlib.util import runpy import shlex @@ -11,6 +12,60 @@ NUM_GPUS = 0 +@pytest.mark.parametrize("legacy_parser", [False, True]) +def test_checkpoint_conversion_accepts_legacy_model_flags(legacy_parser): + path = Path(__file__).resolve().parents[1] / "tools/convert_hf_to_torch_dist.py" + function = next( + node + for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == "add_convertion_args" + ) + namespace = {} + exec( + compile(ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[])), str(path), "exec"), namespace + ) + parser = argparse.ArgumentParser() + flags = ["--use-gated-attention", "--post-self-attn-layernorm", "--post-mlp-layernorm"] + if legacy_parser: + for flag in flags: + parser.add_argument(flag, action="store_true") + parser = namespace["add_convertion_args"](parser) + args = parser.parse_args(["--hf-checkpoint", "model", *flags]) + assert args.use_gated_attention and args.post_self_attn_layernorm and args.post_mlp_layernorm + assert args.hf_checkpoint == "model" + + +def test_parallel_check_replays_the_same_rollout_for_all_gradient_comparisons(monkeypatch): + from vime.utils import external_utils + + commands = [] + launcher = types.ModuleType("vime.utils.external_utils.command_utils") + launcher.execute_train = lambda **kwargs: commands.append(kwargs["train_args"]) + launcher.get_default_wandb_args = lambda _: "" + monkeypatch.setitem(sys.modules, launcher.__name__, launcher) + monkeypatch.setattr(external_utils, "command_utils", launcher, raising=False) + test = runpy.run_path(str(Path(__file__).with_name("test_qwen3_0.6B_parallel_check.py"))) + test["execute"]() + + parser = argparse.ArgumentParser() + parser.add_argument("--save-debug-rollout-data") + parser.add_argument("--load-debug-rollout-data") + parser.add_argument("--ci-save-grad-norm") + parser.add_argument("--ci-load-grad-norm") + parsed = [parser.parse_known_args(shlex.split(command))[0] for command in commands] + assert len(parsed) == 2 * (1 + len(test["PARALLEL_CONFIGS"])) + template = parsed[0].save_debug_rollout_data + assert template.format(rollout_id=0) != template.format(rollout_id=1) + for index, args in enumerate(parsed): + if index == 0: + assert args.load_debug_rollout_data is None + else: + assert args.load_debug_rollout_data == template + if args.ci_load_grad_norm: + reference_index = 0 if index <= len(test["PARALLEL_CONFIGS"]) else 1 + assert args.ci_load_grad_norm == f"grad_norms-{reference_index}.pt" + + @pytest.mark.parametrize("mode", ["save", "async_save", "load"]) @pytest.mark.parametrize("optimizer", ["cpu", "gpu"]) def test_checkpoint_e2e_launch_has_valid_save_configuration(monkeypatch, tmp_path, mode, optimizer): @@ -105,6 +160,84 @@ def load_vime_arguments_module(monkeypatch): return module +@pytest.mark.parametrize("strategy", [None, "mcore", "nvrx"]) +def test_native_async_checkpoint_default_preserves_explicit_strategy(monkeypatch, strategy): + module = load_vime_arguments_module(monkeypatch) + parser = argparse.ArgumentParser() + parser.add_argument("--async-strategy", choices=["mcore", "nvrx"], default="nvrx") + module.get_vime_extra_args_provider()(parser) + command = ["--rollout-batch-size", "1"] + if strategy is not None: + command += ["--async-strategy", strategy] + assert parser.parse_args(command).async_strategy == (strategy or "mcore") + + +def test_legacy_megatron_parser_does_not_gain_async_strategy(monkeypatch): + module = load_vime_arguments_module(monkeypatch) + parser = argparse.ArgumentParser() + module.get_vime_extra_args_provider()(parser) + assert not hasattr(parser.parse_args(["--rollout-batch-size", "1"]), "async_strategy") + + +@pytest.mark.parametrize( + "legacy_rope,position_type,expected", + [(True, "learned_absolute", "rope"), (False, "learned_absolute", "learned_absolute"), (False, "none", "none")], +) +def test_legacy_rope_is_resolved_before_mtp_validation(monkeypatch, legacy_rope, position_type, expected): + module = load_arguments_module(monkeypatch) + args = argparse.Namespace( + use_rotary_position_embeddings=legacy_rope, + position_embedding_type=position_type, + mtp_num_layers=1, + fp16=False, + seq_length=None, + max_position_embeddings=None, + vocab_size=None, + padded_vocab_size=None, + tokenizer_model=None, + tokenizer_type=None, + hf_checkpoint="model", + ) + module.set_default_megatron_args(args) + assert args.position_embedding_type == expected + + +@pytest.mark.parametrize("explicit_path", [False, True]) +def test_pd_mooncake_debug_dump_keeps_each_rollout(monkeypatch, tmp_path, explicit_path): + from vime.utils import external_utils + + commands = [] + launcher = types.ModuleType("vime.utils.external_utils.command_utils") + launcher.execute_train = lambda **kwargs: commands.append(kwargs["train_args"]) + launcher.get_default_wandb_args = lambda _: "" + monkeypatch.setitem(sys.modules, launcher.__name__, launcher) + monkeypatch.setattr(external_utils, "command_utils", launcher, raising=False) + if explicit_path: + monkeypatch.setenv("DEBUG_ROLLOUT_DATA", str(tmp_path / "debug data" / "rollout_{rollout_id}.pt")) + else: + monkeypatch.delenv("DEBUG_ROLLOUT_DATA", raising=False) + test = runpy.run_path(str(Path(__file__).with_name("test_qwen3.6_35B_A3B_pd_mooncake.py"))) + test["execute"]() + [command] = commands + parser = argparse.ArgumentParser() + parser.add_argument("--save-debug-rollout-data") + args, _ = parser.parse_known_args(shlex.split(command)) + + module = load_vime_arguments_module(monkeypatch) + module.vime_validate_args( + make_vime_validate_args( + rollout_data_transport="straw", + rollout_data_dir=str(tmp_path / "rollout_data"), + ckpt_format="torch_dist", + save_debug_rollout_data=args.save_debug_rollout_data, + ) + ) + paths = [args.save_debug_rollout_data.format(rollout_id=rollout_id) for rollout_id in range(2)] + assert paths[0] != paths[1] + if explicit_path: + assert paths[0] == str(tmp_path / "debug data" / "rollout_0.pt") + + def make_qwen3_6_args(**overrides): values = dict( hidden_size=2048, diff --git a/tests/test_megatron_checkpoint.py b/tests/test_megatron_checkpoint.py new file mode 100644 index 000000000..e1d6adf31 --- /dev/null +++ b/tests/test_megatron_checkpoint.py @@ -0,0 +1,190 @@ +"""CPU staging and serialization contracts for Megatron checkpoint workers.""" + +import ast +import importlib.util +import math +import pickle +import sys +import types +from multiprocessing.reduction import ForkingPickler +from pathlib import Path + +import pytest +import torch +from torch.multiprocessing import reductions + +NUM_GPUS = 0 + + +@pytest.mark.parametrize("norm_type", ["float", "scalar_tensor", "vector_tensor"]) +@pytest.mark.parametrize("finite", [False, True]) +def test_optimizer_step_returns_scalar_norm_and_skips_invalid_gradients(norm_type, finite): + path = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils/model.py" + function = next( + node + for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == "train_one_step" + ) + value = 3.25 if finite else float("nan") + norm = value if norm_type == "float" else torch.tensor([value] if norm_type == "vector_tensor" else value) + args = types.SimpleNamespace( + custom_megatron_before_train_step_hook_path=None, + check_for_nan_in_loss_and_grad=False, + ci_test=False, + enable_mtp_training=False, + seq_length=64, + micro_batch_size=1, + decoder_seq_length=None, + calculate_per_token_loss=False, + ) + updates, samples = [], [] + + def step(): + updates.append(True) + return True, norm, 0 + + optimizer = types.SimpleNamespace( + zero_grad=lambda: None, prepare_grads=lambda: False, get_grad_norm=lambda: norm, step=step + ) + namespace = { + "get_args": lambda: args, + "torch": torch, + "math": math, + "get_forward_backward_func": lambda: lambda **kwargs: [], + "_wrap_forward_step_with_microbatch_pbar": lambda forward, progress: forward, + "mpu": types.SimpleNamespace( + is_pipeline_last_stage=lambda **kwargs: True, + get_context_parallel_world_size=lambda: 1, + get_data_parallel_group=lambda **kwargs: None, + ), + "train_metric_utils": types.SimpleNamespace(reduce_train_step_metrics=lambda *args, **kwargs: {"loss": 1.0}), + } + isolated = ast.Module( + body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), function], + type_ignores=[], + ) + exec(compile(ast.fix_missing_locations(isolated), str(path), "exec"), namespace) + losses, grad_norm = namespace["train_one_step"]( + args, + 0, + 0, + None, + [types.SimpleNamespace(zero_grad_buffer=lambda: None)], + optimizer, + types.SimpleNamespace(step=lambda *, increment: samples.append(increment)), + 1, + 16, + ) + assert losses == {"loss": 1.0} and isinstance(grad_norm, float) + assert grad_norm == value if finite else math.isnan(grad_norm) + assert updates == ([True] if finite else []) + assert samples == ([16] if finite else []) + + +def load_module(name, filename): + path = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils" / filename + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("remainders", [False, True]) +def test_precision_aware_master_parameters_roundtrip_as_fp32(monkeypatch, remainders): + class FusedAdam: + store_param_remainders = remainders + + fake_te = types.ModuleType("transformer_engine.pytorch.optimizers") + fake_te.FusedAdam = FusedAdam + monkeypatch.setitem(sys.modules, fake_te.__name__, fake_te) + extensions = load_module("checkpoint_te_extensions", "transformer_engine.py") + # Signed values, signed zero, and both sides of BF16 rounding boundaries. + bits = torch.tensor([0x3F801234, 0x3F808000, -1082097664, -1082104047, 0, -2147483648], dtype=torch.int32) + expected = bits.view(torch.float32) + param = torch.nn.Parameter(expected.to(torch.bfloat16)) + adam = FusedAdam() + adam.param_groups = [{"params": [param]}] + states = {"param": expected.clone(), "exp_avg": torch.arange(6, dtype=torch.float32), "exp_avg_sq": torch.ones(6)} + + def get_states(model_param): + assert model_param is param + return states.copy() + + def set_states(model_param, incoming): + assert model_param is param + assert incoming["param"].dtype == (torch.int16 if remainders else torch.float32) + states.update(incoming) + + optimizer = types.SimpleNamespace( + optimizer=adam, + model_param_group_index_map={param: (0, 0)}, + _get_main_param_and_optimizer_states=get_states, + _set_main_param_and_optimizer_states=set_states, + ) + extensions.patch_precision_aware_optimizer_checkpointing(optimizer) + optimizer._set_main_param_and_optimizer_states(param, states.copy()) + if remainders: + assert states["param"].dtype == torch.int16 + for _ in range(2): + checkpoint = pickle.loads(pickle.dumps(optimizer._get_main_param_and_optimizer_states(param))) + assert checkpoint["param"].dtype == torch.float32 + assert torch.equal(checkpoint["param"].view(torch.int32), bits) + torch.testing.assert_close(checkpoint["exp_avg"], torch.arange(6, dtype=torch.float32), rtol=0, atol=0) + optimizer._set_main_param_and_optimizer_states(param, checkpoint) + if not remainders: + assert optimizer._get_main_param_and_optimizer_states is get_states + assert optimizer._set_main_param_and_optimizer_states is set_states + + +def test_checkpoint_cpu_tensors_serialize_after_weight_sync(): + native_reduce = reductions.reduce_tensor + tensor = torch.arange(16).reshape(4, 4)[:, ::2] + for _ in range(2): + native_reduce(tensor) + restored = pickle.loads(ForkingPickler.dumps(tensor)) + torch.testing.assert_close(restored, tensor, rtol=0, atol=0) + assert reductions.reduce_tensor is native_reduce + + +@pytest.mark.parametrize("empty", [False, True]) +def test_async_writer_payload_is_cpu_only_and_hook_is_restored(monkeypatch, empty): + class CudaAllocation: + def __reduce__(self): + raise RuntimeError("CUDA IPC is unavailable for this allocation") + + expected = torch.arange(8) + + class Writer: + def get_save_function_and_args(self): + if empty: + return None, None, [] + return len, lambda: [expected], [0, [CudaAllocation()], None] + + modules = { + "megatron.training.checkpointing": {"load_checkpoint": None, "save_checkpoint": None}, + "megatron.training.global_vars": {"get_args": None}, + "megatron.core.dist_checkpointing.strategies.filesystem_async": {"FileSystemWriterAsync": Writer}, + } + for name, attributes in modules.items(): + module = types.ModuleType(name) + module.__dict__.update(attributes) + monkeypatch.setitem(sys.modules, name, module) + # Avoid applying the checkpoint module's unrelated sharding metadata hooks. + monkeypatch.setitem(sys.modules, "torch.distributed._shard.sharding_spec", None) + checkpoint = load_module("checkpoint_cpu_staging", "checkpoint.py") + original = Writer.get_save_function_and_args + with pytest.raises(ValueError, match="save failed"): + with checkpoint._stage_async_save_on_cpu(): + fn, preload, args = Writer().get_save_function_and_args() + assert preload is None + if empty: + assert fn is None and args == [] + else: + restored = pickle.loads(pickle.dumps(args)) + torch.testing.assert_close(restored[1][0], expected, rtol=0, atol=0) + raise ValueError("save failed") + assert Writer.get_save_function_and_args is original + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_megatron_tokenizer_init.py b/tests/test_megatron_tokenizer_init.py new file mode 100644 index 000000000..842f1b427 --- /dev/null +++ b/tests/test_megatron_tokenizer_init.py @@ -0,0 +1,115 @@ +"""Tokenizer cache transactions must be safe across training ranks.""" + +import ast +import threading +import types +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from contextlib import nullcontext +from pathlib import Path + +import pytest +from filelock import FileLock + +NUM_GPUS = 0 + + +def load_init(cache_dir, build_tokenizer): + path = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils/initialize.py" + function = next( + node for node in ast.parse(path.read_text()).body if isinstance(node, ast.FunctionDef) and node.name == "init" + ) + namespace = { + "set_args": lambda args: None, + "_initialize_distributed": lambda args: None, + "_set_random_seed": lambda *args: None, + "_build_tokenizer": build_tokenizer, + "init_num_microbatches_calculator": lambda *args: None, + "np": types.SimpleNamespace(__version__="1.26.4"), + "nullcontext": nullcontext, + "Path": Path, + "FileLock": FileLock, + "HF_MODULES_CACHE": str(cache_dir), + } + exec( + compile(ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[])), str(path), "exec"), namespace + ) + return namespace["init"] + + +def args(tokenizer_type="HuggingFaceTokenizer"): + return types.SimpleNamespace( + enable_experimental=False, + rank=1, + seed=1234, + data_parallel_random_init=False, + te_rng_tracker=False, + inference_rng_tracker=False, + tokenizer_type=tokenizer_type, + rampup_batch_size=None, + global_batch_size=8, + micro_batch_size=1, + data_parallel_size=1, + decrease_batch_size_if_needed=False, + deterministic_mode=False, + tp_comm_overlap=False, + ) + + +def test_ranks_cannot_import_partially_written_tokenizer_code(tmp_path): + cache_dir = tmp_path / "modules" + source = cache_dir / "tokenization_moonshot.py" + copying, finish_copy, reader_started = threading.Event(), threading.Event(), threading.Event() + loaded = [] + + def build_tokenizer(_args): + if not source.exists(): + source.parent.mkdir(parents=True, exist_ok=True) + with source.open("w") as output: + copying.set() + assert finish_copy.wait(5) + output.write("class TikTokenTokenizer: pass\n") + module = {} + exec(source.read_text(), module) + loaded.append(module["TikTokenTokenizer"]()) + + init = load_init(cache_dir, build_tokenizer) + + def reader(): + reader_started.set() + init(args()) + + with ThreadPoolExecutor(max_workers=2) as executor: + writer = executor.submit(init, args()) + try: + assert copying.wait(5) + concurrent_reader = executor.submit(reader) + assert reader_started.wait(5) + # The reader must wait for the copy/import transaction to finish. + with pytest.raises(FutureTimeoutError): + concurrent_reader.result(timeout=0.2) + finally: + finish_copy.set() + writer.result(timeout=5) + concurrent_reader.result(timeout=5) + assert len(loaded) == 2 + + +@pytest.mark.parametrize("tokenizer_type", ["HuggingFaceTokenizer", "GPT2BPETokenizer"]) +def test_tokenizer_build_failure_does_not_prevent_retry(tmp_path, tokenizer_type): + calls = [] + + def build_tokenizer(_args): + calls.append(True) + if len(calls) == 1: + raise ValueError("invalid tokenizer config") + + init = load_init(tmp_path / "modules", build_tokenizer) + with pytest.raises(ValueError, match="invalid tokenizer config"): + init(args(tokenizer_type)) + init(args(tokenizer_type)) + assert len(calls) == 2 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_model_provider_freeze.py b/tests/test_model_provider_freeze.py index e1bf45f0b..66aeb65d6 100644 --- a/tests/test_model_provider_freeze.py +++ b/tests/test_model_provider_freeze.py @@ -17,6 +17,9 @@ def _load_model_provider(monkeypatch): "megatron.core.models.gpt": types.ModuleType("megatron.core.models.gpt"), "megatron.core.models.gpt.gpt_layer_specs": types.ModuleType("megatron.core.models.gpt.gpt_layer_specs"), "megatron.core.transformer": types.ModuleType("megatron.core.transformer"), + "megatron.core.transformer.multi_latent_attention": types.ModuleType( + "megatron.core.transformer.multi_latent_attention" + ), "megatron.core.transformer.spec_utils": types.ModuleType("megatron.core.transformer.spec_utils"), "megatron.core.transformer.transformer_config": types.ModuleType( "megatron.core.transformer.transformer_config" @@ -27,6 +30,11 @@ def _load_model_provider(monkeypatch): } modules["megatron.core"].tensor_parallel = types.SimpleNamespace() modules["megatron.core.models.gpt"].GPTModel = torch.nn.Module + + class NativeMLASelfAttention(torch.nn.Module): + pass + + modules["megatron.core.transformer.multi_latent_attention"].MLASelfAttention = NativeMLASelfAttention layer_specs = modules["megatron.core.models.gpt.gpt_layer_specs"] layer_specs.get_gpt_decoder_block_spec = lambda *args, **kwargs: None layer_specs.get_gpt_layer_local_spec = lambda *args, **kwargs: None @@ -123,5 +131,85 @@ def test_freeze_indexer_rejects_unrecognized_attention(monkeypatch): model_provider.freeze_model_params(model, args) +@pytest.mark.parametrize("legacy_spec", [False, True]) +@pytest.mark.parametrize("mla", [False, True]) +@pytest.mark.parametrize("legacy_mla", [False, True]) +@pytest.mark.parametrize("num_experts", [None, 4]) +def test_provider_supports_old_and_new_te_spec_signatures(monkeypatch, legacy_spec, mla, legacy_mla, num_experts): + from types import SimpleNamespace + + module = _load_model_provider(monkeypatch) + config = SimpleNamespace(q_lora_rank=None, qk_layernorm=mla) + monkeypatch.setattr(module, "core_transformer_config_from_args", lambda args: config) + calls = [] + if not legacy_mla: + monkeypatch.setattr(module.MegatronMLASelfAttention, "_resolve_qk_norm_config", lambda *a: {}, raising=False) + layer_spec = SimpleNamespace( + submodules=SimpleNamespace(self_attention=SimpleNamespace(module=module.MegatronMLASelfAttention)) + ) + transformer_spec = SimpleNamespace(layer_specs=[layer_spec]) if num_experts else layer_spec + monkeypatch.setattr(module, "get_gpt_decoder_block_spec", lambda *a, **kw: transformer_spec) + + if legacy_spec: + + def build_spec( + num_experts, moe_grouped_gemm, qk_layernorm, multi_latent_attention, moe_use_legacy_grouped_gemm + ): + assert moe_use_legacy_grouped_gemm is True + calls.append((num_experts, moe_grouped_gemm, qk_layernorm, multi_latent_attention)) + return layer_spec + + else: + + def build_spec(num_experts, moe_grouped_gemm, qk_layernorm, multi_latent_attention): + calls.append((num_experts, moe_grouped_gemm, qk_layernorm, multi_latent_attention)) + return layer_spec + + monkeypatch.setattr(module, "get_gpt_layer_with_transformer_engine_spec", build_spec) + monkeypatch.setattr(module, "GPTModel", lambda **kwargs: SimpleNamespace(**kwargs)) + args = SimpleNamespace( + transformer_impl="transformer_engine", + spec=None, + num_experts=num_experts, + moe_grouped_gemm=False, + qk_layernorm=mla, + multi_latent_attention=mla, + moe_use_legacy_grouped_gemm=True, + fp8_param_gather=False, + padded_vocab_size=128, + max_position_embeddings=64, + fp16_lm_cross_entropy=False, + untie_embeddings_and_output_weights=True, + position_embedding_type="rope", + rotary_percent=1.0, + rotary_base=10000, + use_rope_scaling=False, + mtp_num_layers=None, + ) + model = module._get_model_provider_func(args)() + assert model.transformer_layer_spec is transformer_spec + assert model.config is config + assert calls == ([] if num_experts else [(None, False, mla, mla)]) + expected_attention = module.MLASelfAttention if mla and not legacy_mla else module.MegatronMLASelfAttention + assert layer_spec.submodules.self_attention.module is expected_attention + + +@pytest.mark.parametrize("q_lora_rank", [None, 16]) +def test_mla_q_norm_requires_q_lora(monkeypatch, q_lora_rank): + module = _load_model_provider(monkeypatch) + q_projection, fused_q_projection, kv_projection = object(), object(), object() + monkeypatch.setattr( + module.MegatronMLASelfAttention, + "_resolve_qk_norm_config", + lambda *args: {"linear_q_proj": fused_q_projection, "linear_kv_up_proj": kv_projection}, + raising=False, + ) + attention = module.MLASelfAttention() + attention.config = types.SimpleNamespace(q_lora_rank=q_lora_rank) + resolved = attention._resolve_qk_norm_config(types.SimpleNamespace(linear_q_proj=q_projection)) + assert resolved["linear_q_proj"] is (q_projection if q_lora_rank is None else fused_q_projection) + assert resolved["linear_kv_up_proj"] is kv_projection + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_published_payload_retry.py b/tests/test_published_payload_retry.py index d48a93928..64cef7a4c 100644 --- a/tests/test_published_payload_retry.py +++ b/tests/test_published_payload_retry.py @@ -49,7 +49,10 @@ def restore(): @pytest.mark.parametrize("message", ["Invalid segment header", "Record payload checksum mismatch"]) -def test_native_failure_is_not_retried_again_by_vime(monkeypatch, message): +@pytest.mark.parametrize("legacy_exception", [False, True]) +def test_native_failure_is_not_retried_again_by_vime(monkeypatch, message, legacy_exception): + if legacy_exception: + monkeypatch.setattr(CorruptData, "add_note", None, raising=False) ref = transport.DiskPayloadRef( SimpleNamespace(manifest=SimpleNamespace(segment=SimpleNamespace(path="raw/test.pack", offset=123))), "/test" ) @@ -63,4 +66,5 @@ def load(value): with pytest.raises(CorruptData) as error: asyncio.run(transport.unpack_published_payload(ref)) assert len(calls) == 1 - assert "offset=123" in error.value.__notes__[0] + if getattr(error.value, "add_note", None) is not None: + assert "offset=123" in error.value.__notes__[0] diff --git a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py index e1d30b51d..3fa31e7ff 100644 --- a/tests/test_qwen3.6_35B_A3B_pd_mooncake.py +++ b/tests/test_qwen3.6_35B_A3B_pd_mooncake.py @@ -29,10 +29,6 @@ def execute(): debug_data_path = os.environ.get("DEBUG_ROLLOUT_DATA") or tempfile.mktemp( prefix="qwen3_6_35b_a3b_pd_rollout_", suffix="_{rollout_id}.pt" ) - try: - os.remove(debug_data_path) - except FileNotFoundError: - pass print(f"Saving debug rollout data to {debug_data_path}") ckpt_args = f"--hf-checkpoint /root/models/{MODEL_NAME} " f"--ref-load {TORCH_DIST_CKPT} " @@ -108,7 +104,7 @@ def execute(): misc_args = ( "--ci-test " - f"--save-debug-rollout-data {debug_data_path} " + f"--save-debug-rollout-data {quote(debug_data_path)} " "--update-weight-buffer-size 2147483648 " "--attention-dropout 0.0 " "--hidden-dropout 0.0 " diff --git a/tests/test_qwen3_0.6B_parallel_check.py b/tests/test_qwen3_0.6B_parallel_check.py index 166fb2ab6..65cf6c6eb 100644 --- a/tests/test_qwen3_0.6B_parallel_check.py +++ b/tests/test_qwen3_0.6B_parallel_check.py @@ -102,12 +102,11 @@ def execute(): f"{misc_args} " ) - rollout_data_pattern = "parallel-check-rollout-data-{rollout_id}.pt" - rollout_data_path = rollout_data_pattern.format(rollout_id=0) + rollout_data_path = "parallel-check-rollout-data_{rollout_id}.pt" for i, calculate_per_token_loss in enumerate((False, True)): loss_args = "--calculate-per-token-loss " if calculate_per_token_loss else "" rollout_data_args = ( - f"--save-debug-rollout-data {rollout_data_pattern} " + f"--save-debug-rollout-data {rollout_data_path} " if i == 0 else f"--load-debug-rollout-data {rollout_data_path} " ) diff --git a/tests/test_reference_top_p_replay.py b/tests/test_reference_top_p_replay.py index 8a38f5c2a..1db30564d 100644 --- a/tests/test_reference_top_p_replay.py +++ b/tests/test_reference_top_p_replay.py @@ -12,7 +12,7 @@ import torch from megatron.core import mpu -from vime.backends.megatron_utils.loss import get_log_probs_and_entropy, policy_loss_function +from vime.backends.megatron_utils.loss import get_log_probs_and_entropy NUM_GPUS = 0 @@ -115,55 +115,84 @@ def log_prob(probs, *, replay, prefix=""): ) -@pytest.mark.unit -def test_initial_model_kl_preserves_masked_training_loss(monkeypatch): - monkeypatch.setattr(mpu, "get_tensor_model_parallel_group", lambda: None, raising=False) - monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0, raising=False) +@pytest.mark.parametrize("top_p", [1.0, 0.95]) +def test_initial_kl_check_respects_policy_support(top_p): + source = Path(__file__).resolve().parents[1] / "vime/backends/megatron_utils/model.py" + train = next( + node + for node in ast.parse(source.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == "train" + ) + checks = [ + node + for node in ast.walk(train) + if isinstance(node, ast.If) and ast.unparse(node.test).startswith("args.ci_test and") + ] + program = compile(ast.fix_missing_locations(ast.Module(body=checks, type_ignores=[])), "model.py", "exec") args = Namespace( - rollout_temperature=1.0, - rollout_top_p=0.95, - allgather_cp=False, - log_probs_chunk_size=-1, - entropy_coef=0.0, - use_rollout_logprobs=False, - use_opsm=False, - advantage_estimator="grpo", - pg_loss_type="reinforce", - get_mismatch_metrics=False, - use_tis=False, - use_kl_loss=True, - use_unbiased_kl=False, - kl_loss_type="low_var_kl", - kl_loss_coef=1.0, ci_test=True, + ci_disable_kl_checker=False, + ci_train_rollout_logprob_abs_diff_threshold=0.1, + rollout_top_p=top_p, + use_rollout_routing_replay=False, ) - logits = torch.tensor([[[0.2, 0.2, 0.6], [0.2, 0.2, 0.6]]]).log().requires_grad_() - batch = { - "advantages": [torch.zeros(1)], - "unconcat_tokens": [torch.tensor([0, 0])], - "total_lengths": [2], - "response_lengths": [1], - "loss_masks": [torch.ones(1)], - "ref_log_probs": [torch.tensor([math.log(0.2)])], - "rollout_top_p_token_ids": [[0, 1]], - "rollout_top_p_token_offsets": [[0, 2]], + log_dict = { + "train/train_rollout_logprob_abs_diff": 0.0072, + "train/ppo_kl": 0.0, + "train/pg_clipfrac": 0.0, + "train/kl_loss": 0.000169, } - loss, metrics = policy_loss_function(args, batch, logits, torch.mean) - assert metrics["model_kl"].item() == pytest.approx(0.0, abs=1e-8) - assert metrics["kl_loss"].item() > 0.1 - assert not metrics["model_kl"].requires_grad - args.ci_test = False - regular_loss, regular_metrics = policy_loss_function(args, batch, logits, torch.mean) - assert "model_kl" not in regular_metrics - torch.testing.assert_close(loss, regular_loss) - torch.testing.assert_close( - torch.autograd.grad(loss, logits, retain_graph=True)[0], - torch.autograd.grad(regular_loss, logits)[0], + namespace = {"args": args, "log_dict": log_dict, "step_id": 0, "accumulated_step_id": 0} + if top_p == 1.0: + with pytest.raises(AssertionError): + exec(program, namespace) + log_dict["train/kl_loss"] = 0.0 + exec(program, namespace) + for key, value in [("train/ppo_kl", 0.001), ("train/train_rollout_logprob_abs_diff", 0.2)]: + previous = log_dict[key] + log_dict[key] = value + with pytest.raises(AssertionError): + exec(program, namespace) + log_dict[key] = previous + + +@pytest.mark.parametrize("tag", ["ref", "teacher", "actor"]) +@pytest.mark.parametrize("step", [None, 0, 3]) +@pytest.mark.parametrize("fail", [False, True]) +def test_reference_checkpoint_owns_its_step_and_restores_actor_args(tag, step, fail): + method = _actor_methods()["load_other_checkpoint"] + args = Namespace( + load="actor-checkpoint", + no_load_optim=False, + no_load_rng=False, + finetune=False, + ckpt_step=7, + ref_ckpt_step=step, + opd_teacher_ckpt_step=step, + ) + original = vars(args).copy() + backups = [] + actor = SimpleNamespace( + args=args, model=object(), weights_backuper=SimpleNamespace(backup=backups.append), _active_model_tag="actor" ) - args.ci_test = True - batch["ref_log_probs"] = [torch.tensor([math.log(0.1)])] - _, metrics = policy_loss_function(args, batch, logits, torch.mean) - assert metrics["model_kl"].item() > 1e-8 + + def load_checkpoint(model, optimizer, scheduler, **kwargs): + assert args.load == "other-checkpoint" and args.ckpt_step == (7 if tag == "actor" else step) + assert args.finetune and args.no_load_optim and args.no_load_rng + if fail: + raise ValueError("invalid checkpoint") + return 0, 0 + + namespace = {"load_checkpoint": load_checkpoint} + exec(compile(ast.fix_missing_locations(ast.Module(body=[method], type_ignores=[])), "actor.py", "exec"), namespace) + if fail: + with pytest.raises(ValueError, match="invalid checkpoint"): + namespace["load_other_checkpoint"](actor, tag, "other-checkpoint") + assert backups == [] and actor._active_model_tag == "actor" + else: + namespace["load_other_checkpoint"](actor, tag, "other-checkpoint") + assert backups == [tag] and actor._active_model_tag == tag + assert vars(args) == original if __name__ == "__main__": diff --git a/tests/test_rollout_manager_recovery.py b/tests/test_rollout_manager_recovery.py index b5da9b9eb..c722c454f 100644 --- a/tests/test_rollout_manager_recovery.py +++ b/tests/test_rollout_manager_recovery.py @@ -237,13 +237,20 @@ def test_manager_failure_replays_accepted_batch_with_new_dp(tmp_path, cluster, p @ray.remote(num_cpus=0) class _ResetPeer: - def __init__(self, wedged): + def __init__(self, wedged, blocked_peer=None): self.wedged = wedged + self.blocked_peer = blocked_peer def pid(self): return os.getpid() + def get_url(self): + return "http://engine" + def reset_weights_update_groups(self): + if self.blocked_peer is not None: + with pytest.raises(ray.exceptions.RayActorError): + ray.get(self.blocked_peer.pid.remote(), timeout=1) if self.wedged: import time @@ -253,13 +260,27 @@ def shutdown(self): pass -def test_serving_reset_retires_blocked_actor_and_keeps_healthy_peer(cluster, monkeypatch): +@pytest.mark.parametrize("block_actor", [False, True]) +def test_serving_reset_retires_blocked_actor_and_keeps_healthy_peer(cluster, monkeypatch, tmp_path, block_actor): import time from types import SimpleNamespace from vime.backends.vllm_utils import engine_group - blocked, healthy = _ResetPeer.remote(True), _ResetPeer.remote(False) + blocked = _ResetPeer.remote(True) + healthy = _ResetPeer.remote(False, blocked if block_actor else None) pids = ray.get([blocked.pid.remote(), healthy.pid.remote()], timeout=30) + if block_actor: + marker = tmp_path / "blocked" + + def block(actor): + marker.touch() + time.sleep(300) + + blocked.__ray_call__.remote(block) + deadline = time.monotonic() + 10 + while not marker.exists(): + assert time.monotonic() < deadline + time.sleep(0.01) removed = [] monkeypatch.setattr(engine_group, "unregister_worker", lambda router, url, **kw: removed.append(url)) group = engine_group.ServerGroup( @@ -331,6 +352,9 @@ def reset_weights_update_groups(self): def ready(self): return True + def get_url(self): + return self.name + def test_prefill_and_decode_enter_group_reset_together(cluster): from types import SimpleNamespace diff --git a/tests/test_rollout_metadata_index.py b/tests/test_rollout_metadata_index.py index ede309c95..08128bd03 100644 --- a/tests/test_rollout_metadata_index.py +++ b/tests/test_rollout_metadata_index.py @@ -10,6 +10,8 @@ from vime.data.sample_metadata import describe_sample, validate_sample_metadata from vime.utils.types import Sample +NUM_GPUS = 0 + def sample(): return Sample( @@ -92,3 +94,7 @@ def test_metadata_counts_routed_chunks_without_materializing(): ] metadata = describe_sample(value) assert metadata["tensors"]["rollout_routed_experts"] == {"shape": [3, 3, 2], "dtype": "int32"} + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_rollout_straw_transport.py b/tests/test_rollout_straw_transport.py index 3b6280933..a9aab58d8 100644 --- a/tests/test_rollout_straw_transport.py +++ b/tests/test_rollout_straw_transport.py @@ -158,11 +158,16 @@ def create_controller(config, *, restore_plan): plan = RestorePlan() args.debug_train_only = True + # Train-only parsing omits SGLang options even when GPU counts remain set + # and the serving owner supplies an empty deployment snapshot. + args.rollout_num_gpus = 8 + args.rollout_num_gpus_per_engine = 1 args.data_source_path = "user.CustomSource" args.rollout_function_path = args.eval_function_path = "user.rollout" args.custom_reward_post_process_path = args.custom_convert_samples_to_train_data_path = None original = vars(args).copy() monkeypatch.setattr(rollout, "check_rollout_storage", lambda _: None) + monkeypatch.setattr(rollout, "init_http_client", lambda _: pytest.fail("Train-only replay initialized HTTP")) monkeypatch.setattr(queue_data_source, "create_queue_controller", create_controller) monkeypatch.setattr(rollout, "load_function", lambda path: CustomSource if path == args.data_source_path else None) monkeypatch.setattr(rollout, "init_tracking", lambda *a, **kw: None) @@ -185,6 +190,7 @@ def create_controller(config, *, restore_plan): manager = rollout.RolloutManager.__ray_metadata__.modified_class( args, None, restore_plan=plan, serving=serving, deployment=deployment ) + assert manager.servers == {} assert manager.controller is manager.batch_builder.controller is handle assert vars(args) == original manager.dispose() diff --git a/tests/test_stateless_adam.py b/tests/test_stateless_adam.py index d117957d5..a34242842 100644 --- a/tests/test_stateless_adam.py +++ b/tests/test_stateless_adam.py @@ -69,7 +69,13 @@ def checkpoint_module(tmp_path, monkeypatch): # Only Megatron's model/collective boundary is replaced. The Vime adapter # writes real checkpoint files, and the optimizer/scheduler below are real. args = SimpleNamespace( - save=str(tmp_path), load=str(tmp_path), use_stateless_adam=True, finetune=False, ckpt_step=0 + save=str(tmp_path), + load=str(tmp_path), + use_stateless_adam=True, + finetune=False, + ckpt_step=0, + async_save=False, + offload_train=False, ) def save_model(iteration, *a, **kw): diff --git a/tests/test_training_recovery.py b/tests/test_training_recovery.py index 0a88acc44..fe7206ac1 100644 --- a/tests/test_training_recovery.py +++ b/tests/test_training_recovery.py @@ -90,6 +90,10 @@ def test_configuration_allows_trainer_parallelism_and_memory_changes(args): serving.driver_job_id = None updated = copy.copy(args) updated.tensor_model_parallel_size = 4 + updated.tensor_parallel_num_weight_shards = 4 + updated.gtp_weight_remat_size = 1 + updated.expert_tensor_parallel_num_weight_shards = 2 + updated.expert_gtp_weight_remat_size = 2 updated.context_parallel_size = 2 updated.micro_batch_size = 2 updated.max_tokens_per_gpu = 1024 diff --git a/tests/test_update_weight_factory.py b/tests/test_update_weight_factory.py index 0259cd892..a82995fb6 100644 --- a/tests/test_update_weight_factory.py +++ b/tests/test_update_weight_factory.py @@ -68,3 +68,7 @@ def weights_getter(): assert updater.model_name == "model" assert updater.quantization_config == {"quant_method": "test"} assert updater.weight_version == 7 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/utils/test_vllm_config.py b/tests/utils/test_vllm_config.py index 251a6f41a..372e8b461 100644 --- a/tests/utils/test_vllm_config.py +++ b/tests/utils/test_vllm_config.py @@ -324,7 +324,10 @@ def test_server_group_parallel_config_derives_tp_from_overridden_pcp(self): def test_vllm_server_args_derive_tp_from_overridden_pp(self, monkeypatch): from vime.backends.vllm_utils import vllm_engine + from vime.utils import accelerator + # Device mapping is tested separately; building server args needs no GPU. + monkeypatch.setattr(accelerator, "resolve_visible_device_id", lambda device_id: device_id) monkeypatch.setattr(vllm_engine, "_VLLM_SERVER_FIELDS", frozenset()) args = Namespace( @@ -361,7 +364,9 @@ def test_vllm_server_args_derive_tp_from_overridden_pp(self, monkeypatch): def test_offload_rollout_enables_vllm_sleep_mode(self, monkeypatch): from vime.backends.vllm_utils import vllm_engine + from vime.utils import accelerator + monkeypatch.setattr(accelerator, "resolve_visible_device_id", lambda device_id: device_id) monkeypatch.setattr(vllm_engine, "_VLLM_SERVER_FIELDS", frozenset()) args = Namespace( diff --git a/tools/convert_hf_to_torch_dist.py b/tools/convert_hf_to_torch_dist.py index 5d189caf2..c45d18383 100644 --- a/tools/convert_hf_to_torch_dist.py +++ b/tools/convert_hf_to_torch_dist.py @@ -20,6 +20,9 @@ def add_convertion_args(parser): """Add conversion arguments to the parser""" + for flag in ("--use-gated-attention", "--post-self-attn-layernorm", "--post-mlp-layernorm"): + if flag not in parser._option_string_actions: + parser.add_argument(flag, action="store_true") parser.add_argument("--hf-checkpoint", type=str, required=True, help="HuggingFace model path") parser.add_argument( "--custom-model-provider-path", diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py index 5a3f5eb03..4cea7f6d0 100644 --- a/vime/backends/megatron_utils/actor.py +++ b/vime/backends/megatron_utils/actor.py @@ -696,13 +696,19 @@ def load_other_checkpoint(self, model_tag: str, path: str) -> None: self.args.no_load_rng = True self.args.finetune = True + # The actor's resume step belongs to a different checkpoint. A None + # reference/teacher step must let its own tracker select the release. if model_tag == "ref": self.args.ckpt_step = self.args.ref_ckpt_step elif model_tag == "teacher": self.args.ckpt_step = self.args.opd_teacher_ckpt_step - try: - load_checkpoint(self.model, None, None, checkpointing_context={}) + _, _ = load_checkpoint( + self.model, + None, + None, + checkpointing_context={}, + ) finally: ( self.args.load, diff --git a/vime/backends/megatron_utils/arguments.py b/vime/backends/megatron_utils/arguments.py index 5176eb8fc..bdb980f2e 100644 --- a/vime/backends/megatron_utils/arguments.py +++ b/vime/backends/megatron_utils/arguments.py @@ -149,8 +149,16 @@ def equal(x, y): def _set_default_megatron_args(args): + # Megatron 0.19 validates MTP before resolving this legacy RoPE flag. + # Normalize it here for both training and checkpoint conversion. + if getattr(args, "use_rotary_position_embeddings", False): + args.position_embedding_type = "rope" # always use zero optimizer args.use_distributed_optimizer = True + if hasattr(args, "mtp_detach_heads"): + args.mtp_detach_heads = True + if hasattr(args, "trust_remote_code"): + args.trust_remote_code = True if not hasattr(args, "enable_gloo_process_groups"): args.enable_gloo_process_groups = True # TODO: maybe change this after megatron has good fp8 support diff --git a/vime/backends/megatron_utils/checkpoint.py b/vime/backends/megatron_utils/checkpoint.py index 031caf6c6..633754346 100644 --- a/vime/backends/megatron_utils/checkpoint.py +++ b/vime/backends/megatron_utils/checkpoint.py @@ -1,6 +1,7 @@ import logging import os import re +from contextlib import contextmanager, nullcontext from pathlib import Path import torch @@ -94,9 +95,33 @@ def _init_from_local_shards_and_global_metadata( # type: ignore[override] __all__ = ["save_checkpoint"] +@contextmanager +def _stage_async_save_on_cpu(): + from megatron.core.dist_checkpointing.strategies.filesystem_async import FileSystemWriterAsync + + get_save_function_and_args = FileSystemWriterAsync.get_save_function_and_args + + def get_cpu_save_function_and_args(writer): + save_fn, preload_fn, save_args = get_save_function_and_args(writer) + if preload_fn is not None: + # TMS allocations cannot be sent through CUDA IPC. Stage them in the + # trainer before the persistent worker receives the request; disk + # writes and distributed finalization still run asynchronously. + save_args[1] = preload_fn() + return save_fn, None, save_args + + FileSystemWriterAsync.get_save_function_and_args = get_cpu_save_function_and_args + try: + yield + finally: + FileSystemWriterAsync.get_save_function_and_args = get_save_function_and_args + + def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, **kwargs): - _save_checkpoint_megatron(iteration, model, optimizer, opt_param_scheduler, **kwargs) args = get_args() + stage_on_cpu = args.async_save and args.offload_train and getattr(args, "async_strategy", "mcore") == "mcore" + with _stage_async_save_on_cpu() if stage_on_cpu else nullcontext(): + _save_checkpoint_megatron(iteration, model, optimizer, opt_param_scheduler, **kwargs) if getattr(args, "use_stateless_adam", False) and opt_param_scheduler is not None and dist.get_rank() == 0: # Megatron omits the scheduler together with --no-save-optim. Stateless # Adam needs no moments, but LR/WD progress must share the model's commit diff --git a/vime/backends/megatron_utils/hf_to_megatron/common.py b/vime/backends/megatron_utils/hf_to_megatron/common.py index 06b874b20..342fc3f8c 100644 --- a/vime/backends/megatron_utils/hf_to_megatron/common.py +++ b/vime/backends/megatron_utils/hf_to_megatron/common.py @@ -60,7 +60,7 @@ def get_tensor(self, name: str) -> torch.Tensor: def strip_mcore_wrappers(name: str) -> str: while name.startswith("module."): name = name.removeprefix("module.") - return name.removeprefix("language_model.") + return name.removeprefix("language_model.").replace(".mtp_model_layer.", ".transformer_layer.") def text_config(config): diff --git a/vime/backends/megatron_utils/initialize.py b/vime/backends/megatron_utils/initialize.py index a86607f92..a992c104f 100644 --- a/vime/backends/megatron_utils/initialize.py +++ b/vime/backends/megatron_utils/initialize.py @@ -1,12 +1,16 @@ import logging import random +from contextlib import nullcontext +from pathlib import Path import numpy as np import torch +from filelock import FileLock from megatron.core import mpu, tensor_parallel from megatron.core.config import set_experimental_flag from megatron.core.num_microbatches_calculator import init_num_microbatches_calculator from megatron.training.global_vars import _build_tokenizer, set_args +from transformers.utils import HF_MODULES_CACHE logger = logging.getLogger(__name__) @@ -71,7 +75,14 @@ def init(args): args.te_rng_tracker, args.inference_rng_tracker, ) - _build_tokenizer(args) + # HF copies remote tokenizer code into a shared cache before importing it. + # Protect both operations so another rank cannot import a partial file. + tokenizer_lock = nullcontext() + if args.tokenizer_type == "HuggingFaceTokenizer": + Path(HF_MODULES_CACHE).mkdir(parents=True, exist_ok=True) + tokenizer_lock = FileLock(str(Path(HF_MODULES_CACHE) / "slime-tokenizer.lock")) + with tokenizer_lock: + _build_tokenizer(args) # We won't use this. initialize to pass some validation in megatron. init_num_microbatches_calculator( args.rank, diff --git a/vime/backends/megatron_utils/loss.py b/vime/backends/megatron_utils/loss.py index 4f01d8810..8eb1e7a56 100644 --- a/vime/backends/megatron_utils/loss.py +++ b/vime/backends/megatron_utils/loss.py @@ -1346,21 +1346,6 @@ def policy_loss_function( if args.use_kl_loss: reported_loss["kl_loss"] = kl_loss.clone().detach() - if getattr(args, "ci_test", False) and args.rollout_top_p != 1.0: - with torch.no_grad(): - _, unmasked = get_log_probs_and_entropy( - logits, - args=args, - unconcat_tokens=batch["unconcat_tokens"], - total_lengths=total_lengths, - response_lengths=response_lengths, - ) - model_kl = compute_approx_kl( - torch.cat(unmasked["log_probs"]), - ref_log_probs, - kl_loss_type=args.kl_loss_type, - ) - reported_loss["model_kl"] = sum_of_sample_mean(model_kl) if args.get_mismatch_metrics or args.use_tis: # Aggregate mismatch/TIS/RS related metrics with the *pre-RS* masks. diff --git a/vime/backends/megatron_utils/megatron_to_hf/__init__.py b/vime/backends/megatron_utils/megatron_to_hf/__init__.py index a06446e68..7beb58ee8 100644 --- a/vime/backends/megatron_utils/megatron_to_hf/__init__.py +++ b/vime/backends/megatron_utils/megatron_to_hf/__init__.py @@ -15,6 +15,9 @@ # TODO optimize code details def convert_to_hf(args, model_name, name, param, quantization_config=None, transform_ue8m0=False): + # MCore 0.19 renamed the live MTP module; its checkpoint keys still use + # transformer_layer. Keep model-specific mapping and quantization consistent. + name = name.replace(".mtp_model_layer.", ".transformer_layer.") hf_name = name while hf_name.startswith("module."): hf_name = hf_name.removeprefix("module.") diff --git a/vime/backends/megatron_utils/memory.py b/vime/backends/megatron_utils/memory.py new file mode 100644 index 000000000..e39b3256b --- /dev/null +++ b/vime/backends/megatron_utils/memory.py @@ -0,0 +1,35 @@ +"""TMS allocation policy for Megatron's parameter and gradient buffers.""" + +from contextlib import nullcontext +from functools import wraps + + +def configure_buffer_allocation(args): + from megatron.core.distributed.param_and_grad_buffer import _ParamAndGradBuffer + + # Older Megatron patches implement these options in the buffer constructor. + if not hasattr(_ParamAndGradBuffer, "_allocate_buffer"): + return + + allocator = _ParamAndGradBuffer._allocate_buffer + allocator = getattr(allocator, "__wrapped__", allocator) + + @wraps(allocator) + def allocate(self, buffer_type, numel, dtype): + disable_param_backup = ( + getattr(args, "disable_param_buffers_cpu_backup", False) and self.ddp_config.use_distributed_optimizer + ) + disable_grad_backup = getattr(args, "disable_grad_buffers_cpu_backup", False) + if buffer_type == "shared": + buffer_type = "param" if disable_param_backup else "grad" + disable_backup = disable_param_backup if buffer_type == "param" else disable_grad_backup + context = nullcontext() + if disable_backup: + assert not self.nccl_ub, "Disabling CPU buffer backup is not supported with nccl_ub=True" + from torch_memory_saver import torch_memory_saver + + context = torch_memory_saver.region(tag=f"{buffer_type}_buffer", enable_cpu_backup=False) + with context: + return allocator(self, buffer_type, numel, dtype) + + _ParamAndGradBuffer._allocate_buffer = allocate diff --git a/vime/backends/megatron_utils/model.py b/vime/backends/megatron_utils/model.py index d3c6d77f8..726a1b9aa 100644 --- a/vime/backends/megatron_utils/model.py +++ b/vime/backends/megatron_utils/model.py @@ -315,6 +315,10 @@ def setup_model_and_optimizer( model_chunks=model, use_gloo_process_groups=args.enable_gloo_process_groups, ) + if config.use_precision_aware_optimizer: + from .transformer_engine import patch_precision_aware_optimizer_checkpointing + + patch_precision_aware_optimizer_checkpointing(optimizer) if args.use_stateless_adam: _disable_distributed_optimizer_state_initialization(optimizer) opt_param_scheduler = get_optimizer_param_scheduler(args, optimizer) @@ -727,6 +731,9 @@ def forward_step(data_iterator: DataIterator, model: GPTModel, return_schedule_p assert update_successful opt_param_scheduler.step(increment=step_global_batch_size) + if isinstance(grad_norm, torch.Tensor): + grad_norm = grad_norm.item() + # release grad for model_chunk in model: model_chunk.zero_grad_buffer() @@ -898,15 +905,19 @@ def train( mtp_loss_scale = 1 / num_microbatches[step_id] tracker = MTPLossLoggingHelper.tracker - if "values" in tracker: - values = tracker["values"] + loss_key = "loss_values" if "loss_values" in tracker else "values" + if loss_key in tracker: + values = tracker[loss_key] if tracker.get("reduce_group") is not None: torch.distributed.all_reduce(values, group=tracker.get("reduce_group")) if tracker.get("avg_group") is not None: torch.distributed.all_reduce(values, group=tracker["avg_group"], op=torch.distributed.ReduceOp.AVG) - # Multi-head MTP: tracker["values"] is [num_mtp_layers]; aggregate below. - mtp_losses = tracker["values"] * mtp_loss_scale - MTPLossLoggingHelper.clean_loss_in_tracker() + # Multi-head MTP losses have shape [num_mtp_layers]; aggregate below. + mtp_losses = values * mtp_loss_scale + if hasattr(MTPLossLoggingHelper, "clean_metrics_in_tracker"): + MTPLossLoggingHelper.clean_metrics_in_tracker() + else: + MTPLossLoggingHelper.clean_loss_in_tracker() # CI check: verify MTP loss is within expected bounds if args.ci_test: @@ -949,16 +960,17 @@ def train( if step_id == 0 and "train/ppo_kl" in log_dict and "train/pg_clipfrac" in log_dict: # TODO: figure out why KL is not exactly zero when using PPO loss with KL clipping, and whether this is expected behavior or a bug. assert log_dict["train/ppo_kl"] < 1e-8, f"{log_dict=}" - # R3 replays rollout routing for the actor path, while ref - # log-probs are computed with normal routing. The initial - # actor/ref KL is therefore not expected to be exactly zero. + # R3 uses replayed routing only for the actor. Top-p replay + # also normalizes the actor over the sampled support, while + # the reference uses the full vocabulary. Neither comparison + # has zero initial KL, even with identical model weights. if ( accumulated_step_id == 0 and not getattr(args, "use_rollout_routing_replay", False) + and args.rollout_top_p == 1.0 and "train/kl_loss" in log_dict ): - initial_kl = log_dict.get("train/model_kl", log_dict["train/kl_loss"]) - assert initial_kl < 1e-8, f"{log_dict=}" + assert log_dict["train/kl_loss"] < 1e-8, f"{log_dict=}" logger.info(f"{role_tag}step {accumulated_step_id}: {log_dict}") diff --git a/vime/backends/megatron_utils/model_provider.py b/vime/backends/megatron_utils/model_provider.py index 5e8b70dfb..af827e8cc 100644 --- a/vime/backends/megatron_utils/model_provider.py +++ b/vime/backends/megatron_utils/model_provider.py @@ -13,6 +13,7 @@ get_gpt_layer_local_spec, get_gpt_layer_with_transformer_engine_spec, ) +from megatron.core.transformer.multi_latent_attention import MLASelfAttention as MegatronMLASelfAttention from megatron.core.transformer.spec_utils import import_module from megatron.core.transformer.transformer_config import TransformerConfig from megatron.training.arguments import core_transformer_config_from_args @@ -31,6 +32,16 @@ ) +class MLASelfAttention(MegatronMLASelfAttention): + def _resolve_qk_norm_config(self, submodules): + modules = super()._resolve_qk_norm_config(submodules) + # Without Q-LoRA, HF MLA models (e.g. Moonlight) have no Q norm. + # Megatron 0.19 otherwise introduces a norm before the Q projection. + if self.config.q_lora_rank is None: + modules["linear_q_proj"] = submodules.linear_q_proj + return modules + + def _is_indexer_parameter(name: str) -> bool: """Return whether *name* belongs to a DSA indexer. @@ -164,13 +175,18 @@ def model_provider(pre_process: bool = True, post_process: bool = True, vp_stage else: # Define the decoder layer spec if use_te: - transformer_layer_spec = get_gpt_layer_with_transformer_engine_spec( - num_experts=args.num_experts, - moe_grouped_gemm=args.moe_grouped_gemm, - qk_layernorm=args.qk_layernorm, - multi_latent_attention=args.multi_latent_attention, - moe_use_legacy_grouped_gemm=args.moe_use_legacy_grouped_gemm, - ) + spec_kwargs = { + "num_experts": args.num_experts, + "moe_grouped_gemm": args.moe_grouped_gemm, + "qk_layernorm": args.qk_layernorm, + "multi_latent_attention": args.multi_latent_attention, + } + if ( + "moe_use_legacy_grouped_gemm" + in inspect.signature(get_gpt_layer_with_transformer_engine_spec).parameters + ): + spec_kwargs["moe_use_legacy_grouped_gemm"] = args.moe_use_legacy_grouped_gemm + transformer_layer_spec = get_gpt_layer_with_transformer_engine_spec(**spec_kwargs) else: transformer_layer_spec = get_gpt_layer_local_spec( num_experts=args.num_experts, @@ -181,6 +197,17 @@ def model_provider(pre_process: bool = True, post_process: bool = True, vp_stage normalization=args.normalization, ) + if ( + args.multi_latent_attention + and config.q_lora_rank is None + and config.qk_layernorm + and hasattr(MegatronMLASelfAttention, "_resolve_qk_norm_config") + ): + for layer_spec in getattr(transformer_layer_spec, "layer_specs", [transformer_layer_spec]): + attention_spec = layer_spec.submodules.self_attention + if attention_spec.module is MegatronMLASelfAttention: + attention_spec.module = MLASelfAttention + build_model_context = nullcontext build_model_context_args = {} if args.fp8_param_gather: @@ -198,6 +225,11 @@ def model_provider(pre_process: bool = True, post_process: bool = True, vp_stage "--fp8-param-gather requires `fp8_model_init` from TransformerEngine, but not found." ) from e + if getattr(args, "post_self_attn_layernorm", False) or getattr(args, "post_mlp_layernorm", False): + from vime_plugins.models.glm4 import add_post_layernorms + + add_post_layernorms(transformer_layer_spec, args) + kwargs = { "config": config, "transformer_layer_spec": transformer_layer_spec, @@ -261,6 +293,11 @@ def wrapped_provider( provider_kwargs[key] = kwargs.get(key, None) model = original_provider(**provider_kwargs) + from vime.utils.routing_replay import register_routing_replay + + for module in model.modules(): + if hasattr(module, "router_replay"): + register_routing_replay(module) freeze_model_params(model, args) return model @@ -269,6 +306,13 @@ def wrapped_provider( def get_model_provider_func(args, role="actor"): + from .memory import configure_buffer_allocation + + configure_buffer_allocation(args) + if args.transformer_impl == "transformer_engine": + from .transformer_engine import install_transformer_engine_extensions + + install_transformer_engine_extensions() return wrap_model_provider_with_freeze(_get_model_provider_func(args, role), args) diff --git a/vime/backends/megatron_utils/transformer_engine.py b/vime/backends/megatron_utils/transformer_engine.py new file mode 100644 index 000000000..9651b77d6 --- /dev/null +++ b/vime/backends/megatron_utils/transformer_engine.py @@ -0,0 +1,107 @@ +"""Slime extensions to Megatron's Transformer Engine layers.""" + +import inspect +import os +from functools import wraps + +import torch + + +def patch_precision_aware_optimizer_checkpointing(optimizer): + from transformer_engine.pytorch.optimizers import FusedAdam + + for distributed_optimizer in getattr(optimizer, "chained_optimizers", [optimizer]): + adam = distributed_optimizer.optimizer + if not isinstance(adam, FusedAdam) or not getattr(adam, "store_param_remainders", False): + continue + get_states = distributed_optimizer._get_main_param_and_optimizer_states + set_states = distributed_optimizer._set_main_param_and_optimizer_states + + def get_checkpoint_states(model_param, get_states=get_states, distributed_optimizer=distributed_optimizer): + states = get_states(model_param) + master = states["param"] + if master.dtype == torch.int16: + group, index = distributed_optimizer.model_param_group_index_map[model_param] + param = distributed_optimizer.optimizer.param_groups[group]["params"][index] + # TE stores the low FP32 bits separately from rounded BF16 + # weights. Megatron's reshardable checkpoint expects full FP32. + bits = (param.detach().view(torch.int16).to(torch.int32) << 16) + master.to(torch.int32) + states["param"] = bits.view(torch.float32) + return states + + def set_checkpoint_states( + model_param, states, set_states=set_states, distributed_optimizer=distributed_optimizer + ): + group, index = distributed_optimizer.model_param_group_index_map[model_param] + param = distributed_optimizer.optimizer.param_groups[group]["params"][index] + master = states["param"] + if param.dtype == torch.bfloat16 and master.dtype == torch.float32: + bits = master.contiguous().view(torch.int32) + # Match TE's rounding, including the signed low-bit remainder. + rounded = ((bits >> 16) + ((bits & 0x8000) != 0)).to(torch.int16).view(torch.bfloat16) + with torch.no_grad(): + param.copy_(rounded) + states = {**states, "param": bits.to(torch.int16)} + set_states(model_param, states) + + distributed_optimizer._get_main_param_and_optimizer_states = get_checkpoint_states + distributed_optimizer._set_main_param_and_optimizer_states = set_checkpoint_states + + +class _FakeInt4QuantizationSTE(torch.autograd.Function): + @staticmethod + def forward(ctx, weight, group_size): + rows, columns = weight.shape + padded_columns = ((columns + group_size - 1) // group_size) * group_size + padded = torch.nn.functional.pad(weight, (0, padded_columns - columns)) + grouped = padded.view(rows, -1, group_size) + scale = (grouped.float().abs().amax(dim=-1, keepdim=True) / 7).clamp(min=1e-5) + quantized = (grouped / scale).round().clamp(-7, 7) * scale + return quantized.view(rows, padded_columns)[:, :columns].contiguous().to(weight.dtype) + + @staticmethod + def backward(ctx, grad_output): + return grad_output, None + + +def fake_int4_quantization_ste(weight, group_size): + quantized = _FakeInt4QuantizationSTE.apply(weight, group_size) + if hasattr(weight, "main_grad"): + quantized.main_grad = weight.main_grad + return quantized + + +def install_transformer_engine_extensions(): + from megatron.core.extensions.transformer_engine import TEGroupedLinear, TELinear + + if getattr(TELinear.__init__, "_vime_extensions", False): + return + + original_init = TELinear.__init__ + signature = inspect.signature(original_init) + + @wraps(original_init) + def init(self, *args, **kwargs): + bound = signature.bind(self, *args, **kwargs) + bound.apply_defaults() + original_init(self, *args, **kwargs) + for parameter in self.parameters(): + parameter.parallel_mode = bound.arguments["parallel_mode"] + + init._vime_extensions = True + TELinear.__init__ = init + + original_weights = TEGroupedLinear._get_weight_tensors + # Older slime Megatron patches already implement QAT inside this method. + if "fake_int4_quantization_ste" in original_weights.__code__.co_names: + return + + @wraps(original_weights) + def get_weight_tensors(self): + weights = original_weights(self) + if os.environ.get("OPEN_TRAINING_INT4_FAKE_QAT_FLAG", "0") == "1": + group_size = int(os.environ.get("OPEN_TRAINING_INT4_GROUP_SIZE", "128")) + weights = [fake_int4_quantization_ste(weight, group_size) for weight in weights] + return weights + + TEGroupedLinear._get_weight_tensors = get_weight_tensors diff --git a/vime/backends/vllm_utils/engine_group.py b/vime/backends/vllm_utils/engine_group.py index 195bfe693..94b90e07e 100644 --- a/vime/backends/vllm_utils/engine_group.py +++ b/vime/backends/vllm_utils/engine_group.py @@ -26,6 +26,26 @@ def reset_weights_update_groups(groups, *, timeout): group can wait for its peers, so sending resets to one server group and waiting before contacting the next group can deadlock healthy engines. """ + # Retire unresponsive actors before asking healthy peers to destroy their + # shared NCCL group; otherwise the healthy reset can wait on the stuck peer. + probes = { + engine.get_url.remote(): (group_index, index // group.nodes_per_engine) + for group_index, group in enumerate(groups) + for index, engine in enumerate(group.all_engines) + if engine is not None + } + if probes: + ray.wait(list(probes), num_returns=len(probes), timeout=timeout) + unresponsive = set() + for ref, (group_index, engine_id) in probes.items(): + try: + ray.get(ref, timeout=0) + except Exception as error: + logger.warning("Retiring unresponsive engine before trainer reset: %s", error) + unresponsive.add((group_index, engine_id)) + for group_index, engine_id in sorted(unresponsive): + groups[group_index].retire_engine(engine_id, timeout=timeout) + resets = { engine.reset_weights_update_groups.remote(): (group_index, index // group.nodes_per_engine) for group_index, group in enumerate(groups) diff --git a/vime/data/transport.py b/vime/data/transport.py index 4ae2591a8..57d016015 100644 --- a/vime/data/transport.py +++ b/vime/data/transport.py @@ -418,7 +418,13 @@ async def unpack_published_payload(value): try: return await asyncio.to_thread(unpack_rollout_payload, value) except CorruptData as error: - error.add_note(context) + # Python 3.10 has no exception notes; keep the original storage error + # and expose its manifest context in the message instead. + add_note = getattr(error, "add_note", None) + if add_note is not None: + add_note(context) + else: + error.args = (f"{error}\n{context}",) raise diff --git a/vime/ray/training_recovery.py b/vime/ray/training_recovery.py index 1067f4ef3..0ba882054 100644 --- a/vime/ray/training_recovery.py +++ b/vime/ray/training_recovery.py @@ -77,10 +77,14 @@ def retained_rollout_configuration(args): "eval_interval", "update_weight_start_version", "tensor_model_parallel_size", + "tensor_parallel_num_weight_shards", + "gtp_weight_remat_size", "pipeline_model_parallel_size", "context_parallel_size", "expert_model_parallel_size", "expert_tensor_parallel_size", + "expert_tensor_parallel_num_weight_shards", + "expert_gtp_weight_remat_size", "virtual_pipeline_model_parallel_size", "num_layers_per_virtual_pipeline_stage", "pipeline_model_parallel_layout", diff --git a/vime/utils/arguments.py b/vime/utils/arguments.py index 8bb7eb184..cc59ba5ee 100644 --- a/vime/utils/arguments.py +++ b/vime/utils/arguments.py @@ -107,6 +107,16 @@ def add_cluster_arguments(parser): def add_train_arguments(parser): # --train-backend is parsed early in _pre_parse_mode() and merged later. + reset_arg(parser, "--post-self-attn-layernorm", action="store_true", default=False) + reset_arg(parser, "--post-mlp-layernorm", action="store_true", default=False) + if "--use-gated-attention" not in parser._option_string_actions: + parser.add_argument( + "--use-gated-attention", + dest="attention_output_gate", + action="store_true", + default=argparse.SUPPRESS, + help="Alias for --attention-output-gate.", + ) parser.add_argument( "--qwen-gdn-backend", type=str, @@ -1793,6 +1803,11 @@ def add_ci_arguments(parser): help="Path to the YAML config for custom function arguments.", ) reset_arg(parser, "--padded-vocab-size", type=int, default=None) + # New Megatron versions default to NVRX, which requires an optional + # dependency. Keep native async checkpointing as the default while + # allowing an explicit --async-strategy choice on versions that support it. + if parser.get_default("async_strategy") is not None: + parser.set_defaults(async_strategy="mcore") return parser diff --git a/vime/utils/routing_replay.py b/vime/utils/routing_replay.py index 961704a1a..9f5f18e3c 100644 --- a/vime/utils/routing_replay.py +++ b/vime/utils/routing_replay.py @@ -35,6 +35,8 @@ def register_ordered_topk_capture(module): if getattr(module, "_vime_ordered_topk_capture_registered", False): return + _install_router_replay_bridge(module) + def pre_forward_hook(patched_module, *args, **kwargs): del args, kwargs _set_ordered_topk_capture_router(patched_module) @@ -217,10 +219,37 @@ def compute_topk(scores, topk, num_groups=None, group_topk=None): def register_routing_replay(module): - if os.environ.get("ENABLE_ROUTING_REPLAY", "0") == "1": + if os.environ.get("ENABLE_ROUTING_REPLAY", "0") == "1" and not hasattr(module, "routing_replay"): module.routing_replay = RoutingReplay() + _install_router_replay_bridge(module) def pre_forward_hook(*args, **kwargs): set_routing_replay(module.routing_replay) module.register_forward_pre_hook(pre_forward_hook) + + +class _SlimeRouterReplay: + def __init__(self, original): + self.original = original + + def get_replay_topk(self, scores, topk, num_groups, group_topk, compute_topk): + if self.original is not None: + original_compute_topk = compute_topk + + def compute_topk(scores, topk, num_groups=None, group_topk=None): + return self.original.get_replay_topk(scores, topk, num_groups, group_topk, original_compute_topk) + + return get_routing_replay_compute_topk(compute_topk)(scores, topk, num_groups, group_topk) + + +def _install_router_replay_bridge(module): + if not hasattr(module, "router_replay") or isinstance(module.router_replay, _SlimeRouterReplay): + return + + from megatron.core.transformer.moe.moe_utils import topk_routing_with_score_function + + # Older patched Megatron calls the slime hook directly; avoid recording twice. + if "get_routing_replay_compute_topk" in topk_routing_with_score_function.__code__.co_names: + return + module.router_replay = _SlimeRouterReplay(module.router_replay) diff --git a/vime_plugins/models/glm4.py b/vime_plugins/models/glm4.py index ba42ea1a6..cc8efbad2 100644 --- a/vime_plugins/models/glm4.py +++ b/vime_plugins/models/glm4.py @@ -1,14 +1,57 @@ +import inspect + +from megatron.core.extensions.transformer_engine import TENorm from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec +from megatron.core.transformer.transformer_layer import TransformerLayer + + +class GLMTransformerLayer(TransformerLayer): + def __init__(self, *args, post_self_attn_layernorm=False, post_mlp_layernorm=False, **kwargs): + super().__init__(*args, **kwargs) + # Older Megatron patches provide these modules and apply the norms themselves. + if hasattr(self, "post_self_attn_layernorm"): + return + if post_self_attn_layernorm: + self.post_self_attn_layernorm = TENorm( + config=self.config, hidden_size=self.config.hidden_size, eps=self.config.layernorm_epsilon + ) + + def attention_norm(module, inputs, output): + return self.post_self_attn_layernorm(output[0]), output[1] + + self.self_attention.register_forward_hook(attention_norm) + if post_mlp_layernorm: + self.post_mlp_layernorm = TENorm( + config=self.config, hidden_size=self.config.hidden_size, eps=self.config.layernorm_epsilon + ) + + def mlp_norm(module, inputs, output): + return self.post_mlp_layernorm(output[0]), output[1] + + self.mlp.register_forward_hook(mlp_norm) + + +def add_post_layernorms(spec, args): + if hasattr(spec, "layer_specs"): + for layer_spec in spec.layer_specs: + add_post_layernorms(layer_spec, args) + elif spec.module is TransformerLayer: + spec.module = GLMTransformerLayer + spec.params.update( + post_self_attn_layernorm=args.post_self_attn_layernorm, + post_mlp_layernorm=args.post_mlp_layernorm, + ) def get_glm_spec(args, config, vp_stage): - transformer_layer_spec = get_gpt_layer_with_transformer_engine_spec( + kwargs = dict( num_experts=args.num_experts, moe_grouped_gemm=args.moe_grouped_gemm, qk_layernorm=args.qk_layernorm, multi_latent_attention=args.multi_latent_attention, - moe_use_legacy_grouped_gemm=args.moe_use_legacy_grouped_gemm, - post_self_attn_layernorm=args.post_self_attn_layernorm, - post_mlp_layernorm=args.post_mlp_layernorm, ) - return transformer_layer_spec + parameters = inspect.signature(get_gpt_layer_with_transformer_engine_spec).parameters + for name in ("moe_use_legacy_grouped_gemm", "post_self_attn_layernorm", "post_mlp_layernorm"): + if name in parameters: + kwargs[name] = getattr(args, name) + return get_gpt_layer_with_transformer_engine_spec(**kwargs) From 5e18ac7ad3acd9d597d44c40066ecfc254443525 Mon Sep 17 00:00:00 2001 From: aoshen02 Date: Fri, 9 Oct 2026 22:12:44 +0000 Subject: [PATCH 42/58] [Sync] Mirror latest Slime documentation and native engine contracts Signed-off-by: aoshen02 --- docker/Dockerfile | 17 +- docker/patch/latest/vllm-deepep-auto.patch | 832 +++++ .../latest/vllm-deepep-optional-shrink.patch | 13 + docker/patch/latest/vllm-deepgemm-bf16.patch | 714 ++++ .../patch/latest/vllm-host-cache-ratio.patch | 30 + .../latest/vllm-mooncake-prom-metrics.patch | 220 ++ docs/_static/css/custom_log.css | 9 + docs/_static/css/experiment.css | 3060 +++++++++++++++++ docs/_static/image/arch.png | Bin 0 -> 273013 bytes .../image/blogs/release_v0.1.0/cuda_vmm.png | Bin 0 -> 186743 bytes .../image/blogs/release_v0.1.0/overrall.png | Bin 0 -> 610601 bytes docs/_static/js/experiment-diagrams.js | 103 + docs/_static/js/experiment-engine.js | 621 ++++ docs/_static/js/experiment-reader.js | 201 ++ docs/_static/js/experiment-ui.js | 815 +++++ docs/_templates/experiment.html | 121 + docs/build.sh | 16 +- docs/build_all.sh | 2 +- docs/conf.py | 11 +- .../advanced/arch-support-beyond-megatron.md | 2 +- docs/en/advanced/fault-tolerance.md | 12 +- docs/en/advanced/low-precision.md | 4 +- docs/en/advanced/parallelism-memory.md | 117 + docs/en/advanced/policy-mismatch.md | 207 ++ docs/en/advanced/rl-systems.md | 139 + docs/en/advanced/rollout-scheduling.md | 98 + docs/en/advanced/speculative-decoding.md | 36 + docs/en/advanced/straw.md | 6 +- docs/en/blogs/index.rst | 10 + docs/en/blogs/introducing_slime.md | 126 + docs/en/blogs/release_v0.1.0.md | 160 + docs/en/developer_guide/ci.md | 10 +- docs/en/developer_guide/index.rst | 10 + docs/en/examples/agents/index.rst | 11 + docs/en/examples/index.rst | 11 + docs/en/examples/multimodal/index.rst | 10 + docs/en/examples/workflows/index.rst | 14 + docs/en/get_started/agent.md | 4 +- docs/en/get_started/customization.md | 2 +- docs/en/get_started/experiment-guide.md | 124 + docs/en/get_started/index.rst | 14 + docs/en/index.rst | 10 +- docs/en/library.rst | 18 + docs/en/operations/index.rst | 13 + docs/en/platform_support/index.rst | 9 + docs/en/rollout/index.rst | 16 + docs/en/training/index.rst | 15 + docs/requirements.txt | 4 +- docs/tests/experiment.test.cjs | 575 ++++ docs/tests/requirements.txt | 1 + docs/tests/test_browser.py | 411 +++ .../advanced/arch-support-beyond-megatron.md | 2 +- docs/zh/advanced/fault-tolerance.md | 12 +- docs/zh/advanced/low-precision.md | 4 +- docs/zh/advanced/parallelism-memory.md | 117 + docs/zh/advanced/policy-mismatch.md | 206 ++ docs/zh/advanced/rl-systems.md | 136 + docs/zh/advanced/rollout-scheduling.md | 98 + docs/zh/advanced/speculative-decoding.md | 36 + docs/zh/advanced/straw.md | 4 +- docs/zh/blogs/index.rst | 10 + docs/zh/blogs/introducing_slime.md | 121 + docs/zh/blogs/release_v0.1.0.md | 148 + docs/zh/developer_guide/ci.md | 10 +- docs/zh/developer_guide/index.rst | 10 + docs/zh/examples/agents/index.rst | 11 + docs/zh/examples/index.rst | 11 + docs/zh/examples/multimodal/index.rst | 10 + docs/zh/examples/workflows/index.rst | 14 + docs/zh/get_started/agent.md | 4 +- docs/zh/get_started/customization.md | 2 +- docs/zh/get_started/experiment-guide.md | 124 + docs/zh/get_started/index.rst | 14 + docs/zh/get_started/quick_start.md | 2 +- docs/zh/index.rst | 10 +- docs/zh/library.rst | 18 + docs/zh/operations/index.rst | 13 + docs/zh/platform_support/amd_tutorial.md | 2 +- docs/zh/platform_support/index.rst | 9 + docs/zh/rollout/index.rst | 16 + docs/zh/training/index.rst | 15 + examples/dspark/README.md | 2 +- examples/fully_async/README.md | 49 +- tests/utils/test_vllm_engine.py | 6 +- vime/backends/vllm_utils/vllm_engine.py | 2 +- 85 files changed, 10174 insertions(+), 58 deletions(-) create mode 100644 docker/patch/latest/vllm-deepep-auto.patch create mode 100644 docker/patch/latest/vllm-deepep-optional-shrink.patch create mode 100644 docker/patch/latest/vllm-deepgemm-bf16.patch create mode 100644 docker/patch/latest/vllm-host-cache-ratio.patch create mode 100644 docker/patch/latest/vllm-mooncake-prom-metrics.patch create mode 100644 docs/_static/css/experiment.css create mode 100644 docs/_static/image/arch.png create mode 100644 docs/_static/image/blogs/release_v0.1.0/cuda_vmm.png create mode 100644 docs/_static/image/blogs/release_v0.1.0/overrall.png create mode 100644 docs/_static/js/experiment-diagrams.js create mode 100644 docs/_static/js/experiment-engine.js create mode 100644 docs/_static/js/experiment-reader.js create mode 100644 docs/_static/js/experiment-ui.js create mode 100644 docs/_templates/experiment.html create mode 100644 docs/en/advanced/parallelism-memory.md create mode 100644 docs/en/advanced/policy-mismatch.md create mode 100644 docs/en/advanced/rl-systems.md create mode 100644 docs/en/advanced/rollout-scheduling.md create mode 100644 docs/en/blogs/index.rst create mode 100644 docs/en/blogs/introducing_slime.md create mode 100644 docs/en/blogs/release_v0.1.0.md create mode 100644 docs/en/developer_guide/index.rst create mode 100644 docs/en/examples/agents/index.rst create mode 100644 docs/en/examples/index.rst create mode 100644 docs/en/examples/multimodal/index.rst create mode 100644 docs/en/examples/workflows/index.rst create mode 100644 docs/en/get_started/experiment-guide.md create mode 100644 docs/en/get_started/index.rst create mode 100644 docs/en/library.rst create mode 100644 docs/en/operations/index.rst create mode 100644 docs/en/platform_support/index.rst create mode 100644 docs/en/rollout/index.rst create mode 100644 docs/en/training/index.rst create mode 100644 docs/tests/experiment.test.cjs create mode 100644 docs/tests/requirements.txt create mode 100644 docs/tests/test_browser.py create mode 100644 docs/zh/advanced/parallelism-memory.md create mode 100644 docs/zh/advanced/policy-mismatch.md create mode 100644 docs/zh/advanced/rl-systems.md create mode 100644 docs/zh/advanced/rollout-scheduling.md create mode 100644 docs/zh/blogs/index.rst create mode 100644 docs/zh/blogs/introducing_slime.md create mode 100644 docs/zh/blogs/release_v0.1.0.md create mode 100644 docs/zh/developer_guide/index.rst create mode 100644 docs/zh/examples/agents/index.rst create mode 100644 docs/zh/examples/index.rst create mode 100644 docs/zh/examples/multimodal/index.rst create mode 100644 docs/zh/examples/workflows/index.rst create mode 100644 docs/zh/get_started/experiment-guide.md create mode 100644 docs/zh/get_started/index.rst create mode 100644 docs/zh/library.rst create mode 100644 docs/zh/operations/index.rst create mode 100644 docs/zh/platform_support/index.rst create mode 100644 docs/zh/rollout/index.rst create mode 100644 docs/zh/training/index.rst diff --git a/docker/Dockerfile b/docker/Dockerfile index 139f37edc..e43088acd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -184,6 +184,11 @@ COPY docker/patch/${PATCH_VERSION}/vllm-generation-health.patch /tmp/vllm-genera COPY docker/patch/${PATCH_VERSION}/vllm-tensor-dump.patch /tmp/vllm-tensor-dump.patch COPY docker/patch/${PATCH_VERSION}/vllm-fine-grained-tracing.patch /tmp/vllm-fine-grained-tracing.patch COPY docker/patch/${PATCH_VERSION}/vllm-nixl-peer-retirement.patch /tmp/vllm-nixl-peer-retirement.patch +COPY docker/patch/${PATCH_VERSION}/vllm-mooncake-prom-metrics.patch /tmp/vllm-mooncake-prom-metrics.patch +COPY docker/patch/${PATCH_VERSION}/vllm-host-cache-ratio.patch /tmp/vllm-host-cache-ratio.patch +COPY docker/patch/${PATCH_VERSION}/vllm-deepgemm-bf16.patch /tmp/vllm-deepgemm-bf16.patch +COPY docker/patch/${PATCH_VERSION}/vllm-deepep-optional-shrink.patch /tmp/vllm-deepep-optional-shrink.patch +COPY docker/patch/${PATCH_VERSION}/vllm-deepep-auto.patch /tmp/vllm-deepep-auto.patch RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirname(vllm.__file__)))')" && \ cd "$VLLM_SITE" && \ git apply -v /tmp/vllm-pull_weights.patch && \ @@ -196,6 +201,11 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn git apply -v /tmp/vllm-tensor-dump.patch && \ git apply -v /tmp/vllm-fine-grained-tracing.patch && \ git apply -v /tmp/vllm-nixl-peer-retirement.patch && \ + git apply -v /tmp/vllm-mooncake-prom-metrics.patch && \ + git apply -v /tmp/vllm-host-cache-ratio.patch && \ + git apply -v /tmp/vllm-deepgemm-bf16.patch && \ + git apply -v /tmp/vllm-deepep-optional-shrink.patch && \ + git apply -v /tmp/vllm-deepep-auto.patch && \ rm /tmp/vllm-pull_weights.patch /tmp/vllm.patch \ /tmp/vllm-score-centering.patch \ /tmp/vllm-pd-request-metrics.patch \ @@ -204,7 +214,12 @@ RUN VLLM_SITE="$(python3 -c 'import os, vllm; print(os.path.dirname(os.path.dirn /tmp/vllm-generation-health.patch \ /tmp/vllm-tensor-dump.patch \ /tmp/vllm-fine-grained-tracing.patch \ - /tmp/vllm-nixl-peer-retirement.patch + /tmp/vllm-nixl-peer-retirement.patch \ + /tmp/vllm-mooncake-prom-metrics.patch \ + /tmp/vllm-host-cache-ratio.patch \ + /tmp/vllm-deepgemm-bf16.patch \ + /tmp/vllm-deepep-optional-shrink.patch \ + /tmp/vllm-deepep-auto.patch # ====================================== Install main package ============================================ COPY --from=vllm-rust-frontend /build/rust/target/release/vllm-rs /usr/local/lib/python3.12/dist-packages/vllm/vllm-rs diff --git a/docker/patch/latest/vllm-deepep-auto.patch b/docker/patch/latest/vllm-deepep-auto.patch new file mode 100644 index 000000000..ace6cb9ad --- /dev/null +++ b/docker/patch/latest/vllm-deepep-auto.patch @@ -0,0 +1,832 @@ +diff --git a/vllm/config/parallel.py b/vllm/config/parallel.py +index 7df8f4e0a4e..55565ee04a5 100644 +--- a/vllm/config/parallel.py ++++ b/vllm/config/parallel.py +@@ -47,6 +47,7 @@ All2AllBackend = Literal[ + "pplx", + "deepep_high_throughput", + "deepep_low_latency", ++ "deepep_auto", + "deepep_v2", + "mori_high_throughput", + "mori_low_latency", +@@ -755,6 +756,7 @@ class ParallelConfig: + "allgather_reducescatter", + "deepep_high_throughput", + "deepep_low_latency", ++ "deepep_auto", + "deepep_v2", + "flashinfer_nvlink_one_sided", + "passthrough", +@@ -775,12 +777,21 @@ class ParallelConfig: + or (self.enable_expert_parallel and self.prefill_context_parallel_size > 1) + ) + ++ @property ++ def use_deepep_auto(self) -> bool: ++ return ( ++ self.all2all_backend == "deepep_auto" ++ and self.enable_expert_parallel ++ and self.use_all2all ++ ) ++ + @property + def use_batched_dp_moe(self) -> bool: + return ( + self.all2all_backend + in ( + "deepep_low_latency", ++ "deepep_auto", + "nixl_ep", + ) + and self.enable_expert_parallel +diff --git a/vllm/distributed/device_communicators/all2all.py b/vllm/distributed/device_communicators/all2all.py +index d8790dbf42c..d68b4acd8df 100644 +--- a/vllm/distributed/device_communicators/all2all.py ++++ b/vllm/distributed/device_communicators/all2all.py +@@ -424,6 +424,70 @@ class DeepEPLLAll2AllManager(DeepEPAll2AllManagerBase): + DeepEPLLAll2AllManager._last_mask = None + + ++class DeepEPAutoAll2AllManager(DeepEPLLAll2AllManager): ++ def __init__(self, cpu_group, tcp_store_group=None): ++ super().__init__(cpu_group, tcp_store_group) ++ self._shared_handle = None ++ self._layouts = {} ++ self._has_prefill = False ++ ++ def _make_all2all_kwargs( ++ self, ++ max_num_tokens_per_dp_rank: int, ++ token_hidden_size: int, ++ num_ep_ranks: int, ++ num_global_experts: int, ++ num_local_experts: int, ++ ) -> dict[Any, Any]: ++ import deep_ep ++ ++ buffers = super()._make_all2all_kwargs( ++ max_num_tokens_per_dp_rank, ++ token_hidden_size, ++ num_ep_ranks, ++ num_global_experts, ++ num_local_experts, ++ ) ++ hidden_bytes = token_hidden_size * 2 ++ configs = ( ++ deep_ep.Buffer.get_dispatch_config(self.world_size), ++ deep_ep.Buffer.get_combine_config(self.world_size), ++ ) ++ buffers["num_nvl_bytes"] = max( ++ buffers["num_nvl_bytes"], ++ *( ++ config.get_nvl_buffer_size_hint(hidden_bytes, self.world_size) ++ for config in configs ++ ), ++ ) ++ buffers["num_rdma_bytes"] = max( ++ buffers["num_rdma_bytes"], ++ *( ++ config.get_rdma_buffer_size_hint(hidden_bytes, self.world_size) ++ for config in configs ++ ), ++ ) ++ buffers["num_qps_per_rank"] = max(self.num_sms, num_local_experts) ++ return buffers ++ ++ def get_handle(self, kwargs): ++ if kwargs: ++ self._shared_handle = super().get_handle(kwargs) ++ self._layouts[self._shared_handle] = ( ++ kwargs["max_num_tokens_per_dp_rank"], ++ kwargs["token_hidden_size"], ++ kwargs["num_global_experts"], ++ ) ++ assert self._shared_handle is not None ++ return self._shared_handle ++ ++ def prepare_step(self, has_prefill: bool): ++ if self._has_prefill and not has_prefill: ++ for handle, layout in self._layouts.items(): ++ handle.clean_low_latency_buffer(*layout) ++ self._has_prefill = has_prefill ++ ++ + @dataclass + class _NixlEPBufferState: + buffer: Any +diff --git a/vllm/distributed/device_communicators/cuda_communicator.py b/vllm/distributed/device_communicators/cuda_communicator.py +index d4d588bf450..fc116cd5d4e 100644 +--- a/vllm/distributed/device_communicators/cuda_communicator.py ++++ b/vllm/distributed/device_communicators/cuda_communicator.py +@@ -210,12 +210,15 @@ class CudaCommunicator(DeviceCommunicatorBase): + self.all2all_manager = DeepEPHTAll2AllManager( + self.cpu_group, tcp_store_group + ) +- elif self.all2all_backend == "deepep_low_latency": +- from .all2all import DeepEPLLAll2AllManager ++ elif self.all2all_backend in ("deepep_low_latency", "deepep_auto"): ++ from .all2all import DeepEPAutoAll2AllManager, DeepEPLLAll2AllManager + +- self.all2all_manager = DeepEPLLAll2AllManager( +- self.cpu_group, tcp_store_group ++ manager_cls = ( ++ DeepEPAutoAll2AllManager ++ if self.all2all_backend == "deepep_auto" ++ else DeepEPLLAll2AllManager + ) ++ self.all2all_manager = manager_cls(self.cpu_group, tcp_store_group) + elif self.all2all_backend in ( + "mori_high_throughput", + "mori_low_latency", +diff --git a/vllm/forward_context.py b/vllm/forward_context.py +index 9ddc8e9255e..0ec3009570d 100644 +--- a/vllm/forward_context.py ++++ b/vllm/forward_context.py +@@ -152,6 +152,7 @@ class ForwardContext: + """ + # set dynamically for each forward pass + dp_metadata: DPMetadata | None = None ++ has_prefill: bool = False + # determine the cudagraph style at runtime to be FULL, PIECEWISE, or NONE. + # by default NONE, no cudagraph is used. + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE +@@ -238,6 +239,7 @@ def create_forward_context( + additional_kwargs: dict[str, Any] | None = None, + skip_compiled: bool = False, + is_padding: torch.Tensor | None = None, ++ has_prefill: bool = False, + ): + if vllm_config.compilation_config.fast_moe_cold_start: + all_moe_layers = vllm_config.compilation_config.static_all_moe_layers +@@ -250,6 +252,7 @@ def create_forward_context( + attn_metadata=attn_metadata, + slot_mapping=slot_mapping or {}, + dp_metadata=dp_metadata, ++ has_prefill=has_prefill, + cudagraph_runtime_mode=cudagraph_runtime_mode, + batch_descriptor=batch_descriptor, + ubatch_slices=ubatch_slices, +@@ -286,6 +289,7 @@ def set_forward_context( + slot_mapping: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None = None, + skip_compiled: bool = False, + is_padding: torch.Tensor | None = None, ++ has_prefill: bool = False, + ): + """A context manager that stores the current forward context, + can be attention metadata, etc. +@@ -355,6 +359,7 @@ def set_forward_context( + additional_kwargs, + skip_compiled, + is_padding=is_padding, ++ has_prefill=has_prefill, + ) + + try: +diff --git a/vllm/model_executor/layers/fused_moe/config.py b/vllm/model_executor/layers/fused_moe/config.py +index 158bc90769f..990aa88cfa5 100644 +--- a/vllm/model_executor/layers/fused_moe/config.py ++++ b/vllm/model_executor/layers/fused_moe/config.py +@@ -1035,9 +1035,16 @@ class FusedMoEParallelConfig: + and self.all2all_backend == "deepep_high_throughput" + ) + ++ @property ++ def use_deepep_auto_kernels(self): ++ return self.use_all2all_kernels and self.all2all_backend == "deepep_auto" ++ + @property + def use_deepep_ll_kernels(self): +- return self.use_all2all_kernels and self.all2all_backend == "deepep_low_latency" ++ return self.use_all2all_kernels and self.all2all_backend in ( ++ "deepep_low_latency", ++ "deepep_auto", ++ ) + + @property + def use_fi_nvl_two_sided_kernels(self): +diff --git a/vllm/model_executor/layers/fused_moe/modular_kernel.py b/vllm/model_executor/layers/fused_moe/modular_kernel.py +index 7a4cbd42374..6a017b981aa 100644 +--- a/vllm/model_executor/layers/fused_moe/modular_kernel.py ++++ b/vllm/model_executor/layers/fused_moe/modular_kernel.py +@@ -10,6 +10,7 @@ from typing import final + import torch + + import vllm.envs as envs ++from vllm.forward_context import get_forward_context, is_forward_context_available + from vllm.logger import init_logger + from vllm.model_executor.layers.fused_moe.activation import ( + ApplyMoEActivationConfig, +@@ -1615,15 +1616,17 @@ class FusedMoEKernel: + self, + prepare_finalize: FusedMoEPrepareAndFinalize, + fused_experts: FusedMoEExperts, ++ prefill_kernel: "FusedMoEKernel | None" = None, + ): + super().__init__() + + # Initialize the implementation (monolithic or modular). +- self.impl: FusedMoEKernelModularImpl | FusedMoEKernelMonolithicImpl ++ self._prefill_kernel = prefill_kernel ++ self._impl: FusedMoEKernelModularImpl | FusedMoEKernelMonolithicImpl + if isinstance( + prepare_finalize, FusedMoEPrepareAndFinalizeModular + ) and isinstance(fused_experts, FusedMoEExpertsModular): +- self.impl = FusedMoEKernelModularImpl( ++ self._impl = FusedMoEKernelModularImpl( + prepare_finalize, + fused_experts, + ) +@@ -1631,7 +1634,7 @@ class FusedMoEKernel: + elif isinstance( + prepare_finalize, FusedMoEPrepareAndFinalizeMonolithic + ) and isinstance(fused_experts, FusedMoEExpertsMonolithic): +- self.impl = FusedMoEKernelMonolithicImpl( ++ self._impl = FusedMoEKernelMonolithicImpl( + prepare_finalize, + fused_experts, + ) +@@ -1645,6 +1648,16 @@ class FusedMoEKernel: + + self._post_init_setup() + ++ @property ++ def impl(self) -> FusedMoEKernelModularImpl | FusedMoEKernelMonolithicImpl: ++ if ( ++ self._prefill_kernel is not None ++ and is_forward_context_available() ++ and get_forward_context().has_prefill ++ ): ++ return self._prefill_kernel.impl ++ return self._impl ++ + @property + def can_overlap_shared_experts(self) -> bool: + if isinstance(self.impl, FusedMoEKernelModularImpl): +@@ -1675,10 +1688,10 @@ class FusedMoEKernel: + """Resolve any leftover setup dependencies between self.prepare_finalize + and self.fused_experts here. + """ +- self.prepare_finalize.post_init_setup(self.impl.fused_experts) ++ self._impl.prepare_finalize.post_init_setup(self._impl.fused_experts) + assert ( +- self.prepare_finalize.activation_format +- == self.fused_experts.activation_format() ++ self._impl.prepare_finalize.activation_format ++ == self._impl.fused_experts.activation_format() + ) + + def output_is_reduced(self) -> bool: +diff --git a/vllm/model_executor/layers/fused_moe/oracle/fp8.py b/vllm/model_executor/layers/fused_moe/oracle/fp8.py +index 446825c5904..6e223db245e 100644 +--- a/vllm/model_executor/layers/fused_moe/oracle/fp8.py ++++ b/vllm/model_executor/layers/fused_moe/oracle/fp8.py +@@ -1,6 +1,7 @@ + # SPDX-License-Identifier: Apache-2.0 + # SPDX-FileCopyrightText: Copyright contributors to the vLLM project + import math ++from dataclasses import replace + from enum import Enum + from typing import Any + +@@ -871,9 +872,31 @@ def make_fp8_moe_kernel( + quant_config=moe_quant_config, + ) + +- kernel = mk.FusedMoEKernel( +- prepare_finalize, +- experts, +- ) +- +- return kernel ++ prefill_kernel = None ++ if moe_config.moe_parallel_config.use_deepep_auto_kernels: ++ prefill_config = replace( ++ moe_config, ++ moe_parallel_config=replace( ++ moe_config.moe_parallel_config, all2all_backend="deepep_high_throughput" ++ ), ++ ) ++ prefill_backend = { ++ Fp8MoeBackend.BATCHED_DEEPGEMM: Fp8MoeBackend.DEEPGEMM, ++ Fp8MoeBackend.BATCHED_TRITON: Fp8MoeBackend.TRITON, ++ Fp8MoeBackend.BATCHED_VLLM_CUTLASS: Fp8MoeBackend.VLLM_CUTLASS, ++ }.get(fp8_backend, fp8_backend) ++ prefill_experts = next( ++ expert ++ for expert in backend_to_kernel_cls(prefill_backend) ++ if issubclass(expert, mk.FusedMoEExpertsModular) ++ and expert.activation_format() == mk.FusedMoEActivationFormat.Standard ++ ) ++ prefill_kernel = make_fp8_moe_kernel( ++ moe_quant_config, ++ prefill_config, ++ prefill_experts, ++ prefill_backend, ++ routing_tables, ++ ) ++ ++ return mk.FusedMoEKernel(prepare_finalize, experts, prefill_kernel=prefill_kernel) +diff --git a/vllm/model_executor/layers/fused_moe/oracle/unquantized.py b/vllm/model_executor/layers/fused_moe/oracle/unquantized.py +index dccaf9fca4a..aee4a4b07fa 100644 +--- a/vllm/model_executor/layers/fused_moe/oracle/unquantized.py ++++ b/vllm/model_executor/layers/fused_moe/oracle/unquantized.py +@@ -1,6 +1,7 @@ + # SPDX-License-Identifier: Apache-2.0 + # SPDX-FileCopyrightText: Copyright contributors to the vLLM project + ++from dataclasses import replace + from enum import Enum + from typing import TYPE_CHECKING + +@@ -554,12 +555,27 @@ def make_unquantized_moe_kernel( + quant_config=quant_config, + ) + +- kernel = mk.FusedMoEKernel( +- prepare_finalize, +- experts, +- ) ++ prefill_kernel = None ++ if moe_config.moe_parallel_config.use_deepep_auto_kernels: ++ prefill_config = replace( ++ moe_config, ++ moe_parallel_config=replace( ++ moe_config.moe_parallel_config, all2all_backend="deepep_high_throughput" ++ ), ++ ) ++ prefill_backend, prefill_experts = select_unquantized_moe_backend( ++ prefill_config ++ ) ++ assert prefill_experts is not None ++ prefill_kernel = make_unquantized_moe_kernel( ++ quant_config, ++ prefill_config, ++ prefill_backend, ++ prefill_experts, ++ routing_tables, ++ ) + +- return kernel ++ return mk.FusedMoEKernel(prepare_finalize, experts, prefill_kernel=prefill_kernel) + + + # --------------------------------------------------------------------------- +diff --git a/vllm/v1/worker/gpu/dp_utils.py b/vllm/v1/worker/gpu/dp_utils.py +index 15db7c197c9..b6350f08821 100644 +--- a/vllm/v1/worker/gpu/dp_utils.py ++++ b/vllm/v1/worker/gpu/dp_utils.py +@@ -39,6 +39,7 @@ class DPSyncState: + # Agreed upper bound on any rank's request count. Holds the padded count when + # a FULL descriptor imposed one, else the most any rank scheduled. + num_reqs: int ++ has_prefill: bool = False + + + def sync_cudagraph_and_dp_padding( +@@ -54,6 +55,7 @@ def sync_cudagraph_and_dp_padding( + parallel_config: ParallelConfig | None = None, + allow_ubatching: bool = False, + uniform_decode: bool = False, ++ has_prefill: bool = False, + ) -> tuple[BatchExecutionDescriptor, DPSyncState | None]: + """Coordinates the batch descriptor and DP padding across all ranks. + +@@ -64,13 +66,14 @@ def sync_cudagraph_and_dp_padding( + """ + assert dp_size > 1, "DP size must be greater than 1" + group = get_dp_group().cpu_group +- tensor = torch.zeros(6, dp_size, dtype=torch.int32, device="cpu") ++ tensor = torch.zeros(7, dp_size, dtype=torch.int32, device="cpu") + tensor[0][dp_rank] = num_tokens + tensor[1][dp_rank] = desired_batch_desc.cg_mode.value + tensor[2][dp_rank] = uniform_token_count or 0 # (0 means None) + tensor[3][dp_rank] = max_query_len or -1 # (-1 means None) + tensor[4][dp_rank] = int(allow_ubatching) + tensor[5][dp_rank] = num_reqs ++ tensor[6][dp_rank] = int(has_prefill) + if should_skip_dp_coordination(): + tensor[:] = tensor[:, dp_rank, None].clone() + else: +@@ -82,6 +85,7 @@ def sync_cudagraph_and_dp_padding( + max_query_lens_across_dp = tensor[3] + allow_ubatching_across_dp = tensor[4] + num_reqs_across_dp = tensor[5] ++ has_prefill_across_dp = bool(tensor[6].any().item()) + + # If ranks disagree on the uniform token count, or its 0 (means None) set to None + synced_uniform_token_count: int | None = int(uniform_token_counts_across_dp[0]) +@@ -150,6 +154,7 @@ def sync_cudagraph_and_dp_padding( + uniform_token_count=synced_uniform_token_count, + eager=ubatch_desc.cg_mode == CUDAGraphMode.NONE, + num_reqs=num_reqs, ++ has_prefill=has_prefill_across_dp, + ) + + synced_cg_mode = CUDAGraphMode(int(cg_mode_across_dp.min().item())) +@@ -168,6 +173,7 @@ def sync_cudagraph_and_dp_padding( + uniform_token_count=synced_uniform_token_count, + eager=True, + num_reqs=int(num_reqs_across_dp.max()), ++ has_prefill=has_prefill_across_dp, + ), + ) + +@@ -207,6 +213,7 @@ def sync_cudagraph_and_dp_padding( + and synced_desc.num_reqs is not None + else int(num_reqs_across_dp.max()) + ), ++ has_prefill=has_prefill_across_dp, + ) + + +@@ -224,6 +231,7 @@ def dispatch_cg_and_sync_dp( + allow_ubatching: bool = False, + uniform_decode: bool = False, + dp_sync: DPSyncState | None = None, ++ has_prefill: bool = False, + ) -> tuple[BatchExecutionDescriptor, DPSyncState | None]: + """Pick a cudagraph descriptor for this batch, agreeing it across DP ranks. + +@@ -254,6 +262,8 @@ def dispatch_cg_and_sync_dp( + microbatches. Agreed across ranks before it takes effect. + uniform_decode: Whether this rank's batch is a uniform decode, used + to pick the microbatching split. ++ has_prefill: Whether this rank contains prefill work, agreed across ++ ranks and preserved when reusing the batch's sync. + dp_sync: Agreement from a prior dispatch over this same batch, to reuse. + Must come from a batch with this same padded `num_tokens` and the + same `uniform_token_count`; `num_reqs` may differ, as neither +@@ -322,4 +332,5 @@ def dispatch_cg_and_sync_dp( + parallel_config=parallel_config, + allow_ubatching=allow_ubatching, + uniform_decode=uniform_decode, ++ has_prefill=has_prefill, + ) +diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py +index d0c08707f48..7faf5805414 100644 +--- a/vllm/v1/worker/gpu/model_runner.py ++++ b/vllm/v1/worker/gpu/model_runner.py +@@ -1766,6 +1766,10 @@ class GPUModelRunner(LoRAModelRunnerMixin): + # cross-attention cache with dynamic encoder outputs. + skip_compiled = True + ++ has_prefill = ( ++ batch_req_state.has_prefill if batch_req_state is not None else is_profile ++ ) ++ deepep_prefill = has_prefill and self.parallel_config.use_deepep_auto + batch_desc, dp_sync = dispatch_cg_and_sync_dp( + self.cudagraph_manager, + num_reqs, +@@ -1774,15 +1778,23 @@ class GPUModelRunner(LoRAModelRunnerMixin): + self.dp_size, + self.dp_rank, + max_query_len=max_query_len, +- need_eager=is_profile or skip_compiled, ++ need_eager=is_profile or skip_compiled or deepep_prefill, + num_active_loras=num_active_loras, + parallel_config=self.parallel_config, + allow_ubatching=( +- self.ubatch_runner is not None and not skip_attn_for_dummy_run ++ self.ubatch_runner is not None ++ and not skip_attn_for_dummy_run ++ and not deepep_prefill + ), + uniform_decode=uniform_tok_count == self.decode_query_len, ++ has_prefill=has_prefill, + ) + ++ if self.parallel_config.use_deepep_auto: ++ get_ep_all2all_manager().prepare_step( ++ dp_sync.has_prefill if dp_sync is not None else has_prefill ++ ) ++ + if batch_desc.num_tokens == 0: + # All DP ranks have zero tokens to run. + empty_output = self.kv_connector.no_forward(scheduler_output) +@@ -2016,6 +2028,11 @@ class GPUModelRunner(LoRAModelRunnerMixin): + slot_mapping=slot_mappings_by_layer, + skip_compiled=skip_compiled, + is_padding=input_batch.is_padding, ++ has_prefill=( ++ dp_sync.has_prefill ++ if dp_sync is not None ++ else input_batch.has_prefill ++ ), + ): + self.kv_connector.pre_forward(**connector_kwargs) + if ubatch_state is not None: +diff --git a/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py b/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py +index 46ff8268156..c3e003fb0c7 100644 +--- a/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py ++++ b/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py +@@ -9,6 +9,7 @@ from vllm.config import VllmConfig + from vllm.config.compilation import CUDAGraphMode + from vllm.forward_context import BatchDescriptor, set_forward_context + from vllm.logger import init_logger ++from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager + from vllm.triton_utils import tl, triton + from vllm.v1.kv_cache_interface import KVCacheConfig + from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer +@@ -285,6 +286,10 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + max_query_len, + input_batch.decode_graph_eligible, + ) ++ has_prefill = ( ++ dp_sync.has_prefill if dp_sync is not None else input_batch.has_prefill ++ ) ++ use_deepep_auto = self.vllm_config.parallel_config.use_deepep_auto + prefill_batch_desc, prefill_batch_sync = dispatch_cg_and_sync_dp( + self.prefill_cudagraph_manager, + num_reqs, +@@ -292,9 +297,17 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + uniform_token_count, + dp_size=self.dp_size, + dp_rank=self.dp_rank, +- need_eager=is_profile, ++ need_eager=is_profile or (use_deepep_auto and has_prefill), + dp_sync=dp_sync, ++ has_prefill=has_prefill, ++ ) ++ has_prefill = ( ++ prefill_batch_sync.has_prefill ++ if prefill_batch_sync is not None ++ else has_prefill + ) ++ if use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(has_prefill) + num_tokens_across_dp = ( + prefill_batch_sync.num_tokens_across_dp + if prefill_batch_sync is not None +@@ -320,6 +333,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=prefill_batch_desc.cg_mode, + mm_inputs=mm_inputs, ++ has_prefill=has_prefill, + ) + self.on_prefill_end(num_reqs) + +@@ -345,6 +359,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + advance_draft_positions=self.advance_draft_positions, + ) + ++ if use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(False) + decode_batch_sync, num_batch_tokens = ( + self._build_uniform_batch_dp_sync(dp_sync, num_reqs, num_query_per_req=1) + if dp_sync is not None +@@ -400,6 +416,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, + mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None, ++ has_prefill: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor]: + input_buffers: InputBatch | InputBuffers = self.input_buffers + is_padding = None +@@ -416,6 +433,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + slot_mapping=slot_mappings, + batch_descriptor=batch_descriptor, + is_padding=is_padding, ++ has_prefill=has_prefill, + ): + inputs_embeds = None + if self.supports_mm_inputs: +@@ -466,6 +484,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, + mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None, ++ has_prefill: bool = False, + ) -> None: + last_token_indices = self.last_token_indices[:num_reqs] + positions = self.input_buffers.positions[last_token_indices] +@@ -482,6 +501,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator): + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=cudagraph_runtime_mode, + mm_inputs=mm_inputs, ++ has_prefill=has_prefill, + ) + if self.pcp_manager is not None: + last_hidden_states, hidden_states = self.pcp_manager.restore_draft_prefill( +diff --git a/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py b/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py +index d59d87ae7e6..4813e3f3b6c 100644 +--- a/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py ++++ b/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py +@@ -10,6 +10,7 @@ from vllm.config import VllmConfig, replace + from vllm.config.compilation import CUDAGraphMode + from vllm.forward_context import BatchDescriptor, set_forward_context + from vllm.logger import init_logger ++from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager + from vllm.triton_utils import tl, triton + from vllm.v1.attention.backend import AttentionCGSupport + from vllm.v1.attention.backends.utils import PAD_SLOT_ID +@@ -445,6 +446,8 @@ class DFlashSpeculator(DraftModelSpeculator): + self.sample_from_anchor, + ) + ++ if self.vllm_config.parallel_config.use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(False) + batch_sync, num_batch_tokens = ( + self._build_uniform_batch_dp_sync(dp_sync, num_reqs, self.num_query_per_req) + if dp_sync is not None +diff --git a/vllm/v1/worker/gpu/spec_decode/draft_model/speculator.py b/vllm/v1/worker/gpu/spec_decode/draft_model/speculator.py +index 5be7b0c86dd..cdfd8f1932c 100644 +--- a/vllm/v1/worker/gpu/spec_decode/draft_model/speculator.py ++++ b/vllm/v1/worker/gpu/spec_decode/draft_model/speculator.py +@@ -10,6 +10,7 @@ from vllm.config import VllmConfig, replace + from vllm.config.compilation import CUDAGraphMode + from vllm.forward_context import BatchDescriptor, set_forward_context + from vllm.logger import init_logger ++from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager + from vllm.model_executor.model_loader import get_model + from vllm.triton_utils import tl, triton + from vllm.v1.kv_cache_interface import KVCacheConfig +@@ -122,6 +123,7 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + attn_metadata: dict[str, Any] | None, + slot_mappings: dict[str, torch.Tensor] | None, + num_tokens_across_dp: torch.Tensor | None, ++ has_prefill: bool = False, + ) -> torch.Tensor: + num_tokens = input_ids.shape[0] + batch_descriptor = BatchDescriptor(num_tokens=num_tokens) +@@ -133,6 +135,7 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + num_tokens_across_dp=num_tokens_across_dp, + slot_mapping=slot_mappings, + batch_descriptor=batch_descriptor, ++ has_prefill=has_prefill, + ): + hidden_states = self.model( # type: ignore[misc] + input_ids=input_ids, +@@ -160,6 +163,7 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + num_reqs: int, + skip_attn: bool, + num_tokens_across_dp: torch.Tensor | None, ++ has_prefill: bool = False, + ) -> None: + if skip_attn: + src_tokens = input_batch.num_tokens +@@ -172,6 +176,7 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + None, + None, + num_tokens_across_dp, ++ has_prefill=has_prefill, + ) + positions_buf = input_batch.positions + else: +@@ -225,6 +230,7 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + prefill_attn_md, + prefill_slot_maps_by_layer, + num_tokens_across_dp, ++ has_prefill=has_prefill, + ) + positions_buf = self.expanded_positions + +@@ -374,6 +380,12 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + seeds, + ) + ++ has_prefill = ( ++ dp_sync.has_prefill if dp_sync is not None else input_batch.has_prefill ++ ) ++ use_deepep_auto = self.vllm_config.parallel_config.use_deepep_auto ++ if use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(has_prefill) + self._prefill( + input_batch, + num_rejected, +@@ -381,11 +393,14 @@ class PlainDraftModelSpeculator(DraftModelSpeculator): + num_reqs, + skip_attn, + num_tokens_across_dp, ++ has_prefill=has_prefill, + ) + + if self.num_speculative_steps == 1: + return self.draft_tokens[:num_reqs, :1] + ++ if use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(False) + self._multi_step_decode( + input_batch, + num_rejected, +diff --git a/vllm/v1/worker/gpu/spec_decode/extract_hidden_states.py b/vllm/v1/worker/gpu/spec_decode/extract_hidden_states.py +index 5afbe640b2f..ae51df6a8fb 100644 +--- a/vllm/v1/worker/gpu/spec_decode/extract_hidden_states.py ++++ b/vllm/v1/worker/gpu/spec_decode/extract_hidden_states.py +@@ -9,6 +9,7 @@ from vllm.compilation.backends import set_model_tag + from vllm.config import VllmConfig + from vllm.config.compilation import CUDAGraphMode + from vllm.forward_context import set_forward_context ++from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager + from vllm.model_executor.model_loader import get_model + from vllm.v1.worker.gpu.dp_utils import DPSyncState + from vllm.v1.worker.gpu.input_batch import InputBatch +@@ -141,6 +142,11 @@ class ExtractHiddenStatesSpeculator(DraftModelSpeculator): + name: slot_mappings[name][:num_tokens] + for name in self.draft_attn_layer_names + } ++ has_prefill = ( ++ dp_sync.has_prefill if dp_sync is not None else input_batch.has_prefill ++ ) ++ if self.vllm_config.parallel_config.use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(has_prefill) + with set_forward_context( + draft_attn_metadata, + self.vllm_config, +@@ -151,6 +157,7 @@ class ExtractHiddenStatesSpeculator(DraftModelSpeculator): + cudagraph_runtime_mode=CUDAGraphMode.NONE, + slot_mapping=draft_slot_mappings, + is_padding=input_batch.is_padding[:num_tokens], ++ has_prefill=has_prefill, + ): + self.model(hidden_states=self.hidden_states[:num_tokens]) + +diff --git a/vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py b/vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py +index 71f7f89416b..79a661ffd9e 100644 +--- a/vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py ++++ b/vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py +@@ -9,6 +9,7 @@ from vllm.config import VllmConfig + from vllm.config.compilation import CUDAGraphMode + from vllm.forward_context import BatchDescriptor, set_forward_context + from vllm.logger import init_logger ++from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager + from vllm.triton_utils import tl, triton + from vllm.v1.attention.backends.utils import PAD_SLOT_ID + from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer +@@ -193,6 +194,10 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + uniform_token_count = get_uniform_decode_token_count( + num_reqs, num_tokens, max_query_len, input_batch.decode_graph_eligible + ) ++ has_prefill = ( ++ dp_sync.has_prefill if dp_sync is not None else input_batch.has_prefill ++ ) ++ use_deepep_auto = self.vllm_config.parallel_config.use_deepep_auto + batch_desc, batch_sync = dispatch_cg_and_sync_dp( + self.cudagraph_manager, + num_reqs, +@@ -200,9 +205,13 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + uniform_token_count, + dp_size=self.dp_size, + dp_rank=self.dp_rank, +- need_eager=is_profile, ++ need_eager=is_profile or (use_deepep_auto and has_prefill), + dp_sync=dp_sync, ++ has_prefill=has_prefill, + ) ++ has_prefill = batch_sync.has_prefill if batch_sync is not None else has_prefill ++ if use_deepep_auto: ++ get_ep_all2all_manager().prepare_step(has_prefill) + num_tokens_across_dp = ( + batch_sync.num_tokens_across_dp if batch_sync is not None else None + ) +@@ -251,6 +260,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + slot_mappings, + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=batch_desc.cg_mode, ++ has_prefill=has_prefill, + ) + return self.draft_tokens[:num_reqs] + +@@ -274,6 +284,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + num_tokens_across_dp: torch.Tensor | None, + spec_module_idx: int, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, ++ has_prefill: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor]: + batch_descriptor = BatchDescriptor(num_tokens=num_tokens) + with set_forward_context( +@@ -284,6 +295,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + num_tokens_across_dp=num_tokens_across_dp, + slot_mapping=slot_mappings, + batch_descriptor=batch_descriptor, ++ has_prefill=has_prefill, + ): + model_inputs = dict( + input_ids=self.input_buffers.input_ids[:num_tokens], +@@ -389,6 +401,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + slot_mappings: dict[str, torch.Tensor] | None, + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, ++ has_prefill: bool = False, + ) -> None: + last_token_indices = self.last_token_indices[:num_reqs] + positions = self.input_buffers.positions[last_token_indices] +@@ -427,6 +440,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator): + num_tokens_across_dp=num_tokens_across_dp, + spec_module_idx=step, + cudagraph_runtime_mode=cudagraph_runtime_mode, ++ has_prefill=has_prefill, + ) + + # Sample draft tokens for the current step. +diff --git a/vllm/v1/worker/gpu/spec_decode/speculator.py b/vllm/v1/worker/gpu/spec_decode/speculator.py +index d07c043de91..1a697160101 100644 +--- a/vllm/v1/worker/gpu/spec_decode/speculator.py ++++ b/vllm/v1/worker/gpu/spec_decode/speculator.py +@@ -540,6 +540,7 @@ class DraftModelSpeculator(BaseSpeculator): + ), + uniform_token_count=num_query_per_req, + eager=False, ++ has_prefill=False, + ), num_batch_tokens + + def _build_uniform_attn_metadata( diff --git a/docker/patch/latest/vllm-deepep-optional-shrink.patch b/docker/patch/latest/vllm-deepep-optional-shrink.patch new file mode 100644 index 000000000..903ac9c7d --- /dev/null +++ b/docker/patch/latest/vllm-deepep-optional-shrink.patch @@ -0,0 +1,13 @@ +diff --git a/vllm/distributed/device_communicators/all2all.py b/vllm/distributed/device_communicators/all2all.py +index d8790dbf42c..59d260f8448 100644 +--- a/vllm/distributed/device_communicators/all2all.py ++++ b/vllm/distributed/device_communicators/all2all.py +@@ -374,7 +374,7 @@ class DeepEPLLAll2AllManager(DeepEPAll2AllManagerBase): + allow_nvlink_for_low_latency_mode=True, + allow_mnnvl=envs.VLLM_DEEPEP_LOW_LATENCY_USE_MNNVL, + explicitly_destroy=True, +- enable_shrink=self.support_fault_tolerance, ++ **({"enable_shrink": True} if self.support_fault_tolerance else {}), + ) + return kwargs + diff --git a/docker/patch/latest/vllm-deepgemm-bf16.patch b/docker/patch/latest/vllm-deepgemm-bf16.patch new file mode 100644 index 000000000..1a581da8c --- /dev/null +++ b/docker/patch/latest/vllm-deepgemm-bf16.patch @@ -0,0 +1,714 @@ +diff --git a/vllm/config/kernel.py b/vllm/config/kernel.py +index b8bfca082a4..0cd768e48d5 100644 +--- a/vllm/config/kernel.py ++++ b/vllm/config/kernel.py +@@ -297,7 +297,7 @@ class KernelConfig: + - "triton": Use Triton-based fused MoE kernels + - "batched_triton": Use batched Triton experts (moe_mmk) on the batched + activation format ([E_local, max_num_tokens, K]) +- - "deep_gemm": Use DeepGEMM kernels (FP8 block-quantized only) ++ - "deep_gemm": Use DeepGEMM kernels (FP8 block-quantized or unquantized BF16) + - "deep_gemm_mega_moe": Use DeepGEMM mega MoE kernels + - "cutlass": Use vLLM CUTLASS kernels + - "flashinfer_trtllm": Use FlashInfer with TRTLLM-GEN kernels +diff --git a/vllm/model_executor/layers/fused_moe/__init__.py b/vllm/model_executor/layers/fused_moe/__init__.py +index 120fbd15266..ec4d4728da2 100644 +--- a/vllm/model_executor/layers/fused_moe/__init__.py ++++ b/vllm/model_executor/layers/fused_moe/__init__.py +@@ -100,6 +100,8 @@ if HAS_TRITON: + CutlassExpertsW4A8Fp8, + ) + from vllm.model_executor.layers.fused_moe.experts.deep_gemm_moe import ( ++ DeepGemmBf16BatchedExperts, ++ DeepGemmBf16Experts, + DeepGemmExperts, + ) + from vllm.model_executor.layers.fused_moe.experts.fused_batched_moe import ( +@@ -144,6 +146,8 @@ if HAS_TRITON: + "TritonWNA16Experts", + "BatchedTritonExperts", + "DeepGemmExperts", ++ "DeepGemmBf16Experts", ++ "DeepGemmBf16BatchedExperts", + "BatchedDeepGemmExperts", + "TritonOrDeepGemmExperts", + "XPUExperts", +diff --git a/vllm/model_executor/layers/fused_moe/deep_gemm_utils.py b/vllm/model_executor/layers/fused_moe/deep_gemm_utils.py +index 1dee9bad54b..f5f56ab1a03 100644 +--- a/vllm/model_executor/layers/fused_moe/deep_gemm_utils.py ++++ b/vllm/model_executor/layers/fused_moe/deep_gemm_utils.py +@@ -184,6 +184,7 @@ def _fwd_kernel_ep_scatter_2( + topk_num: tl.constexpr, + expert_map, + HAS_EXPERT_MAP: tl.constexpr, ++ HAS_SCALE: tl.constexpr, + HIDDEN_SIZE: tl.constexpr, + HIDDEN_SIZE_PAD: tl.constexpr, + SCALE_HIDDEN_SIZE: tl.constexpr, +@@ -211,7 +212,7 @@ def _fwd_kernel_ep_scatter_2( + for token_id in range(start_token_id, total_token_num, grid_num): + to_copy = tl.load(recv_x + token_id * recv_x_stride0 + offset_in, mask=mask) + +- if PACK_UE8M0: ++ if HAS_SCALE and PACK_UE8M0: + # Pack 4 UE8M0 bytes into one int32 (byte j = group 4*pk+j). + base_s = recv_x_scale + token_id * recv_x_scale_stride0 + g0, g1 = offs_pk * 4, offs_pk * 4 + 1 +@@ -234,7 +235,7 @@ def _fwd_kernel_ep_scatter_2( + | (b2.to(tl.int32) << 16) + | (b3.to(tl.int32) << 24) + ) +- else: ++ elif HAS_SCALE: + to_copy_s = tl.load( + recv_x_scale + token_id * recv_x_scale_stride0 + offset_in_s, + mask=mask_s, +@@ -261,13 +262,13 @@ def _fwd_kernel_ep_scatter_2( + output_tensor_scale_ptr = ( + output_tensor_scale + dest_token_index * output_tensor_scale_stride0 + ) +- if PACK_UE8M0: ++ if HAS_SCALE and PACK_UE8M0: + tl.store( + output_tensor_scale_ptr + offs_pk * output_tensor_scale_stride1, + packed_s, + mask=mask_pk, + ) +- else: ++ elif HAS_SCALE: + tl.store( + output_tensor_scale_ptr + offset_in_s, to_copy_s, mask=mask_s + ) +@@ -276,13 +277,13 @@ def _fwd_kernel_ep_scatter_2( + @torch.no_grad() + def ep_scatter( + recv_x: torch.Tensor, +- recv_x_scale: torch.Tensor, ++ recv_x_scale: torch.Tensor | None, + recv_topk: torch.Tensor, + num_recv_tokens_per_expert: torch.Tensor, + expert_map: torch.Tensor | None, + expert_start_loc: torch.Tensor, + output_tensor: torch.Tensor, +- output_tensor_scale: torch.Tensor, ++ output_tensor_scale: torch.Tensor | None, + m_indices: torch.Tensor, + output_index: torch.Tensor, + align_m: int = 128, +@@ -296,6 +297,14 @@ def ep_scatter( + num_warps = 8 + num_experts = num_recv_tokens_per_expert.shape[0] + hidden_size = recv_x.shape[1] ++ # BF16 (unquantized) permute has no activation scales; pass dummy tensors ++ # so the kernel's HAS_SCALE-guarded loads/stores stay valid but inert. ++ has_scale = recv_x_scale is not None ++ if recv_x_scale is None: ++ _dummy_scale = torch.empty((1, 1), device=recv_x.device, dtype=torch.float32) ++ recv_x_scale = _dummy_scale ++ output_tensor_scale = _dummy_scale ++ assert output_tensor_scale is not None + # grid = (triton.cdiv(hidden_size, BLOCK_D), num_experts) + grid = num_experts + +@@ -306,7 +315,7 @@ def ep_scatter( + assert expert_start_loc.shape[0] == num_experts + + # pack_ue8m0: scatter packs 4 UE8M0 bytes per int32; else copies scales as-is. +- scale_hidden_size = hidden_size // BLOCK_D ++ scale_hidden_size = hidden_size // BLOCK_D if has_scale else 1 + scale_packed_size = (scale_hidden_size + 3) // 4 if pack_ue8m0 else 1 + + _fwd_kernel_ep_scatter_1[(grid,)]( +@@ -347,6 +356,7 @@ def ep_scatter( + topk_num=recv_topk.shape[1], + expert_map=expert_map, + HAS_EXPERT_MAP=expert_map is not None, ++ HAS_SCALE=has_scale, + num_warps=num_warps, + HIDDEN_SIZE=hidden_size, + HIDDEN_SIZE_PAD=triton.next_power_of_2(hidden_size), +@@ -466,7 +476,7 @@ def ep_gather( + + def deepgemm_moe_permute( + aq: torch.Tensor, +- aq_scale: torch.Tensor, ++ aq_scale: torch.Tensor | None, + topk_ids: torch.Tensor, + local_num_experts: int, + expert_map: torch.Tensor | None, +@@ -504,9 +514,13 @@ def deepgemm_moe_permute( + + # uint8 UE8M0 (MXFP8) -> scatter packs into DeepGEMM's int32 MN-major + # TMA-aligned layout; float32 (FP8/FP4) scattered row-major as-is. +- pack_ue8m0 = aq_scale.dtype == torch.uint8 ++ # BF16 (unquantized) has no scales: aq_scale is None -> no scale output. ++ has_scale = aq_scale is not None ++ pack_ue8m0 = aq_scale is not None and aq_scale.dtype == torch.uint8 + sf_k = H // block_k +- if pack_ue8m0: ++ if not has_scale: ++ aq_scale_out = None ++ elif pack_ue8m0: + packed_sf_k = (sf_k + 3) // 4 + tma_aligned_mn = round_up(M_sum, 4) + aq_scale_out = torch.empty_strided( +diff --git a/vllm/model_executor/layers/fused_moe/experts/deep_gemm_moe.py b/vllm/model_executor/layers/fused_moe/experts/deep_gemm_moe.py +index 0eef7a83356..70c3546e123 100644 +--- a/vllm/model_executor/layers/fused_moe/experts/deep_gemm_moe.py ++++ b/vllm/model_executor/layers/fused_moe/experts/deep_gemm_moe.py +@@ -4,6 +4,7 @@ + import torch + + import vllm.model_executor.layers.fused_moe.modular_kernel as mk ++from vllm.forward_context import get_forward_context, is_forward_context_available + from vllm.logger import init_logger + from vllm.model_executor.layers.fused_moe.activation import MoEActivation + from vllm.model_executor.layers.fused_moe.config import ( +@@ -17,6 +18,7 @@ from vllm.model_executor.layers.fused_moe.deep_gemm_utils import ( + deepgemm_unpermute_and_reduce, + ) + from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import ( ++ TopKWeightAndReduceDelegate, + TopKWeightAndReduceNoOP, + ) + from vllm.model_executor.layers.fused_moe.utils import _resize_cache +@@ -40,12 +42,17 @@ from vllm.platforms import current_platform + from vllm.utils.deep_gemm import ( + DeepGemmQuantScaleFMT, + get_mk_alignment_for_contiguous_layout, ++ is_deep_gemm_bf16_grouped_supported, ++ is_deep_gemm_bf16_masked_supported, + is_deep_gemm_supported, ++ m_grouped_bf16_gemm_nt_contiguous, ++ m_grouped_bf16_gemm_nt_masked, + m_grouped_fp8_fp4_gemm_nt_contiguous, + m_grouped_fp8_gemm_nt_contiguous, + mk_alignment_scope, + ) + from vllm.utils.import_utils import has_deep_gemm ++from vllm.utils.math_utils import round_up + + logger = init_logger(__name__) + +@@ -691,3 +698,384 @@ class DeepGemmFP4Experts(mk.FusedMoEExpertsModular): + expert_map=expert_map, + output=output, + ) ++ ++ ++def _valid_deep_gemm_bf16( ++ hidden_states: torch.Tensor, w1: torch.Tensor, w2: torch.Tensor ++) -> bool: ++ """Whether the bf16 DeepGEMM grouped contiguous kernel supports this problem. ++ ++ All of M, N, K must be aligned to ++ ``get_mk_alignment_for_contiguous_layout()`` (128) and every tensor must be ++ contiguous bfloat16. ++ """ ++ if not has_deep_gemm(): ++ logger.debug_once("DeepGemm(bf16) disabled: deep_gemm not available.") ++ return False ++ ++ M = hidden_states.size(0) ++ _, K, N = w2.size() ++ align = get_mk_alignment_for_contiguous_layout()[0] ++ ++ if not _valid_deep_gemm_shape(M, N, K): ++ logger.debug_once( ++ "DeepGemm(bf16) disabled due to unaligned problem size. " ++ "M=%s N=%s K=%s (need M>=%s and N,K multiples of %s). " ++ "Falling back to the default backend.", ++ M, ++ N, ++ K, ++ align, ++ align, ++ ) ++ return False ++ ++ if ( ++ hidden_states.dtype != torch.bfloat16 ++ or w1.dtype != torch.bfloat16 ++ or w2.dtype != torch.bfloat16 ++ ): ++ logger.debug_once( ++ "DeepGemm(bf16) disabled: expected bfloat16 activations and weights. " ++ "hidden_states=%s w1=%s w2=%s", ++ hidden_states.dtype, ++ w1.dtype, ++ w2.dtype, ++ ) ++ return False ++ ++ if not ( ++ hidden_states.is_contiguous() and w1.is_contiguous() and w2.is_contiguous() ++ ): ++ logger.debug_once( ++ "DeepGemm(bf16) disabled: activations or weights not contiguous." ++ ) ++ return False ++ ++ return True ++ ++ ++class DeepGemmBf16Experts(mk.FusedMoEExpertsModular): ++ """DeepGEMM-based fused MoE experts for unquantized bf16 weights. ++ ++ Uses ``m_grouped_bf16_gemm_nt_contiguous`` for both grouped GEMMs, with a ++ plain silu-and-mul in between. This is the ``Standard`` (contiguous) ++ activation-format analogue of the FP8 :class:`DeepGemmExperts`. ++ """ ++ ++ def __init__(self, moe_config: FusedMoEConfig, quant_config: FusedMoEQuantConfig): ++ super().__init__(moe_config=moe_config, quant_config=quant_config) ++ # Unquantized only: no weight/activation scales, no block quant. ++ assert quant_config.quant_dtype is None, ( ++ "DeepGemmBf16Experts only supports unquantized (bf16) weights." ++ ) ++ assert not quant_config.per_act_token_quant ++ assert not quant_config.per_out_ch_quant ++ ++ @staticmethod ++ def activation_format() -> mk.FusedMoEActivationFormat: ++ return mk.FusedMoEActivationFormat.Standard ++ ++ @staticmethod ++ def _supports_current_device() -> bool: ++ return is_deep_gemm_bf16_grouped_supported() ++ ++ @staticmethod ++ def _supports_no_act_and_mul() -> bool: ++ return False ++ ++ @staticmethod ++ def _supports_quant_scheme( ++ weight_key: QuantKey | None, ++ activation_key: QuantKey | None, ++ ) -> bool: ++ # Unquantized bf16 only: the oracle passes (None, None) for this path. ++ return weight_key is None and activation_key is None ++ ++ @staticmethod ++ def _supports_activation(activation: MoEActivation) -> bool: ++ # Phase 1: plain gated SiLU only (silu_and_mul). Other gated variants ++ # (e.g. swigluoai) can be added later. ++ return activation == MoEActivation.SILU ++ ++ @staticmethod ++ def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool: ++ # Match DeepGemmExperts: exclude the FlashInfer NVL fused kernels. ++ return not ( ++ moe_parallel_config.use_fi_nvl_two_sided_kernels ++ or moe_parallel_config.use_fi_nvl_one_sided_kernels ++ ) ++ ++ def finalize_weight_and_reduce_impl(self) -> mk.TopKWeightAndReduce: ++ return TopKWeightAndReduceNoOP() ++ ++ def workspace_shapes( ++ self, ++ M: int, ++ N: int, ++ K: int, ++ topk: int, ++ global_num_experts: int, ++ local_num_experts: int, ++ expert_tokens_meta: mk.ExpertTokensMetadata | None, ++ activation: MoEActivation, ++ ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]: ++ block_m = get_mk_alignment_for_contiguous_layout()[0] ++ M_sum, align_used = compute_aligned_M_and_alignment( ++ M, topk, local_num_experts, block_m, expert_tokens_meta ++ ) ++ assert M_sum % align_used == 0 ++ ++ activation_out_dim = self.adjust_N_for_activation(N, activation) ++ # workspace13 holds the permuted bf16 activations (M_sum, K) and later ++ # the post-activation input (M_sum, activation_out_dim); workspace2 ++ # holds either grouped-GEMM output (M_sum, N) or (M_sum, K). ++ workspace1 = (M_sum, max(activation_out_dim, K)) ++ workspace2 = (M_sum, max(N, K)) ++ output = (M, K) ++ return (workspace1, workspace2, output) ++ ++ def apply( ++ self, ++ output: torch.Tensor, ++ hidden_states: torch.Tensor, ++ w1: torch.Tensor, ++ w2: torch.Tensor, ++ topk_weights: torch.Tensor, ++ topk_ids: torch.Tensor, ++ activation: MoEActivation, ++ global_num_experts: int, ++ expert_map: torch.Tensor | None, ++ a1q_scale: torch.Tensor | None, ++ a2_scale: torch.Tensor | None, ++ workspace13: torch.Tensor, ++ workspace2: torch.Tensor, ++ expert_tokens_meta: mk.ExpertTokensMetadata | None, ++ apply_router_weight_on_input: bool, ++ ): ++ # Unquantized bf16: no activation scales are produced by prepare/finalize. ++ assert a1q_scale is None ++ assert a2_scale is None ++ ++ a1 = hidden_states ++ _, N, K = w1.size() ++ ++ local_num_experts = w1.size(0) ++ if global_num_experts == -1: ++ global_num_experts = local_num_experts ++ ++ assert w2.size(1) == K ++ ++ M_sum, _ = compute_aligned_M_and_alignment( ++ M=topk_ids.size(0), ++ num_topk=topk_ids.size(1), ++ local_num_experts=local_num_experts, ++ alignment=get_mk_alignment_for_contiguous_layout()[0], ++ expert_tokens_meta=expert_tokens_meta, ++ ) ++ ++ # Permute bf16 activations into the per-expert contiguous layout and ++ # build m_indices (expert_ids). No scale scatter for bf16. ++ a1_perm = _resize_cache(workspace13, (M_sum, K)) ++ a1, _, expert_ids, inv_perm, align_used = deepgemm_moe_permute( ++ aq=a1, ++ aq_scale=None, ++ topk_ids=topk_ids, ++ local_num_experts=local_num_experts, ++ expert_map=expert_map, ++ expert_tokens_meta=expert_tokens_meta, ++ aq_out=a1_perm, ++ ) ++ assert a1.size(0) == M_sum ++ ++ # Cap DG's BLOCK_M heuristic at the workspace's per-expert alignment; ++ # see DeepGemmExperts.apply for the IMA-under-cudagraph rationale. ++ with mk_alignment_scope(align_used): ++ mm1_out = _resize_cache(workspace2, (M_sum, N)) ++ m_grouped_bf16_gemm_nt_contiguous(a1, w1, mm1_out, expert_ids) ++ ++ activation_out_dim = self.adjust_N_for_activation(N, activation) ++ act_out = _resize_cache(workspace13, (M_sum, activation_out_dim)) ++ # Plain (bf16) silu-and-mul; no requantization. ++ self.activation(activation, act_out, mm1_out.view(-1, N)) ++ ++ mm2_out = _resize_cache(workspace2, (M_sum, K)) ++ m_grouped_bf16_gemm_nt_contiguous(act_out, w2, mm2_out, expert_ids) ++ ++ if apply_router_weight_on_input: ++ topk_weights = torch.ones_like(topk_weights) ++ ++ deepgemm_unpermute_and_reduce( ++ a=mm2_out, ++ topk_ids=topk_ids, ++ topk_weights=topk_weights, ++ inv_perm=inv_perm, ++ expert_map=expert_map, ++ output=output, ++ ) ++ ++ ++class DeepGemmBf16BatchedExperts(mk.FusedMoEExpertsModular): ++ """DeepGEMM bf16 masked grouped-GEMM experts (``BatchedExperts`` / EP format). ++ ++ Unquantized bf16 analogue of ``BatchedDeepGemmExperts``: uses ++ ``m_grouped_bf16_gemm_nt_masked`` for both grouped GEMMs with a plain ++ silu-and-mul in between (no fp8 quantization). Selected via the opt-in ++ ``deep_gemm`` MoE backend when the layer runs in the batched activation ++ format (expert/data parallel with an all2all dispatcher). ++ """ ++ ++ def __init__( ++ self, ++ moe_config: FusedMoEConfig, ++ quant_config: FusedMoEQuantConfig, ++ max_num_tokens: int, ++ num_dispatchers: int, ++ ): ++ super().__init__( ++ moe_config=moe_config, ++ quant_config=quant_config, ++ max_num_tokens=max_num_tokens, ++ num_dispatchers=num_dispatchers, ++ ) ++ assert quant_config.quant_dtype is None, ( ++ "DeepGemmBf16BatchedExperts only supports unquantized (bf16) weights." ++ ) ++ ++ @staticmethod ++ def activation_format() -> mk.FusedMoEActivationFormat: ++ return mk.FusedMoEActivationFormat.BatchedExperts ++ ++ @staticmethod ++ def _supports_current_device() -> bool: ++ return is_deep_gemm_bf16_masked_supported() ++ ++ @staticmethod ++ def _supports_no_act_and_mul() -> bool: ++ return False ++ ++ @staticmethod ++ def _supports_quant_scheme( ++ weight_key: QuantKey | None, ++ activation_key: QuantKey | None, ++ ) -> bool: ++ return weight_key is None and activation_key is None ++ ++ @staticmethod ++ def _supports_activation(activation: MoEActivation) -> bool: ++ return activation == MoEActivation.SILU ++ ++ @staticmethod ++ def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool: ++ return True ++ ++ def finalize_weight_and_reduce_impl(self) -> mk.TopKWeightAndReduce: ++ # Let PrepareAndFinalize::finalize() decide the reduce impl. ++ return TopKWeightAndReduceDelegate() ++ ++ def workspace_shapes( ++ self, ++ M: int, ++ N: int, ++ K: int, ++ topk: int, ++ global_num_experts: int, ++ local_num_experts: int, ++ expert_tokens_meta: mk.ExpertTokensMetadata | None, ++ activation: MoEActivation, ++ ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]: ++ assert self.num_dispatchers is not None ++ assert self.max_num_tokens is not None ++ num_dispatchers = self.num_dispatchers ++ num_experts = local_num_experts ++ max_num_tokens = M if self.max_num_tokens is None else self.max_num_tokens ++ activation_out_dim = self.adjust_N_for_activation(N, activation) ++ workspace13 = (num_experts, max_num_tokens * num_dispatchers, max(K, N)) ++ workspace2 = (num_experts, max_num_tokens * num_dispatchers, activation_out_dim) ++ output = (num_experts, max_num_tokens * num_dispatchers, K) ++ return (workspace13, workspace2, output) ++ ++ def estimate_expected_m( ++ self, global_num_experts: int, max_tokens_per_expert: int, topk: int ++ ) -> int: ++ dp_meta = ( ++ get_forward_context().dp_metadata ++ if is_forward_context_available() ++ else None ++ ) ++ if dp_meta is None: ++ logger.warning_once( ++ "DPMetadata unavailable. Defaulting expected_m to " ++ f"{max_tokens_per_expert}.", ++ ) ++ return max_tokens_per_expert ++ ++ total_num_tokens = dp_meta.num_tokens_across_dp_cpu.sum().item() ++ total_num_tokens_replicated = total_num_tokens * topk ++ ++ # Assume even load balancing across experts. ++ assert global_num_experts != 0 ++ estimate = round_up(int(total_num_tokens_replicated // global_num_experts), 16) ++ estimate = max(estimate, 16) ++ estimate = min(max_tokens_per_expert, estimate) ++ return estimate ++ ++ def apply( ++ self, ++ output: torch.Tensor, ++ hidden_states: torch.Tensor, ++ w1: torch.Tensor, ++ w2: torch.Tensor, ++ topk_weights: torch.Tensor, ++ topk_ids: torch.Tensor, ++ activation: MoEActivation, ++ global_num_experts: int, ++ expert_map: torch.Tensor | None, ++ a1q_scale: torch.Tensor | None, ++ a2_scale: torch.Tensor | None, ++ workspace13: torch.Tensor, ++ workspace2: torch.Tensor, ++ expert_tokens_meta: mk.ExpertTokensMetadata | None, ++ apply_router_weight_on_input: bool, ++ ): ++ # Unquantized bf16: no activation scales. ++ assert a1q_scale is None ++ assert a2_scale is None ++ assert expert_tokens_meta is not None ++ expert_num_tokens = expert_tokens_meta.expert_num_tokens ++ ++ assert hidden_states.ndim == 3 ++ a1 = hidden_states ++ ++ E, max_num_tokens, N, K, _ = self.moe_problem_size( ++ hidden_states, w1, w2, topk_ids ++ ) ++ assert w2.size(1) == K ++ ++ workspace1 = _resize_cache(workspace13, (E, max_num_tokens, N)) ++ ++ expected_m = self.estimate_expected_m( ++ global_num_experts=global_num_experts, ++ max_tokens_per_expert=max_num_tokens, ++ topk=topk_ids.size(-1), ++ ) ++ ++ # GroupGemm-0: (E, T, K) x (E, N, K) -> (E, T, N) ++ m_grouped_bf16_gemm_nt_masked(a1, w1, workspace1, expert_num_tokens, expected_m) ++ ++ # Plain (bf16) silu-and-mul over the full padded batch. Rows past ++ # expert_num_tokens are junk but are never read by the masked ++ # down-GEMM below (it is gated by expert_num_tokens), so no masked ++ # activation kernel is required. ++ activation_out_dim = self.adjust_N_for_activation(N, activation) ++ act_out = _resize_cache(workspace2, (E, max_num_tokens, activation_out_dim)) ++ self.activation( ++ activation, ++ act_out.view(-1, activation_out_dim), ++ workspace1.view(-1, N), ++ ) ++ ++ # GroupGemm-1: (E, T, N//2) x (E, K, N//2) -> (E, T, K) ++ m_grouped_bf16_gemm_nt_masked( ++ act_out, w2, output, expert_num_tokens, expected_m ++ ) +diff --git a/vllm/model_executor/layers/fused_moe/oracle/unquantized.py b/vllm/model_executor/layers/fused_moe/oracle/unquantized.py +index 25b55670d1f..dccaf9fca4a 100644 +--- a/vllm/model_executor/layers/fused_moe/oracle/unquantized.py ++++ b/vllm/model_executor/layers/fused_moe/oracle/unquantized.py +@@ -40,6 +40,7 @@ class UnquantizedMoeBackend(Enum): + AITER = "ROCm AITER" + TRITON = "TRITON" + BATCHED_TRITON = "BATCHED_TRITON" ++ DEEP_GEMM = "DeepGEMM BF16" + MOONEP = "MOONEP" + CPU = "CPU" + XPU = "XPU" +@@ -145,6 +146,14 @@ def backend_to_kernel_cls( + + return [BatchedTritonExperts] + ++ elif backend == UnquantizedMoeBackend.DEEP_GEMM: ++ from vllm.model_executor.layers.fused_moe.experts.deep_gemm_moe import ( ++ DeepGemmBf16BatchedExperts, ++ DeepGemmBf16Experts, ++ ) ++ ++ return [DeepGemmBf16Experts, DeepGemmBf16BatchedExperts] ++ + elif backend == UnquantizedMoeBackend.MOONEP: + from vllm.model_executor.layers.fused_moe.experts.moonep_experts import ( + MoonEPExperts, +@@ -185,6 +194,7 @@ def map_unquantized_backend(runner_backend: MoEBackend) -> UnquantizedMoeBackend + "flashinfer_trtllm": UnquantizedMoeBackend.FLASHINFER_TRTLLM, + "flashinfer_cutlass": UnquantizedMoeBackend.FLASHINFER_CUTLASS, + "aiter": UnquantizedMoeBackend.AITER, ++ "deep_gemm": UnquantizedMoeBackend.DEEP_GEMM, + } + if backend := mapping.get(runner_backend): + return backend +diff --git a/vllm/utils/deep_gemm.py b/vllm/utils/deep_gemm.py +index 0eb979ce05a..ffb6fec4244 100644 +--- a/vllm/utils/deep_gemm.py ++++ b/vllm/utils/deep_gemm.py +@@ -115,6 +115,26 @@ def is_deep_gemm_supported() -> bool: + return is_supported_arch and envs.VLLM_USE_DEEP_GEMM and has_deep_gemm() + + ++def is_deep_gemm_bf16_grouped_supported() -> bool: ++ """Return ``True`` if the DeepGEMM bf16 grouped contiguous GEMM kernel ++ is available on this platform (used by the bf16 MoE experts backend). ++ """ ++ if not is_deep_gemm_supported(): ++ return False ++ _lazy_init() ++ return _grouped_bf16_impl is not None ++ ++ ++def is_deep_gemm_bf16_masked_supported() -> bool: ++ """Return ``True`` if the DeepGEMM bf16 masked grouped GEMM kernel is ++ available (used by the batched/EP bf16 MoE experts backend). ++ """ ++ if not is_deep_gemm_supported(): ++ return False ++ _lazy_init() ++ return _grouped_bf16_masked_impl is not None ++ ++ + @functools.cache + def is_deep_gemm_e8m0_used() -> bool: + """Return `True` if vLLM is configured to use DeepGEMM " +@@ -154,6 +174,8 @@ _fp8_gemm_nt_impl: Callable[..., Any] | None = None + _fp8_einsum_impl: Callable[..., Any] | None = None + _grouped_impl: Callable[..., Any] | None = None + _grouped_masked_impl: Callable[..., Any] | None = None ++_grouped_bf16_impl: Callable[..., Any] | None = None ++_grouped_bf16_masked_impl: Callable[..., Any] | None = None + _grouped_fp4_impl: Callable[..., Any] | None = None + _fp8_fp4_mqa_logits_impl: Callable[..., Any] | None = None + _fp8_fp4_paged_mqa_logits_impl: Callable[..., Any] | None = None +@@ -235,6 +257,7 @@ def _lazy_init() -> None: + global _cublaslt_gemm_nt_impl + global _fp8_gemm_nt_impl, _fp8_einsum_impl + global _grouped_impl, _grouped_masked_impl, _grouped_fp4_impl ++ global _grouped_bf16_impl, _grouped_bf16_masked_impl + global _fp8_fp4_mqa_logits_impl, _fp8_fp4_paged_mqa_logits_impl + global _get_paged_mqa_logits_metadata_impl + global _fp8_fp4_sparse_mqa_logits_impl, _fp8_fp4_paged_sparse_mqa_logits_impl +@@ -256,6 +279,8 @@ def _lazy_init() -> None: + or _fp8_einsum_impl is not None + or _grouped_impl is not None + or _grouped_masked_impl is not None ++ or _grouped_bf16_impl is not None ++ or _grouped_bf16_masked_impl is not None + or _grouped_fp4_impl is not None + or _fp8_fp4_mqa_logits_impl is not None + or _fp8_fp4_paged_mqa_logits_impl is not None +@@ -297,6 +322,8 @@ def _lazy_init() -> None: + _grouped_masked_impl = getattr( + _dg, "m_grouped_fp8_gemm_nt_masked", None + ) or getattr(_dg, "fp8_m_grouped_gemm_nt_masked", None) ++ _grouped_bf16_impl = getattr(_dg, "m_grouped_bf16_gemm_nt_contiguous", None) ++ _grouped_bf16_masked_impl = getattr(_dg, "m_grouped_bf16_gemm_nt_masked", None) + _grouped_fp4_impl = getattr(_dg, "m_grouped_fp8_fp4_gemm_nt_contiguous", None) + # DeepGEMM exposes fp8_fp4_*_mqa_logits as the canonical symbols that + # handle both the FP8 and FP4 Q/K paths via a tuple-typed `q`. +@@ -563,6 +590,24 @@ def m_grouped_fp8_gemm_nt_contiguous(*args, **kwargs): + ) + + ++def m_grouped_bf16_gemm_nt_contiguous(*args, **kwargs): ++ # BF16 grouped GEMM: activations/weights are plain bf16 tensors, so there ++ # are no block scales and no ue8m0 cast to disable (unlike the fp8 path). ++ _lazy_init() ++ if _grouped_bf16_impl is None: ++ return _missing(*args, **kwargs) ++ return _grouped_bf16_impl(*args, **kwargs) ++ ++ ++def m_grouped_bf16_gemm_nt_masked(*args, **kwargs): ++ # BF16 masked grouped GEMM (batched/EP layout): plain bf16 tensors, no ++ # scales and no ue8m0 cast (unlike fp8_m_grouped_gemm_nt_masked). ++ _lazy_init() ++ if _grouped_bf16_masked_impl is None: ++ return _missing(*args, **kwargs) ++ return _grouped_bf16_masked_impl(*args, **kwargs) ++ ++ + def m_grouped_fp8_fp4_gemm_nt_contiguous(*args, **kwargs): + _lazy_init() + if _grouped_fp4_impl is None: +@@ -1046,6 +1091,10 @@ __all__ = [ + "fp8_gemm_nt", + "fp8_einsum", + "m_grouped_fp8_gemm_nt_contiguous", ++ "m_grouped_bf16_gemm_nt_contiguous", ++ "m_grouped_bf16_gemm_nt_masked", ++ "is_deep_gemm_bf16_grouped_supported", ++ "is_deep_gemm_bf16_masked_supported", + "m_grouped_fp8_fp4_gemm_nt_contiguous", + "fp8_m_grouped_gemm_nt_masked", + "fp8_fp4_mqa_logits", diff --git a/docker/patch/latest/vllm-host-cache-ratio.patch b/docker/patch/latest/vllm-host-cache-ratio.patch new file mode 100644 index 000000000..db6d5093c --- /dev/null +++ b/docker/patch/latest/vllm-host-cache-ratio.patch @@ -0,0 +1,30 @@ +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py +index 0bd8ebe3cd6..247d85d927d 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py +@@ -3,6 +3,7 @@ + """SimpleCPUOffloadConnector: minimal CPU KV cache offloading.""" + + from collections.abc import Iterable ++from math import isfinite + from typing import TYPE_CHECKING, Any + + import torch +@@ -150,6 +151,17 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA): + ) + return + ++ if "cpu_to_gpu_ratio" in extra_config and not any( ++ key in extra_config ++ for key in ("cpu_bytes_to_use", "cpu_bytes_to_use_per_rank") ++ ): ++ ratio = float(extra_config["cpu_to_gpu_ratio"]) ++ if not isfinite(ratio) or ratio <= 0: ++ raise ValueError("cpu_to_gpu_ratio must be finite and positive") ++ cpu_capacity_per_rank = int( ++ ratio * kv_cache_config.kv_cache_tensors[0].size ++ ) ++ + logger.info( + "SimpleCPUOffloadConnector: role=%s, " + "per_rank=%.2f GB, world_size=%d, mode=%s, backend=%s, disk=%s", diff --git a/docker/patch/latest/vllm-mooncake-prom-metrics.patch b/docker/patch/latest/vllm-mooncake-prom-metrics.patch new file mode 100644 index 000000000..270c45c8a --- /dev/null +++ b/docker/patch/latest/vllm-mooncake-prom-metrics.patch @@ -0,0 +1,220 @@ +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py +index 5f064259e8b..f7ac323d59f 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py +@@ -34,13 +34,19 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import ( + KVConnectorTransferResults, + SupportsHMA, + ) +-from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats ++from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( ++ KVConnectorPromMetrics, ++ KVConnectorStats, ++ PromMetric, ++ PromMetricT, ++) + from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_utils import ( + MooncakeBootstrapServer, + RegisterWorkerPayload, + ) + from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.stats import ( + MooncakeKVConnectorStats, ++ MooncakePromMetrics, + ) + from vllm.distributed.parallel_state import ( + get_pp_group, +@@ -864,6 +870,18 @@ class MooncakeConnector(KVConnectorBase_V1, SupportsHMA): + ) -> KVConnectorStats | None: + return MooncakeKVConnectorStats(data=data or {}) + ++ @classmethod ++ def build_prom_metrics( ++ cls, ++ vllm_config: VllmConfig, ++ metric_types: dict[type[PromMetric], type[PromMetricT]], ++ labelnames: list[str], ++ per_engine_labelvalues: dict[int, list[object]], ++ ) -> KVConnectorPromMetrics: ++ return MooncakePromMetrics( ++ vllm_config, metric_types, labelnames, per_engine_labelvalues ++ ) ++ + + class MooncakeConnectorScheduler: + """Implementation of Scheduler side methods.""" +diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/stats.py b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/stats.py +index a9dbd6bdd4e..24968bbfe81 100644 +--- a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/stats.py ++++ b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/stats.py +@@ -1,6 +1,6 @@ + # SPDX-License-Identifier: Apache-2.0 + # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +-"""Stats container for the Mooncake connector.""" ++"""Stats and Prometheus metrics for the Mooncake connector.""" + + import threading + from dataclasses import dataclass +@@ -8,12 +8,50 @@ from typing import Any + + import numpy as np + ++from vllm.config import VllmConfig + from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( ++ KVConnectorPromMetrics, + KVConnectorStats, ++ PromMetric, ++ PromMetricT, + ) ++from vllm.v1.metrics.utils import create_metric_per_engine + +-# TODO(mooncake-stats): add MooncakePromMetrics (mirror NixlPromMetrics) +-# and wire it via MooncakeConnector.build_prom_metrics in a follow-up PR. ++# Bucket edges for the per-transfer series, named after the series each feeds. ++# Timing covers 5ms to 5s; post time is not measured, so there is no ++# sub-millisecond edge. Payload sizes run 2KiB to 8GiB, doubling every other ++# power of two, and descriptor counts run 10 to 50k. ++_XFER_TIME_BUCKETS = ( ++ 0.005, ++ 0.01, ++ 0.025, ++ 0.05, ++ 0.075, ++ 0.1, ++ 0.2, ++ 0.3, ++ 0.5, ++ 0.75, ++ 1.0, ++ 5.0, ++) ++_BYTES_BUCKETS = tuple(2 ** (10 + i) for i in range(1, 25, 2)) ++_DESCRIPTOR_BUCKETS = ( ++ 10, ++ 20, ++ 30, ++ 50, ++ 75, ++ 100, ++ 200, ++ 400, ++ 1000, ++ 2000, ++ 4000, ++ 10000, ++ 20000, ++ 50000, ++) + + + @dataclass +@@ -58,8 +96,8 @@ class MooncakeKVConnectorStats(KVConnectorStats): + self.data["bytes_transferred"].append(total_bytes) + self.data["num_descriptors"].append(num_descs) + +- # Failure counters store a list of 1s so a future Prom counter can iterate +- # with .inc(list_item), mirroring NIXL's NixlPromMetrics.observe. ++ # Each failure appends a single unit, which MooncakePromMetrics sums into ++ # one counter increment. + def record_failed_transfer(self): + with self._lock: + self.data["num_failed_transfers"].append(1) +@@ -144,3 +182,99 @@ class MooncakeKVConnectorStats(KVConnectorStats): + @property + def num_successful_transfers(self) -> int: + return len(self.data["transfer_duration"]) ++ ++ ++class MooncakePromMetrics(KVConnectorPromMetrics): ++ """Prometheus metrics for Mooncake KV Cache transfers. ++ ++ Mooncake is push-based, so the histograms are only populated on the ++ prefiller (P) instance; the counters are bumped wherever the failure was ++ detected. ++ """ ++ ++ def __init__( ++ self, ++ vllm_config: VllmConfig, ++ metric_types: dict[type[PromMetric], type[PromMetricT]], ++ labelnames: list[str], ++ per_engine_labelvalues: dict[int, list[object]], ++ ): ++ super().__init__(vllm_config, metric_types, labelnames, per_engine_labelvalues) ++ ++ mooncake_histogram_xfer_time = self._histogram_cls( ++ name="vllm:mooncake_xfer_time_seconds", ++ documentation="Histogram of transfer duration for Mooncake" ++ " KV Cache transfers.", ++ buckets=_XFER_TIME_BUCKETS, ++ labelnames=labelnames, ++ ) ++ self.mooncake_histogram_xfer_time = create_metric_per_engine( ++ mooncake_histogram_xfer_time, self.per_engine_labelvalues ++ ) ++ mooncake_histogram_bytes_transferred = self._histogram_cls( ++ name="vllm:mooncake_bytes_transferred", ++ documentation="Histogram of bytes transferred per Mooncake" ++ " KV Cache transfer.", ++ buckets=_BYTES_BUCKETS, ++ labelnames=labelnames, ++ ) ++ self.mooncake_histogram_bytes_transferred = create_metric_per_engine( ++ mooncake_histogram_bytes_transferred, self.per_engine_labelvalues ++ ) ++ mooncake_histogram_num_descriptors = self._histogram_cls( ++ name="vllm:mooncake_num_descriptors", ++ documentation="Histogram of number of descriptors per Mooncake" ++ " KV Cache transfer.", ++ buckets=_DESCRIPTOR_BUCKETS, ++ labelnames=labelnames, ++ ) ++ self.mooncake_histogram_num_descriptors = create_metric_per_engine( ++ mooncake_histogram_num_descriptors, self.per_engine_labelvalues ++ ) ++ counter_mooncake_num_failed_transfers = self._counter_cls( ++ name="vllm:mooncake_num_failed_transfers", ++ documentation="Number of failed Mooncake KV Cache transfers.", ++ labelnames=labelnames, ++ ) ++ self.counter_mooncake_num_failed_transfers = create_metric_per_engine( ++ counter_mooncake_num_failed_transfers, self.per_engine_labelvalues ++ ) ++ counter_mooncake_num_failed_recvs = self._counter_cls( ++ name="vllm:mooncake_num_failed_recvs", ++ documentation="Number of failed Mooncake KV Cache receives.", ++ labelnames=labelnames, ++ ) ++ self.counter_mooncake_num_failed_recvs = create_metric_per_engine( ++ counter_mooncake_num_failed_recvs, self.per_engine_labelvalues ++ ) ++ counter_mooncake_num_kv_expired_reqs = self._counter_cls( ++ name="vllm:mooncake_num_kv_expired_reqs", ++ documentation="Number of requests that had their KV expire. " ++ "NOTE: This metric is tracked on the P instance.", ++ labelnames=labelnames, ++ ) ++ self.counter_mooncake_num_kv_expired_reqs = create_metric_per_engine( ++ counter_mooncake_num_kv_expired_reqs, self.per_engine_labelvalues ++ ) ++ ++ def observe(self, transfer_stats_data: dict[str, Any], engine_idx: int = 0) -> None: ++ # A snapshot always carries every declared key, so a miss here is a ++ # connector bug and should surface rather than silently drop samples. ++ for per_engine_metrics, stats_key in ( ++ (self.mooncake_histogram_xfer_time, "transfer_duration"), ++ (self.mooncake_histogram_bytes_transferred, "bytes_transferred"), ++ (self.mooncake_histogram_num_descriptors, "num_descriptors"), ++ ): ++ for value in transfer_stats_data[stats_key]: ++ per_engine_metrics[engine_idx].observe(value) ++ ++ # Each failure appends a single unit, so incrementing by the sum costs ++ # one Prometheus client call per series instead of one per event. ++ for per_engine_metrics, stats_key in ( ++ (self.counter_mooncake_num_failed_transfers, "num_failed_transfers"), ++ (self.counter_mooncake_num_failed_recvs, "num_failed_recvs"), ++ (self.counter_mooncake_num_kv_expired_reqs, "num_kv_expired_reqs"), ++ ): ++ num_events = sum(transfer_stats_data[stats_key]) ++ if num_events: ++ per_engine_metrics[engine_idx].inc(num_events) diff --git a/docs/_static/css/custom_log.css b/docs/_static/css/custom_log.css index 3edb005df..2e4ce75dd 100644 --- a/docs/_static/css/custom_log.css +++ b/docs/_static/css/custom_log.css @@ -65,3 +65,12 @@ div.output_area.stdout { .lang-toggle-btn { --lt-border: #30363d; --lt-bg:#161b22; --lt-bg-hover:#1c2128; } .lang-toggle-btn .lang-seg { color: #adbac7; } } + +/* Comfortable article text, with a short categorized library entry point. */ +.bd-article { font-size: 18px; line-height: 1.8; } +.bd-sidebar-primary { font-size: 16px; } +.library-categories .toctree-wrapper > ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 0; list-style: none; } +.library-categories .toctree-l1 { margin: 0; padding: 24px; border: 1px solid var(--pst-color-border); border-radius: 12px; background: var(--pst-color-surface); } +.library-categories .toctree-l1 > a { font-size: 20px; font-weight: 650; } +.library-categories .toctree-l1 > ul { display: none; } +@media (max-width: 600px) { .library-categories .toctree-wrapper > ul { grid-template-columns: minmax(0, 1fr); } } diff --git a/docs/_static/css/experiment.css b/docs/_static/css/experiment.css new file mode 100644 index 000000000..0bcfc0baa --- /dev/null +++ b/docs/_static/css/experiment.css @@ -0,0 +1,3060 @@ +:root { + --paper: #faf9f5; + --ink: #202725; + --muted: #62706c; + --line: #d9ded6; + --yellow: #ffda35; + --blue: #a3e1ef; + --mint: #e5f3e8; + --radius: 18px; + font-family: Inter, "Arial", "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--ink); + background: var(--paper); + font-synthesis: none; + font-size: 18px; + line-height: 1.65; + scroll-behavior: smooth +} + +* { + box-sizing: border-box +} + +body { + margin: 0 +} + +button, +input, +select { + font: inherit +} + +button, +a, +input { + -webkit-tap-highlight-color: transparent +} + +button, +a { + touch-action: manipulation +} + +button { + cursor: pointer; + color: inherit +} + +a { + color: inherit; + text-decoration: none +} + +a:hover { + text-decoration: underline; + text-underline-offset: 4px +} + +button { + border: 1px solid var(--line); + background: transparent; + border-radius: 8px; + padding: 9px 12px; + font-size: 15px; + font-weight: 600 +} + +button:hover:not(:disabled) { + background: #e8ece5 +} + +button:disabled { + cursor: default; + opacity: .42 +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid #187991; + outline-offset: 4px +} + +button, +input, +a { + transition: background .18s, border-color .18s, transform .18s +} + +button:active:not(:disabled) { + transform: translateY(2px) +} + +[hidden] { + display: none !important +} + +.skip-link { + position: fixed; + top: -70px; + left: 20px; + z-index: 30; + background: var(--yellow); + padding: 16px +} + +.skip-link:focus { + top: 12px +} + +.site-header { + height: 88px; + max-width: 1400px; + margin: auto; + padding: 0 48px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line) +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 28px; + font-weight: 850; + letter-spacing: -1.3px +} + +.brand img { + border-radius: 50%; + border: 1px solid var(--ink) +} + +.brand span { + font-size: 15px; + font-weight: 400; + letter-spacing: 0; + color: var(--muted) +} + +.site-header nav { + display: flex; + gap: 25px; + align-items: center; + font-size: 15px; + font-weight: 600 +} + +.lang-toggle-btn { + border: 0; + padding: 8px 0 +} + +.lang-seg { + opacity: .4 +} + +.lang-toggle-btn[data-current=en] [data-lang=en], +.lang-toggle-btn[data-current=zh] [data-lang=zh] { + opacity: 1 +} + +.lang-sep { + padding: 0 4px; + color: var(--line) +} + +main { + max-width: 1400px; + margin: auto; + padding: 0 48px +} + +.hero { + display: grid; + grid-template-columns: 1.45fr 1fr; + align-items: center; + gap: 40px; + padding: 80px 0 70px; + min-height: 440px +} + +.eyebrow { + font-family: "Courier New", monospace; + font-size: 14px; + font-weight: 700; + letter-spacing: 1.5px; + line-height: 1.6; + display: flex; + align-items: center; + gap: 9px +} + +.live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #459c6b; + box-shadow: 0 0 0 4px #e1eedf +} + +h1 { + font-size: clamp(40px, 4.1vw, 60px); + letter-spacing: -2.6px; + line-height: 1.12; + margin: 26px 0 22px; + font-weight: 800 +} + +h1 span { + color: #417b88 +} + +html[lang=zh-CN] h1 { + font-size: clamp(36px, 3.4vw, 50px); + line-height: 1.35; + letter-spacing: -1.8px +} + +.hero-description { + font-size: 18px; + line-height: 1.9; + color: var(--muted); + max-width: 525px; + margin: 0 0 25px +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 24px; + border-radius: 9px; + padding: 13px 19px; + font-size: 15px; + font-weight: 700; + border: 1px solid var(--ink); + text-decoration: none !important +} + +.primary { + background: var(--yellow); + box-shadow: 2px 3px 0 var(--ink) +} + +.primary:hover:not(:disabled) { + background: #f4cd15; + transform: translateY(-2px) +} + +.dark { + background: var(--ink); + color: white +} + +.dark:hover:not(:disabled) { + background: #36433d +} + +.light { + background: var(--paper) +} + +.hero-note { + font-size: 14px; + color: var(--muted); + display: inline-block; + margin-left: 14px +} + +.hero-art { + justify-self: center; + position: relative; + width: 330px; + height: 330px; + display: flex; + align-items: center; + justify-content: center +} + +.mascot-frame { + width: 268px; + height: 268px; + border-radius: 38% 55% 40% 46%; + background: var(--yellow); + overflow: hidden; + border: 2px solid var(--ink); + transform: rotate(-8deg); + box-shadow: 10px 12px 0 #cbe9e8 +} + +.mascot-frame img { + width: 100%; + height: 100%; + object-fit: cover; + transform: rotate(8deg) scale(1.03) +} + +.orbit-label { + position: absolute; + background: white; + padding: 9px 13px; + border: 1px solid var(--ink); + border-radius: 6px; + font-family: monospace; + font-size: 14px; + z-index: 1; + box-shadow: 2px 3px 0 #d9ded6 +} + +.label-top { + top: 14px; + left: -15px; + transform: rotate(-5deg) +} + +.label-bottom { + bottom: 6px; + right: -8px; + transform: rotate(4deg) +} + +.spark { + position: absolute; + font-size: 38px +} + +.spark-one { + right: -12px; + top: 38px; + color: #3988a0 +} + +.spark-two { + bottom: 34px; + left: -14px; + font-size: 29px +} + +.lab { + border-top: 1px solid var(--line); + padding: 38px 0 60px; + scroll-margin-top: 20px +} + +.lab-heading, +.recipe-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px +} + +h2 { + font-size: 30px; + letter-spacing: -1px; + margin: 8px 0 12px; + line-height: 1.35 +} + +.lab-heading .eyebrow { + color: var(--muted) +} + +.history-actions { + display: flex; + gap: 8px +} + +.history-actions button { + font-size: 14px +} + +.presets { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 0 28px; + flex-wrap: wrap +} + +.presets>span { + font-size: 14px; + color: var(--muted); + margin-right: 6px +} + +.presets button { + border-radius: 24px; + background: white; + font-weight: 400; + font-size: 14px +} + +.presets button:hover { + border-color: var(--ink) +} + +.steps { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 4px; + margin-bottom: 18px +} + +.steps button { + padding: 14px 5px; + border: 0; + border-radius: 8px; + color: var(--muted); + text-align: left; + border-bottom: 2px solid var(--line); + font-size: 14px +} + +.steps button span { + font-family: monospace; + font-size: 14px; + margin-right: 8px; + opacity: .75 +} + +.steps button[aria-current=step] { + color: var(--ink); + border-bottom-color: var(--ink); + background: #f0efdf +} + +.workbench { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 20px; + align-items: start +} + +.builder-panel { + background: white; + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 27px; + min-height: 585px; + display: flex; + flex-direction: column +} + +.step-heading { + display: flex; + gap: 15px; + align-items: flex-start; + margin-bottom: 23px +} + +.step-number { + font-family: monospace; + background: var(--yellow); + padding: 8px 9px; + border: 1px solid var(--ink); + border-radius: 9px; + font-size: 18px +} + +.step-heading h3 { + font-size: 21px; + margin: 2px 0 7px; + letter-spacing: -.5px +} + +.step-heading p { + font-size: 15px; + line-height: 1.7; + color: var(--muted); + margin: 0 +} + +.field-group { + border: 0; + margin: 0 0 20px; + padding: 0; + min-width: 0 +} + +.field-group legend, +.input-label { + font-size: 14px; + font-weight: 700; + margin-bottom: 9px +} + +.option-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px +} + +.option-card { + position: relative; + display: flex; + flex-direction: column; + text-align: left; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 10px; + padding: 13px 14px; + cursor: pointer; + transition: background .2s, border-color .2s, transform .2s +} + +.option-card:hover { + border-color: #839790; + transform: translateY(-2px) +} + +.option-card:has(input:checked) { + background: #e8f5f3; + border-color: #397d78; + box-shadow: inset 0 0 0 1px #397d78 +} + +.option-card input { + position: absolute; + right: 11px; + top: 13px; + width: 14px; + height: 14px; + accent-color: #337a73; + margin: 0 +} + +.option-title { + font-size: 15px; + font-weight: 700; + padding-right: 17px; + line-height: 1.4 +} + +.option-description { + font-size: 14px; + line-height: 1.65; + color: var(--muted); + margin-top: 5px +} + +.option-grid.single { + grid-template-columns: 1fr +} + +.toggle-row { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 13px 0; + border-bottom: 1px solid #e8ece5; + cursor: pointer +} + +.toggle-row input { + accent-color: #397d78; + width: 17px; + height: 17px; + margin: 1px 0 0; + flex-shrink: 0 +} + +.toggle-row strong { + display: block; + font-size: 15px +} + +.toggle-row small { + display: block; + color: var(--muted); + font-size: 14px; + line-height: 1.6; + margin-top: 5px +} + +.inputs-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 13px; + margin: 12px 0 +} + +.input-label { + display: block; + line-height: 1.5 +} + +.input-label input, +.input-label select { + width: 100%; + border: 1px solid var(--line); + border-radius: 7px; + padding: 10px; + font-size: 14px; + background: var(--paper); + margin-top: 6px; + color: var(--ink); + min-width: 0 +} + +.input-label.wide { + grid-column: 1/-1 +} + +.input-label small { + font-size: 14px; + font-weight: 400; + color: var(--muted); + display: block; + margin-top: 5px +} + +.step-help { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 8px 0 20px +} + +.learn-button { + border: 0; + padding: 6px 7px; + font-size: 14px; + color: #326f83; + background: #edf5f7 +} + +.step-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + border-top: 1px solid var(--line); + padding-top: 18px; + margin-top: auto +} + +.step-controls>span { + font-size: 14px; + color: var(--muted) +} + +.preview { + border: 1px solid #c9d9d5; + border-radius: var(--radius); + overflow: hidden; + background: #edf3ee; + position: sticky; + top: 18px +} + +.preview-heading { + padding: 18px 19px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px +} + +.preview-heading .eyebrow { + font-size: 14px; + letter-spacing: 1px +} + +.preview-heading button { + font-size: 14px; + border-color: #bacdc4; + padding: 7px 9px; + background: #fff9 +} + +.world { + min-height: 252px; + padding: 23px 24px 10px; + background-image: radial-gradient(#bdcec7 .7px, transparent .7px); + background-size: 14px 14px; + position: relative +} + +.world-top { + font-size: 14px; + font-family: monospace; + text-align: center; + letter-spacing: 1px; + color: var(--muted); + margin-bottom: 18px +} + +.world-nodes { + display: grid; + grid-template-columns: 1fr 36px 1fr; + align-items: center; + gap: 3px +} + +.world-node { + background: #fffdf7; + border: 1.5px solid #35413a; + border-radius: 13px; + padding: 14px 10px; + text-align: center; + box-shadow: 3px 4px 0 #bdcfc5; + transition: all .35s; + min-width: 0; + position: relative +} + +.world-node strong { + display: block; + font-size: 15px; + line-height: 1.5 +} + +.world-node small { + display: block; + font-size: 14px; + color: var(--muted); + margin-top: 4px +} + +.world-node.trainer { + background: #ffeb87 +} + +.world-node.sampler { + background: #bdeaf1 +} + +.world-node.external { + border-style: dashed; + box-shadow: 4px 4px 0 #bdcfd6 +} + +.chips { + display: flex; + gap: 3px; + margin: 10px auto 0; + justify-content: center; + flex-wrap: wrap; + max-width: 135px +} + +.gpu { + display: block; + width: 14px; + height: 17px; + border: 1px solid #64736d; + border-radius: 3px; + background: #fff8; + position: relative +} + +.gpu:after { + content: ''; + position: absolute; + inset: 4px 3px; + background: #9aa9a0; + border-radius: 1px +} + +.flow-arrow { + text-align: center; + position: relative; + color: #61796c; + font-size: 22px +} + +.flow-arrow:after { + content: ''; + position: absolute; + left: 5px; + top: 7px; + width: 7px; + height: 7px; + background: #318994; + border-radius: 50%; + opacity: 0 +} + +.world.running .flow-arrow:after { + animation: flow 1.5s infinite +} + +.world.running .world-node.active { + box-shadow: 0 0 0 5px #8ebea055, 3px 4px 0 #bdcfc5; + transform: translateY(-4px) +} + +.world-colocate { + border: 1px dashed #94a59d; + padding: 16px 10px 10px; + border-radius: 15px; + position: relative +} + +.pool-label { + display: block; + font-size: 14px; + color: #5f7669; + text-align: center; + margin-top: 12px; + font-family: monospace +} + +.reward-loop { + border: 1px solid #a0b7ac; + border-top: 0; + border-radius: 0 0 14px 14px; + width: 72%; + height: 27px; + margin: auto; + position: relative +} + +.reward-loop span { + position: absolute; + bottom: -9px; + left: 50%; + transform: translateX(-50%); + font-size: 14px; + background: #edf3ee; + padding: 0 8px; + white-space: nowrap +} + +.pd-nodes { + display: flex; + gap: 4px; + margin-top: 9px +} + +.pd-nodes span { + border: 1px solid #60929b; + padding: 4px; + border-radius: 5px; + font-size: 14px; + flex: 1; + background: #dff6fa +} + +.cache-tier { + font-size: 14px; + border: 1px dashed #698b96; + background: #f7f2dc; + border-radius: 5px; + margin: 7px 0 0; + padding: 4px +} + +.simulation-status { + min-height: 29px; + text-align: center; + font-size: 14px; + color: var(--muted); + padding: 0 16px; + margin: 8px 0 12px; + line-height: 1.5 +} + +.summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + padding: 15px 20px; + background: #fff9; + border-top: 1px solid #d1ddd6; + border-bottom: 1px solid #d1ddd6; + gap: 8px +} + +.summary small { + display: block; + font-size: 14px; + color: var(--muted); + margin-bottom: 6px +} + +.summary strong { + font-size: 14px; + font-weight: 650 +} + +.advice { + padding: 15px 20px; + font-size: 14px; + line-height: 1.75 +} + +.advice p { + margin: 0 0 8px +} + +.advice p:last-child { + margin: 0 +} + +.advice .warning { + color: #825c20 +} + +.review-link { + display: flex; + justify-content: space-between; + padding: 14px 20px; + border-top: 1px solid #d1ddd6; + font-size: 14px; + font-weight: 700 +} + +.recipe { + background: var(--ink); + color: #f9f9ef; + padding: 34px; + border-radius: 20px; + margin: 0 0 60px; + scroll-margin-top: 20px +} + +.recipe-heading h2 { + margin-bottom: 7px +} + +.recipe-heading p:not(.eyebrow) { + font-size: 14px; + color: #afc0b5; + line-height: 1.7 +} + +.recipe-heading .button { + color: var(--ink); + white-space: nowrap +} + +.recipe .eyebrow { + color: #cde398 +} + +.artifact-toolbar { + margin-top: 25px; + background: #303b35; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-radius: 9px 9px 0 0; + padding: 10px 13px; + flex-wrap: wrap +} + +.artifact-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap +} + +.artifact-tabs button { + color: #c2cfc5; + border: 0; + font-family: monospace; + font-size: 14px +} + +.artifact-tabs button[aria-selected=true] { + color: white; + background: #4a5e50 +} + +.artifact-actions { + display: flex; + gap: 9px; + align-items: center +} + +.artifact-actions button { + font-size: 14px; + padding: 8px 12px +} + +.artifact-actions>button:first-child { + color: #dfe8df; + border: 1px solid #67786b +} + +.artifact-actions .primary { + color: var(--ink); + box-shadow: none +} + +.recipe pre { + margin: 0; + background: #141e19; + padding: 22px; + border: 1px solid #3d4d41; + border-top: 0; + border-radius: 0 0 10px 10px; + overflow: auto; + max-height: 490px; + min-height: 170px; + color: #c5e3c8; + line-height: 1.9; + font-size: 14px; + tab-size: 2 +} + +.run-guide { + margin-top: 25px +} + +.run-guide h3 { + font-size: 18px +} + +.run-guide ol { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 30px; + list-style: none; + padding: 0; + counter-reset: run +} + +.run-guide li { + font-size: 14px; + color: #b8c7bd; + line-height: 1.8; + counter-increment: run +} + +.run-guide li:before { + content: '0' counter(run); + display: block; + font-family: monospace; + color: #ead77f; + margin-bottom: 8px +} + +.run-guide li strong { + display: block; + color: white; + font-size: 15px; + margin-bottom: 4px +} + +.run-guide a { + color: #c8e5ea; + text-decoration: underline +} + +.run-guide code { + color: #e3eec3; + overflow-wrap: anywhere +} + +.recipe-footnote { + font-size: 14px; + line-height: 1.7; + color: #afc0b5; + border-top: 1px solid #415047; + padding-top: 18px; + margin: 23px 0 0 +} + +.notice { + border: 1px solid #d6b763; + background: #fff3ca; + color: #594516; + border-radius: 8px; + padding: 12px 15px; + font-size: 14px; + line-height: 1.75; + margin: 14px 0 +} + +.notice.error { + background: #ffe3dc; + color: #8b3328; + border-color: #f2b6a8 +} + +.notice ul { + margin: 0; + padding-left: 16px +} + +.library-teaser { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 15px; + border-top: 1px solid var(--line); + padding: 40px 0 70px +} + +.library-teaser>div { + grid-row: span 2; + padding-right: 25px +} + +.library-teaser>div p:not(.eyebrow) { + font-size: 15px; + line-height: 1.8; + color: var(--muted) +} + +.library-teaser>a { + background: white; + border: 1px solid var(--line); + border-radius: 10px; + padding: 20px; + font-size: 14px; + color: var(--muted); + line-height: 1.6 +} + +.library-teaser>a:hover { + border-color: #618c88; + background: #eef7f4 +} + +.library-teaser>a>span { + font-family: monospace; + color: #7d9994; + display: block; + font-size: 14px +} + +.library-teaser strong { + display: block; + font-size: 16px; + color: var(--ink); + margin: 12px 0 7px +} + +footer { + max-width: 1400px; + margin: auto; + padding: 25px 48px 35px; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--line); + gap: 15px +} + +footer .brand { + font-size: 23px +} + +footer>span { + font-size: 14px; + color: var(--muted) +} + +.toast { + position: fixed; + bottom: 25px; + left: 50%; + transform: translate(-50%, 20px); + background: var(--ink); + color: white; + border: 1px solid #64766d; + padding: 12px 22px; + border-radius: 10px; + font-size: 14px; + opacity: 0; + pointer-events: none; + z-index: 40; + transition: all .2s; + max-width: 90vw +} + +.toast.visible { + opacity: 1; + transform: translate(-50%, 0) +} + +dialog { + background: var(--paper); + color: var(--ink); + border: 1px solid var(--ink); + border-radius: 18px; + max-width: 620px; + width: calc(100% - 32px); + padding: 32px; + max-height: 85vh; + box-shadow: 8px 8px 0 #0003 +} + +dialog::backdrop { + background: #172e2466; + backdrop-filter: blur(3px) +} + +.close-dialog { + float: right; + font-size: 22px; + padding: 1px 9px +} + +dialog p, +dialog li { + font-size: 16px; + line-height: 1.9 +} + +dialog a { + color: #24758b; + text-decoration: underline +} + +dialog .formula { + background: #e9eee6; + border-radius: 8px; + padding: 18px; + font-family: monospace; + font-size: 15px; + overflow-x: auto +} + +dialog .learn-links { + display: flex; + gap: 15px; + flex-wrap: wrap; + margin-top: 22px +} + +.enter { + animation: enter .3s ease-out +} + +@keyframes enter { + from { + opacity: .5; + transform: translateY(6px) + } + + to { + opacity: 1; + transform: translateY(0) + } +} + +@keyframes flow { + 0% { + opacity: 0; + transform: translateX(0) + } + + 30% { + opacity: 1 + } + + 100% { + opacity: 0; + transform: translateX(20px) + } +} + +@media(min-width:1550px) { + main { + padding: 0 64px + } + + .hero { + padding-top: 95px; + padding-bottom: 85px + } +} + +@media(max-width:1000px) { + main { + padding: 0 24px + } + + .site-header { + padding: 0 24px + } + + .hero { + gap: 15px; + padding: 55px 0 + } + + .hero-art { + transform: scale(.82); + width: 280px + } + + .hero-note { + display: block; + margin: 16px 0 0 + } + + .workbench { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 12px + } + + .builder-panel { + padding: 20px + } + + .preview-heading { + flex-wrap: wrap + } + + .world { + padding: 20px 12px + } + + .steps button { + font-size: 14px + } + + .steps button span { + display: block; + margin-bottom: 4px + } + + .library-teaser { + grid-template-columns: 1fr 1fr + } + + .library-teaser>div { + grid-column: 1/-1; + grid-row: auto + } + + .brand span { + display: none + } + + footer { + padding: 25px 24px + } +} + +@media(max-width:720px) { + main { + padding: 0 18px + } + + .site-header { + height: 73px; + padding: 0 18px + } + + .site-header nav { + gap: 14px; + font-size: 14px + } + + .brand { + font-size: 25px + } + + .brand img { + width: 32px; + height: 32px + } + + .hero { + grid-template-columns: 1fr; + position: relative; + padding: 38px 0 40px; + gap: 10px + } + + .hero-art { + display: none + } + + .hero-copy { + max-width: 560px + } + + h1 { + font-size: 42px; + letter-spacing: -1.8px + } + + html[lang=zh-CN] h1 { + font-size: 37px + } + + .hero-description { + font-size: 16px + } + + .hero-note { + display: inline-block; + margin: 0 0 0 10px; + font-size: 14px + } + + .lab-heading { + align-items: flex-start + } + + .lab-heading h2 { + font-size: 24px + } + + .lab-heading .eyebrow { + font-size: 14px + } + + .history-actions { + gap: 4px; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 155px; + padding-top: 12px + } + + .history-actions button { + font-size: 14px; + padding: 7px 8px + } + + .presets { + gap: 7px + } + + .presets>span { + width: 100% + } + + .presets button { + font-size: 14px; + padding: 9px 10px + } + + .steps { + gap: 2px + } + + .steps button { + font-size: 14px; + padding: 10px 3px + } + + .workbench { + grid-template-columns: 1fr + } + + .builder-panel { + min-height: 540px + } + + .preview { + position: static + } + + .preview-heading { + flex-wrap: nowrap + } + + .world { + padding: 18px 28px 10px; + min-height: 230px + } + + .recipe { + padding: 21px 16px; + border-radius: 14px + } + + .recipe-heading { + align-items: flex-start + } + + .recipe-heading h2 { + font-size: 25px + } + + .recipe-heading .button { + font-size: 14px; + gap: 4px; + padding: 9px + } + + .recipe pre { + font-size: 14px; + padding: 15px + } + + .run-guide ol { + grid-template-columns: 1fr; + gap: 15px + } + + .run-guide li:before { + display: inline-block; + margin-right: 8px + } + + .run-guide li strong { + display: inline + } + + .library-teaser { + gap: 10px + } + + .library-teaser>a { + padding: 16px + } + + .library-teaser strong { + font-size: 15px + } + + footer { + flex-direction: column; + align-items: flex-start + } + + .inputs-grid { + gap: 9px + } + + .option-card { + padding: 12px + } + + .option-title { + font-size: 14px + } + + .eyebrow { + font-size: 14px + } +} + +@media(prefers-reduced-motion:reduce) { + + *, + *:before, + *:after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important + } +} + +.ecosystem { + padding: 38px 0 65px; + border-top: 1px solid var(--line) +} + +.ecosystem-intro { + max-width: 680px +} + +.ecosystem-intro>p:last-child, +.advanced-section>div>p:last-child { + font-size: 16px; + line-height: 1.85; + color: var(--muted) +} + +.glm-lineage { + display: flex; + gap: 35px; + align-items: center; + padding: 28px 0; + margin: 12px 0 25px; + border-bottom: 1px solid var(--line) +} + +.zai-mark { + font-size: 35px; + font-weight: 800; + white-space: nowrap; + letter-spacing: -2px +} + +.zai-mark>span { + font-size: 17px; + font-weight: 400; + letter-spacing: 0; + margin-left: 7px; + color: var(--muted) +} + +.glm-lineage>div { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + font-family: monospace; + font-size: 16px +} + +.glm-lineage>div>span { + color: #9daaa1 +} + +.capabilities { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 25px +} + +.capabilities article { + border-left: 1px solid var(--line); + padding: 5px 22px +} + +.capabilities article:first-child { + border-left: 0; + padding-left: 0 +} + +.capability-icon { + background: #ddedef; + color: #306f78; + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + border-radius: 11px +} + +.capabilities article:nth-child(2) .capability-icon { + background: #f7e69f; + color: #716022 +} + +.capabilities article:nth-child(3) .capability-icon { + background: #e4e9d5; + color: #607240 +} + +.capabilities h3 { + font-size: 19px; + letter-spacing: -.5px; + line-height: 1.5 +} + +.capabilities p { + font-size: 14px; + line-height: 1.9; + color: var(--muted) +} + +.capability-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 20px +} + +.capability-tags>span { + font-size: 14px; + border: 1px solid var(--line); + padding: 5px 8px; + border-radius: 5px; + font-family: monospace +} + +.scroll-invitation { + margin-top: 42px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + padding-top: 24px; + border-top: 1px solid var(--line); + font-weight: 600 +} + +.scroll-invitation>span { + font-size: 23px +} + +.advanced-section { + padding: 0 0 75px +} + +.advanced-section>div { + margin-bottom: 30px; + max-width: 690px +} + +.advanced-section details { + border-top: 1px solid var(--line) +} + +.advanced-section details:last-child { + border-bottom: 1px solid var(--line) +} + +.advanced-section summary { + display: flex; + align-items: center; + gap: 30px; + list-style: none; + cursor: pointer; + padding: 24px 0; + font-size: 19px; + letter-spacing: -.4px +} + +.advanced-section summary::-webkit-details-marker { + display: none +} + +.advanced-section summary>span { + font: 11px monospace; + letter-spacing: 1px; + min-width: 120px; + color: var(--muted) +} + +.advanced-section summary>b { + margin-left: auto; + font-weight: 400; + font-size: 22px +} + +.advanced-section details[open] summary>b { + transform: rotate(45deg) +} + +.advanced-section details>div { + margin-left: 150px; + padding: 0 0 25px; + max-width: 750px; + font-size: 15px; + line-height: 1.9; + color: var(--muted) +} + +.advanced-section details a { + display: inline-block; + margin-right: 22px; + font-size: 14px; + color: #286e80; + text-decoration: underline; + text-underline-offset: 5px +} + +.advanced-section details p { + margin: 0 0 18px +} + +.hero { + min-height: 560px +} + +html[lang=zh-CN] h1 { + font-size: 64px; + line-height: 1.2 +} + +.hero h1 { + font-size: 76px +} + +.hero-art { + width: 380px; + height: 380px +} + +.mascot-frame { + width: 310px; + height: 310px +} + +@media(max-width:1000px) { + .hero h1 { + font-size: 62px + } + + .hero-art { + width: 300px + } + + .capabilities { + gap: 15px + } + + .capabilities article { + padding-right: 8px + } + + .glm-lineage>div { + gap: 12px; + font-size: 14px + } + + .zai-mark { + font-size: 28px + } +} + +@media(max-width:720px) { + .hero { + min-height: 450px + } + + .hero h1, + html[lang=zh-CN] h1 { + font-size: 64px + } + + .glm-lineage { + flex-direction: column; + align-items: flex-start; + gap: 18px + } + + .glm-lineage>div { + gap: 10px; + font-size: 14px + } + + .capabilities { + grid-template-columns: 1fr; + gap: 20px + } + + .capabilities article, + .capabilities article:first-child { + border-left: 0; + border-bottom: 1px solid var(--line); + padding: 0 0 20px + } + + .capability-tags { + margin-top: 12px + } + + .advanced-section summary { + font-size: 18px; + gap: 12px; + flex-wrap: wrap + } + + .advanced-section summary>span { + min-width: auto; + width: 100%; + font-size: 14px + } + + .advanced-section details>div { + margin-left: 0 + } + + .advanced-section details a { + margin-bottom: 7px + } + + .ecosystem h2 { + font-size: 27px + } +} + +.resource-card { + background: #f4f1df; + border: 1px solid #e1dcbf; + border-radius: 10px; + padding: 16px; + margin: 6px 0 12px +} + +.resource-card>div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px +} + +.resource-card small { + font-size: 14px; + letter-spacing: .6px; + color: var(--muted) +} + +.resource-card strong { + font-size: 21px +} + +.resource-card strong>span { + font-size: 14px; + color: var(--muted); + font-weight: 400 +} + +.resource-card p { + font-size: 14px; + line-height: 1.75; + margin: 10px 0; + color: #65644d +} + +.resource-card a, +.parallel-explorer>a { + color: #286e80; + text-decoration: underline; + font-size: 14px +} + +.resource-total { + border-top: 1px solid #ddd8bd; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 14px +} + +.parallel-explorer { + margin: 22px 0 6px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 15px +} + +.parallel-explorer>summary { + font-size: 15px; + font-weight: 700; + cursor: pointer; + line-height: 1.7 +} + +.parallel-tabs { + display: flex; + gap: 4px; + margin: 14px 0; + flex-wrap: wrap +} + +.parallel-tabs button, +.cp-switch button { + font-size: 14px; + padding: 7px 9px; + border-radius: 5px +} + +.parallel-tabs button[aria-pressed=true], +.parallel-tabs button[aria-pressed=true]:hover, +.cp-switch button[aria-pressed=true], +.cp-switch button[aria-pressed=true]:hover { + background: var(--ink); + color: white +} + +.parallel-flow { + display: flex; + gap: 10px; + align-items: center; + justify-content: center; + margin: 15px 0 +} + +.parallel-block { + padding: 10px 8px; + border: 1px solid #869991; + border-radius: 5px; + background: #f3f5ee; + font: 11px monospace; + display: inline-block; + text-align: center +} + +.parallel-block.blue { + background: #c8eaf0 +} + +.parallel-block.yellow { + background: #fae597 +} + +.parallel-block.pink { + background: #f0ced3 +} + +.parallel-block.green { + background: #cae5ce +} + +.matrix-shards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px +} + +.parallel-explorer p { + font-size: 14px; + line-height: 1.8; + color: var(--muted) +} + +#parallel-diagram>small { + font-size: 14px; + color: var(--muted); + line-height: 1.6; + display: block +} + +.cp-switch { + display: flex; + gap: 6px; + margin: 12px 0 +} + +.token-sequence { + display: flex; + gap: 3px; + justify-content: center; + margin: 15px 0 +} + +.parallel-block.token { + padding: 7px 9px +} + +.cp-ranks { + display: flex; + gap: 12px; + justify-content: center; + margin: 12px 0 +} + +.cp-ranks>div { + border: 1px dashed var(--line); + border-radius: 6px; + padding: 8px; + display: flex; + gap: 3px; + flex-wrap: wrap +} + +.cp-ranks small { + display: block; + width: 100%; + font: 10px monospace; + margin-bottom: 6px +} + +.cp-ranks .parallel-block { + padding: 7px 9px +} + +.parallel-current { + border-top: 1px solid var(--line); + padding-top: 12px; + margin-top: 16px +} + +.memory-ledger { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 15px 0 +} + +.memory-ledger>div { + display: flex; + flex-direction: column; + gap: 8px +} + +.memory-ledger strong { + font: 18px monospace +} + +.memory-ledger small { + font-size: 14px; + color: var(--muted); + line-height: 1.6 +} + +/* Scheduling makes storage, partial continuation, and overlap visible together. */ +.schedule-explorer { + margin: 24px 0; + padding: 20px; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 16px +} + +.schedule-explorer>strong { + display: block; + margin-bottom: 15px +} + +.schedule-explorer>p { + font-size: 16px; + line-height: 1.65 +} + +.schedule-explorer>small { + display: block; + color: var(--muted); + font-size: 14px; + margin-bottom: 10px +} + +.schedule-explorer>a { + font-size: 15px +} + +.schedule-lanes { + display: grid; + gap: 8px +} + +.schedule-lanes>div { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + font-size: 14px +} + +.schedule-lanes small { + width: 74px; + flex-shrink: 0 +} + +.schedule-lanes span { + padding: 8px 10px; + border-radius: 6px +} + +.lane-gen { + background: #cce5ff +} + +.lane-train { + background: #ffde79 +} + +.lane-update { + background: #c7eddd +} + +.schedule-lanes .lane-muted { + background: #eae8e1; + color: #6a6862 +} + +.queue-pool { + border: 1px dashed #8ca399; + background: #e2f2e9; + padding: 12px; + border-radius: 10px; + margin: 14px 0; + font-size: 14px; + line-height: 1.7 +} + +.queue-pool small { + display: block +} + +@media(max-width:380px) { + .site-header { + padding: 0 12px; + } + + .site-header nav { + gap: 9px; + font-size: 14px; + } + + .site-header .brand { + font-size: 22px; + gap: 6px; + } + + .site-header .brand img { + width: 26px; + height: 26px; + } +} + +@media(max-width:380px) { + .steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +/* Reading scale and the two capabilities that anchor the landing page. */ +.hero-description { + font-size: 20px; + line-height: 1.7; +} + +.hero-capabilities { + padding-left: 24px; + margin: 18px 0 26px; + font-size: 18px; + font-weight: 650; +} + +.core-capabilities { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + padding: 10px 0 48px; +} + +.core-capabilities article { + border: 1px solid var(--line); + border-radius: 20px; + padding: 32px; + background: #fffdf8; + scroll-margin-top: 25px; +} + +.core-capabilities article:first-child { + background: #fff4bc; +} + +.core-capabilities article:nth-child(2) { + background: #dff3f6; +} + +.core-capabilities h2 { + font-size: clamp(24px, 2.2vw, 32px); + line-height: 1.3; + margin: 16px 0; +} + +.core-capabilities p { + font-size: 18px; + line-height: 1.75; +} + +.core-dataflow { + grid-column: 1 / -1; + max-width: 960px; + margin: 0 auto; + text-align: center; + color: var(--muted); +} + +.capability-flow { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 16px; + margin-top: 24px; +} + +.capability-flow span { + border: 1px solid #6b8177; + border-radius: 8px; + padding: 8px 12px; + background: #fff9; +} + +.glm-lineage>div { + flex-wrap: wrap; +} + +#step-description, +.option-description, +.toggle-row small, +.input-label small, +.resource-card p, +.parallel-explorer p, +.schedule-explorer p, +.advice p, +.run-guide li, +.recipe-footnote, +.advanced-section details p, +.library-teaser>div p:not(.eyebrow) { + font-size: 16px; + line-height: 1.75; +} + +.option-title, +.toggle-row strong, +.input-label, +.field-group legend { + font-size: 17px; +} + +.input-label input, +.input-label select { + font-size: 16px; + min-height: 44px; +} + +button, +.lang-toggle-btn { + min-height: 44px; +} + +.option-card input, +.toggle-row input { + width: 20px; + height: 20px; +} + +.option-title { + padding-right: 24px; +} + +.option-card { + padding: 16px; +} + +/* Each choice changes the mechanism shown in the live preview. */ +.world-top { + letter-spacing: 0; + font-size: 15px; + overflow-wrap: anywhere; +} + +.world-nodes { + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); +} + +.world-node { + padding: 12px 6px; +} + +.managed-pools .world-node { + outline: 1px dashed #7d9387; + outline-offset: 4px; +} + +.managed-pools .pool-label { + display: block; + position: static; + margin: 14px 0 0; + text-align: center; +} + +.external-map { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: ". a" "trainer b" "c ."; + align-items: center; + gap: 24px 18px; + min-height: 410px; +} + +.external-map>svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.external-map path { + fill: none; + stroke: #5d8b8c; + stroke-width: 2; + stroke-dasharray: 5 5; +} + +.external-trainer { + position: relative; + grid-area: trainer; + text-align: center; +} + +.external-trainer>small { + display: block; + font-size: 14px; +} + +.external-cluster { + position: relative; + min-width: 0; +} + +.external-cluster .world-node { + border-style: dashed; +} + +.cluster-a { + grid-area: a; + margin-right: 6px; + transform: rotate(2deg); +} + +.cluster-b { + grid-area: b; + transform: rotate(-2deg); +} + +.cluster-c { + grid-area: c; + margin-left: 25%; + width: 120%; +} + +.external-map .gpu { + width: 10px; + height: 13px; +} + +.external-map .chips { + max-width: 90px; +} + +.external-map .world-node strong { + font-size: 15px; +} + +.external-map .world-node small { + font-size: 14px; +} + +.diagram-caption { + font-size: 14px; + line-height: 1.65; +} + +.world-lesson { + background: #fffdf4e8; + border: 1px solid #c9d9cd; + border-radius: 12px; + margin-top: 22px; + padding: 18px; +} + +.world-lesson h4 { + font-size: 18px; + line-height: 1.5; + margin: 0 0 16px; +} + +.world-lesson p { + font-size: 16px; + line-height: 1.7; + margin: 14px 0 0; +} + +.world-lesson small { + display: block; + font-size: 14px; + line-height: 1.6; + margin-top: 8px; + color: #52655a; +} + +.diagram-row { + display: flex; + gap: 7px; + align-items: center; + flex-wrap: wrap; + margin: 12px 0; +} + +.diagram-chip { + display: inline-block; + border: 1px solid #b4bfb1; + background: #f0eee5; + border-radius: 6px; + padding: 6px 9px; + font-size: 14px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.diagram-chip.blue { + background: #c4eaf2; + border-color: #84b0be; +} + +.diagram-chip.green { + background: #d1ebd5; + border-color: #8caf94; +} + +.diagram-chip.yellow { + background: #ffe591; + border-color: #b9a044; +} + +.diagram-chip.masked { + background: repeating-linear-gradient(135deg, #e1e2da 0 5px, #f7f7f1 5px 10px); + color: #526050; +} + +.diagram-arrow { + font-size: 18px; + color: #628071; +} + +.reward-samples { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 4px; +} + +.reward-samples .diagram-chip { + text-align: center; + padding: 5px 0; +} + +.feedback-return { + border-bottom: 2px dashed #a79b47; + padding: 10px; + font-size: 15px; + text-align: center; +} + +.architecture-strip, +.route-demo, +.score-demo, +.cache-hierarchy, +.determinism-demo { + border-top: 1px solid #d5ddd0; + padding-top: 12px; + margin-top: 16px; +} + +.schedule-lanes>div { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0; + align-items: center; +} + +.schedule-lanes b { + font-size: 14px; + width: 60px; +} + +.prefix-tokens { + margin: 14px 0; +} + +.bit-row { + display: grid; + grid-template-columns: 65px minmax(0, 1fr) 45px; + align-items: center; + gap: 8px; + font-size: 14px; + margin: 20px 0; +} + +.bit-bar { + display: flex; + width: calc(var(--bits) / var(--max-bits, 16) * 100%); + gap: 1px; + height: 30px; +} + +.bit-bar i { + flex: 1; + min-width: 0; + background: #a4dce6; + box-shadow: inset 0 0 0 .5px #5c8f9a; +} + +.hybrid-cache-map { + display: grid; + gap: 12px; +} + +.hybrid-cache-map>div { + display: grid; + gap: 6px; + padding: 14px; + border: 1px solid #b7d5d8; + border-radius: 8px; + background: #e6f3f4; + font-size: 16px; +} + +.hybrid-cache-map>div+div { + border-color: #d7cb83; + background: #fbf4ce; +} + +.serving-scene { + display: grid; + gap: 12px; + min-width: 0; +} + +.serving-prompt, +.serving-connector, +.serving-transfer { + text-align: center; + color: var(--muted); +} + +.serving-pool { + border: 2px solid #80a3ae; + border-radius: 12px; + background: #eff8fa; + padding: 12px; + min-width: 0; +} + +.decode-pool { + border-color: #8ba885; + background: #f0f6ed; +} + +.serving-pool-label { + font-size: 15px; + font-weight: 700; + padding-bottom: 10px; +} + +.serving-stage, +.draft-stage, +.verify-stage, +.commit-stage { + min-width: 0; + padding: 12px; + border: 1px solid #ccdad1; + border-radius: 8px; + background: #fffdf7; + font-size: 16px; + overflow-wrap: anywhere; +} + +.serving-stage h5 { + margin: 0 0 10px; + font-size: 17px; +} + +.serving-stage p { + margin: 8px 0; +} + +.draft-stage { + background: #fff3c0; + border-style: dashed; + border-color: #b29b3b; +} + +.commit-stage { + background: #e9f4e8; +} + +.serving-cache { + display: grid; + grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr); + align-items: center; + gap: 5px; + margin: 12px 0; +} + +.serving-cache[data-hicache=false] { + grid-template-columns: minmax(0, 1fr); +} + +.serving-cache>div { + display: grid; + gap: 7px; + justify-items: center; + text-align: center; + padding: 8px; + background: #e8f3f5; + border-radius: 8px; +} + +.serving-cache>[data-cache-tier=cpu] { + background: #fff2c3; +} + +.serving-connector, +.serving-transfer { + padding: 10px 0; +} + +.serving-transfer { + display: grid; + gap: 5px; + border-block: 1px dashed #a4b8ae; +} + +.serving-tokens { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0; +} + +.diagram-chip.rejected { + background: #f8ddda; + border-color: #c5887e; +} + +.world.running [data-serving-phase].active { + outline: 3px solid #c39819; + outline-offset: 2px; +} + +@media (max-width: 400px) { + .serving-pool, + .serving-stage { + padding: 8px; + } + + .serving-cache { + grid-template-columns: minmax(0, 1fr); + } + + .serving-cache>b { + text-align: center; + transform: rotate(90deg); + } +} + +.probability-demo>div { + margin: 12px 0; + font-size: 15px; +} + +.probability-demo i { + display: block; + width: var(--prob); + height: 18px; + background: #649fac; + border-radius: 4px; + margin-top: 4px; +} + +.probability-demo>div+div i { + background: #e0b838; +} + +.route-demo>div { + font-size: 15px; + margin: 8px 0; +} + +.diagram-files { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Full guides and derivations stay in a dismissible reader. */ +#learn-dialog { + max-width: 1000px; + max-height: 90dvh; + padding: 28px 40px 40px; +} + +.close-dialog { + position: sticky; + top: 0; + z-index: 2; + min-width: 44px; + background: var(--paper); +} + +.reader-article { + font-size: 18px; + line-height: 1.85; + overflow-wrap: anywhere; +} + +.reader-article h1 { + font-size: 30px; +} + +.reader-article h2 { + font-size: 26px; + margin-top: 32px; +} + +.reader-article h3 { + font-size: 22px; +} + +.reader-article p, +.reader-article li { + font-size: 18px; + line-height: 1.85; +} + +.reader-article img, +.reader-article svg { + max-width: 100%; + height: auto; +} + +.reader-article pre { + font-size: 14px; + overflow: auto; + padding: 18px; + background: #eef1e8; + border-radius: 8px; + line-height: 1.7; +} + +.reader-article table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 16px; +} + +.reader-article th, +.reader-article td { + padding: 10px; + border: 1px solid var(--line); +} + +.reader-article .math { + overflow-x: auto; + max-width: 100%; +} + +.reader-article mjx-container[display="true"] { + overflow-x: auto; + overflow-y: hidden; + padding: 8px 0; +} + +.reader-article .eqno { + float: right; +} + +.reader-article .admonition, +.reader-summary { + border-left: 4px solid #689c8b; + padding: 12px 20px; + background: #edf2e7; +} + +.reader-sources { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.reader-article .highlight { + position: relative; +} + +.reader-article button.copybtn { + display: none; +} + +#mobile-preview { + display: none; +} + +#mobile-preview-dialog { + padding: 12px; + width: calc(100% - 16px); + max-height: 92dvh; +} + +#mobile-preview-host { + clear: both; + padding-top: 10px; +} + +#mobile-preview-host .preview { + position: static; +} + +@media (max-width: 1000px) { + .preview { + position: static; + } +} + +@media (max-width: 720px) { + .core-capabilities { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + + .core-capabilities article { + padding: 24px; + } + + .hero-description { + font-size: 18px; + } + + .hero-capabilities { + font-size: 17px; + } + + .site-header nav>a[href^="https://github"] { + display: none; + } + + .steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .steps button { + font-size: 15px; + padding: 10px 5px; + } + + .steps button span { + display: inline; + margin-right: 5px; + } + + .workbench>.preview { + grid-row: 1; + } + + #mobile-preview { + display: block; + position: fixed; + bottom: max(18px, env(safe-area-inset-bottom)); + right: 16px; + z-index: 20; + border: 1px solid #415e4c; + border-radius: 28px; + background: var(--yellow); + box-shadow: 0 4px 20px #17392330; + padding: 12px 18px; + font-size: 16px; + } + + .step-controls { + padding-bottom: 60px; + } + + .toast { + bottom: 90px; + } + + .preview-heading { + flex-wrap: wrap; + } + + #learn-dialog { + width: calc(100% - 16px); + padding: 18px; + } + + #learn-title { + font-size: 26px; + clear: both; + } + + .reader-article h1, + .reader-article h2 { + font-size: 24px; + } + + .reader-article p, + .reader-article li { + font-size: 17px; + } + + .world { + padding: 16px 10px; + } + + .world-lesson { + padding: 14px; + } + + .option-grid { + grid-template-columns: minmax(0, 1fr); + } + + .option-card { + padding: 16px; + } + + .recipe-heading p, + .advanced-section>div>p { + font-size: 16px; + } + + footer { + padding-bottom: 90px; + } +} + +.core-capabilities .capability-icon { + width: auto; + height: auto; + display: inline-block; + padding: 5px 12px; + white-space: nowrap; + border-radius: 7px; + font: 600 16px/1.5 monospace; +} + +@media (max-width: 720px) { + .recipe-heading { + flex-wrap: wrap; + } + + .recipe-heading>div { + min-width: 0; + flex-basis: 100%; + } +} + +@media (min-width: 1001px) { + .preview { + max-height: calc(100dvh - 36px); + overflow-y: auto; + } +} + +.library-teaser>* { + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 720px) { + .library-teaser { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 400px) { + .library-teaser { + grid-template-columns: minmax(0, 1fr); + } +} + +#learn-dialog .reader-article h1 { + font-size: 30px; + line-height: 1.4; +} + +.reader-article span.math { + display: inline-block; + vertical-align: middle; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; +} + +.reader-article mjx-container { + max-width: 100%; +} + +@media (max-width: 720px) { + #learn-dialog .reader-article h1 { + font-size: 24px; + } +} + +.external-map .pd-nodes { + flex-direction: column; +} diff --git a/docs/_static/image/arch.png b/docs/_static/image/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..b809bd3ffee17e4c361c73a9cca454a849c3a8e8 GIT binary patch literal 273013 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�*^?np%a;aE`D+D9OcuckW^ZoqhyzK99`8da6vF((1YI z{?f*>r|!_e)48$Na_4naV6AnB-r4*#GJN8!e3L9esZ%1h%6-JbELEIJRtX$Z%7kU% zp&-N1MVieDR4nA^l~If6JJ|DLy=rEkV2;fSu#u6V;{sR05C)F5KgF)U8|=#?EloM# z%D3hZ+zS`A35XWD$b`mhlOKI<^%7myeYw%11bIS$YA#%brAzY!_i0vGNiUF>e7E)G2$r7X|m^?23 zcI05v9*?A$fb;M>I*3di^R149dOw3`$=vb=i%~eBSbmhcr6r1Ldbx9hBxPesG?(Uh zFvmJqWI%|bq1h?+&W*Le+Z$mU;%%y<^uDDG;YJa?!j1-8|^5t}njI+t%_fa}3^m{n&V zU-B4*EDY5K_e-O55fP$03j0vnt~GX3pXfb495&ypXP$ogBQpzIkou~{dTrOLl8XK0 zI%z;5KOK^a(&mR$RI4GX@Af6)7aSdS@>Cs8rbe2Us#FdQ1`#$@q2QTUC|g40Snl7e zz8|?J?-A*oM;xNP>O5FcIp=Uh?qc{62y`TWG{C7LfV>yLC(oDOFOp6P0U%B4bku{QmR9?J+`eoyM2qm%1!-tx*!g+bg z3hGu0u8tWRyW`-)7aLc8Rv0%i7nC-QvtYaZc>DvhAes=uw9pN-Mc1dS!plxe6TJdU z>33I@x&>@IG=_h%7&8(b$``!rR=n*;)v{r66A&Sq4u_dhM7d76|H3hLBT9h znh?=sjmWi;UUgkdeV*664laV`({C)M=Gh8VZhyQsCODX1kvmz?-d)>E!URPlXNBKN zXBJn%r@p)26?_8hZL`k43sna@Z#jV};vemKD?y>+u=CEWtE5j>$Ygy=OeT9|$KoWr zN^Uktms}I7y`GapL)}K97O&jG6a6GuRcQZuf6I!TLzMt*1fWmaa4ao;W+LGQ~YYhzG;dSV2T_)D^UbsiX-WBfYv&uV2Y`lXJodT>yKk$ zh|ND|c(&bjk*naY6MACip&hT&w@H!ilGPb!(KNCHDy%y6arKk@$oFPXK(q#_!#$e$ zbX2o@Ux~sm9VtnfMmiKWW1AnnB8cU$e)1BI4+jl_>r|{LNp&Ud<*TmoZ;Jc1@m(Fh zWp!WYU1@S`vBFvA1eu1k6F?BlT@Pj;(V^wVc0_02i(j|@5=e&cb7d+YjOWM4|M(l! zC(4(quL&0D(%H_~&ay;w++tBzh+A9yaIs(^KtjKm(uZ02X#o!Us1Ch}WF;_0Ag~FA zjHn||s5$K0YJS_d$x%`Xud!DD)7yTPtO5%XT6y}Mp>nyi+HKJ{-*mq`I+3uKvF$`q z&pD^FXQq1IcC=3Ij-?#}~XCUW|JG zb_x+SMkuP;$Ef6sKI^9G7Q@dh=Fu|+x zV2UWaErm2K0FMxOS(`!k(=b6Ddx!%(q63wa`LUdIZkzcWh0r7%!c!9Cfr{Xtoa4_5 z7XAVIA4W_-5^{0>_M6haAvcaf|9dE<3Ui zOk^s96`qv+ds$)=xl!T(Wq1)X{Zfde_k*NgYX#k+#&Tltj(rcSUc$ls%n2KL$**hp zSEcgEkK*v<@Y;J^Nqq*u|-{H8%n*^>tTpeK_SyusO? z-a}BU2C(xn_x+Baz>ozQ-+79cJ6;8xTF}(UIi;PaI$K~Jlhg9Iv*cTU#uunf?D2`i zTmN+%id6z`J|G8oYbM#>to>h57WENS>6Ww7&a>XOu#Te~+}nBC)}MI?$px8_SfQ=| zx($6whMcv5Icl33z%8W8fY7_tv>SH(1Yb>9M<=3x$1`_^~JjQGsFdK(oYj&7i0w$O6zbKdRCAs=9HAt1~I>XPy)ED@>t;2>r z-=JJivq`|*?vr4!P@NqAAForRK78!kUVW7NC3c?c(Ad4xcT$)Y{MKk-25!3_I}jGOcg*8Ye*6#FJ%ZBnf!%C7CLy5Oq3)X@{B8A*C;r2-2YLJHA&CVJ7WN;SWIR z`52z^7JTGw+D-$!Y&FoAJsL!BEk5K+QUq>@o z$97r^mz}NxJ`<>t3~}5h+w>3p`ZqsV!aDYiyUpxem5>wkH+1@TzK-g!ju=S5Kmzvv z7D(90%MnK3I!@?*`~;Is{ZIw20?sktD+i%7b*gu4b~@_$ zz9v~%3p+MDZgw51Q^cb?_hKeQRL@KWzMb6}cEYzjaEVJjP$E0`$zyo!JBHu< zB)20i+(`pFmSGZy4qRC8wz6XvL`A{PbQo`b=kSdN8KMtc>^z(*#P-lH%n#kU4ACG% z+uh?kS7a4XWUA(w9m}v8WY}hw@4RClN(_ojvfF9rp-dadu#F^r=ZdTgiu^we1{Q~0 zy0*0jT+EY8GgCJneg6GG!zkh6hH+EZ?~Gb8$$ZMv6SC!hf7Ifqc=#{zikNb~FS~lq zx1H+4H5_8h>oLOL2^xqge|sj&DTs2#Z#QppU2T=$p<{SWJ}H@;(wVU1Yu3J{1E}7y z+_S^?!$rYhM5A$Ow|8npq=K9}Qns(##djjc%?;pda&9K!k+Cg~VQ({D@FB_;Szq_j zw(-q>mcM4JnK`<7j^|HUG0BcvNSrVf>7(7&C=X$2GnHTCKn_9fXv?bvAZ2y zyPq5L`u|UaKeTs{eAh0Pic9Ckul=Me|I>PaN-OTt=Fe{5nHIyhj^i&LRgEw>v{Q7E zf_N!%gmS0x63X4}2R}9ToB8D*;SmEn7|_LlE(Uae#D#y_ z4q#jk#^qpK&QH6be>NkG!^JpUjKjq^+lBC@EgmXIX{|6craf<)| literal 0 HcmV?d00001 diff --git a/docs/_static/image/blogs/release_v0.1.0/cuda_vmm.png b/docs/_static/image/blogs/release_v0.1.0/cuda_vmm.png new file mode 100644 index 0000000000000000000000000000000000000000..5bc852b9ed901dbadd6c4156ebad3f6c10d3164e GIT binary patch literal 186743 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∾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 Kqx0?q;Nz3K}F&6T5 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

4;ILLM zO9`oQjj+El&4%E2#+FerVi0-6#NRe9kVDlks8e1f$!S(`aYN_~Z0ou-%yk-EbQuQ& za-NEX2rgD?u|CZX=gVLWsu)_85E}Utz)l3!XtVqdjJ7YUSmnxxQo-UpJW4$(e&7cr zF-ha&RNM~dt3x`e7(#(~+ys;5k;Jmf_1cOS?LYH>WRn;nqg_vPke5(MDUhwDSy5yW z1vT?~WN+bYasAcrZQ#PVbXZR3C?1jElRhhMl*0F-0US8-`Gx79wiaPx^jSYLbuz~p zOghCal2i{&t%?AWMlr8J)*qIb(^`!ViG+=n+Fv{uy2MOFw4q;?w^Gw@Z))o@{QM{6 zeG$@&cj0T)G2qtqv;}$X%O>D;w)ZxCeZ0abp3La5eB-UCo>LJo?Eayd-uCa#&G(QD zV!K$W^*osk*#YpyhAKZ?6$gNTDD70XTF`kq>AKRg^H^Q&x*NCiJwnX>NlWu(ga~re zwc>iVA00iRRO)^;Lm}B~eA-SG#>mCRpi{Fe1lcAk)IdN&h}W!m2PA;^TLlPCEPD@7 zjnV2v{dKkLVS3fBt)pPHP50$l!S}i<`|-%AE)meVlzq%5 zPFweq?Ebs{_}-o9z3&`+g!{eq2~>G@v8xD-5oAyBrtfCaBxwyiU?3tR8)Gr zmpuxu7r^(K93NkQDQbG1xbSuEeU1WM&M7OrzX5H%skoq7w#Op{pPlE$Q|%gS4<+f} zlCc$^Pb#&6cfB>^t6bG!yoA|&J_ovmPn~Q&fa(Kmxknclb~bIQ6cgMpAAV^sF-=0w zTRmMAqnJde^m4~b-eOPF%2fW2Es*Gg@Z1d*+pt1c|53s-$4ObAk(0kec)V`7TdIz~ z-)1;nT&QWexu{iHx|C_XbS_%Wu&&^=RLz7CAC~H(rp&lfu zEo0AX!AilOENW+Fg5C)YPtveV4{gfL$7B&K!QUK{$r+NkV%& z@F=3WJM&Bx**(s*yFpM4(`!5(ih9sNz!O$sB$J@ zta=a4UGT*#*N8_LOEw>rcR^*^ujS1QI1_l1N1^A&qww{A6S4|u4H&M$g);Yapjk<4IT~93& zj%F;_-iKmD{65dOz#-y}&Fni7uebTdM)G2X79xBBYS@y!pWfu4&{Y`UMSEE+(^jow zW)QZU0AwInjHkl)y3c5}YI9*xs|L}qgW*oI!qb8h-Z6@r^)4wV2aaIuyZrZbEZHqf z!4Lk@*+fK;vaaVFJKs0EPyQOmQ7t#vj5xj4a)Q{MCrMhB9j@tO6T*hD@R9S_J8Enq zub)`J(xx^94$W_}F9dy|y#FGEf&~TGc&6~#T#6pBp9ZXG@c65(9*paRd7ZGUhj>M zl}UdeZx<@%3s(-z^sauQI7%p=Il0IoA|_@=B%uv`RfHcA*t4oyzdy`J8Rtl2I`=eK z;B=qw_S+Oh$pNb>>y^j|n(JSj;WvC-NU-Lmr~&L53&us zt0j{pe@y3C2MCks22#qZR-RM!-p2UsS}5Y`x8#KGL9)W&9Mof_(j~TbSX52LQPrD| zGEyN-s%3R`b*|?XV8!i#psGwEzYTH9WIAy;BJWFsebpQlG49&FG4IpI>0YVNI(28q z5>L4(K9{{tcd9rgmiewbeOe!N`7Y-Y6oej5>k8QG*zv}nwb}Hj+Z_(4=5Fg*Omn*Y zdP9)kV0=J7lnfdc+MfuxI_?JkS~ggwgh|IaLOD2tH1Yq=?)DFp4Mu<0PAR#)Jza9} zLESyAaryj*Le6HdflrTfe9mG8JUed)L%AIBguVV36r04elvRX=$QsSo;9Wm^>>O!> zXQ#$j1acUhskxzUr4B}<31a$K6OUjM-YhY-1-+$;)K?1UFAmlR)dwy5mTX{zNt5Fn z&}tjwk}!nNUL$lTPqthH#6*xw0x@NHoaWrmr_w9;TXxd4DLZT*DvO3+bj*7EAuUZZww|Se04UKM=G%GgF&(k-bCQd5X z2k0da69uYX$2VW^hLx$1rJl57Cl4ayc+6`p2)UadZn7b7n~zbO+REoh^~8$h4du;P zq-6Bn(2+8^7jefp!|5>r3lpqx#<4{f4dm$hm?EeKWIc^v*WiZe*}nEVMZwXW9-l4& z9kqEi9VZ9V#yJgLeqLDGW|j1>LVk_Q=KlU+dB=sa2^bi3Y5>AVN-j}^3z!4e91bSV zR_&;FoqBuoxz?OVg?wKw_P?FC^f0U#~<`{S>VCT8MnY6%sH5rPE~dGUP{) znboQ?hK(JAj9Ai372V;gzj=EL8z`mv?XDsy9<7 zRRULY|1LZh&%om1;zJCxvu%fse6G4pXPN~2j%k&Erv2h)zZqu~sufqdcs##Mi;e@e ziPRkR^?+?l=*6(7)`BJFIn$U?W}oCrl#H_)pAUwhtr{H#DD)t{be$N zZFjZGJGJEt>?6!NwP;hDWSY6=Yu{$R_D4V~EzXTTQ3P=1cqN8m@d7 zNMnY7L{Pi(z`7?9)qkPa@T<6?Vg=VOQT@x3W+-iwthpd-Gfi3~Z#39v(yW!=i2WcO z?ZxjohZfQHtFZ5mBKzUbFUUx|qT=Lm&^2nc&U zGy|8b1#I9*vL}=I_?%jp!;xUHuAklm-p>~GAm>^e4ZiEo+KiLb@N%7hsSw%n;BnAQ zdyBq^Haj2H0(0RZ#j%JCeWJe&Ng&tf$z88P?~snF&iK(_ELIM@iqf#*0d27peMiTg z&Wrb6F%;hz_y7|0tgI{+MOllqj5M4s`z&OxbcVva&bKz8p;NQ?`CQ2F{H(O}NR}=6 zYg7@X$!cwb969m)aiMD6C(=Zj$?mP#;49L=Zdu8sUo9E(5@I072I3?Zc*RI+y3ENe zzMGZ5izyz?*3RQA4KgGVsMQKYuKHTmuG9Q{yLzt)l55}VooH)F3Q1=kT7 z51d+(^)_a$Ie^w9a2?#ZJK?O?oE~k{^WC|UDmJCmb>CiQdcV_qKRFL7t zbsdnk_%qzIV~ch^E|h-VG$vZ%E6*u-;H!BEjD#i{+o}Ut{V&~E&%mL-bZN=5l6dF_ z|6^2@@6%Ri2dR50?@Pc#2jqU$R;%4z_ycXYTuv_q*{I`ou+NL`)3(?qo{-o6DKJxL zGJ{7*FuEkKWImkRQX+{PnJogx=oc;&5W)V5nK_NR)_vHsHO}7D^Z8OMG(`fuSfSJA zIR9rN8XX5j7YTPO!&3 z#?uP3=t;4@)lOhMsGVi-z|JS5ie@HZz=!0-p7s8N9+98PpeG%~xw-y{F8Kz;QB}z0QuR;3AsY0 zV?HA-!+16=Ewx;=Y{0&5n&0_w9PskcV#0>2lLB?^E3{*&r-i9$hz6*7+M17Bnp*ik|#AX(aqPV6?I!Y_vE=YngMOFrcl-ehdk969!YfTbqQ(9Dg$dt<|A6I znm~)Gd9PWb^b1}==2Un33QbS<81fNzfkX4;^DTGp;sgS*hY*yj=7(mwuK?Wetv$8V=cSlLUg0o z*5f)@EYL^=)oaT&mU|=7N`gpEU{XWOQYW)X7w6xUtJ}+}8~Hd7rPXva!vIZ^k)e+QPY4wEnD&<*&x;;7fMQdgy=<((ox&w7= z7?J@gsRJRaFnM;+=Wo0XK&X~j4z)y(AxkGS71~?4!&+uSxfZYnb)La^}S3P+PaXuiE|`s8n<0o86$@YShnYEg~m7+sY9l^H&8dYj@2 z@K4mkKJE0&HfWAFWI4b6sa{SIn=_ki*U7Aa7RCu#*I5UmZJh2}8}w1Ftvs%NBuXHu z3+x>82jd<$40OJ|5c!=>2Yr0VoKQ}Cu4o6k| zKnveP3?F9sojuH@aJ$p`5#VOwgi3)5maSKs zKJb>@I%6Yx6vu|a4=-q1tG5-Em65SBtQMk*9iJW^o*$Eng2-nKz9%RZsQBq^0$YZS znOeFc7+NqM-;U&%xmtwXNNz4%ybl>o7|q2kxvIr9okhL^bIF!51Wl2K-S8;rZA6@a zK>kgy9SyZ%MzhqgW@>Y@%X(SetuXI57TC%|L>fM}+=2|>X#8nk246wCcYo}C z0Na3&p)i4%e%}BNn@jG|Q)D-jy+;HhJL-3?JL+)r#SG{jm~TL$h_Vf9nLDMD%PqTp z!H3nOIoPkKtLef$73!PHHM8Qj*A>oWV>U`5ti*V_WhunpoNg2U2>d;@xzU(zqZSW8 zEY#4oG>{%fvdc$QxRG5M@GEj~q3X8|8K1eDbk-x70v9=uPA*)6f|OdK(C}!*s%o{( z&+cyKlx>nh1h0Vxsg0v0|J2`m z0xs>Qx97dlq--It^H6~QyPZ`C;T6_<%d4j(6*uQdk238gq>vJyvDV-}?8{*LWO%^C zD;uyqQ+QlsB&CqWzdXC{boW#1TU=CO09tz5p8ARjc1?5m>n~X)C}FzKsI#{o9+C}f zL_}F5=`oQ=ih}Gk4383w6Z^lGLzuB?kb}q4`l%*Snltq1hu>J9 zT}zMk1UBic)j?9At8p{tWvXwR7Ix%%fw+ABHj>0`6Mu#$3Ix{kXn3OsJ13{VvVer3JYF1y)*s6wSr86@AK z@k6!`m*TTz6pU;um#u-F&CuH$zt0~OZx}6CMu8+1O(fuYYi`qs0N!(ah^H{xb8MNn zgfl3?lsH}4SC6z@T56>$WG5v?gQ-t5eK^e_PPfgf=5cF)vjmH4JTaDjJRIA z+o5{PH;$MNV^LV4OPcUsU}ZG;KENviBdh-!FXEoGg%ta*r6v!|uDxTaO1QPch zgM|kd)WaEd<3Nd!5|~>})UX8lk4$6?x$aR32V!x5jQYu}`lpp0l!t^hEYm&eU}C-6 zH{$r0&*&^gdyum-K>fDYL z-|_nLkJ{Bz|NT4O9CmgvK3+Aaq?j(d0d2euBnk|idoJK@NInDQE8nw|bB*2X(){{G1|Hk{cAE$pa?6Su9Gy2_DDK|nR{Fiw zQ9cvq(q%XZeGXQZ8zlVvBvuSx4tm&g#@c+YB#UM`wcCL)n|TbN_+BQPg8?tR_D`dvoGol+J) zeQ(qIIbOr@>1we8-WI@DZd2q7baWg{wpV~-tIZlkej51W|wcT zl;HJrS!HW$QnNtl< zJpWf?rcORqY-K!TIhEAi13a7$AD<@LDjFLc#o{^(3yM=rp=L=qD;WBtD=NNPjoizO zkBi&lJ9t+ZoyX^kP!3;Rqs|Nm5=Ns@k?#%m&i(JI^qbV6H=xK-t>p(bjuX@;pbcfm znI26&CI2(-&#iNnR-5T-k9*xbujB1dnU`*-<7kmYM9412Gd+L;Zn<#)(&PZ2cA|MdSncE&%>OYv>>Z3&rQ~!0#Nmj-!Y&3=P18viLoor-_7~ zPwNyuh;Xy=Ur)!p%|sI{q9A=<#ET00h@#$*EBzst&v~u!TuN94ArYD!O_-EKWz|&O`B)sJ8q6V6l=*w6!>7@t5p=oqyGVDD5 zl`KKUikG38PTrk-)Eoa-70ivsDj2x*C+s3hAuY%Q=F)Ph%{HU!%b5_1>>$$K*N=Vt zREE9$qMVaiiRS5^1H|XLXsCYS(QY@C|J(>kiWkoed7Dwa*jG)X-rZ=Z$t-fWt%a(0 zY`5>ejr(=7+r50NpAhmf<8ArALKMTr&PFrFzgKe_c!9n6q2C8V`*l80Sloy^Jmo{= zwyNvUbb6G=Sx+xm=ntAr%cz884DgcRO7oG?$lY(wv^V z9RrMhe7rXT>xsl;47&W%r}W8ooqIYh-i+|jq<^^fqX`8B`LrrjTIo6jq#HNO ziJ{}uGBVoj4f1r;5qo0$s3K4SP?BaR&6{r#4^3w~{Mgp2LMtyMy8NO#>2y#a;!!_!{e zf_vHFD`VnN&iS79uU%IPlgJ?r1NwMdu6AyOF{h4C$^P>aH^s}Ge{YH2TV4=-U5|?4 zbA8@sUL&^JxG9oQCfa6E*0s2X>py`tkl5;6I&vq10k-HXp~m$Bo?U%jQV!B*#;*hf zbcr(JnZfPQ-c(h4wqkR#XjeXORjiPWP;V(w5l4}aw`h0F=r;dno=j?k z*I}pGMhyek$mXrVYVlshb3@R_)6j5d55NE~HaY;?)qwWD*I=hf0f=>jCWT)?{UAy7 zl(q)qPFMof=Z-s7R9M+TNo*7bbP$Xvrw6jPEK;$vxS@X7aF1yznXHS(S!}Mt^{K0e zeCGQ%AG(U4AAHX98)EAo|DM=L+fWzp1{Ht1V(>Z5&@ddoFpf@!g=X|)BJr?meH2bC zCW6rY;=Un(e9&HYElyPA&GPdO9d#NK4Egp@H|neZKFDfCHJqLRXVXbIPHdvGH#P;E^ z53TmV^});lhPYqU;c6$>JB4k0|1mhM)1jO^#cfX+@;%npak?&TXZO~qeWX2kxo(hcz#zh^M3Q2Z$i6OrF}k$-_T zSF>h}WkKrJW=@yo#Gf09Eh@AadWGBE&O9HbP_nok__Ifm88w|(3EbYsCg1Ui$)2F4 zzGQj7%xQ&gV*V8lEmNy{+EegJ1E+^CqoRfNFjrS`^<;H(vnm>shD*~> zL?it>Cm<)?b6gzxS>|{B_z&!UIL@Uf^yq1)Q21jkto!jX`0eE(Dw}9ghOJ4pLZdjb zan+_NS_BD?tTCClnk^N>3|C!k(ED7TYzrslpUEQ~rAycQrv(R)QPvmonwYcNS~43* zL+(ZUy}Ii=;|zpX_fwplBkB)r=@7GxHPyS~Ug$50V>UlF;19dox{nq}860k%vvI8MXp3l!Sq&eI1{K`rh5xTVIi<6Do%o3+ zBmPuhbRr8usn{~_0-V0B=jZfn$X8q}pLY7No*BQka!>p4tqKa;7xJ2cRii>h>`=`7 z03Tt_^a&)4ifiqpMK#60m+ueH3LOgya3q&;sI{;dv#A%`SCbB%~G8z?L^8 z4sNvUku`Om&()B*dFK{px>t;3*HUD&{19iGI+QvBayV~kZe@VmZyCTRuHL5m@A8;L z2VC7aIRx%@BA8H1)9pGbu=m}%UT1oNXVTO6^f$m4TC>v-nRb188W%VJH?QyM^rrp2gH=_L)qL4T(}rIWD!^)LC;*XBt_Z; zp_YAT+%)-u)}cp+>l$c8e?tDkn^ZGZHTUuIL<-JEYoBSu8 z(+*Yc3&&0lCOQqw%{Ay$3s7~w#n>Yx*=W=27-23ovXtu{WkLhxk9prX* zEa2@Um)&w8!u{F09|Yz>tt|Pp1jys=czebR4Z&KyKj`r3&{Xa_zMLjwI`c&1kf=CJ zRJ->+(bB6L=g_R67PeU;mafyVYeB43{9RSTUurr z@P*tqx!r%jIzIIrH{eyS`p%}#0!gHAu8her+sxRv$VePt?@aS8x=iyHuJQ+6a&R80 zVgx)Nr_x!HTmOAQl_`uTIJT~E1@f!}s@i8ZS7(=X&JRyn?{9yAP^W-Y&FANjm95V! zFXt~4!H5{ZYxD7WvFAq&gU*U|W0vRfFwyIlmy?rIz(S(TCeRYX3@`NbNs`uZl28}d zHZ{V$2~NQNZZkPJ`^l%s`^kZ83CZu=l%QG1pY6K~Jmq<|FVy5%@ zZe~tI=p~AdEo{{kckyTjE+rcAIkk!DVQ$Cu+P#ef%v*!#Qg~Y3yyl%D2YSA?fNRYigNFVKqMB{Ta|i*K;L~lw-N0H?*IUfNMCN+4g_BKVqt`Dct=L3w9QNG!x;LY31h1c7bRULL&l%~x~ zuI(K^}GMMW-DRpR4ig00Z#;kYS9N@rm&dn%LKcZX2)XS)CgN|9BMXSfyeJ z-ZD3zx@^LN(Ikb9mQqw1v2|PS{^-4(R=GUvicjqcF2CFDQpe(aJEaMQCy%^3hJuIZ zaT?8h3fi(7Voj8_(Q3JTFFHkjDEiHld1<%WKUZYN_$);Air1B8_^iOAxq7szUZH(m^BOr_+p_qD}YrM*Je_ym_HK# zK!sY-zHnxbua#0W3C-5G(v#-0TU_&FZR7Ows|bx!>LH(9g;9QOi_OG6(0D9rLG0h_ z&c@rI44tjJLH=ZoLPGpQa}StzoIPuhQ!h!HIlE}PdcPDyl$BYAh4553sump?Ni!qD zNM4~LsG_;dP0Ni0jN&;Kr*&pk_r8-i{JJv!L;od{R$lW&_@hOk(OV14aYG0mdh3J} z7@iy{j)fjB5gw|JdZwpmNR%2Hq1)BO1Ot~dY9QkaMi9&!knVHbBWDR3`s=YIu7QgoHwB<`gq3e%?HJ`$4u+ z+02mBJu7_QU!OK)CZ~3`S1+31c1shybbU=64R<~YJ-(w5xr3l0Q{FqFB>sert71wF zfM^w5cZw+4y)5o;hn1BR67UwvH|s4h(89nWkH)y8htVqV>)VUcga!GV3%_RC`N2mY zS9c+S9=P{hh{M_JZ(~6Sx`O0zA&_ci{!fAm9e#r5Qfj!TRRIErBSl(TY$yVG3gZ*3 zjxyFfirTrFJFJeRGsX3Wdm<)ky8iP( ziUMd#tz45`PWQP#wPE;WI?x!l3+i2x>!)iZ?009%!1?t9&LOEa{G_NW5pR~P-_0r? zUTCzSdU_f)_$)?(Jqj_O9^;$ID$8}u_Udt>HRC|E6%E&6>Q|(FjohT>6;hHRn-6tF zj|oiPB&_yZ-G6(f-o0|wLH;ez891Mvc$6#;CcIQDbb+Mqnpcz60z_;E&D*#bpWB|f z-A^pX-y&*XPBqCu9{^_+L1OInya87Z+X^6Pa8a&SdD8Uw(-+`@VUmJNupX zg+cD6{D7pELQDX*nLBn0_-uN>_=Om|ft1s9dMZysEaJE7z$Jj^K|(j*yEv_voS$#6 z)bxBydHF&?1NI%d*;%P9r1Yo~l6{#qCi1wl!PZ`JV6Z)1EtRBjOC38m_ml=>j2@0u zp3uvz(9>`Xkp?g4{YKjbjjOY}Tx2*reAEz76n6KL_0n3nVG3|B?*Ia&WAhDVyrqSC ziqm{b=jeU6SO~B}R-4<$iAJVm`)v2?y+rvnSufWrQ^gQ@+xYo3!ueOIs+28KQBkG@ zV@5|uVqJd_|5-M<4hee6_B|dh%r|=IglwQdcBE1(zR?R-e3$9pNn+4C_t<=a+>b(n zGzfhkbAvwW;o)er^JxiqT=x~q{;UY{X7QW+DA;WKR>X{S>{LgpA9*%Itba2r0pFc6 zgo7awr+=#N;sO1+p-)a^gYM|9JUS#d^C!vP*GLo$=^D#{PY3NVS$5t*Pyw)qgO}@9 z(Kd1<8=Yt%1FhIC`mbJ}cCzNfc11sg_YtOoGgY2Izq7bB|Wjvm{b>5)(K~TmbVGVHhBM43w z$~T&AW!226;#=4_O%r`7o-8gHR$btnWX zI5n(f+1&LrJ%X6$-Mqjg|DCfj&X-^jeWXp#Rh>m0H~E$|10StJK8*1(ZO^B|bwj=; zb(3!To+r`orTVUw(*^fq&sG6whS)I=aNBk^Ha!BwwxL$ zGJI~j2>Ed9rP)iWg!HxDTiVnt%4_tAo>1L=tDhbm7??yN99B&nz#;bS6a1>vYKH_l z=>Sy~8t<93?MSEcXRq1*EDN)klCg3Hr2 zb1)gcem&J=?^ZO*sftI0z9Rnkwvf!n3*^Tt+l|dz)`>;HAwIQJ@duKI8kX8UY9Adv7M>e%bwAoaHVGUsi{0cJL?P zNOlwQjXGzLdYwUiO#vx;MsjltM@X3q6 zyiYwj{ER48T<3Iw%Ecr$2{LT)9m4MOj`2*TE#o88S{aw_jhr42$2 zJN;BiEWD$4SpL;+m3TmvP4kbd5UQAjOx7ih&Q(BYJO8T04#PRCWXi;9F zh|>N6YjOpS_9e|NE?!DUTCGUN*2ik2iM8mfY$cQe-6T={t63XYgMnuGH@^3Tre@IU zfVmIWnl)#|hs4n@Ll)6vKgv}^13mLMEG1kQ8lOIbq@g{D840T!uHW8Zvft;-ii()t z-kLf&csMl57EhdY6ey)K?QHX(1?Bt#0FS?rHG9dGx1!P~NW&oeiZUCdl+ zi@6IjK)1q49zn&$)ph)sBm3#p%&z5WUn#c){sZw?@PCnafR_=7U|Kd>s@HUKib`A9 z-s;Y)&{8||eWCz?^Gq=tQpQKVlvTF4z93F^x!*3aa86eyXZhTxn)PI~UXPSnI*lht z#VnjOu$gUabopI)+4?DriZ`^h+Q3-LKe-o@867uhl85AjMWjj4_pgKZ=+tamu1A3L zg&qySkZm_9Wa1p2{0~P#xW2#gs%uuSTh9W_V$;r@=gwZU{rc`CP8>;&acv_}nvyxMXy+ySs0EeEj0Y z>2v4KJ@d>{H{En2i)_Af>)7%YM~|PV&(&+y>TLC*G10lR=NX{MrrB(=FmuErVJ_4< zSTwx&-~O9_J8@y+d*8c{*&IJ{?D&b}9$uS#zT@8a|IBZF?sK(T6T=_~Lm(HDRz#r= zTv3+TBCbe8-0?pu&R2#sbND~b<1d?PB`Jo$)SdI$x##jYY#EFQ8C6M?s|GHx3870j@z6W!v0O-gFSF6OKZ z@(^y}#X-odXli|M-i@)*g-Kf~T;SK?!%+IV^wK5C>@j>`N>n)o%{g*%b!k_&2 zFFgPJb2BqDN}~cTB3kLq8#e#9FZ}V+(NUdO5u4%B;ZJ<>ll}eufBmH|9Y1~?fQ?}y zP>NQpTJ^Dyee$k5-&?742{Brq5eO3WRJwou!@qFs*x~Pg@4gEcCX9&+`P}Vy-0{bM z{DoY;qzH*fDMAt>kp`udQi_aC3r1w?ED+9xxniN1Q`Au^*5>MpbPxvlFbtJW7ElO8 zki;Mh2sx?*Ahu>qfLaV#cxkP7au2twjd|oo*2(euNa{{>_%Hh+ z0Q6^vf=4Y*tYxQ=Oe7X%A_4}G0Y3DhpS$;-_Yq(rm(yAUK!CJj#qz)W;umYx*^XkV zqob_zK_t9>!-g;Y^_OQaRXYoXfq?;)4-Ff1U2ISgN~QAu`Rl*+k)Qu4iB!6~1{Mw4 z5usNl0?<{d{Nd;SYok_=TCH3dbd)=6N+JSDYRbv*-9oXA3mFztxM9=g-}TO0YHOvZhfRct0n6gWLx1s?U%dYZ_wU*B3IMEHwfd%a+_Ze@ z@`>>aqr;0{KX@R_<=3rU9cXP|d)Is4d-XL}J^#WBd-l9qo1I;?detrOxTU|h|J}FV zeB$KsLb0%U{g#em2|&F0sR0la?A!n9!9xdgg<$E}lHP%ShuN)MMr34{e-~3bvftwr z6?yKAeHke-j`i<9MW0C{#EjLRrcfVJ=}+DKAy9w;J z=7(K$@f)L*S~5C%=bi68bLNcJ>bh;$<@0$XARy%0vWa^d5Jfhu-!Oh|d~#}P+0v!U z#+HhJ0hbuzeQAE%0jwjk(9!wt|NXDue(UW|J^93|d-t3_f1Vk-y1F)O-0-eM?HWZPYZ@FdV$~DhF_w18TKJogSuV1=&k&QqXT(~eEgu(Rm^s9UK ztzEOWudmmd$UzYHclUn&^Z(^rU;oywT`!GKjO##e+_>@1ojaE-S^EF|`JaxTd0XjV zc=2#YM==b8JMMaKS9kaKzVi?J_wAdlRSSjUyKlYy7eDr~1N#qr|2y9~eE87fk&#Mw zm(2!PwQ6;<(TrNnrDJ0qr4GM=P$(7GtXp^C{J2(Xu(wZXZ3IMQ)#}xs`t+ykwb{20 zz13_slv0~FZyp@%FBQUpzMkQsML`g(Ub(8fQU<2(a@Rm_e^+N`ptROn1caGgd>#d0 zaG-B=$#8FX`L_4m8fx{euYKjz$x}^ZLajG$-1yN?eB7}7_@Rdn?B7@Esr2^r+C{B& zcmCSH`?bYGLodAW!pzJxBCcGqV&kSw3USr4<(oHds?}>thDQeb2W_M3K&et^=O6y( z|Ga(s_8

2X7rZRISyRxwE5l)27XL-F^2vZ@vW)^MwLx)LM@$9$vq8eY4qEwPHnQ zA*TUb1WF*64@XDGCN7LK%V7UNptR)_7f(nv1Bsa?;baSlyjD5=J3G5+YC{;t-IqO%IYp zLXx$Qtxv=DBDW`JKr9%*OI3e@V*9K?AZ~#u9;e#%qf9+R93hcZTqd(VDXC)T9bc66 zUm@vTo;bY4sRRQNFa~r^f9`jFXJ&F* zt6UI<0vvIqC@7Q)>$-^(!C#@JA$yin(xP$p|wW5=3NT02DGJ zKtjkiMiVhIAP|ON5D~=XN?F)PDUyg)&^-N;!UsuYH47Rt{A-ba%)gRp(YY|E$LUM+ z^{=G!m#kr(pl1J+(~zmx+h|7SJp$I3=XJfmTl_)w&bR8>0}%n;1Vli5G}Y-Q1VM3! zfdHTYAu1~x6bRW086ro=0$@NCrt?o?w18r}DSFWr`x3kchB&V7bGjG>Z9T37cYadI zQu~a5w-D*AO!hY$+HL1w^gD@hef>SXJ>3X4 zdsB(9j|zfV@T{O%DwK)^H$1Y*KR!a>w}LQ^Yl$Z*_fI5TEESe5A6q&)Vi!kgZP1W) z@B<4#XLot?6`O@OAs`WHtp%7_Y-gT!^DpWemg+w$kG;mA*dv3bLJARZ(csV>cii=^ci&0?TI(_AyY^bt6bS=>5)u>v1{*B52~sZT z?#{On@wwEADUqeRSpUZI-_jWT6#7Ij3Nn;DU#cW=4-{c1e`0l-Xt#KHp1;!aCnY)% z09eqSlomh3Nf&zrCsss+P=tjM37HL`AW^KKZ2=Sd>9;|Vlr7?=Q94b|Pg~OK;};E$ z?bwxmUD|3)G&rQ$H7#vp*F`LKC>6I!L~N={EDOhi{ZjSPSHJnlu6;*!nETngcWm3b zrrg<)+9cmO3DA3atx;wFCX_Y-lM%)NdP(45Dv2--EBnm@0bt>|dVOkgGKwMqsB~A# z<+42_9qkxgay%Fj6qp!4KXd7lF(x18DwRq{S6SSlZ-SJL^f`>_l8S}vbB&ql>3Xf! zYDEm9wJsKlpaz_cx;G?CI0> z*;x_k?ymIo_U7|B0AS|1xw%uPPDN3Kh<$y1-QC@!wN+Hr+1a;Got&*!8;wSwb)~0g zU~rMr!T9+3b7#(wqV96n$mkLPI`rn77cb6;uvVHBi2#ToB55K}LLBOFaBxv~rDB+; zXJ*FFojHGgyk4K{?&=y^JhW(N2t=l*rt9^Z*1Fu))!*N5H>F;$UznJBZOceO#OayosmaNy$;oPU763XrJ1dn+rKhK( zv&$e500Xdzrp7N+FI`07LZR3{Fi1k9F;q#c9{@b6E%7_)8q>&eX`ttfin(N`aRa{p|-wqj}J*6Y`lJBvmn(6*sB zDXkDuk;}$Zq);OYDpI5f6_6qYN{O;TJwyOvKtyAczuEIO#%0z5^24Ve`QbBUA<`SF;4%K8H&JMG;@F+Pi>s~b6@*nA;^s4*vo5#n@cv6;|H5s^)awzs3W ziP$$205D6fUYnh*&dt@CMJrt>6gxUPMF15V#ekv&iAhAFD5}-!bB#JghO{meiuqzO z(6%d?h{)M9=RW<*pL}J{tITF>bmR~I;6L4Z>#at()rF zUsxJzwwh1xdGW8m|FvpucKwo7H*dLNba2D~6OtxEQh`QNnv~K+N+}}c8u%*GS}RZl zBq*#zp=+8C0KnFs&9DA}@9v_M+_jlXr@z9|3^~KnPuF zOicfPj3U6wMb9lFvR@ZhQUfj(3J7dtLJ@ICX$l%(ZOid|-WUR+OUne8;Nv$4JPbxe z*RNj;3Z)iS00o!<$vtEPZ{i~B2p|Y-W2fR;ec1IPKm=mfL<$)UfwkT2jzEl8?ef^} zZfU23?Jeqs1d6by1q9ce4FJUUBn1HU?Z{C`L>P=nWSd(1^iW|iAdW8Ws?=+>dc97h zi=|>NAKDlkiDEEd!|yJ^QZidDuF*N9teP}6W~j>(PfQZARLtC&_HA)YEq)^`5iG#q zF@IzBo%2P_eEA>%Nh3-VneYAJNB{Gy-?}h4KDuPd|MA&RSIQ+o+sGDmpox@n|0jzL z2nh+bQucriw_6BEN_i&%Bmxe7mle0;S#%`n38v>_m9nLrqSKh>%Om&$>gBNv2RjT8%(r)rijq zv7@p%rln%BR4m3Ml~P5qmIV+X0A}gw?W^?np{*73n}XupQV5w*S?p$6s!+%m3X7}) ziV2P51B^4Hk2ep&=JfyoU=RkCo=T;s!pz2)R;$%$H2?L#R@49KKfZS0wTX!fFTV7` zGYx0i>1R~PaRq5)gEEyd$YydzhZ7YiekN}89 z1Ck1hLqjyY7(BwTY|0`!pI^3gsl9A@dp;zBSi$?0eV3&zS^mma5T;k&@fRfbv=!LJ zO2BeMnmb}R?$s5#m2lOeq6vlR(z0iPwiSQYE1u!W4c) zM7vEXbH%AhP6N;6&#^?ytbaQ414&jyZ=UIi-GRR%1RF9PwNAVhIFXe|@5h!FumMWnS3 zwJH{qZsSgp0|0c&mux>6s19;RmA9#88~I`eoHPX$T3}F-@ zw)^3dB*+6lSHqB!T7DH7nB5{|FqR0T>mi$xfmUdFfRA=O9=N1UM2I=1ovHn?7_`v- zX3ZG1h6N@rn79+8UoroFxeWazjEI@1JX3oC&IqLAlaD2XZ3}C=-0@``T^3%g~FI_V+K}V&YfUH`!DQh+(%f1=~by7Q# zEUGsk1Q03C=1TY?zA?5HYyqVc{_c#?6e{)+MC*=@qopaWmw;qJ5(I@_qX~!rd(j<- zD4|d+7K(@B7cnK}1!5oPFyAUS0 zJ-u^~k`$S*f|bZw@twUhS^VtdZWkR6@J))H7m);tpsc> zL_gvT5SR4eD1s1CCF)ba{#_B-MY81?N`*GlCYH8dEP^=kgw|)J{ksCK6?)OvMiuFG z_C#eHS=yVdIE#DX#rOdI3FiVp0!nK|LHv*au!sUAK^;RePfZZZh-6`6(BS04Je`;M zxg4B=G&5RsgNaIT@1K8XvM9-zGF;ow8oi<4k=mc`E(kd1n{K*cSkNLPv5j_JCSnsn=P8zWvCbeV- zC6U4HKqs|E3N`;P+TJ`suHw2EKBw;O?%6b&(P)=uM*D&gNJ0_zRBI^ceMN&0@TOehESqji?7i940 zv4E#5`A9L3CfuP}^_$m0L2nFwGZ3V1dXOc5slAcX;$GijLvkR5voG7+z$5P|}Q ze7>jHXtI4UjEDyIOC2!AL|{(8H64((A_Fmuiv|Yzo1$T|ykh3UBdnV!a{Dmd)Vc}M zyoN*eTu5~Lx^2=kf+TLa#dHI(WzVR~i2$p^;fRFIp+ zEoeOf0(-mT3WrjB{3{b;&3h#k9UAdil!kqPY_}TQ|{v;-I0nNfDO8W}%Ovr|CKX zPb^P)(n2IU06>}`;Pq2p@)b7K9Cng?VgRcaOSQ1mmRbX15U8rE%8z{HBMas)IC1i1 z97U^EuH3R^OD2=?dcu0y99nMZ14w~+sd)kKK{dfVP-wyzQqis?X$q5D0>Cn)f6*Fv z_9ul};Jz!H4rJyj0HXu~h2|h?uAJzZDB~}oa7B;|MwX?&)V4yORb(Xg9u84htzkjE zl5`-CJAnAeGYZ7$vEoc29!ZOG>jsqWo+Bwah3Io#S*c81#B%gc-syay6n*@-J_{X) zB^S+3!jonsq`@Q-Xv0ce2Kk|0U$t#WCe6NVc!jT#;_y=5uQT zlig$V87ICQs7}u7Lv+T-T3`Y+*hNr_7%sNhsLa`l+7+@#Akvy4knvtM0BFz(1Ex7} zTvY>PGnthuS4~e%1JLru#_H;7(~QmKGsxSQ6xM!rx@`BY(()j%Qe5;OQE?YV!P%EG`T-J4saA-bq|YY5UZ0IJ@okpl|FUEIU8|=AzkaS607? z{xn7aIo6q+bn~2;F9(hcJYZq6J@QnU8z2Cbo!<*ex}#e{JPD@%#v>HScF8$+iL@t$n^VZ(-y`1ne;a0q=2e_)uV{Q8oM~ z?zAa4EpW@TFO01X>|p6Mu-SxgY{@|`!fpsCT!?_c)c_Gq>2qdJ5F#h}Gr@IaQL8gA z_`dlQgnyunen>ed#Ku~en_n3n6FUX)nEyPDXMaq_P@LH{NRPbY$OddGMu6xiQ;5{R zuTjgLoQz6@i0P_m{=*lzn9$ux!0q2-`jBW}z@K*otk4-p&EiPctW36j7ka-ZIw0o8 zDFWtE`?w(?f)u9kClYLr2~-La`J}K@Arv66K+wD^p-1dwAR=;v)AY))pqG^a+>q&8 z>Tx%ga=flJ)zs8nd)>8HU40b-s6Z*DG>0RMt^`X9J8HC7IS9n#B&nCcW2E%VF=q0u zwAHy}`)Csetoae!EU6mG_C%(^@T%f82~duAF_BfpN*EB8Ssivf$MH+QLR^xk6R-ssW zf3V)%f=IBhe*`q7=>z4^TMC#s`cQ&u;0@W)2dsGj8Fg9#n)y|13FW?oEZa_&@TYh} z-cyP8QOsV+OSLHvOB`e&@vHpDUC@Ng(d@*4EJX^@7UrJ-ItJc2gn{EKfUp&7)pfs365W!q;Sc97RV3ZJmaPg$eSZK05u3MUj)^m;`?C zX02p`lKg4u=9w@%>0SbPnI%@s6_!;zsYyt>_fk6}CZEfs--8_p3m$?3lV*tt20}MF zuQ|EMv2|ma&_Z_-VWZ&;Ei^W@U!7H6zv|v*yOGPb2wIO-ovlEV>y3%FxzM z9iV^#H&cc|1Rs*UnXM}K&t#r(Op?g3nJk{LPelMMn*bz*sgkQH0AP+YFuP_^A&|8i zj@0b(5O`~=%JVrzIhi{R(~<~`RWG`P0mQ3GvZ5IjCNGv@TAq|L_#9Fnl$e+GJ>;SW zZ^l6sh_8OpoKpxQWp@7_x&I5o6b&wIS+TX8;`XOW`~IL(1ALzsL;Ka6GH)@Bmm{g4!DlEj*96>AQ@ZP z2{1XesaN>s2tAiNd0#SH(K4l>hN}kZQOfEE!8FPCJC_zX;m-OqNA{>hRl?>%9^0l==)%cCj1!&sK*m6J0;N}LnQ8a=5%D}@LlXw(W6 zYL-)Pc@h+X^_G#mG?da&VlqgA07=hqR{F>d;`RUlrGBN9{_(_J=tGv5%e4*;wRl&g zpP~eqKmBz`_moq~_8lUx7FMHcQ2s4bWoO0yV%MxeVmi*wS`f_twjnppk#V|$7i7r( zL2O%iqXi{61S_j*K6cA30;oB2&hN9Irl-6KRmziVK?%ODB;Xu26o@UuCnPYHhzSW4 zC<^T9Nt`}x3Xl^4noJc;*q0R9{YmsxM`;AXe=04%Ai`(`Ic3WoCu0I*`TEUk&5JAB zk3{Z(JZIi29uRpetI5gZ7N7qK$lEC4lgP$xot2e8C0-wJIx2%8>%#K45(}$5u*Cq$ zQ$0Qj5X=&>cKv4tFj`6rlkz;-@k(06ERtI~%yPFn`q;H!%c&(EY?&5B2_)>j({Mc+*B)!gyc_Sx=zru;2Z7nF zV!Q&9WfK5v(UH#pi z*Rye&_nXh~?oq}}+%4y4W9mLgTT-Ek5|HcLJQovAbqhZtf{hXBe`cEnnIP|I5UJMvG&_y?Tq z*=0CHM7RAWu|5FkS5|fXGhb~%67p{F`Y)|mONKUMm4}uj1&A_2caexsK}ZtT^OemM zI<{9Rvy$kQ^Q5eG@a>Ta%t%FGm26+x$|gK^*B{H zW$W+SV1wrQH78hk4fAmPu$k>I305QU+E3G8Zjh27jHSHpvsF+CR(4WTllU`X>6wEQ zDp-m*+}McGqo9uHL$<} zXsXcxfEFE^V3h=>0swCd5-b5Ma#J%0)EN~J`oIDO>D|Nc{Krj3R)|W9gJuQ*Qu)hij6|c#u z09C+VEPDth2OSYG3`|xVd7Wh?n(RL*@H8NDvaSb30IW_RnLooEJeN#f1Vj+81~ii5 zz@XwxXXMJxT!SQax`IU9Qq%2yv3>L{PEv5l<7A5;%m9STDZ%R2RVcN;+TH1Yk`5%l zN!S}b{zoFx-gmEk?Y$@!Nb)#eEC`7ZqV*vWrB0`(D1fEbl=mUjTE(Qy5~1ZsAc~>n z$hBBrL} zYBMlYiGscI-Ku}(QOh@hl;$)~of^Gu+XSJb=yX1?)tg_zy?o6n4bmiovSMizgMoR# z)PsaACeOa0XJT#6p3`rhrFN)7DiivaPUvFwGr3m-_&OZCb8G-iYEUxdOy2S+k>eZ2 zuLNW&52oTHo(<3@4PTc00MUc2rX5XM_&{EPG9QQ-69teco5&FY5G;s1ndqGf?Ho<- zq8pc2CJ#tdgXv<)z6;k?WHAxBK_W=e2a+ZN-S#Yj&}N>fr2pnLUc0;3Ylht7t~~2$ zTbGZ4rUDgO1E!Rx#F2xJ5z166WS(4o!s=4O)@dUm_h6At|%-fdnnFx-eyAP#AzoY}A7R$?PcEv*Eo|4jvsP z=-`bS)BMAe=iTgwjI5xh|VA8eLY>wq{2gnJ=Fk#Ud`t@l2tc4w z#%EH%NFwhBx=6Z?*dql*pg{))@Q|4jAgz&*&pFTvBz(d1t1QhWYD);D+1_dygc&Jg zY|>;BPZZbmQ%I!U3_6?RDb*6_Ag5x3?Z3}j?jx8ot=~W@1^IGKcFM&5@_b&{{-z|| zg_cVAKf&sb5dcfucSAG1h>e&c$_bAe1RqO5E>l{MQxc7vt^!0Am~jSnRTp^Lc(IUU z)s}_{Rt7@SZg&FuRpw-(lETMZtvtyv>ZxQZ2}|Tx1`b5p>{FMY2*BlI^L6hb6tL^$ z9pxej7Bz|tF^x~k&4S|!AqpE=QFcV6%%TY2s{w#a31&n@ien8i5>ki(DT+}mvt$lL zU@COk%z0v4OEL%p=Mc%9-Dh{jF#iCMiV1*Kfd(W{f!m+Pc{hg0AeNk%aE25Lkd&0; zL6^-Vi!cIenZSeunz(KRY6j>M$Urjl4v3QR4TMSnHxn2+J(u;fGO*CC@Y<*v0+8Ko z00aP#W4iD zH<^fJnr!u7BT9x8t^l;*ED%4#s#4GXlIP{1s|3%K@U&+F;|i7))}eHnM7fzK57@nP z8nvW-uNUN|7ZFT-0|Drs9TF8|pjB$!#u6Y2BAvQG?|nj0t0@!COu2Ef6kCrI5~LGI z&HRJDTWS00A=z~Q(KTYeB!g)T98htf7ug;%{Nyb)%C#vd!Y*P!>XX!}F&tOe{$C$nTZ(6|0}>Nuqu5xX34(P#qoleg1J5q1+8#TNdA^ zXeJEkX1An$1AY)OP=aaE@*#j2XgldXe_BWcl$tV8QbG`)ZXRGNE5GRhycc~COz9|x zODu&H*Uw2X6#}4vw~7tBDKM%Pn7}O%aIDtoLHEkUa0Hkdy=#|T{7`h78ve)=KY4eO zr~z#wQXUtTkjGE}P3m-_vq_>Q;uZ?D*)s*1b-^++HQJ&-S&qJ1_KLWy7Q)H&xC>zp92&e);rAg~J22Dr`ARvVrO~o=h z51RQEMEjY4^L}fI<*=h6AqfN1U37*(bP+%aI?-TR#4;#vuF>WpQDFc9D$+`8?jRAx zqybe#6mjl666rXOj9#C3Ae+W;ZnCGB1SB-4yuZK zpLz@-c}75z@>oirvJ}Smpo~w7_h>(j-5KYUM(k2U!S~5O-s6U)c4bpS*GYdv!e94HOQ z1++;4DKJOIlSU1S0t!F_C@_bLh;}Ho`rH9763SY>yo4r1BR#irfjo`I#B=)1&`0~Z zk(NL-D_8h$@V)Ez;vd&-A{v{RiVHIeK`9I~xvHvaqu>L>HYSkB%TOFAa&C_B6LDOP zfH$)Gdp(U&BzwBC5oXU5wb28oBDp~X08%Q5qIj@>sQr9fO;uH0eO+B$eWoHSXb(-! z0a`#~0h{b{MwFa#0NF1}e5#B+LL^BasWpil(G^51R>A~wi95<>l&i;@J4~HinO`#7 zfvx5?t~Ync7;QvKi=Sua{vG-u{Sl{gR^#RF^fdFV`i(^JfdwdOwH?@USBYbrN&nj6 z2G_o!nP+zZ$<|*$dptG(D&mzdGTpH1L6%YhKxwIC49r}1K^_3)L{vvQu`(*QYC*Uv zNfZ9=)T0U3rd6<=HAf{4#YnfEZ##bCcw1Zh#Kc5JF1K{)vWqrtT(@>zCQw>y6)1B6 zmtAEoGrw34pCqkzass43;IW?AE(g*=***?Hl0EtLzn4T$o<6D1w@Zc+%Fu47;+dp{y z2bU~a3}DJ9di^&58YGiP7*Nt-(=%?4!65*QP0?XDWB}BpRX_w0VhE8Y!l{}3`S!LW zM~}3&wN6jZgt<)PvZn2qZC|mnxgwhdL{y3())XOzNSrWaTzTevSNsBmu(OFmDFQ^{A711F5!}?`s+%5vyroFSIW`F-M3lP7N{e@Q zxEnaH##lD*DYB1~BfWGgK_ftI{joIG7s9(?5a9`CMTvmtD5S z+4(1F(NjadFxS2Ri-`}DLcX(ANlZ$D{yMF-5-w_z{+u}FM-LMZ{%7;&f8b)`ND5ya zI68n;VB~J{dPU-`p-xDrO5Qs$8)JsHuVb}87W4Onv_zmXlhn>8=5(wDC^B}?a* zZ#CCnc<#AppLwdUuXo8?-Qg9tjwKOm6J)5RPgGg8SmCIP9;DW_s=IWJ#c zZRlXuPi#FYc2ofEnHR)HacXMnz=4_kV~p#( zYmU|!;>ng$0<8%YQB0)r#X`sV_PMol>g($>nJjA+K;XO)L_2F{P871!Yid1%Tx9N% zcS*z?J^mp(1?0^)$QbObX)+o5;`gOL95r##jrWpY_3sgC@3_oF#{p#D0SmCV)bD_0 zy8RNND5F11^eB<+b?{LDGUcR0hQ!Q?;G7oJL`bAaX-&%dMnK*G69!a36%YUhc1MO& z#O_Y(x1-?aa=0_8bqN5q)*T(4|8(DdhYr2g-`6)YH511%BIa_rx%KrIZ@%Q-PknmR zrVSOU!on8Y&V}r6xob)N@7?0;ul?eeJNM}0&>yj;5xUzg;EK1@BTHf&x3-}vBD@I;$qdiOac>Kx7Upx5PVE@3(^h^}TsIao4s$u@TE3dlht~>9#XyYbR^UV~X zX9b70bF9-1gpneg+JSCc#EfUqD3P*8Mj;t@!BTd*}nqa48w*1WZaGDj-BsikINcWVkj-vi?+Z0i4|QklTgp zp)WDqeze-9w2*6s&#^&t(_sFzs|3vYDxo~mPf1iGCI2KGp}1HzvlQAWh}dIef!PD} z&TO`}rvxdw+(by)o>EN7HL?=6$GN%wsI`C4X(BJPggzz7jw+=DFVw4aKD3XmIj-XY zIG?t&A!e?no={tcJ9z!mF0XL=-kom7t!KlNAyWe-^J|GTD}C(VJ`azT@((%g^e@VN z$@Zb-&V=qLaY8)$c&jBA6v~}RFVw1xG<}3)Nrb|J1~{rMU$NP>IJu{5oqyy>q#fo}SlL{(@-2{uXi)nI4iRvraBNi~uGsk7)MV zgoa!gKw!NZU;u1Js_py|zl`mi)whXJQwi4@`jp#E_rCNS0F=tF$3JMlC*_*AvOq%C z)AE&^JCmMZ8M~w7{K12V+S*zdE?lr~&05*DAWJYM3LUs4u791#BZ&Aw#*gHsMP*V+ z+!hgLN24(`nG^don;8N`d(brjU{ov)4i3C~>}U{Xdb+!Y1_w!#Qp$+{xq%bv*z9&7 zMWFdGMo+;JX@J-m`ib%J#~**{_16z}bhO`o$DJSg;D;70Sil~MCPdUofmuso>KTD0 zV(fzo6#*{?`Nd=rIGG;Nstxh1#L?0KXj35U`~qnfJp|I}TKs}{O@uS4AH3L5sZ{EO z4sl4!4$9|Gl~j5NZYzT2YDhiBR+SgQygRkjxGS%pFQ`9>K%4VEkc%{M_r!5AN`X&7 z3;@WaWouBpiJfQMhBd_>BS`F4N@y(vKsiy(q9+f>An$uIlBLAEqB8PJtc>C)9_Z`) zvp@gSmk#V78y+bX3UM5pLjnR7ba!{Pww`NmJ@=(AeQDd)t(8?(>>&H|D_Yv`Gl0kZ zyr~^MZXwSiFjhAr<6Da4j4)gK07G~diGaly0RS|SG9{rADUOSAG&MavGBPsIKadNv zGgH%AYgsYlp=cmwZJ)9RnoPbbCw~w<_`m}%?t8JTyW<1bU4QpopQx>!OT?*dQ6WDw zH5C<$6l;Y_S@Yu55n)1V9OiLoM)5EOtm2W`orL3XAbmH-5nTrto97T_r> zfNA_A<3guYExOR=BCy;#`e>JkPoQCXYI=BRXrO-}j^gq0@wgZTr~-h(%*^QMNIsvh z$mV8dW;~7}i(Zt+fyBbktZwb2)i8$shyWTvq1LQoK|(iXRPTwegn$6-OM5lh5kU4- z8Csgglx&v(090J%N=cRkBx^e*%%!l15IO0|B=|=p9h}If&#|$e#KkhdCl#W6QC0{& z+$_DEy?r5$eVxxhF4f9v5Zfz(tLdaDxswwx5=0yp7vc(EvzO+!xjReW zz%GULx1?(fwaT>*=-tO!-Ccw-{F&gNB>k=4Ox;N(&6LuIhzuy1wSDw_R6A4tq}uBu zf(Dey{NE4{QT1V*}$tpB4 z7BTRdh>!@CsrhQN)KDQPLrcdh)<9Y%OO8m4Wo=(l3d)1~km8^B)NixWM+oBavyB#+ z_Zpe?`O|WEdH#_R8V@9nqM7NL>FJs2$*Gy?JZWtF%s%n>qy7NSVwUX23Ae6rPHJp z#S|fE*_NRw22dtwXDFZnOs0_{9Z!r+Jo+y`dGoC|CMPB~U3|&qJ9o{Wzkt=D5(mIe zFWW*eCX#VCpx_TX0p524NEjbGA}u2{YKQ)d!mus_)TA?JnaKb6Dbq; zuxx}xKu9t14hq{cp$}H%x@Y;N8MN_p&U8ui-AX#nqTpQr83)4n_}G(AKJm;mPfbtF zXszee%xP?DoIihllrMI7_jYx6j*g7H_UbGD^n<#}s;W&FT~tv~L7Y1TGTGqvAUFq= zi}s>SEhASMmvvK_3LF>mz-n1TjQ$ENp8_PYc1>sw!$l&9V~v1jr#~Pds%#KuR2T+< zj~~o9Vj|EsH_3l58Pi6DQ54P0%T7H7`NZ9C zy!lqKSh(t{-HR75ARY5*FucHvR+Cum0U)s|5EP!8DKY9EGa$odPPJ12YOufmr$7C- zJ^S{KjEsgsP&coxd3j@1RprFQbVos2Zhc+Ds?{rlz(hSvaVlXb zS(_j><*}72zh?;nl+L2ZsG{Ks0ZopZvNH^1S5<9(600jB4SY~{pfD2#DhSMxgQ!p` zR7!p9%x$YTJ7jO~5yNQuFEL|G zT=eEX5$hsKUG(QM;i=Ao2>;#mvHBZ5nv-iM^#ZL4(R)hTAxpQiJs|+8;PigzUv{Q2 zIhNVzL-ZeP-_D&Q?1ky?IrE9@Hp&73cHc!>zR(viUqSpk>BA&7dp!+8Qiw4n+VNE< zKW0QpF+S(AJc=|>V9HWyG4W*35FgCIgE7@iO))Oh7Lhy65WQeW^7H?@{L%&ISA0S% zuxa_V#!gnS5mlhf9(K@LM{yhm8P*TzRGER4CVz*BG*_BIAV9O#7BvQ_?b%(7Jv{|2 z3QqKfCP1@62ATU2NSX!9=s>2ftEMmrO@4=_*lY+@d`u*-^N<1!F(UBf0s#^#Wfnkm zOhhrCUWB0I7yt{!;Bi{#V9iU)mo#CloCiGB4op9k&xju9)jb4^Eb7+ z001BWNklDfl`QCYimwPWYAPQ0D{<{GBiGit-1u-97KpJ2)sjFh|E$I0%~56 zao`aVtrHIKM}LyL0%GiD$tPcZ?bSfxOE2xaa`#ouO-;Fq90GSlA;xhGfce5qu^0tG zkj-YpAXJh*r%3}r7yuEF#z@D-VzF4vBSJ;4A_zi2P=JK2&;n7IQA{zAjv^gLVI~N} zj8X~#;y5OR08v2z%J7{@^dxN(#*ktSU^+&cfC9x}DO7>M$x#%MplMAIYppfuSWgzF zrluwku(GPMvZ5*s0!=XyDa1lCo}8Q<9UUGX8i)!7&=f~;6vc5Io2A!WHoW`pd$#Y~ z8AtJ|mCbeab;V*q1H_sXP_D9)v>Z(aM4IAQn~7EdlGZ^O8bxQYfYy;FtrUhq02*S7 zm6FYIgl5OQ958^0>e#VkZ@vB2$nb~?f|V;)-0;Ecw{5+&s-j|iI)C=enTH>Fw4<|Q zbadq0xt6Yuj;5w%07?Z)N3p^X0HP?0v{p)GGGV~SUTZ+$!fSynb!v*^7!kB46$B~> z80#Zwttln~B!mzZAcsZF%3&O%)}{tNktQ8Qh*;D*);bKrAP9^GK}Cov)`SRw3WC6t zSV0viK*;8DH{5XJs#PoVGc$`9Eoy9R3<6&KOu?Ht*0GKeK`CYSva%qEcZG<=dnPD~ zVy$%;hTT2gk39OwQ%^qG-Q9ilRaY-wyku_u+=K__%NN*PH`X8^kW+gEzV>hddN$d} zDXF{&qsbIS<>nBOP?N1-_8-6}U{|P8y3Xyf@tg3TEb*(nPg32aeJQ`EIBWaN#O&uA z7YE(L4$gW?97Un9!A^odU)?KIiF#$p;gs|rJO|hFF1?%G_gQEHf@AD=2U2Yh>_Va6 zH9yps8mUh$|I{V|@Zj$5!QCymOK^Ah;O@=aB zP?T{U8>c1UfI5ibrh~?b-r$R>E+-?MuB%ceITA6s4)){BB=^yW;Gm*JouG}CzNWww z!MbXdleDW{q*0X{)1|*-vi2+0RKghfnV*=_MQ^^I(V~UHV|WW2aG%RY|NApnL25Q% zAxuHpWJN*wEGg9!;V0raB0M^BS(&4CuQ*bIIWMbuXcfUQ^rQu!j@Nsm6^tU<~*Xl5l z29!m#pmdsIJY^UWDbTZ5-W&M2`l32~#6hgQv{HNi&mRYOV{!4AIECg0q`OD-V_nXA zi@*7%W2vU~T9~9_0YJLN{rx=-0$b~7gy|TNZ`8JKML`>gl%Q&;C${)(iA~)VaSt6u z){%RjEcq^UayT0sT@v;!dazqWfwp$Pi*THEodL0_H0s-g#@e3jobl9_GvyVxpp9MV6U;|E8-~WMrf}CSz-#dg&se zVTXhynTg*yY+KfJ)UW*HZMWUr03AL3DvM zziMyX*CA|z*Hj`9-Lhx8-wNKJlxkiI!h%#hNIQ?Uv%VII!cAchVmNc$1R^G0UQ`l6 zE4=B2dLZZB`Gb0x2})H|?UO<-#xr)+a92wh4_o}48aWEpsiK1e#+ZezRzY;EC+EB6J!=rFE0?qj!e*{mz^HFFjOx@`$O%t?|=Ovy3mw;ZKg z3;r=)sxCi?D8+Mod=R=x3>_MRrKhK}>v}dm<*Gzw&5LpIe0`?Q_A;`tP$KgNFRpRg zZt7}j4X}+u2XM!*qmx=Xea)x6whm;JGGmeeA%H?8L#G-VI<5y#;ZTXr&d#C`f=^C> zbaoBJ-md`7Y2*%Z&=T8&k0s*Br{!MnQ75DLMrT);| zd@4ID{WpBT&zbz-cGrux>tY}gx3e%mpH=Lu#50NDRS)nTD<~+eJI~2KuHQPH9#%^F z8Leqz1G%O zSM}PfpU$_QH+UMIF5542Vmq8n%sftvn>RdO&)wwYF)6-X*?YADgKRV0O%GBvK-vlc|CGPT2U^Qs(TzXr1HEmKxuOOe0-LP@7DrYW4jM))m;ZC ztuFVnsSH&$706hFEC(fshm*~h8|{zNd4W|742hVSGBCIJ2L^5Xvjp}n$2WTI2lHyM zbOmIqzS}+TsSn7gs8#y)2k8|x0ymRuQ~a;Ys z+nH4vX}9q=1(Xy7I%R|)X8Jx~l{_{C=`_eckgz`l6P!?}@bS}0;XG;EcSIv8QU=UW zs1(JZ^~2`m4;2O(vPy%YV@U^ua1%l@FkX*O{fCn`I`6G1j7)VTmL~0$?%7Y^c${4E zv@p+YW(rTCv(d|q**%ir`}2FGpiACk-T9|anUALwWQbNSj$_asZu+Gq`!D;&-FNw0 z)Pkh5C6Xg1Dc&N|ZZdq9@tLw5$)_X!)R|lf1T+q@l4*C5{A*rh`ILB$I3*~Fg`k}G z`Wv}4J50(rJEK`y1MM9jK7)UqaFmKa$J+Kj_lrr~S5mMbO(hDi?kd#d46Y#$_b}lI z&gP;+n(CoK+zd<43QPSM&9!ugg$ZB&i$5RGck}BS*>CK4b_*E4!jMo&{a+W)z?rcWo5c&m5-fsd ztrv#y`T(|>ie&OSaLVbvPtl>B^jl;Se3g@QlXOvUL|`T0vac?$&K$wjR>%Qoe7xb# z1*erqM8F^eU7)^-fq~}mVJwlr3##wq2TBZ*S?~60MKxp-#YfmdChvYlsF*MQR`T)EI_3KhGpEJVUDeo4 zam#5+aBXu&S&NN2LW(Tg-rAba(_# zRR{a2sQeF4m`l2z=Opj3Of?p|5n-kC;cl^l5BzRNw?&+bVgFf6X#g+M*PV_s$t4gM)%eN|H?BW7w_ShYm|G| zuhe98U;0cMKB`KsoX$hAz5ePKNY7Pt2%3b|IT|85itZ?D8u$PnCo8}uV&~?^hyDwM zMrV6ptmul{0TAbfWc5~EyMYMx(?#38ck5_)<=S7>Mf+*yIg=)Jw*&_yK&JcEKQ4fe zJXt~bnb^vnZ)s!Y-uL6dck7Rjw>lLU4+|SV$4ddp2LQ1lv9q>Tg-?@%g&!Oo)LvVQ zCFT+BuCZ_V_s!+jDqhWjYRjYg%@ZJ@E54u4q@{V`m7+nB;}PQXyWi}^yT!iuGQIBr zyizPlw9@VVkzF8oJ|jDO^^ecn@=+-Z&VZ5OuU|(;e4&yOgLP?t?0=BvFirk@2{X$f z8lAKYd z*yX&PTz@!etJ{qN!7gI$`Fd*K;RAlU9xWfmo}kBJ(9O(APh%VhZ)|y=mX+iH{9zTk z;gHXy8%uMy4}6y$9#`82KIcpTGAl{>Gv>E{{8D9%^>yw8nLJLt%rp|$^fg53T$hqL zt&elahzFXQCiR9b$2$|f^em)t#m6IQO))Va&q5zZ0NflmA{fL{APPDqd9ru>@X*BL^szU((O$oDw!FLyRA$87S1RJB`(>);n?A3@t&2_$gy=I* z%STvseYHB)$@XeNz!l;u@N0S5d7#NPLE8?*NRKPI|4>&3_Y`W?Rv?bA^c~GY z6354Tm)Gtb%Odlyz5^Gjy^*da_7l{)$| zD}xcWlp<8o)=2%J*%>%Yu0)r{w+Hzms4MH=ROX}U=D1^WJsybN!;I=4QD#gSm}y+4 z+)D+NC$zX(eO_cQI`wuLvwQ6)lpo)i`lWhtQW6Qv%7U;gRp>9tW0z~B*!aAI_(e&R zNzJB`MIhDW+2ljnyOq_IaFFMb zi`7NPqZI~WF?t{?ksr@ux;CqTX`N~IbMC+i@I~k+Y6&!;9Iv6nviSBn*X90_Boeo! zVx^d($_34Fg8cK_IW{;0?|WabXH>rSwtBA4FDxy|D<}XVpf-+F> zT`taE{E1~2`dw!`kHIC!_vsag zS5_&tDz~yKx2{V&+n!b`EVwQ!BN6<$1yBWrI(OJyTpFY4@rO#N5rTiyNMOT?#EqF+ z9WH99X`mg)*UnJqcv9j)I>Bjfy|l9@w>Yn7{uFPFfa|qm_hWJ=(Sn$!}J;)oYVEV$&6$cF~GISe>dcN zGt@^x0k5Q`rL3oA%PO7EE$IGoxAYEH{5WIk@_CNQT5CE#y14j^hi_#I95h#Je9f0Y zzQ_?KbqqmlX@1>F0)q}?mkHYxCj!MMA!@Ce4W-_kn2+EE4$#xSlDFaG^OocN*B24b zgrm(#-E$NNPQK~j4?P$xNBp^QZpb(Zq~SC3l4bbr5nmM(n^1D*?hGguWlWfCe=?9* zfMX$P!Qbh^cK`6=+(Y#|9_mPtpVyDR2hF|Id)%Mj^SYWIUI11ZAPwZsKlS&cUUq*B z;2Lz8+#ZZI0qvgg&?<*@W3kQezkb5BVYFh&6DF)_QH%3iOG_KxKzxGXOz*&e(fraH z9}f?YK-SjdY}Yg5f$u%N0Zb7ceI2U6T>+dgSlHa$oD&I$LBGLp!$ZvF>1qusFc1J_ zY&{)pjpuNEq+7e0*Qag1XAhBhp8d_?FN9!~snY87SV<@f{ML84sx2uow8;-%M18*| z$sow!v}+49DjzoOBULKK6DA~JC(H#}&BPJF4+{j8eicwO2=H_AQNzMY3wHcd9|T{= z6Q9A(@-6^&s>1I1Xb2&=cQ;nRwfbIHjfRl-*Uz`MXq_e-+uxeq-fK-wF}J`W$HoQ& z`Fn}kS6}bHoGpNOWmQg(IACOI%3%TOF(M<83^tVodN1d(gfFtIQ#mMGnGXlzn~6)MC_`{OQY(6_}!l=2y`GqI?H;olF$3$Y+SCZx%mu zRJFU>ZMh6!kEN#r@+*WXYbz;ySbqFWyuHtDje*a;Fx9x4IuM25YV{W!l9!j)^zaZK zKGM|m(Ea|h}%+*cCyqXaPzpAanx3)%CPS>p8)>u(t?(E$1Jqjpt_5r-PC6TfIwM3KGD${FbWQ@oe0@NgnFn27@NQ`WvcBx(rPn?ReQPNwl9Csi1UvYw2U-^4*)0 zfYD&Hw2}CXBonyQ)E;W*%W9y|%M25^ck0lQN6h1i8ddOaJJz$U?T}-%r5L<*_xs!eq=AOUyjn~%heRf# zjl$)IJV!jWu=^)hn5MC%?FMCD_oYsHcPB$f%)3ff(^qeqUtPc@ZcKUAHIDWu)=CCc z=l!I3DZikt6Sf^s5cxrFnnWRB=go%UrOygL2Nhz`i14Jl1gi+6QP9d-GJA|8L-UVZ z{~>~OpvXsES3J3ifJWJX_|bB;3@GYFbR&8uFnz z3+IzC+4hWEEO-~SYi(vFeni70;3~5FO19bSY>{~!ya$joUPc0e$>H;JTOjq^%~j7~ zp`+D_Y4y+bUO1+zs_Nm|>SS}H-D-oSmE+D;hCOLOj5O{2!jSki(8hx*h?(H4iKjr#%F5r21SR}Wa&%C{yiX@-s?;uo4Bs1`4p6ti z15EF;fFv*__yNv&ThW9ABvx3sEgtY9cDt+X`k&)5-y8EwVxf0xgZBa>0A7}}*>rys z>vKnus_W$dFm7~dTwR>OK-@Tbkciq>JKgS!!|tcs)XU`=$6{b`RN$#MQixt+K+|C5 zW46m%Q{O?ni5xe`KTogrdu3T!7Z5>y3GUx;ApAJieBaBWkc7`|uDRUUm27Wo2OJ7s zR$g%P7I^h&xl}Y$RrR(^am({|qFGKsUI3gJO8k~88KHdLA6|g^{`79}+PKwn0h}{} zjxD}Fb4yANv)nd`KYuRE;ff{qzMB3!bAA2&bRZI^N~_)Oy;rH?dPw}vta_oVZ=R<2rMTK=bKA?_V4^H`B91`H4U=J2z9X?rAQ4pC0xVzkbQ1dQ53?CdvJ6sV7P4-yVuBL}iwPXkOq zP;)a-u8??~HyV6I8;}qX2)SLHRluQE>Tk?1&)-e*Nxt8g#NzC7r;b$X0mrlNy(Vzb zP8{BDy>33fKfk~7@~*|^cs*_rzb-}N+E2ra`1y-ciq%O@L&AtjK^Wz#`Oyi(W5Mt?{|1ABqJ&0fB($!~AnhQ=|2w z*X!EW@hQ>M*`d&!N40uj*W0e!=IgO%DuYhK1Wl0|mOP{tIV7?J792vK-1PE%gYn^{ zldY}x+@?3vru&j!vN|>${A6Q8Lt|sP@Pq;LROIFu$2y`{ogD=8ZZiG z#L{N=h~51p+wDBTu@r@Z@?kTT{qYXhjVV5Z$i(k?8ftH8rLC`=m7GgLW(mY`oe~^) zU)<&p;S;a7o7)pg{{FN!<<`)c<9kz9Qf^hQS)=bhCTZWgZ`{nus8b%P95*2roCery zuwkvt;Q7uR@E)~Lz1Q{ZWc4IqWACkPy|;_jPOq-sT-3S&K}?l(bt)^L;5vU@*BLzT z8+6`g=kR&njN5-KrpDxOD}TiSx_pgF3jl0#*?F;w+R&W!aVzv@we>?NpBI>L-Q3Jp zuUN`e@xSK#bo&gxC+T>nH+VCUkCs=Kd))Hf0;n8ZZEbv#5|SiO|Ae-{@v+M;$h*x- zm9!DklK7-C##N1o)({p%xRK9*k6vuVLSh!cCZ_T{J#C}(_=$&X*koRLa%~^?0})DlCfgCJ4`y&> zwrk;uwG=w))*0^=#EEn9D1VW}=zyy`uaPTf)HE(0To$>BH#Q^b6OSyVg8J8$V#9Z4 zjZ&uq%$NA~DCnG5Qb}-7A1(4vOlj2zV@oHX;}HxAdJ%(SBAeEuD~k12OfVDWL0cLy zDEsM_xK3r4eEg@`VYo>u1?y`;$GG+o48}$xmhSxHw)$PI*5S$DXZrz7|2fhDd~Z zdU~vCuOU3|99>;q8xI9A2L>5ZIdyfu>5i>L0twv2#25U&`eYg=Y-?_3@NA!DaYBPX zr$a9D&S)c=O7#>_1xf7^HE%WMTZ$VPY}E~#^0}VrGW!~0%~dsT+#GcavV%{Qx=P{T z;6mX-IV<&=%_hp_Oq=q_`Kph=?CIOmqO9#80B`S^H~5i02DeJ>wB zegIV>pa6~GYnIx$I@^Hvq2SC|(@Jt)j!jebQYwb?R77&ab^rz4zTR=Yd`8s6AYNn*?h@h$=OWgY&lOeQ$QAUOh*;FkB-`@$+8 z%xBgN5IpwI`YMm_b7Re0-(%P7`kbQ6qw^`ZMwqg4Y^-87ugih1>}kEC&@13xoGy89 zueNe5_~(-X`P0FGg^<(vSkL8i^({FW`*U!!_gMi^)**2AMa4#=A|rPKgNPLxHJkP3 z07FKSB`1fkhxxD~1H3>FHVJsHfHm zbR8>!~ixLh&rTN_1 zcsblJ7uQY%1n;qbcEpWyh^&}~*^}#YM+U(O8Ox zni@(p(m?CFuy4+%ss6%=1P{Ppzb9$A07N7;EPx*nBHN_93QR@t^RG@bUoPs{zuJG? zr`E5mpDa|M5P9AI%i$*!;pY_0p2FSXj@iXSQ_E%lAz^yaaa;X-dqPhyD%|*5P9NLl zxi^Fg{u9TPDd>H07mhhmp@FXTmu*!okt%B9s}*Tjfz%KS$f9cZdj_{2;gHbdh$kRo zJpj{LB4{>iwYGf~xJ1j%219MWKbRxfLC!8Nz`bKJn~bdvpvZK)ylAbmMoa*dK@y@P z!aR@-dN}UL=BdTv0CQF4)nIJLtf<3N(6h54x zDAH0GG9qc){OWJWB5!mJ8U!iy3}hUbyLkk{^s8rJjh5}tdD)xVB`=t9KakcwdK|r- z#A3~r9;v%5B;RW3?~V9NZ!jNvPgPpcOAaji>@lmGN8cex{rD!VB=_xo3{7U&YFl3C z`CW(RvZUvW&J{BW+H>~}gAZYKlHfL5B-aU+QB(9&Bx_MpDEmj!vu@a_jOfNezViS` zq&mr<$$JU{N_2_O$ZxdL-!DD;}-xT!cO2oHCTy>oW)3uR%JAo;WN& z$Dyb1Jw~3ntqzchJFuk_5ulN~i~=Z{foD>bjOU$P^452Q-w7kFq?dfZ%Yu{Um(@9> zCNIwt^y#``zmn(2<-}TX&RK-aEiJj7%#YO8UX#M(vX8f)UgYT1nMmV>1bg{B-xT56 zt~LNGfGKR|L)6p&d1)J&YqQ~bRuR&;a2gPxG52jcaH zh6X1yc~{mp1h|=}eY*6@a?8sdUnlhpIc2xa)$tQ4lBeZ@RU6>yA+EIfEJ?AI@Jtpf zb((+9>;O7CmgMekf#d`D%*%Xg{7y|#u_lHCScf&1$1-D6Co}7dg2Wz5cP51WuCA`7 zr5+3q2v3)U?$i;D`P}0{fGi6JlwO!LG>F&Nm*Nt0Kjw)0>)jz>y8vG~4Zot{$2(Y- z(}36a9dP}Vd$H*?7st!qg(^EOe-jQlkmAQ{se3=(-K|qIvw>i_q#{7lS2)tjxL+dM zzODeVcy;0{?GIgtQ>S`Dm(|^GU3#z2o%i(&?zSrlHd?b0bqwjvV&RWuD$rp7B(5KX zH}0L|t!COm;;yFQf+{t-M)_C5RYSwfWc@Sx2p*pw#0=tfEoC4dd;Y4QGNsM#`?SOYV7P}PJA zVayi1WdFyUu@LJ=1Qi-f2Xp3#b~x{M&^HzZISnXHEi=Emex7&~;~99^rUlbe)QAtX zv{U0Vj-!f(qC8WF-6Q=U7SDct&x6RF-PflEUY7!Ho!5f|l7Q{*cCo(KzXxRC4Lm}H z<_NOgmH56;qYMGIviR^C9r|a{Z-TySRn6EpJVP$DrETH;gV6-5Iy$KbxJxqDB~zIW zdXHECzExO%F^D658d3CN<>d{K@~c$2p33GqBLN)04UZoaadvI`& zVb{WvCF8hc8gT#M>iyIQgl13ap&YKrr3cV`QZ9R0<)cM(i<@C+;FK&zGjNVfUdmxf zkvHYMvpG9E%FY3Zfkn76L;|k=z`?Sql8muY%##)BwHoD>$)i2f4lVFrpoC;N$SrE} zK_Xa49&?cYWg>iB5Sl2o81#yl&s+*a1}tY5x4WaA-Np1jE)P#GB;>3%Zm++O6}Glj z0LtBNm`knvJ-)y`X>6aAlx+LhJX?^%8Bz*9Ah~G&x|Ri10L|#=t_;9vW+QJPA(4nu z=8_6esz|#HEi%|LbbDNSUI@CMJt?C8XVHYm$I~ZUVSDN7Yrl^u>dRANWFKyL-O57- zR8MXi8MpUs#LvhozCDGSPiD3|AK$dz9?I^sLZXU+M00yt5rB`qcZ;0CiF)AZjuGHg zvOK@n_KDoe`tpcmSdq4nuZfHqT+5B}A8Y?nO z0}(>!V@V>4oZcw`an&9|CU(($)Z|T^e!=gCQfIcUl3NsOklNo&hE7j>#xdy51wO>o z@c0LbPPZeuzI30%9WKN6H&}~X`Ks3w>PiV&>_uY?_+o*?1M&2FMED?Q7%i>e=<1{z zl$svxeG+4zV>tvSlxDu*cgM`YkQJM0(MR{eoj%UX=o4x*y}aV62H$&VQzdeADZ#R> z>UwWn7s#}-L>BNB<0oJJd90B55!&R4)N*=P87QRj@ud14^&OJZzmhHSszDwiTC^p& zFkb+iB7!GWTh^|syefeu5@VGZV{5LcAYD{EuxV;bCZ7?R7qrf^~<%nN#_v)23oC3e3bjy1~_3zq$AboVz%#cs`oCd9`jLCwNx zxlZ1qwLv^`1a$#nUJv*nkoODRixeWn!NK9+;E*)v@t2OLj;3PlX&ZDu5efu77Ctbl z1U!g&VRSkl0Ay70by^|_l*q@YOM>bN=p zmM|m$xD9o&?vky&^;(l{#p%xh?QaZ(VY%96acH8Wn2?bwd;ZaTYjRJ?YOJ*#{V^m$ z4>w~>Swg;M9#%%~MIC=yI@;Rgpu?h^E?O8#$(hx8{b|r3(TS?r4{o$At&Bvi94aa* zVuc=lvL+|N)*0MuYHC3IMFj&ZNGfIId-AK$UE z`H;T6dcZcL;ABMPG7du2*SkDtO#jot@nn0Vy{ji<`m&big#BE>iFE(esLO)ki{rzg zT1(ksyD$s|Y4#nK)kJR0B80}fnv9;~DON>YOmr~c5G&-)*{~r!AT0zeatsABBq@-? zqo4fBF52yK2lwpoqc0>oKwL_$I$hFIVR;HAgNN3rFynNM}x?c8R( zyy{LNf%kG1+}bOH&t92TNMge;|7rEc^)ZK{OF&6lv;ssbaq{vD^q0(QVBN ze|SjrVaKZwYZ&z$zj=|G4t^M6wD2A{a#AuccT7g;1yH=aq9X7MeaqAh?tQ*B2dee4 zQzHWq`RanXnakxCUOZj@C&Gyy|AoLdbagTUl(WM8Te_-;p3h z8^9G2asT=;mLh-YvDBF9#FWPCVRY15lEdSEvOK&mTVG!fXm`mnq;E!|@FF)dq?V=O zr0|486DD1krf(Is1b zWiMt+E80&MhK`K9jUsbvq5LV9q=`!FPm}fT8dJ0v=LhQNnKE;#3&JPojX7Kz zfke?r$zQTHGT4dYXPxF3%yK)@%jP^Ee&wODlumn5@C!JV*VwB@ zTJ*#by&@x9aYg-NRc%9qJ2p5a%sZSXn2qPu^-sg~oCNA2Knh1%R9*>hxt}A%%^gF2 z>*?oj6GMwA3%$DnjTqL}Hvi6g_vd-vjeoYtv=6rbyjxva%;_$I3$+FjUE`iwLd%~g z0Ws6FE(fs6>O_7~i+BjJO8ljQ_9TKF$vZpTIAdlIItYpPg(=P+vx^q4&3NozljpS? zIE459HGgVw;hSIPzpkp>5UG1yYLMQd_S-{D+3i>Pi+nJU{44N4MV!Lg+a>iDrvjxF zH-X<{<`0*MQSnW|1NW(_IV1*j<{ptA9frI(Hk{a34boB0SkrBo((kIQ?ABU8twvJZ zYwptAYYcLrV}j zs~F_azx96skYoWeMFH^Dr${I$T{n(~9>Dyy5qSO@6PHPsG~b5GX-VLcN5I+0(F#0z zl)OtFJ2gIDU0Kb}&ThBW#E>&Z``ZF3p%*sHWV*RyeLYO)wJ%OuXl_4#2G}hEkC5;? zm|0j@T9Rcca5Xmpa|ltojK{s5VY7r^;*_H)!yqA*lx2l!0&-Q#Af@w!7leQL|0E2F z;`zjsCDYi{^mFp{m8&H?a+L{fz!wA}1D;@bBHA&uFt5+b-;$($MLD4PMYZ((WW_8mtP`G4Od#UTIzV7l{khdmcy8Trx!%=;xb_=HKjV9AfCgEH&drm;-Z0 zX`tH!$v~e8h-*A0$neF^Gmf4tSk6}k|M+-{`H1v?Gn8j&kU@$b(5i3nG_f={&0#Kx z&!x-6KM}UleRdXj78MJkq}hB=p|2BgHhb_VsjNzdL@+ZemJUlPE{hb~voIpW#23XWFUdy{hb{B1u~{h@g=gEuh}C z?hS4tGcZh4j}e9P#q_mkR;ji0>7?Ux*_~BsKux-Q(IOwz>pMk<_&f>)N@^a$pw$t@ zCjg0&N^LeviJ(bYnMURDlO!I%V#Hqs*94@ag)r%R?ZrzHdp{Y3Fm;88>A*GHt##Em zHojd0Gq}7i+C^1}dHlC>DZkW%36*3iI9fQ>k=-$rjExOH-ru%-u6(O1tBHktz~gCb zB??*TS!wb`bKc%>fR=_72A``^4GE0)cjdL@HJ6lqch-fA=gFj~E7~^|DzyVx%4KCV z5Rjs|cQ=@cs`Q%W`;H?n-|5mU>?|!Ut#Nw|e(MpgHwir7*K|Ma$O1?Dt&S2(=Txj_3hl03w#zLi3kNN>PfYfD zE-_hN74*{Q_yFBmQC(o55C~)_pXrQC7Pt5Cm0}j3o2ng}&^L*I+ z1ysGAx07ywB?9Gb5s^E~qZCAp@I|_D8|DiFrA(TrIq%^B!XL?k<;1!mb||tw)NdNO z0*NQ?A)|xJSQzo;GCgCoHT1sdeuRCX6Ia&Bcn>|KA2koB9@=@I4a^hzc{^Yon3#}a*Jof%%>n*Ohum^7_D+L8Ob$$wdAE}59zwVa2 zV!KhPpzcvGuUJgGU$gMxBk@E%G3-|#?2Nb}YEC#o`W(Zs5>s^{tOxcwMWsc?y|WCL z8~KtTg3i*;mnl@H`UL`-EkS87qTqg7QU5jUpLxSIk#8xE zz|u3eUyzcZ?Wr5NXpnGNISI7rk!P%5SR4iDS9XRRWf%?G-`*X7tT*~3z2s2tFC0>I zv%1qujsO{8!#KrljMN19aEqHh58!9O`tA!wnVgX4TVMH;=LU2wP#LfanY@A=0^WB= zGmQ)CWHebLy1s9l+$KqcC@jMIACh9^SHaG76_F`uXtcsYe&*Re2Y=y6_+3uKc(XR^ z4`1GzH&@r!GZoC=!Bg+fF7Cm?!x_?dy+w~=xp#pH5ore~ibZoYQt;hn%RV1(B-XaJ z*BB_|6v9I;=pgj9HEpLAEXVoO&_Vk$&05ygBy#d47sTKIw0<6)@87@cFu?e1|04Vh zFXA&`pr_}eyCnNdxF2l}n1E@r_4XzJCf|U<$LA@KI+Q81Mx%U0Y)5a&U~6RohWC^7 zxmnTO_*B!(B-GgyL!fVNd{VWeZ>7VEXZL)!@Po@WFg;0iZ_3g7}aCb zNh;3y_B)5W!jQHzuCJy0f{UR2ZV?fMyw4kfsxL-SF0A~+N$ByP?OJouoaG>CmV(Xt za^iSl_eU(Mf}$eJpv=A_2RyXpfDHa-0A1HqFcc*y>-qV4rIqK$Hn)PYDw`v)CFH2b zC z+tl>3Bg@2pH!YMS!#}((WU!T=Q&YUtIb8vUnrnQM0RgB5%ZtSrTCi`IP5L- zqdZ#(4UKtFjhh9)l#Le7Wi;7*J|f}v*?HQVpTC_F@boykySb5ZQ=B?r2#g+}0_8Yl z%am|`!|?IcS%VY0%G>aCrHDJ_jv`~$)zvl382DFV5GRo15JTxvyR-YTm`_;cvXt{)z(I?1fF+*ysr4pk(oaTA9tGqJ1m`%i5ol zc;AAo8fC=DV81pbTw1kl*r)GCt!X=<11hSpThl;Yo=?*7RR3 ztNH>RFe$-ohsPEIW0XjM*~P!dk*?^cjy3$!`+KsBeNpdBJYItBc)BBs2G=87>6Ke7 z#=&ZLdqyMqtXe`o7W-dxm3Wm?=X1tTt$u-BEaTSlgu@>S2`sm~`i@hm2@U^K#i@{Q zNEU{kwvqacDD&)Vqe@4hAFnG{HgwLYrSFSRco31MW*I><_)i3Bx~H90^;YFe7XJAP zNnnp}3-%2f#*;r!dUD7!h)Fp4b0Ba+eWl8j>|c9NOEw+LPdP?a4Q4B5+J(!p(s_QR z6q5BKx~f+xe|v|giL}b=xhlq*|B9g|8l6su4~wr?P8VpM)f=zocjtuTMYB3bS50ge zr`KZXt@In2)gcg?4026iiNXre8SNdNF@D6$Ixcy}`JD_B13q_f&`8s*d6%0U9#|w( z$mI5!i+&}Xg!t)h2`H7az@h<)8~@Tqhh>}t9NH@!r^}TR2C=FV#b2*LCYv1I) z8=M`zaheipud6%L4D9Nvu3Brt!6zs{Qj+2~i1lTqf0`S#QuR375P0INVwmqZ?e zs``bF^4EfNR$GaVLuEV{@qQ7 z4-AM8xp;N2PEQ}Xf9kZu0P5h`{die#C<1W#6nagR-e+D8b^(D2qLh)sX8Sc__*%f= zCwV`joia>zq)nEwTl=s6ZOu%jUx**WO-)Vp@?Y_iB~P}nv)%lYN1Y{8nLNf;Uq2=A zdPXhyTx){h&tpE7<$h8|k|pR=T-4|elvQq9PBnoBiG5}Nn+4cwV>E^b(T|N4Envec zJ-iha0f`qrC$A7?x~9pGl&JcJLS@?5Fv@7qyStb`z?Q?m-i&ZD$GM&ZgV%eehsXPx z%4)eNMYRgkBvaJS-zL>yWT~t>gh^@O%YC*JAMmAU*e2s4ZP&<2Stk*rqq(-c#=;Y+ z-^xGMZ>J{5Ct*dV7Z!lDV^`>%D9!21(@W3YZ4nTi=IB824#$%2Zfho4%4iWVmd(h- z#DN36LG-aRteO{Yt}w=4BjHIMdBWLknKT)8P@OhOM72Us_ zK6<||5lc~JdQoLu@e4z02`k?GG!-GkC_hCg#4kSKPw!Q=I5lhIq$tWH2wtzj)Lzu+3=TS-mRCccZLBor?#%}z~cfzinDAfnM*px`B0 z@F)E-4QN(Gm6BDA#BDyygv0uU=!Fc<^5!X-{gt3^@(7q|GRY*67?6&FN`DUIW&PHw zV?AXh3Rht+;Z?l*sBq6bAWiPK-#P;c4|C3`_ti+6sT2#mUZ_U}+NI2xxMciW% zYYu*Ef3lk@M3S&w)Ork2wRV3@NF}1uUiCrh#0%7jY^)>l7y(57_>I^JLYEMgx2y=| zRL+t7@XHw`-l(Ze8!)uXZ)*x$8!#RkrIM6^`2E9nJ+aT~)ePs3`Y|HNPJ)ORvoSvcKV4SrH*#wl-qGc&5 zLJl4kMdS_FEW8@7AiC1;tKsE$BMXMAZ`}yyu5JPgq%pM}T3$&1bF@bK1Fo~_5ve^L%ofRpcK zf%SQ(|GEIUe{V)LpKHcEgcc7Z8~Ap)ZI8*tfQPV0YBgTw%8}M;yWABmx2R%)*4MBCIqn`Vb5MZj~5#N zJx-?*f|w757(M7;Hw?$xSY&i*GFtUhA!$J6R1nCAVPjh9IOT_cZ~ zSeAZGG7M1@Neol?Gu=}S!%tyyvCpbCimXyFkCv*z&K!(HDw7aWXe?{VfqrzfHG*Ek zQ}P=q=rF}<;wE_<6r)C0EYl5D#UX>e_pc8E?@z|xzLmhh08te1WEWfA-DR~b@Bv&* z9nXp%cZ$GHzCNo6DX>?!IX}Pm+V$Dd(BXP!h?o%zAx$oY<_WN;|I_BI>Bqu@mKIjr zcagcEyL|hiU-@{u`1rWE1lZmHY0=fyMX%mG_Vj9{^%;%?Jb66#EnIDf;?WljtV&o~ z(AWUCfzY(?+0(U3+1&Ze^Y}c?1N!GKmY!aGYg!JM`&-JnvXwRh5C))M z3i0-Jw9D&k=kq?^0XD$u>Kc-97y-nj7)>hT+j&?hAgJKfXv=U7DcN>HR?4Fb<6 zbBRD;hh1m}MuVSIWVw-{fddT@U{4+I(;Lt0rpj#pK@kN9VPjGRftN&ri8G=X-iJOOn9j*{Y| zPQ;CcoG|jYNdNOaJf(^pNZh_-wfQHIKbZNbvd)VmwselR>C3T8te~kYiU;eF#iQr$ z>5ya|wyd} zR=SclO`fZjdvx%&ZhR;mthWBf zH<$Ils(3>Hz}^zU*L1*Gz6p4$0e4!<4~YANL3dAUW2@VxV9$xrf8RkPCC>$dLF_l> zmM+)Fcv6QT9pBGaTa9+Fg0~@%rQJtwyP+7p;k=rjp00w|{t;{dxb3)J9Y5(Pl~(d=EzWx9+Y&o^BHL*hkvU z^Rm1fd)f1tQdATSOrT_rjh2Ij!c@X|5ZOWoiC+Ql zCTAZZVbGN0)ALh{?I!EnS%WAD$|JkQ@BIx(Mxx+~DuGD2oJuVazEm>qd?WjuAt@In zV6WsejJ;1&nFkG)RYF?cB}z6Xsn8!W5E(6j2aU#?9AL~+fXYw9lJ$l8oTQ?k)9F{m#ozv(rLzO|EzzWJ zq;L-Kl;dGI9oVe5pTAzd_g-BMBqui|mdL+eT)x>|hD0MrBI7ar)X~+Z`w$86+P;@w zn}@Ay=>1z;TNO2(?VX+89($vkFXx+rPs_X6hkz-9fr$|+4}&h&Ado*x+JxN_R(R6y zc0Tst@W8I?t~uK0wN3ZOF3-NS$Ii3T5TC7S!VN{O|3p8?bJq zR^WoHF0KA_TIP0DB~wt44>$irGzwsN8FD(o}!6b%-Z}{F9TDnf>wCU%>tPoV;AE+NyiOiN zK6NqJ%5Kp!;1*liE@2i%UDXs+bTY*6!gD@*dc{j6y z8Eo$5b3o-9Xmwxm#3BO}ZzPtNCR>Zs0Yw ze&2c8Ztc|e*;sbJUJ4bR!I5@^;Xsy5ivj+umjbF6F*&)=+ekuaTlUeW5oYzMB7Sum zzWHQg5IStw09xyJRkkk?-)+`afNLwu=%{^sEpeIj!e?C5^JW8VSh7Hj!9C8XucyQZ z`mnXFbi(6MA5_%yt&y_?#SKYVN0i8)BDk>j=2c-GUyQX-Gy-BxtLCF}H4Li|o4$ge zWFIN+W)Pe${y52Qt`{o-ydn~kSCH!#%v0r$My8l{8q0@e>uEcFa-<5MQSQXi@_Aak zfY_HI!trK(oXLz5mTUT;KMFWA$cXue_U+6kh^tp$JH&AOWV4h@5lB%ntWQrxXW#HK z{1KJwa}wH_LzGw8#x}pb;4kK(euN`!(^Lr_39|fB=@QP{)UEFoPv3f+=}An?#o{=|E;IB~y|*DcEy6L*mEWzJD)LKHlF~Be$}% z?DoCX-}j-4GZQKw+m%Yu5$$){;tvc|)7H~6)m_QQ+Hd_0+Ik2txbM9D0h7s1>bbks z*SE7DA20Mw`aWgwzF_bml1#>D*XKwQXbA8Tjz%BjV7_r>eq3vRy*+l$KAjwweZMOE z&!YJES@2A5zpZb+k|_BPV~qgd^J=s6{Y1<9 zg{Kr5Ed~^u@Fc$Z8fuqol(QmhJcPXss!owX@T$nq`Qd5?&%isrVQV~HWhjQ248*+6 z_w#UWWy<&84e7(Q51JbRc^n2M{L{1ja>wO9o&oJ66nF-}B_}w&2$4`tG^=OtPc^}( zD_kk~gR9wtK7psm_|IkSJ%o=s=G2_CRo!=RDDeazAa{(o7u29u?~h>|tRcEgE{2k( zGM|2=*fO8;M26WzqqHaylkM49IEwz^H##ubb%dRMg6H}`==YYGLrhBa{?DyjfL-8R zSRgx>Rkw%$(H8^P;h~!gjtWU3`;7Q zKVEj@`xr^j(^w9SVHw~;t-9?^?QU#se3;sNA38oh9xI@*M?=?mEkAW)j)(QX_t^qM zbZ)XYHuRys8O{|y3;nB`shhbJc<7QYa`M=l4c;~qHT>yO-2$u-Q$BCVUiarWpSHF- z4>#@wo^PEK6B9+YrkE$ECZ1X?0M4mEq1fQ`M2 z{(2+yoO}Y)(`NlVDZt!$1tgvxJv{UqyiN&y756t3y98qNpP39gRm+lSN{VAh1+Hge zeWO6msKF#1|4_1s=~89X40Hk2m$$c#*>|h^rb{}mCe8^!C;UAAfnAL<52ZeepL)@@=(7h(&l)^v7vZAgc<@zDZbIo%rk5D9+F`$0a2U%q)gwD5tVX_&j#NV$wy*kD{0aq}^9$)Tsn~J}2vL zJ^Y9eaO>{?5n3Gj{;|*cZ)xaTmNbOjz$R}Tav0wYU*Rw z3lW!&XebI&f^M_bTbLp%8; zszuycx!_N*w+z@swxhLCy*y7eNc$A=(5i~8{`0f(7Mk>Ef4Zxzo37tmA-CMA1kNRP zu!0TPG`+t#Vs%E(?~M`_Z8`;2f@^lRZwa}gp;)ydKQ9FR(@ehZwX!9JD|w*{bAk0D zD^3#sdas6zsqu|}bFJ-5yjZEcr+I;R+TVPeY~Z zYD`vc5_BI_9=XNwN6dBT!?8TuFp)^gjs%1pEeP-r8dFkYf+(Qj1Lx172<=L5DZ~dz zA!{0ueWK1wXT`qwR(0|lAvRYkthQ_#o|gI_zrw;Gm2J{Lc_6(d9HCmiO>cP)L$2NebZx*!0vbeaIGGRhZLxY9b zQdQ*oJ0zN`Vn$eO#1PP3 z_W|G7dcP?|_qY9P$0o1CR_~mUZ@F%@Zrf)zCl6RKhKHEg-}e_G-^1&xfxJ1ffY>#u zR>YK1O1-I193M8EF9i&q2RGki-|rOwrB`!({SJ$Ke>`yhzXjYjUrFD8zb{NLL&Ue* z<(riM(yM`C;h=AmZA%qZNFeI;3s!9m1zTUpU-A4NQ#T*}^my$21?WK3)J`l6Q?~T1 zKP^5_b3N{xUj_Sm9f>6*De+Wj+SJWO0vG4$QeU4a<6pU%(Jwn+iXN(KsN z1(>;leu-(+KsRG?kFxN3Ra`v$fFDEZi#XKcpv#3cA#Qa@ARnGoxfO9nrJyRPF&LdC zKkEy5tdZ^iW=Pi(NQYr^0&CeN70zF;K|^GE1eI>UfK;{puJjLY(!iQG7cCBZWY7Nn zUr*OT#;Nbs-fk#5X+}nNxw>~qoOFq2yVrl*;4|R+T}){f?-Lp1apT!dz_?yngE8>l znx8%%`uLU_A3q}uE0D>({=S{_yhr2LUDQA#2{Ip1#rpO9D)jIJ$R^m%3+?v19i9P@ zcp>oTDQhb_z_fYqcQV&AJ+lz}lfM^kHU!nD=l$8Q*8UPTR`6xG4f!xNH6=KAG3CIm z=Xss9?fp&9>mOL;C$AbAV1|F=@43Hxe_P)EPp&L9$EC(8NzC{VrB|iBGzPsG!B_#&0BNdTjk(9LC?Ti!W zkGfpRla0;IxJ(A}RLT?46J;h5STBS)jC2hQ9BUg*}4tCDVE%VDaudf~R=)ZPy2F3h_ zNZhz^GjrTu>Pm7v%-r2uRrM1ezx=FQOi8)#zV9M^eW3Y%wzkbvecDbUAn<;S{(kEB z{QI5l^JfOh*3WvhI2+I1_v;=@0sv+8G@A4NDIQZU7tD;pjJ@=F6yI~%y~ZC~c_3aHX0 zcZq*HoFwVFmFao;8|w!RA|3?iHdHh;6x>X3Z{Ai2!oH2}i1KBLhLDh)M+Zwx-DM!l z1?w_p$}>@Y9?V)?R3~>LaDt7A5OIzv6DLm{@j_%ymLvzq0EsEB_fK7+Xdh{l7oVQE z{@vaJnxqH__+{&Qc)xCDW}&Hxdn(88Y$nvrcC-2YKqu;gfr^xFv--BYf7d{gic@;@ zO92fMdG(0SUs91?*6(Db@r&q6;WlMKA*}TYUi5M*yc!hJ$dRA+m1IQz#hD^S%Nyf_ zDtg5Ysn$i9Cd_?WCs(Cwa#N|JI`>}q4qZBniV?${l9JN>x)rN0;Qi|kE9d2kk-O8{ zT1=1vA^eU*+7@;<9Hpj(a38hYs4>ULpX=BB{&BHDBL(B`UgwB z5^R_x1EP^Zfe0WiE7E7EryxlRaPeio?P9Fx;IX4|R)6MMimAOUPNMSZH+9?$+3& z%p4R}EF%ta!^I4f&*o$G-VQYQOK=DUM~U7fWpE2GES|N#p4nn!X=nEVcA>ndmBr2U zY_trU25@%-V*vFM$kBbxgeN459C48roa8v|_I{iNn645U8t3%!EugLF_%1Em>V7yS`moe}%1tGKS7o`C1=FcAWJu`)rbvq@Og$Vm|dT^t|z<>Y`- zhYKC8y_%bk&l6C)&y~neP66o$ZKnV_59H}CKRj%6Z)oakkDE2{yPdB#d2x$fAoShS z1MHvQ&CANbV=x5lv~mU{R&b%(Nfpidw`$W(OpgB)ay#f-aaKu849qPmavfXz#rWd~ zV^viYJ7uZ*7>#^(C!5m%vqr?gAfrypcNZ7wt10I3kGw7xcHch+9Ua+hvb8oX&y&Xs zfL{g%27(8I|IXC4t?+_qxp0BlUO+b`Oa0pgQ3DfpUwNbkeoE+m?ecZV&(FhzGG6lS zuix|Kz28{@YOrYQabatEzCqX^*aBW#!^)q_D9WEg(PmUv4(17mxUq}mTas1O+yMXX z)ZK&mLQVJgvn>O`mx<)2A`+tJjk=qZ>=b!XGRtho1BVd*;kGf7L0&iFy(ls#*vWYI z(6K_87-AkQ4D3K?%E>rP_-md;@s;>&;WF*l_09Jh1V7JORVJSc4Z)kb=Syo#y8MIp zrWf0+N1`fF*u2k>I8aiXYa3hJ>)RV!Tie?s{8wz6r!|L7*n&gg#Z96RF(^`Nyw5BA zZklS|AomN($Z)uvq5#zX!RjZ zW+w7L{$acLH8nN_dazcPX@Z$aUS4)mRu13*5XlIUg8`LdaY~A}*v9r6qThSK`H6K4utLxF>Gz?!c!OfYO?1_B^RMgjVVDG52`Ba=L$;pY^^Y_jV z1W%F5%*@Ed$;o|pfA99))y$c8 z7FSb74y2Q3$8XR2`T`BA_!EOB9cgpQ>v=!rVWW;d_eELiag(7 z&9Jx?Q?Ha?R%$SF=O>-G#Z2NS6&Y@zUx`}meG*84vk%Oo{pcm8WMZq8J7&TIYE2O$i`pB4;Fj>f`LTh>yd@vTWQls5$x9t_GpY|BOff%jTF%Ye7E z&e8aC(5a0Q)a9A5ptzBz%U-NDb@cf8JEIYLn7EqVXh5#9!o|r0!q)ZH2fqDnx32l?v+4L#G>e&c{n;|_;+CQ!%flGG zWG6)MXXf}3)5PPX&Ug$!wZG2QYCG3!26EuJ$IAZY?_F(l*-Zok&;mnBA|g(!KX1O( zEiY>xot%}cm&5M0Ri9ENWeO8fhownU*66l5Tul=JYLx0z*XE{m_uc3+7rr$`uY0O{ ziSi#N;1rdS4>si=;|W9?zW=xY?3pGJ$fblJ%oz)P{ma55^9KuxoW3iVFeT*U>ka);QcW}F`qRw;~mE5FZ#fD3U1m~d} z48{4}J#nLNl2~B_ zP1shlr8&7d)#zas%F#hFyVQw@lj(hFwABP6v}C~F4m=RXb#*{0t?s8WEYs>;>IB4M z=QXOo4wDVXx5WVg)stvFC(aj?3E8_ml(}T7*7mlD;#&?wjwIwq7bh2IM;M|k;zN!Y z@t9;ggTUHwAy2*&#am5ZW9~>&($rMdRu&va9Llb$#3F(LHUQHYqI4B5{rS^iIp==` zGbKZ*BK4T>9~Ka#_RYaT5lj|v>@*oqW()`?&Phlld)06lsk_9zymZXXLz4(+g+b7i z(%)5Cm{@H=NoL}#9;4&Oxc7|^@?Pbk}18`tKv%y ztnAD&v&aGJAjM|oG@d-~^6L)JwA(VKmPf4wkvJAun-44sGCb*3OIA?(qQFTPho(>; z#(^U^icM5v7g_2m2zz964!%n?*80MEh?oog#fp)pr_`^5^w2Dc6PDI7Bs zK@s$#7K!-h;u}2HDdrMff=YREWPg~84ss_ee|t6#9@R7ap3aQ)Q>ixuKEd4x$nfu5OX^Y4SG5%G$+|2G$T3+@IEd-^UkS=LjPT4!sH&miXN#{#ifS zm!jhXVtr*O?)ICgfaK^E(L`^QWmps|Ii)OhBAb_f=2JZGfH_>DEXmMcq-bT-L=paw zxs#JjD?hy@N8P0(73!Q{+iN3*6G`JD0iCYkLRu9bF>suG$=tC^lFeyi~Ocnj(XZP#*aO1DB-7ljGfT@TttNXn9 z50GBix*JD={UxMD6Inc%97{-;-owN+dHzaaIzc4{Mw}8+?*ma|jw#(_1bMkP$s5W6 z?6K`|4Te$?J52brs*uWXo=gZxsvb-HdQ|g{)EHV4KFey7tgU)hAK%!26w02V>uS&u zMzIQ>Ku+w3%tm6~gE8YfI-=ayv2V0{d#D#jEC{V9pU%>p6Zk{_Q-)nW2lmPovbFs@ zrRr;_$A{4%mo_~mBX<^Xz&%W&M^w1QQ6FB1NfKG4Wy&?>cTbr4f6cXQ*{dp-el

ep{pMdNh+*#7wuI^ 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 z�WZA5<67u#|jyX3lU_P7f1}JM362$a@2H7;p&;LvYfhp5!5E;XKum;xWDDI1i$V z1GS#!r}65_1KhIQ3v8aedp*o~D*cQ<#Sc1jBE!WuJ;$~0(NT@+9dxlHU|}VJXBapY zWf~1O|I8@VOu7Xe z{`NLjL?ge$MZ>G^w8;{4#oB6rz&z3Uk(7%X@|!ws+^RlUoB|noQRi1pMHQMR@Gh{o z*9Ux;!RYrwPmfrI{e=$!*Y8i8h5&Td>3`~`@3+3PvNAI>b7$|Q)BNrw!C5s*WuD5Y zTNm)64-f|ggoNW3b8=#M5)ClM$*mjL=bVVv;rD*w;@6`AzZ2 z6yeAqfg?}BzdbOeO4+{W$0F;mebFhrORgQKln(&;>bR<4<>KP<5ezf0u1?zZa_Pa{ z&n*!Xh87GI^Atk&6>E6TeqaZ@%d*=qx*zRy>Tch5Nmv!8A`vH#vtbnX^*s8)g0=U| z__SnBX_2ABBU9uA)wAf*2aVCdsy!-zh~+W;0SREX;vo#dTDH_i5q4EkYrHs zTt-RsV8ZS$8RDO_v$?VcH7FM|McehD;D=j=OF#2hLQ>kPQ#QA1cc=0K4aJ`jmkmR? zP}O$7&71r&vwX+YJ?(NEw2X;U5LKJHL%NtIwTgLxs}(a*g}Kq8QkG3pMKuQ8^+tjZ zHc})wr3$uzVK0`b;%RtZWVGpy8z};@`gO>EUJF8(G``>U+?5K$jIhJXU@q2Wk-?CO zNBfhw5*8NO)x+Z8-!SA3UF2;PxM%zyls_uHeOO@eu;{|E_V=Bg*6*uCo~IAp{=IC! z^bQN(*S0p7qrQaNx`dTo zaBW*eRIMF^45Q(&CrPeBp8}i_n3o4YgjB^*jNfZcq{VZU=sqd7>Seol?TPb_z5lFV z)TJjPvvJvcir}=`W6APn49^Fpm3LGX<%;}L?uCTz+2*2V zb4Zci=vd&OZkm;6N~*ZbGJvWm3PyGILois(>4xTE>e85jGBI|qnN%H9v(OHESBu2%0iDl zZTiboXD7UF=O+v`4(UI$vY3@h=S_4=c0Y)V*5&tCTX6 ziv^Dmf4$TGVJVFVSDu5A$APiRxPnYM;wC&)_WK6%)0*F9Ybh_s{Nj97YY^Bmi7-5b zCH=pFMR;DX*A;n|PD%dD(?5D$9X#2iKhdH%IEraW)YpayAV`#~yaArcNsxO{T9!R(SB`e5q)q%-F>b5iN7z$AT>x zP0;4D!`*zMZF2jrf(k1~@;OpLGXyag37I_`ZH4>z@B2qlr?)@xuqR94)Wqf1CxhhZ zEg9@YG6I1?W>rItd^^Tg0VOkmQNQk2qphpK?t`0`K{}RE`Du4$GkLArDl7S9T2(Wg z%$1%`n8alKvjTup?&kexdZ!=M+7r%7VvwJ=wY9a)tyOT>ZuT$if!a}!7ovlbVZ zVL%uIKr3aZ-!cG_KHruCmNt_!$|FS6ZYV*nDvEb8Bg7ndw=XTFvH2 zlQv`B7Y_|><&fl>zBfC+%lZs{-ye>kR5YSR87`a4^9*4CTGlEfaMRdQsK)zme22?RQrdm4abO(+vIr=<3iATQo2AfO&_?(9A zxY@RN`_x0Zd<*{-qB-^{kw0+sNBe1xp_ii#i=hwNS+XC}rORP~wQTQyJ@I$99@Lm# zN{pWsmG`q~h%kQ8=;`S(&NI@}$AtBd{B(>yIy$OYuD)F9Dl963Rh`}rrv(Swkk`6A zp6mv{zn9(%XQ!v17G%%qQ9xV#sr=h$za03yX5Z@L1H>>H$hy`>IBTwY>2juw9A&Xh zc}{!YvAZBVFPDw}YFRTJ8mu^@BPp1G;d;_|N~rsFVCfQ|JvR6&3sLtLN%j}@?tvoD^T3k-xIHWN?H?2PqU#UPQ#vq>Yu&q>0NuTMg=hPp14 zI?qmKbYVe!(uTQs4*z^zi}#Zq@vD7l*PHsniL^$qSyY{P3Bmwc7@a(~?VP&sW37Y% z3pUQUJO){U3uc`A{X{xoQUB_1>gDtq11$p9z)qSxvQ_dB^Aj`xH-W<2p~zf9pJ0UN zIbo63n=GR!En-}iMB(7zaz8e3rKF|Z&Do|axzYLF!EbK+|6!+N(>4=YoupE zmq859vy-J)4i8fxq!j%YX&)-DoN`O0wozJ~Q+14NX~k4RDlYdqsD%S6n0cgXc=>_d z($RJ5Rrq5Kjj;Q@(b>YG<|Erx{FGHaXBU|{IiI;3xwjeV znWrWv*%Uuf@j@fA2R)&VAKy$)OpyqAJM9EZMrg2FDWAHc>J6WVl6lq_{ZX4i6LI}N5-j^!<&n(PC|UWR9hN{-N^88 zSoX`vf%Eei)1Ub!v^x*uaqH{>N?m2s$I)T^0Rf#n8JW&^_nOIRFa_Bz@bD2+*?g3+ zPp^Sftz@qM)b?2dpS);^-a|jGAsdiwCl^>SSEcR)%g41cf{3t+hDM9`mAZJm?e7?y zq2{i;wBq7HYID>mm1d&P-0aL%P{17xScdmeqL%KPGMRYJ?CoboG0hy@{_G8+aMaHS z2W@lW9n(fe4)k@HN4K~T4YgY8rCPEMf$@;!1}?$}Ivm$y+$D!Vd?BN}U}SPkBtO$$ zuaP4nrt#}^_~We1tV|+&yAjPcy0TJ3^ca zKc%oz6<7N#UwWY&pPrC8eFcp+j9-LI#T#SDLR#FgQi8S}76c9lmrQHxYU--$2HTY- zpaR1X_$W*zjX0-+RMgbe3=GcQh$XDtutANUcNO+_V!Sn7*MDXyrOWN@179kPndlj* zpm%wbu+)LV=uNVxzNsNCL8Yk3LGhbR+OCWZDL7U3P=<& z^6(IHTG1WVMJdVl4Q)*zK2kn$U=*|$wV zK%g6tXFfthRZm72RUrLrh$|zd{^!6gn?QQ1W7_ zyfmLe@3w^J5731-`yTM3l+AxP)BnT&=;=6029b?-<^$p5i4Z?s7xEAHqtkbho&pe$ z3i%(*AdsFLH#zu-i3Qt^f>7LzMG>Kb)RWXj0`3-vP;jr-8n~E*%nBwhq%lKIeL;~5 zD`sD9PNe1=0Zr1s+JAG2n2n66;i6!QE|UlJTV;h9OACwS&uN%rl8{@E&opRRHZcD& zRe{o5Dh-M8Gvaw8&a3mfJcEsut9K~QdJhd8%YLC81dCi5GWY+PLKWSzrIp070HSz^3~i#zIiP*xUReHbjv|s)2KCdYY!N^16Ljix zUUMSQ+8TdJY3YrdyStkkB9a&*BUSaL4gJV?tfeJk#20x+YKPfn5ts87)v4HjqoecI z!d1G4Nl9tW8G?AX>G0m?VLMX(!n+SIN5@C}?fiKAPjFAOHX7{IJa|Z%Y>WFV7#8bO z2f%SFsYjKVk}mtnbzY-j+uaRb4YKfDM7@-xGE7wrjo>mJNyDctFeh81h3R6q3+qBI z#6_m=+s7FbG(FwQ$H}>}^0mp#2OpZOjzKXE$iOD~LG$_!k}yFM@YyJ?cW2?O=NmlT+ZbS^di$%~5`eQhlgM*`qQam4szA(=U59z)BevNDd z*9Wy+NaIGduCxN$L3s=oUCTHKBFo6m#8T=zzHxT~^IcOlQ__+&AMd$GSUv^_i^f48 ztRlmCLbuJLroE8M<|TG-bh?!3ZG}`XX^mH-+_q&gJ9{KJRH5Ns)u{XpXDrOufkq5d zsG8-aL;`cOsRs-O%YfObU*;0QVXz~?GM^7|Xj{C(!Q`Vx$rE>O8gX1>KNYXP(zaet z9%_6k>rll8i7wh~O5}n@ConH`!UC z87A@D8x1M&r@|v7bZ+&j?{4N1VBi+uIECLmKHjHM&VGI@M1OgH{{H>@WjEle6YxeJ z78?TXPPB4db#~WOH8%eK{pMAF^5zXg z-VHTjt!DspLAN(sjO>9HJ6Kc@)&`a>pm=c74)iT*!)_KQ>!(UCLg4@fn-cKh^JNng zI}#Hgrlcge;DI9|oB@3in*fKOnO&a=B4SK!YSaD0_O|!l?Z~bP>+ML+Fp2+8a9;tmEcx75 zQOP9taZddGs;VkS$EuVR91yF3fcx!A?K)+D>NHF2uYdo_?6qs|9_}wU8qc;iA6VhT z6cAS$d~ODZP@KE|J%``mUUnTDt*)-J^GZYxV@=fISyREXLc>dndWnm1O_(4?n7YiL zTwRF(1>lB;XqYS907&^~sfvW`S~u0!1r{@|9{eL$!0$5c>fP;EcXv^x!!+fO#pPv$ z5U3$CQyTuZ_Q2ahNT=(CuaTIkq2t@*qa-I~KdS;LhDH)%{83T!dazeK)X>l{OuF43 z@N$2C&N#$-OeAy0IIY#@s}3`($BqH=DMw&)seqd1=*NCO=G0`g7`3Oism6G!%@(9j(o&HDPoN$bATJAxL>ix^*;Yv`1$ybtQZ zC&x4&{PKm5uXbPh$DP+UJsllVI3-Nz+wpXces^Gigkb|x9S(c1QY`li1 zDLd6SjyXWj0ApEKsUgHJAuDGDe|~A$?(64UZPdCpx2C15d5R9Jy6EhRn+cAD!pnk5 zi_az6Okt7Lq^`fCnk?t-+JZ_&?gy#LyN{=<=PoTS3SFO2!Y?;gT_Uyw5veg(8(GXL zCFa=9jVxW2bHi-PtC5PyCK=6szkP&j?L7EmB3eyZYHaW>55~bhn4VBD?(}MGuBj>d z#mYw7y|ngf^OzI=;-_jB_6Cb7G*At?J42Np-v@M@OhM+uiO{s4MK zE-pu74^RB@NgM$jbi+m z2+o^gYiEW!Nts0=u|o-Ox7!LwkmQLA-RjGTu2ijYuD1bQHMTqo59Xg}wWm$1m6?g= zjoL)rRZuIIX9$51!D>h3&}wq-5)ah*eT&tM@ln`Y_McT_jH(y-`pwF|XoE+JHD})C z=ALz_dPLP37@t4kgap?e!L+DyF31i`ibtNGpW8?c#S-%P`M!*eGqjkZqJ9FxD0$iV zx*VRao0?J^WWwCvmolBR`23Cl7oP9?({Xs-qxkDuBY>m(8AFavfw-$(xS`|)z~c9Q z68hHtI+&Si%Wc%+a=15WEpn4yn&WdB`gWBq^uGXtT%Qx5J10xv-^It%g*2((>v<_n zjbUe&4DT6_)7Q(^?UgP^0oyM-yiNPO2R(ov7f8qSKG_-GH!lSEyN8YXmuASBcMvS5 zenY0DIQ6VeiTtLOcfyRLH8Kd3SQ&$W*(s?AoLtuzeO^LKkg`iPh-+P9DK$qCx6f(R z@^@jY6bk6}<@Gf4wJI+v()sl%aRP&rTlguI)c<&6wSHbbsaM5jzNDSR@4Obs;sO13 zWE13kElhiVyDdwquXi6$V}U%UTXQ>iF<{`vN&E}jT={xBNcz!ISV#f)a6PsXI|9=EQ znJK&E;@)32?+hn`eS_7zo;7pq%3rnhbP3pG>}VFfqp7&4&+7dc&B2Hf2j+8nMtUUG zUomq=XiDP0OR3$a=v}rBY0>9W6g>}Jj%~<_lLa|&Dul@J!b*NV4uNucsaDK{yw9g) zW>ll2Z{}oX`gw16B@K&2N%rj*nga_=E349v`h^!B+Rfh; zdEWovU}33gD=GQ+8r~rD^{#xe7)aP&*jk$X`mbQ0*YC2cw4sO=`x9n7+1{F_No23f z*O#~pQj)LTPe+q`At4PiMTLcLzVmmLm6eBwIJr6en=_oe;KvDXG#)Y9w^_k56MEaBc@>{LoaJi%P{9?P{W> z1*1##K|w^pDp~_-PN*SM6e%sP_SG5k#g3Y+=jz2PqBHuX%E0H1p>))GzK zj)rK0hzeZXtImh6OdcntSY?WGSQOj>t*5<6Dw$tcJ;;*tzf>^k5;!o}8@_nhv+-$O z5K%!nla1He*`KARX%|V)siuein$iWGR8J5rJYb6?7>CkiF5!6ns%w(@C}8?xX=JxO zw;1v3&7n1jKC(35Y&mDiqyYN#ws_=sM*%w}WqtFpmbSK^t*okG?aZ2+IwD4{am(t4 zfwnp!JuMzdmk=-O&-C<^q%?E92uBo2t92BRMFuTwS ztd5VQq#}S7KF8cVO-TunfU0I=A15Q3sD(Hn`tEsLvGMhlQgwv_f%%%9AGS$|h)8o= zgaidyIl;*AjPurbKn74kSFaHTEj=Ufq(XNRkB)>g#v}>;KbpQWDyr^%8;}x^Qlvvb zy1QFKK$LD&-~mLsYe2d?q(K@4q`RfNrMo+a0cLpj?_KMEKC}2R=j^@jeI*x4&b@H} z3S&yfaG+?RQ7(=!8|hj=d)}By0!JzXHKpj>m|(7gO75z)PB1e^#Ax!sr)B z^_lo6^8I}f#B?^~`l25Uq)YY3VD9ue8WnuKd9ikXXcr{&WvpppHA-x=WZtA`$g09( zzmznFX+j^jU^yg_ro1nmue|wdK#5c5fFY7`*JBROcRn0r&5yCEsSC?fQ-TxOtUQhRx zh=&8-A^=ceV_~J+3AEki`POV*m=CjV*7FK;!xFMvU#a3*m|M6x*S0}VTS!UuV6Y(U zs|&>HYGL8gw8nJ(iD4V0uB}a+Y*e@1TMwRACzzgY;tStHgQ2y!oq_WPUgUe<4|dX5 zL`1W%h@jWYo;y3l3L$pB2dpNPl$7v2UqpX&^jIl4eoTEGwgtz4 zj_+nGGDS;Sbn*)n($Z{O5q(F*oJRYH`=v%kSAaO0ee%4*V`bGAySe5~B1)3mT&!aS z0mGZPbUk z$!lfAL_3MFP`BVzLRMt?rdfi@+)}S;S*`!xU6YfNSKEDBV7pP7EoYQe%`5Xu&u|J08NloXm-dzZ(oQQN9cW1=|H1ZyzUTeO z_4VCf-K-8j%>Q=(L!6!=6g>6GEHG(gx;`5D^ni0suIEkSH*yk8DcU|=*rmBdM`c4% zQCnNySC5^)$Nz4u=f541iHM5y_bZTYg#`jp=k4y1-CxJAPd6R#Pi(wiKpfi7iEw{H z_~_~By|A965O*xDX*&V;gGX6BvB*$r?{frsWyhPqXGjg6p0{lefGJoP?0_-ymst-g zc|R;YUEMJ%q$DNTCixM^;mc$ayeq&a@-MjJ5sMq&K0PX|Vacx`;y6%oVR+U)MS_G% z7)TLj_C9OV6Zx-U14?`_ZTHuTc?54sr(%5ZGWO0f3D-C&Io|Y<*dNWF&ZP9!;)3Kq zNP~hH^H!5?D2g?@HBlc6QHo_VhT3lgNZj8291@+{G&Z57WAz+z6v5FdVU;?Owz^Rg zaYw6=!mw%CAKTt%i1gnx6EdBQ+y}Cjq2Uy@4=N+0qw)9|;2-DhT&RupwYAmNJrR!!w{vqg1`X_gw`@+z4<>PF8y9Bycz7~W z(nUphL_qY=r%$A~=+b`n8PL@ybKv6tyDevIJ62yVI#<4CnFi|9HSxAI>_~=9i$I0eHvDRabw*w$54~hO@G4X_zXD8_y6J0yc}= z4c^QQs2FfO4nk`( ztjRI@12zU`>gz!*ny*t>dmI$Le%`g4OdcQ`Fc~>wu+ngU@5!q3 zJG8g@$H1i?&SW(klA$7fAby4uX$Ro!lX%a`i= zt4(WoYvlgEcqR;()9dYgN;OpY4^H15K^;vkko$=^wx|>k5XcgOv{qNE*JIPu(mI?T zgTm&qSFb=8=}6727ORNJV*6FwSu1Rp+-;?Z! zIuWH>YI`v57#DHa{Hx5_?tVEF2E++_oeCI0#ppHO20dwi$cnt)2RjV}HpVF-aW@&= z;s#=^hf@x}9Ra9;g_W+Y>f-V5_V)1zA5Y?X;&sc)e^(ruOkZTmt@sc3;u2+0D5%(` z3QV1w`edJ|WTk8?Q_4R!a~-M`cGm&fG|P80Lep8sfRC^W+j5i>v;b$P8HXk_MZF=E zlO?~1OoG_=-qQ}q;*faA=Se&UxYRshZepu~js-2gAI%0P2ENdi;{HFGMWkeN2isWu z=*&MapEG{h+u0GnJEy}FyZcP@Dw6Xr^mw6$i@L+-!4+D5dRt!)uzc7;Q&m-5X=%9b zhYBmQ4MibFMn*|VHO$GSB~$wzo~XzXm29xZ4eej)`Z4*j{O>6P`Whhp>}TK+6%ipZ zJrev*K}z|qkMIH&$*|ELV(V%<-8Z__V51xn2;MJXHxZ8pzV`>I8ujcVi;Lk{;es}; zWzEeZaiQa5D%7H`nwS_!m49Im;bcwD}JA$-98$!>pPt@mCzS2i?f(I5gqc`iqS6pwd#V-=^tC8?`f zt65p6Y0b_4{wTDB(KgunpXxRvvY&q0TYh=7D(+sESCfE*{_yZJ`UI0Ox;9T5Uzpw- zvX*SKq+MC@AClZA9B-BS|3Ad2(MT0Y&Y#hpD2l3EY1g4{uC)M4V`detx%j%ZynH-n z)FR=2=3{RC{EZEL0IB2H^QGMkf?V^TgUXP&ig_ZlaZZbaAX9v zaX89{^)Y(D;dptub>#T>Zt^ZbT{#W39d<_!7Mhws7$wNR9USBy`?r1fca&T(I2hgJ zZh7zM+fg?{%1Y@XDqV1O35vL})~TuHNl&viGy5Mion`*^Z3cNd2v*w;dx=To2d+>? z%`+q)lq2MM`xqGmAij_Ya*o=rmkKvnrVF2y1O0@S;6S5ZK z?eAkyL}6sK^75qyug3Js%1W7b#U<=nTiXgyro@~A?eJRzqlRYf-^G`y?PWR~ zAPG_OdYw+f1W9-+5_)q(hR(^({ye0In3ErIj`zo;^M?z{J6U ziy;f1CxqS8;`DqAp!eP!7h2CV^f4-i0>*(u2}haC+nA{Onny7WP1zL#N$lrBW}iu` zwbBH8Q%v~L>nPdv|H9*<8wRDKRQl%~$VXd>J>vPkAGgyXmq@VX^vVofokzrd%$G0E>z{U9p#GFQmA{Gi&6X*_p7MS#;7iK&YD!27>sNL|&Dwb!-3bp=c< zf6CL~pX<#%w%{{d{GS$}+rRY(FGHXr{p(P-6yO8&H$im5RK=DlJED6CnHaaY{Dt;eN?P zS+b-0{7C)&T7NaiElO%mg;822uxDducvvl4!e#ScRsXFRWaqLs2-b8s%U@Gd!yzt5 z3nHCwoWTV0&GY_t;>19_0dx_nPE&wQf{IkaV>97+By!Jpi}&r@0t&!WEl>Pm5EaEn z7V1}Ia$kc(ZI){nEc9iB7}dOaBn81kj)hLTS3JZev>mF;rVVPlYMRNA6%^!ocz?lt z+K{dfii(OX3b(K?Dk>@y7=Yk z3OCmQTGMi_OSHW1zjy`D!M;)AB(844H+k+nK@hR0Ijm3gYa-7(OA0=o{S~rcu<#$K z&rEG&jmoZuQ!P9ck-l$e@bI1m~GWckEIj(OUJJB8ot z*Snx?F6k335x42`>uy?T)qZfP+2E)Y&7UvRg>H}wS2epe8QRr_g}C&T4%ZdHv5O#K zUbu@1=92>+KcXZpiH;`#vwVSE93QKAn48-gXPN5-oBwsZnwpxX z4DtKo`y@sCMX;e(MMYIlA|bS|uWx#a7yOv{Hv5>>HcJH2$c6FQIiDDHG`!xHGxB%d zG=CYq&F68sB&+nEs3+s<6Wa1)3at6E)d%E7^>uaNU%9R|zYP_zTlflNlTxJrPKy=P zJzw!66U;|@+R(?x$Mb=ovb1r3& zR2&MQSIg^#G;ei97MId&mvx((Pam6{QuP-2XJ@H>&VQh;%Wf#@>*u$$Seu#2Ei7j! zwWnvL>isl1hk5__jE{slP#bBH<;B6ss{G&eok~r9qtNG6rkG^>LApCRGtua^GfrlX z=Rw_o)bx1_E5fCGY|P2|yl}Y#a_7-dKY#z~Sz1|1jLgbXrZW9Cctfzf+I;v~*E&#K z1qA#roi(oZIwQldkAy~!mj~WOgB**GT`w*nAtBmueyPj}dz9$m!$7}h1UaDpS-iYx zT*mX!CholXkf6k0^81iLSY0W>o08DausZ3HuygC05ZRvw8I?ziMM%-JLhWsK!s|!q#M)t{5@IaKP?_5ON$_6lquT4PNT2T zHuJ?|S9Gh3q%3OA&Yg;gdDqFzK=m`)m09V4_9-?EQsS&22Ep&^SwS~HE+vk1y*bIjo_K9pRF3iB%F^W^R#ZAytYRH%cn(n@6W^6F^)hX-_!5P!=wDx^Ab;~<5F|azr-JG&JIEw?Lfkil5) zF{DYZ7jmg@7?Aw9%?!22l6*_+&km!6Wcvb518B?!grCha5`+fI)nY}2vkjx5Zi|WY z@{-_2fkMfsD34TMM@OD4#YnYClNQ(BGblmW$Veo=^&F6;kYqF|H)tZf1T9Mx;7`gD z?(HgMQl0@yMW0d&^*-wA>eX#d^+w1xgp5J%tvo&N!9r+hZrOHx!!_1PFqTUf;%Vn1 z3XnE_Kr8DQsuQZw|5s=MfVua8A_!Mzo2HRQ3wj58_fBeNcCw2g0NMAQc~BQA&%nR{ zCc3O#ekltbA(F<00awE5}u70n?XdZ+TRx&x#?Jy<1;F7G86la*Swy?TT*$V#U6iC=L^0Q8DL>3yUNli{-H=YOX!b}M&m}k^~UcldF37*A6{O1;YOU1 zdglR}gAPuo>SOTsdyZK$q`$&@kxl_l2?1FaUIs`1{#iOWI0(IBK<(?~QpsLZlT+zj z@9Z3h+)kg4jg<}&firS%3;UmYZbzlskjjQoR=X}LW~U!_a3pCYua?M=%PrqL?Z0TB zc_RZ{aN(S_)>&}LAj$sMXr*CnT;Wa7UuC`{!~A+#RS@ST()XpfwCoZm+|W)TL3&$O zSv8rDUp_h57}?OQ)6r~v)Tb}6ua|cfrTG;|N0CdG7x3ZCVg3yFeqL*N{p6@B5Q3nq zfL4p|8Dhr$*&^E6L3{TuGm|m|7c#RppZ;>#s<_O~#!A-g`4W20)!tYu;wl^W#fowJ z@77LY3P0W2&=ypSaPLDK|_@R=P>mAT+!V8-G>hqnSYQ6d7>Up z+Zu|CC$`Z|Bk5?KOT{mSB{Ft~n&qQlVO_Eg4UcC^0#S5{GBKz4-9(RY6~$Q7OcQP# zI|WMO!O+y#ugkI5TSB<*&97+v@aa*x7uG&vPV)ss_J8Fb<{|7ZVd4bocV(4jf;b#6 zKT`J3{VqX#x_5{xyzQJwYr1aWK$IxT#&qFpi6i$SMPSU3gr9Qf{4-o4r<%NdlId^{ z$HDowb%kdH+}zl*ycH*;wd$E}v2 zAWY*iR*=sY#F|L(2~+B%xbY|tP3-D-lXooH^H-nuridDay(;$S_4Vwq4tQIp0;l_A zFOe5tLeWnR|1tC;U*+r$s?9vUtB%UtBdhbNVBasm;+&oQ`n9D-qfK%8$mkgop{BOB zzOLrK)zwKL-^tE)3M{UyR2v^~e<=T~W1+x>PeMxdAx1tzS-HsKzi0|!@3NMo5{s-7 zV*Ex$RUgkoJ`D~BuZ5)<;*)m^|~An!XUNN6uZ z3LxdS3)SZE=9@70?n?tsfXa`JMZ0_F78eVVjhMbn1>kR_r9JnN;>ZMNePM=U_ z0?{}KEPrWAG8`30BNHbkBBOL~SdC!T@0Kl#sCY#cuV`Cf;fs7)+rbiJQpl|oAxqR6 z-@$0al#;!&689LaUrG?G$+_)G$rO>3kNHB0TAgSzp1ZB3vPhLC@Y7>DcW{B?hO!_24ccYs=y%piinaZgVYrm>W3BU2G2a z@Aii^2`9qDFeS2skiz6-gsKO_>pP#HwA{x^DYD!*Z7a-z`1vObEDX# zDg}r8aJJuV6&}qW9UWF*)}T&qZ`0$);YV)?MSl)vrl6IMbdZ)2!u^(mgZ!e0uki~R z!OD zkjzIFT&aBiLi|Dde8QpkgvGRlm(OMJQIux)+bHohGYQ1~hHD7PuKhHa^EdA?*M*Na z^aYWM@rKz8Sk2WMiHKVr@sONDkx0iz$D^X7d-rUXKH#iUdG2MJjEs%U%*^$F74D&H znA~ASlAf7bblg~8Xb!F$_t^~!4(11Zuy`Awcf{3E-%rO5(jr2o%*d9x?q)Q_xFxBt zA7WVv@>Htd1E%FfdMPxY^E*JE&2g+{b_Aa@M`*60N!)j~y zlgd(7eV|6Ps3<}4bl-MA@}EBU+eXHd8WTz}dacf`74|JDIVmYc=XeP^7D9cR4I}`~ zJ|SP2I6q+$M9J$^7!r*NIUaz6KZjyYn5+__fkI#Fn^g(jc+~HCe&FvTUcu1^s*Y?h8sio3E>ix#ohKFckw%ecAAwwnh zCnV!Vv3$14-$wJBpRF(syWlFdHAqC=*4Aqq1>$u$m8Wd{^EYyR6^w4JW_X zF68j@uW(f>9!GNlkI4`lt+U~!8b@dyMUp3ZPMg@ zv)S9{H=goZ3AnoSm|n3kF*VMvhzp7e3P@<{YW_^gmgMJlxfvoFgCz~?+Q;yMxsA5ximEP z2_Z6HIwXH8(&zhwts=Eh(+=XB)7W@7sX#4izuu`V3Hzj^bOEpi0o%n_IKdPO_O^}K zOwKmky4(HqYr-Bf3{A}umDj7X5rX^CDa@p3NJ?2r6;g-)uqf@r@NazcrE^iLe{S|j z9U|FU<>lLd{EncW%2sxYNaYWDn}CKsAe&f`Hk(}jx({`mUE!2++T2ttLax2U6&YVz zsGIuyrKuLH_N!N~joWAT|GB!dX=@lL({JATZ?j6j!U~D2+K2;zb8&ualKjFT<^6>o zL`uj%_n1poMu>v@6{c>Xu(>9=%R@+Nj?piQ}xI3p_)!0Q=V87!ZK;zJ4b8ziKFYfDccI0Bp#tE7j6_jl)$`y0IlZkw*@?rs@t>mx>mXt8GK zpq=*?ly{03U=Yk|ON^U{ z+s~?bp>rze?C&EPx(J9NHD2hk+*e!I)BCzYDD)&kZpy-<$c}GK>|&4+NDP+SZM;6% zo}Tajp~06CvHy&{9+_VF78NH3XRUF;84w~7{By=NpKRLanYV&DEDX065tj&lGofEMBwgeHoSqi2caO#H2(l z!ZMxt-R-{|ChfZV`pt#8UmU+3e%%V%O&3HoJi+m3GS1lG)Bn1KaiZR zutS%5$`4XgK^Y~O#`}n0llaq7ybBQ-iV2-6+88i%G9z_MvX!q+LisiE;S)%Q^?*5M zn;Ug_HvMXKTuHmha6qS_kVZ{5pPl)=TJN&t^{@&@Ks%tloIb)7xF8w)#LI52&RPc; zI=-(~_flgb{jw1Sr10vhgowl!`##Vp*?9)6O#lq1v0$^Z5xn#KM{A<_Iko5#O2U}+50e4vvt=Tm zm2?PJDwSFym4r{V^OY(J{l5>=A+%f@CkCI}GNcH&)c!%Id9@f9+gLcW$uW}2R#8Os zj0O|`5?`{BB)$KWM%J&r*_FgEZkZZfpH9YTa#%ku|CZy_Pj)Ub@_5HQXxYe=-ESmC zymG;IN!j`&npWgioxCjg7;{X2R$q4GR{j#U3;a-moOq-HS(4eLqq^GlY-FkpY}PyB zS|D^7h-0x(A7LIIai8ryzj}ZB79I*RF<)V!v1OzAMOLLPHUk{W2KvkDXJi%oQa#^_ zr6-Vfaq$9F;3!rLBjs;S zdpgihGt>ILyUyl2H~g!R9f^vvfJGU2ihm}q96ibMqn*}v$hVCdVMDISx#5ekWfjj!y9%9f>;FY7Qv-uE7J{Y@5oxIkR!|Fj zhjMG1bjVYsBLlSxlKqvD0mwV^MRW^ik1y8S9D(4GK@CRH7F+nr8p4P^es^dxKj`Os zbGKmVzsg~Y+aY6JHtLTiCGQ;>(K0@SI<5$fy%;`=+1Z|{)O)x;k~FIOx^sA#rl_xp zt&1=7&lN@3BG}{M_8g=)GiFoRSlczI05MXnrSoX;3rW~yjIr0rthg5yUa&uKKR`i~ zw`l$8zq%u;u|}!&jF$XJHcS3em?5b1FX@rCmz@c5pI72vJVY2$Yb%?W>o) zIHx~c{WMIwzpJ!8B!x8LF-4W|H&0Iemd9Mk?lO$w8)5NS@7sd#$c|X4ZA>L+9;;cd z-O;tVA^*3^l%-Nu>R!2CDjdSRh5Y#J;d+DnF$%c!5ex3bLuKoV@2RZGCtz2b+7A;?HEIP~1?WGGIHa6bA30b6rYbh>x3} zn@3`|;c=Q*!_U&*Rxf$yihGvh`E(vB_%1$VOI!&)ZP~e=EZ($qK)t|FgQK-UFxEJJ z2~)|oA{nsn34YNZO(B3|3d*#>&Jl9F67%#R8h-;&9j_=SI)=qXy|7CU9} zES{l^gyUpdI*s4>4gN@hxdfKxf*wBP=5H0?_uA;Q9FedP>1=t_}HdnzhKP8S39F`%?rT3Q5}6#R1x64{=0NcMQOcbAkU==8&rtlqPzY=4~hy&(o%P(uhh zBV&>48W8kFrB_#*8r0iLf>QZ(yVgl(xCpJv_(VIYmsch|s)k#H7r8tWNmhAqe4+X{_3TKrD zX+j;;)^QW+MVpw9h8pFFQ)#B&1;--AdeovfCl-?|EBmR#u>{A;Psq6phk9N`<)ct$ zjlWi&eKi`$+;H$g_mF%l<aWf-Zldx<>?7gGsAVSMLjgs z+Oav5DCjEM;Lpg*%*n|qD(VV?>vdgkYsTTh-xEMk5DRDAf!HjS!76$_@tFzL0TY(&VW966pWN);@Z9z0t9E2pp7zwO@CJs;jGi{D4gV z!6y|IqM4hiSZH*(0>JH}O5ZHjmS(sU|F?zPPwq*V-uLoP%_X%4huMG``t$3Zm z|L2|o!ne_uvjv~|-O-mC8d9A?vSVlj_zd6oIG-NX;;t4_7l2j3BFpDASAp6bhrP#a zB-W@bh1~=(kOfje6xJYDqYbOx3EZKDMZh$`yHG_UDC941rat;N(ZxtkgsKa{y3<3+ zew9e=9HM=hkUJ41{p`nQF8s|%Zo7G*DFSXbR# z+I)W^=nFZ#W5l7M0?2<1@$233(XrjV9m$)S8Xb_N0Au8Yc%Rb+e+M}!s6Z}iDh&jugvf7VXcTQKa@RMO`LBO@a-6%|bnr(Ut-Ic=CIa;XY0I^*xn zCjFbq^BZM?pNA=$hq>Pd$1embX~U90whwV(D{f-s-l2K#y}m?o0PW#d^?tLu@?Qza zV)$Xhn^Meti9AbhYXjW!$x{R;O%>gV#N6!#&)h=6psz0nI;(5hrg_Tk>4Ao-zOCd5Y@dqY zmNv(3pi^EN9Z08+*xA{WY=RzyRfY9ML_x9-!cAa@xu1*wb9rIGMu^MP2MjP&3pExH z2ngImMnd_~8D2_+CwBeOyw`D>k3IW|)dcb0$7m(4C&a6{WjH0}>9(!I1t_JkQD~aI zpr?_R*Lir7->sTbDI;d*_#<&*?%1aKNfrlhShPo?mR&29dY>C;oP8uWp#kY7KXwmd;nzv*jeB&4KkeEi9oBxP<6 z=-jKAdfTOD-?axI)>x<-Dau-YSP#G>A|}Ru6H5+ohx)=Sr|A#>ZoM;NkwV|l?-Cye z(s2v_prMdL*V9|r*+~L95y4$G)iuCM`lBi?&Pdb^{qZ%Awq6r02~Mx0=>QWa=UIfw z-PbRWE)42+Nj@%;2y^kP5hqdC)1lGfXMYzL7bRg^PY<&?CRWXpnz7l?ZtQV4XJ>s0 z6M6X=E)R39UJxo^!P#*=l~ksq(&K#xR<`GU*X9&qze9NVd)Mzv+`|0A)Y)|Jbz1i4 z_ARoW=@=(FK}DI5LDL77;hUYcM#Yz`sX6`cxp&q1>Xq3qIsf+dWJ9dD*!8+N0bKx}wy07fxRNk#Nb06TdXW=lSOTAO}xp;syJtR?M)BT&M~@e(djC z`A{0ofgon-4@fvnZ-X?m-+w{sW|(RkRU&6PXfJj_h5LsV^IIL}aWb@FOH-{cw2a<2Qu+6nIH?VA8JVdm+bP-bxF$NUUI+i`XP7ScUPej7w9O@{ zM7cTsk44K;=E|%HKEzs}Q7ZldPLOkbXh<(b|49mZu^7XsEx#tI6ErLx$IBON*W03%+0J z5ENBOIl*yTDd%VGs68}qtR^h^$jHTX>6W$}RQ`E#y$N{|>Wi_zi`nZS+OQ9VL{i@B zzKlPP{T_rN`ie@gTRYI4{*4|Ema-Hbs_L4?0d=a$f7<1B<4x?$tyW_zULfeG^zy=7 zTU%@H^3uxppkSKQ=O6}AxrWHBuYFiO?=*5-@qpD^o4^t{P1?NIs*e||b(PH)ZYqE( zCDy#dWMsuejw9316SgGiyXWQ$zQRYi&Wgw0nfHa8?_m*S6SgOLUe$4_vvxPogC}`8 zu;+UvIICkD=gA$XzDev6=UwvVMaRyg`Fosbzl3yxZHfbGDt03xViD?hOrG-$l;zTL z>_@EcB8K4yc478EiWFZmFVHBr3hccm^}NtwrLi;@v8U=g$2l1J&&0E@Zswyo-<$W} zChT?1rS&$6{|h)n&LQ(q@V*@sIYwyd_}rA8B3hnyJ7ymmIE|X!YoKneSD8ATCWu?* zHP8JG1ti0E&l@jEM4Ot}?HzCzHY^PXC16WF_DDYNr>{O9bs)yo9u8i{h~Hi>t=?We zZFjVs^x1%tQwNY+qlTC;SvyPiI`y!52`m>UCJ*yZQNWV2E%*doYy2bH4x1XbgO4$; zfy9RPok@jU_|h8WU>BTrm5(Rm-bd+ccaL_DeK$+CzNfG~kk)qrzi5Xdf-%mXE;_DE z?cCS9026s|aPY7OZGSjxfo8*N#{bvk+UUA-D7tnL-OzqgVy;JvRQNGZmLFU8G+eis zC2zL)?xQKBw=hh`3!m%vnXEq5dCx~2~&GYf4c3sH<5%krE|7D4pKi_pZOwS^KFP#N%(!lna`P* z$yF)%T>5QV@-q3FPU@FB9)iw-2f$b7ajHAORoHRO8KUCMlUm>FybI&0 z#`ih~a^rhH%lWuwvhR66Ko`?`v~uRT{^Nn&s&O^&Snj4)gUUg0nPUQ@)!f<$Py3ivRk#ojmH4ocV_Qz%E{YB)fEgm#Fw}D~kpD0Ar+-eo~CG zAD>u+X#M?v#QqBF-TaeP1$QQ-Kkq`G5DT5wu%T5GhtF)S!SZ7hHx*|m-q;A++^2aN zKfopLp6%d7y{R^MU3^?&1pf>5 z*#yhsVtSgR6VkE7F3Ng&>;>LiJR?CbwQL#uIJJmRW~-Z=c-W(L+QV; z9DX#3tJIDciq^EM?Vz&MeIp2OQDYG-tsQFEWCt@Yr}KMFBKOCOerliVPn;gVsqfY? zvb_+npuPB~PCP^o<0kQ0FVJX9UW!wDY{+uz#^_tSx>-3cKCD!F+^8Y0)ePn*TQU*# zd<4ByuLa)-u8jNkgVcj-(bX!pVMs&qX1Sg3eOQA;)zV3F{j=>5P$T6zZ?2~w;M(TB zGt@5uDw4gAD?_tnCS66VjQq-(7X$Dl?|7e1$BS&>lbK`E$vj}H{Stp-ng z2&5HYZd*H}_Poml{hg0KNDSFlzVB#GHMD$JsTwroKZn6bSYNZn;rIC5acrgvt8}?z zWuSaxmi)5hn0Qt7D$U-y2l=$*W^MpoI^=y(O<7jcCohEs(}W6j>6G)cP zbV=8$5Und@JYL*)@b4_NlAjJ`rOP&RT3GWT^xk>-X#}^@y4OmSrouQuWODb7B|k!t z6`&|rXc1n6!V)9WWYUQ!BO0B*@KeZmza>p`2#|ADjm4Y(Ja&oK`LSuvlC!@YJ9;l( zNs?9jIP9 zg97jYL?Y^^6>nZJWW6=B;?N8!$gSklg73YW@0W(9w!2Lo zYWE&;iSuPAok~MOgG?Fi*J(j<19nG^`Z#1mTjY70qIJef@L~xfYk9t5`CxVne!`r$FMket279q5D_)H1qp~ zAWH_!23BegcTjUUxX3{9i^!PHAT zpTjW5Y%yVBZo~=@#EPGJjRPdX^#@R>Qc_Ys&7M81n(IZ!#8gTFGa5AFbB82~S;GdOxEQ4K2)$_cnFXZgxasc6Sjh=j*QchJh@rvS0b zLP3f1^=A(&wjIY%Y~akuQUlfI|C?7UHtxEE(ggu!xwS%P3dBJ| zA>Q5_n}Y>##QuK$Qnu&P5=f97j3DN$FL=H^wWB=lyB|R7=xj55H$xzT@867qFT)LSirJzBnWqX+JZM|Q- zPC}veu~*4-HzC6hI#ey2KGO(g+UUz`%aEj6Ow(-?sssr$CakQ*S5 z9TOL}CM9zp>2Wo2@a^nLVMwF?UK4ZMy0>-SXGYC?GP*RDFRC&7gIbIdAkDRfHjMQ< z26FF2Fzi$(*cw#Otx-E6<4tJ<+3)@=z39*59F9d74CZr|uVPE?`BtSP`$#c@ZZq|V zxYT%}-%nXTfFcNeG_u}gm8rtiw-%Cc^aw6x!`_~WHIT8@y3JbY_QJ-CS|Vf7 zs#DDMa38kaE1{&Zx%h#FG`qT3&8CdgulhDXl-bDnCv>c3!h4GG^NyUsocrxd#9*+E(1*suw-m&jMO#r|Jc>g$uF>W^3!DCSWIe8#s^ zUWy>pCr@6uqKFjHtjmn&E*y8EhZcQTnp^89P@b|gM`!r{R$PX$s&~}}QBN{w@qPTP zUf{A}P^bNVliK$L_=$}h9pLx24dnVPW%qAQwz(2(Zw(3{qe$Liq~tl-EdSdRHZ|1R zZl<&EXWF1EZhKLpZ11+F7DFT-#bYFI{{aoE+s&dD0b2`vuy+{zQBgKXQkQ&6&wg5r zFlm9`F0DT9=K;m3Zi4H^-CZ8oBZlGEV=cDcH>|#o!+=~lp9Q6|TWin@Fu3SGj8^)d z^gh9yvL9gCPmFe6{i0M}k73mC$&Oiluk{}oBCmewR2so9s2?r>d;*s18g3W)+Ff7A z1)bz&pJ3Z38a#=|2^+!FyVPtL6p=FSbBc$^mi%{;eC7^sv-23v0|}gdY@*lKu)o9I{oDEKX{md3?fqlNhB;W-x+ApES6KINCZ%JLK*EcehL4(P^BKj9xfhJK` zHikS)T=hx?9N4yD^rzo?#@OkUzR($el1iR)C5{$1L($W3@x1xQE7%U*%7b4_N@S#L z)l+~*h*Cl1lWYe*T|{f2W(z1^+sK;yi|HA+dB$R)>PcrSOWrPF5pouKH(^T`Wf=ayX`u zs{HhS7koV*63!$PBDfM_!b%IYvg4HtM=qiXb*4y^lz77R30}(`=Py=Jr9^FnFEZ$H z=D^GW@S%pF+I?!}Yl1>az^klh zOysRS3R+s_3SkqmWu7_VI?JX4Xde$I-pUgrd@Xg@{<~;-|I(yzOMT!|6pE@SjZtvV zr2j-y)7V?CM^9OHP1tsCg=w=zb*+Pyg!tLaPm$dS;2K0y%|v!;_j@t)IV;p2rG%rI zIMEXMX(CW`fktW`*J^}MMu&KL$?S@*{`pUtv$m=#h}K~%=9lFXoNriEX!T<`Y}_Uc zL2?dn6}ZxVT1R})d)A4pBsRu5mT^AU8az3ss#aB+Rxw~!TYku?y0gSzbgWsO%FI%S z_L7-!>=hV}sJ%~pA8sDbyucmy{tnWiAQk-T6M>_c&MEGSlQ*=I|5cIM5CxRK*Ti*L z7Ej!H-pI{Jq-kIRJ9%#PaaU0CZbBA#!CT;WaI4F!fR*ou+I}rO@F}@E5oS7Mh zjkXwi&PJ{+MOhFsWiX|gAWkG7%b%{Ud~Yp$?+X>y9sy&s>J3}s^w~;(+8p-1mP8Cy zdc%8(W1?NvBw&B5*%bQw1xnl<9WQ_2`Rs!vzV5*=q!bP#&&M!E|3BVyxGdj zYY@9l*6FxECGJ3cO#oNvB-AcwRcPA zQ~Ib-ZL)AOTsW@7@$u0ecig!1#*H0hXIr=H8opMBAEj0C@tmXvq!v8Yau6rSHna#d zxU>8ykl8xK=ODlQ>lmOs#oftKTE1W*m?246i6(7PR#Sseg-NC2S07Ii`bpGTu! ze5t`?cu5T)5D5oXi}|Ji6F=!!TI(yq!&X4>MtF2#(6tz#R2CqHJ*rUnnSH`V4??Fz zIinMz7{fn6q%B@w96d5ZeH-vU=mQP1PxzoFNgSMOuu0Jx)ci(Hyn<0^U%VRm+@wP( zK_-|g-Wck1H>b}hKrFIjPD}VVDO5VIN(lB)mB0<|Qb!JwkQgc>Yf&lm5sIn@R?~@! zB}Ng07*(~_Nl_y79WeUQ&AEmQQai}oa{O?!i~*J>hHa^9)P?RDH61>56bKsE7lemo zJy8z}c*qDqk)MX9o>K|Vi%E7MKGVNAjp0TN{$WC2nkRK9!$Xi+!BDIBmI;0+ z|BFT35U}G03ks3m)fe-0;=8|ovcI_mE;1nZuiOBYoncg{LqM|KhtDZw=j^+VAT+%4E*ypG8e}x8}U6KxT9+q{!K}_*v_R4lkYf z3lZn6U?N2Ww9OYo7((h_{Q8T(_q$)bdinBc*Q`Id&?@~Cs$$(C>sN|TT#1(|JK@o5 zrSofs_{x>bSFT*9_iX1^;Nr!Ti*kufQGxDvi}rr)>eZ`Puh>QRiJLBvqwCj1q@SK% zyLRnWcfa~e|Kgwj(l7qP?|k74qWaP|zxjWB{x{z7_P4*`zI!iUx_If*MLQ*pQh_^e z+(GdN6clq8ecGC?C~q$^QUu z4ms{bB9vzrn$U+8a}M1rZK<}(nS`j{bkPe)8P%kl>&sLuQ06-f0vd@1v-Gm#fiQO6 zmzbubY5vv${h|k4eT#uUT$>>UA4DxfsjG;OSsIwg)oJ9a(A|&eu`i{kXY2Zl)}bbi zX2i93v{)Nvi=Jc8Atj-!G`Vu+%KM*t?#7K9Z+i4iCnpynxdNJ~;g|_)WwyE3W~@~4FtfS6 zYNIZTJ_?|sJUNoa*Gw@)ETRlEUfn{%(yjhjljBZ&7skc8_5q!-&ww!qdIZhH=UK!( zA@{LDInqGv#GXRpKDai3Cb1GeM^2gOkgdE55ZP@bJxt^*EL6D zLg!3LK+`X%G+Vo$K$xgPsBY@pjuaghrwyFI+pByb%{IYVdH;*-PhoeyIWWHa>&Q4y`dY_A!;FXUN0T zBmQepTSjF@eAnglY+Qh1#tF8=T46{Sg9R=nH1z>DSsMj9;lkfd>P2srb~V<}9I(Hf zD@y;f2yL*kam}SPxEA?FH352Sjn>@SClEcgHC;-j77^)C9}se?-{@RhXjJL6ckpI} zb~yGy1K$A9C_Cy&cir!{4x4k%RuZ~I7L!t zMv2QZ*P~-k=e8Um&Kd1js>)Ey+=DYd$>u|^X0b!9JLeO=-Nw5qvxJRyyAQ^zUmM{WYb*{Nr>C9}dg1++?A@2Pi^8` z=4vDhIfADFrtttn=14af03u>pq&i^)#el_Mef=(#VN!#jyONE ziaLMD;Vi8VRd)~#YC8R^Akp}V84|6EnpQ_^$@1RY_=X|W_)xKEQ z8CMgpQ|l-T!?y@~!2DP9>c&@;wWH-)6v{1=e-8QCvcJ^Yy2B^1EShd9P7uzO`zS_c ziGdqvBeGk(oa<>fBp3tbLtOY{DDF#D<0)mRuT)0)IZPm3*X$2AS>HLVzt+)u&Y_$k zMQ8Xw(+LC`e@a6`rdP|`&az)Y^2uuz`qOEH(hOJUHJ*$Cw${qovx83G>dI_ulp{)% zq03-WOYtX)R*Jn*v0K3Git(n9!avcpwcn@VZ03PoF%Cu35nsEA+)KKGy zZ@`5O^=n%*J~v!!VA~YTp63`ELpLXP9VkmKdx-TX9b>-Z=yfbm;ov;yY8@)*#cYHn z=JwAQ>ojiu)wDfzWWOG{o?pM0v4Ejplma(r)Iaqk1)$Q$MwPlD3AF+(bH2U1byyRM z(E^vBAd#L||DlU0TY$tZjTi1o>ZFWD-&G^X?cDW{Mhki>^!UPscfRvo4?q0ym;Ue% zzw#%4qS~)tzjn`SUwiut?` z?=Ax0W>F#c#H&pH-a1v)gNk-by-z+jV=Y$Ih+)}lA(=0BHbtbZ{gm1ZHZ`US0ZAt{ zeT}GuQeByjl>bUJ0J$`Olz)`UNQanOY=p961jVEO-K5Kct*sHoEJ1>wM+YdCMSQWV z6UHO-1wKWPrkxcsbYxqUKsymN{h+&_D6WNbajP1sEM0q|X(-cHML24?k2@VHpp`d3!I~^Lz-9 zk*3O|xs(y>K-F8Y?{NLCk0T1N=&$LJjQH$gEGVWnB@JuPuA`%npZcya@h<+V6h$aH z2MX?Pse-dWDJm_2l`ed?-~H6df8(5I{0#oG<&tc4>f((603ZNKL_t(k>#T$|x9WTs zN-DJ$_Sr;=>yj;MV9>&5U*t3+I1(e8!vAcN|5O)sLrk?fM#osHv(2GFJbg}D6L_xo z1_hQpb5t!U7qz~bQV>p4JrUY5O!7;5P4HC!`B{10vGa3~Pmx`D#8+ZeyA?L0I-TV` z>5GI`tTyO#L2bZ+vrFL1qg8v2lcgHh&*UMQXl%sAT;9?-Hs1#T$QZWDSgS*$$~b>$ zM09{fXZjQXT{mfHf0<()OE_FjpfCXn5MGb}9P4)*7D?vvtr+lSvwj_m9_ZlI#Z=a* zWC&6>hoA(JOJ`;QJM`IX2^l=R)Z0Y7GTCad1@?YM+xlG+nV*VjwE9(xW1+GL+Ophv zdT~KwD z|5C3ERTuf3$&&>I$ZX^GkgrluO(!YjQ((*13f$0Z)UaE5PWRXzSaMJ5dsj%yjFMcr z1zNx!Zj<#T#A{7??IMkMIpLeN&CTcdlrK|3`V#B5u&~0m|k0#h7h6ZMl3=QKgzsyCnE}Ew23->MN9l zonM*0rlzR)dG35~@YI&5Yvq~*6Dtg9tZ%D;pEh(;{hc=Cg%24ajpx9?Pa_I+3vnXm&(ziokrs1uPORapuxsv!xUg`M);Mn<*%oQyBb;y`h(F;6Piood zFS>dSIWs03Q1kA%KP?(clOPYk<0f3FAB$V0Ktm`{B}F*++4r=MX(3UqQbv2XKZopZ zd3^HRMh7ZQqAE!xKv~aaWL6G11)MrbL1v}-(|IPfRi&^5g_Q2QaXQFIO%|$BHrf7I ze`+Kvx7^xNPb(!hzK8V3W*PcaFLVW&& z`425ZG)$_LFX0t)sAHgH^FNd#h4N$(F-&4ow4Cs)?N2l8@jc;N1qxZ%%z)t9{lb%C zKm!*OEhbUSfepg8F;HSmiUp~GL>Ma?o*KCkzW(s6k5@f<-pl)1AY<_nrXfwa7@FBU zFh78(S0L2o7@h_8ywcftoHT?TR%9|i%a77I zh!lp2r z+7^w6DtrT3Y#_*mE`?KEA0LxXc|f@3a|x3Hgbi>#8V!pyrV95C2x#R8d`QF4ayTa; zo9w4>bU5<2(5-j?1>dwznkxXUGk@ZmB%v}^%*~;#a}Ni^l){E~%?xA;m)Z(4QQI@z zB-KB|z!bD*ObG&d8*(w0H{14RF!QL(Veq$5V}PVBc>^$*$zpD+4Nvn?m+YFMgf<)P%gJEJSwNpP;B_zEtj2hZ8ErPbdZz+ z2!|jg(SqN!Uq0Z4yTY0S6VB{d;J`9B#atgfT2AVQYhncCwk{3+w(xb5ziFv`N@ul6 z55z;GRBuVV7;w09c~>B=;HG|}K<6$zAAtGB`M@88PYtY;aA7H0dTRW4r_g#&S0u-N zBBI>DFtmTu{<>+7;hcLM7VC2M7MK?i*a61{UMADFNn9Jm zQyev#Y#ZH1v&F~-1I4Z?DjijRa?EVso$2s(mID|TH!C`uYRf@r=vxthBpPWu_&+Or z$obNtVA4M)F@pZq`x2*!i3`h4m^rzkN>UYu7G@4c{MqnNL?jnGMEir1j)!z_LP6n% z%X<~!cm>R&XqiM}cR2~=5qZFe30(60R8WssGt!6e>28f97{ey}ucA#7sJ|CHp zgytr&_dXq`>iay4*Jt7a4iqz4R~T;b$;Md4UwT-jjn8QMeB*!&G&cFOfI}1(+h0Im z2ZQq_0Kug`T$((Nn%uc?@ij(K1v|BxH9Niz?X4{|v$*P1X}^v?9psZf3PQ9~jxEej z4Df32s*B)Os?H>&uQfWIC_Zmig?d>EokXHFNy+jC4NAKG90|Z)pFyAPpAzn@XpE6c z)S=+?MEnv{YQrrNF#2r*nHj*##>ki6*7WE1jecr0mKUr6&Dzvdu$1_v z6_IceN(a1S#_ci=VM6*qRh96jX*EOolwu#Pi-msC6S{W}QA8`h5@Njy!4IE$uT>@S zt*^1LP6Uf7TJN*I>2SJM`bVm2*jecx>juGjB6DyG^#}JONf6|v?q?V@>*p$h@+eC4 z4iZ6JQTXbxQZC|wjqV{y;)8~a2VVKx8SWA7J>0zFL~DmA(apt)?GJPilE<$sc31~s z64;;%s~kiz8$9;3d|w;CsSK++C_v#87bMAhErG>@3J%=?S(NxXY>sXt=JA|9B9Sq_ zJTLT%+16Bnl%){7>eH}!G=iG;im3vryiqTX8V1Q4cl;NW%2{0A~93*N9Vv0v8%qiL`?*7$Q z(N~sU1z1xL8SXX#Z@G8}K8+Ddy45uWkl7l>k|tVha=uaEz5w1C2H*_&Av5_Ohv0OI zGmP9S@y?>AKUa%(RyW$TROK@2U zo7u^Sm|Td6YZRz#-e5Oc8|4$AecT_L{1*lH@NuoXO-o&g4rKjd_qszOe+ z?|8U)^SNw85#jbJq3@Jh>H1XLuhIS>WpuU`Qe;oNo{G;EWw6D@Cw_bfjf)?BrCMjG zBkWPZf)~3;8#gI=gr?A=GFmBotH7$}j7piQswHJ?iP3C}zr5}O;8)md-IfHCNf8mW zTxThwHM>Q+ewav&B0s$7b!f0EIaT^nKrZM*>%@4#NdEw!ID2&BlnaJdo92g0QrU3| z6=*?85#fdnAGnuY*QsA#)-uUznIe!;R4Q0h`6;5}J&mz|V@jP`BmK1w2)F4^STMgD zvJixQS|%J$@!3dAkfoGb@>=OSXf%)X4^E?dHc+#C-KM8i(3{$Z79&oT|Ctoj4rLg4 zOTQw-XxcQF6i1JcRd+PS&dZv{+Y$OC{F-!FtSx2>O~C0Z6B_J!G98l3Aw6+TU4B5O zDs$Wm02NEY$5LyzQ!LhW6A`x+t&q2Y_X`X0XrO1!gGlf?A8!zZS z!B!1S>Y9WFivfl(L}&K`^eTTPj;QDQyb#4$T$(1$*M0ekedbO$hI=BQ%)S*nao_^e zY4p?ga>;FJeVr`Ekp`dZtLd;sY{k{WpnZd_{s4eF_rH|Prv*Qof|KE>s8YoaF|$gR zdDr8RTt{*Q{VAxuLm4^o?rGP)$kL%r!hy3i4gR=$p9(;FKb6)v`DLbBLP$8(gCU2z z2yd7+@ejKnS|2vc{!gxvRiWeo2tJ|_CVJwmwJ!?;p|T8aIFv0Of)c+0!Kq@Q9OSr~ zLhC|;@TsWOs)gEO+kM$VTW#U=vD+g)rvQ~S&0&;9#xwD<21)sGlo+yKZsPwT{Z*|O z;hqvLP8q-v^n?l?k5i0u?kE&dosyG(l8{2IEA5=*Kil6?QtE{tg%NTZ*Yt9ytFTp_ z6?+IwyME)F-;$+WIzDQxjGb1Nh}L{WD(A1W(ykSs{2cm&DbIwe0K#+kgYT5t@V)&i zVjI!9D7QA%;a$ZSMyhscu{tijppX!Mz%bb2u;{+UcMZ|ZfA9(TQac$M zn?!wa0vuV3k(`DiMjChU;vz-rz^FzJR+SIwKuK@kW=mw;hv40>{!XDGtx^Ha%Cwwd zEg%FP=-me^kRv6kqJR;`8HVtZllw@kvMp(Be;ed~{?I6jfa}giVjG3P z0fz+@eAjR&a+PX6Q6()#V0O`08mZcn1N5#w`VkJ7hR+$Qw1(#L&L9P~Ct9Vn5|r51 zB&`>_CIZj!tI5qSP@l>IeAlUh?mHlbzhYz8Q;o#~25ZvkloY?m+V3_AVf+h7%Ft_z z*80&0Gk^JHv6NvTHzgtcWWGQ$C%(cbF6#^Y-}EFx!b|=6jZSPlUT}D&s?k#`)K^L& z_3R6OLfNRlD$CIXj#9hJHTraS#S{U#NN)VKW&upE`rH;yKX2a<>*X_z&42d79ej$HD*(SK#Me; z6kq|QNcX%NF#figKI>i=4<-mzN;WF6lE;x8!u#S2c&yc%_0JapO4o3)YH=mS@LdZQ zhyGQ3#%z@1`|w7a)(YvErjFA;DI0uwF|4n+9wpggNTYpxDO7DT{Hrg1?U#P}mw)!Z z_%FZl(l=wZXFBl(pticNYzCV>@OR~3jRg(>w8zhc_Q|&%3Od3gk(;z{oo!Xv;&M?D zx;k1_HS7t1&WTw!_)x&287fQ$s9o(;afrmiOOJg-om?&11-9Ze-X-}$3j;Udg115E zt+U)f!V~ohd34C%HxVo8UMrmG9&U+UBnn82MtUG8B;BtO!9}so_0OMeFl{aAqectTe640mlH1kL%D zHG|rGT~KLs`p>36+z;{vwjn)iRi3O+!M=Elt~EJlZfx^#NC=6S)S|Q5k?zxceQdvX zX9L;-r}7Qw^z@LiELxvLLowcDx&m7F9x+O#%%w-Gx5g(34Z86jujIx`X@hITx)Usc- ze&@gTg2@%MKpK^1mmBA=fZe)Sd%Y5#b4nJNkWf`?wFSz}ubDc_pOc(!_=hQG2ldx| zI$6D7{W;%$t!mlyT2btHG&6b{XLI$^Lu0-8TyBq5TL8N1>CKy8`IA5S{onunn=ijS zQY8@EDw!l9z?|*h=v2c-`?tGq-aOSfJ801{jrk>}{1H{C^f18Ahx#016av+r6A5l1 zVO3E z@w+p~(oxDppD^30Gn2DUzGh zE`26QFR(VCxvKj7dg{FLq6y;$I^=k{2c=ln-IZ+db_eqX;-R?Zx!e1)x@@s$B%i>Bq^|DA4_Yhy>6cma@D_ z2mIW&Q>3}iwM<-7RR&Lz`xF`x9;gi;(;)`RP>7`gbN3?cF^5Hfr$nZ4bx1aA;e)&_ zwmW?-ve`RlfxRz+5{yftF)&c&Fp$BAm@!Nm+lyI#66LxIxbM-~U2tabo>Q(urpjkn z0X2LYC2F(4+4$Vue2|hW{`NwjDCupSmW@dG3^mr-*Q6d=MY0cqoKzU8l=y5kJ71G~|LxuS{>=9P(93?!SmihV$Ac$v7bY zhjrOo+leH~m1D{iQ@9C;zOGp;FvM(^vNH~^mU9%od!m@TSSRdOof@qu%f>v*3v!dN zchm_Uv+=~|?AJgKUCV#208HU!9|>QyWW-N`p6n|5oS%i@T9yXdb(7VQiBb}@ZG)e! zY5vDmdj8#G(j6+@c5BRTsQPM+n4Ga(Its4w5EQuK>uLYEX@UAJU>oHim?*$Xk}d*EtXWQbRWb@RS#f`!jGx^?RH zBng{^Bidd0n-6uSLT_mzyzrUzYw3HGfQu)0Spw@EpqeXxX8Nbv4)n<;*yUzxI7<1U z%aXmmCHjolxAY72L}O=hm4CVgX^$0ap#s1hbaEztqenQC{^NDE*%a0Af^3T%*Oiek zu&5uoGc;T1dW&sw>F{;-T*kwt=eAtc-C1{P>-ZvV49H&{r&uL6V+^4Up?(cVC3u%x zl=3SIP8l~P;;C_+BO)K_u>D*mU*c8C^m6t>-Kg4px6JIk^Ze_XzpxE>92zB$w)ty) z8hKp`rwr$QgLCn})Vz)VTl<HKp}z3YPHX2|_5Vd<0ulL?gK)wxx5`3)vtgh3qBiE!}hO%BDO-7 zfYe-bOqG*s)+RFrb(G>vXGF}OUf~Zo>2RR^gDh(#_#aCr{Ru)_{3YK>xzL_TF+HX4L3IeE~c)(Vvm^mqL9LVne8ffR>S94!rx(jV+D)YaO-SL9b zM*d=pzj~OzXVWKQj0tA^)wR>V*U}{4CmL?nz}boBrB$s&1k;f&sMl8b7DU)^<>prX zW$5qd-<*s8y0t{!o=rzjBL-=Id-P~Ruu;0#`*vQj{+6cz_?axC=io!|!fKYG{$W(3 z;SZ=TIg znSE|tTqWVOAH)Z2UYUDc+?8QqQ<94he8srwb9zk(=;2RNGvbGZ?v2h5{U^${TRXCF zMe-PSDAQT=Nc#ZK!B_z?2#q@0F1j>04OVmgCS0*V!H|4B5VJf-ncLtE3snw=yMsXJ0wN)6Nrvo~-o{bnvmhU1>oy%eyWMw5hyp-#kaOB2IpflJ%_j(LS!_ zWCI@#wS~&5bmkrCh!1l`3XDl#suYF(>bdr76*V3jRsE9@dZLT#o*s~&ctpWXdgfJX zxnU!lIK$5eIsgV&qpDy*5*ip_3Rc3fQ8y)RF1gkI+`g(bAYG)OBGmtfvWni?9cCz- zFJQJCRi(SDhR4ca?`h1C-;0e8|IRQ`{cO~f`~VBhiwThhO;lsIN48b#CYX58{=^}7D311KQvZ4| zV;+qs5^a)6q05&8E@tG=sF`~vb1KyF(&gxF^#${7l$MaBlu`e8vd2^X1)3+W8FcW{MQ|OGq6FDBhYc7?24%#t z)s51If|QFTY#D9Vt&%!?pQXjUUKi^*1U^8}mZZfR>#*5H)GL%8P}TZ{7+}0( zw`0OnuekKOFo{Lzng1mDUJX^*rnTB0X`z)+)zUDR#N2{%R!>*@>lGegWw}=5Gv40V z?9Iokny>8ZfDfesFSTT_?af>toy^l>WwqwsnxC6Hl{!K%y#>e}UW4YX?+ zfJWEgtxfuO!ZTGN=;IQQR-;(WxSdi^27K!BU*_^$G9~EmjB{cRk)6SQMd#f28QwmP z2S!r&;i5W3Cghs}D^xplszRW4rj_SGVZcJ7`y(oPE`-n^njJR~T2SkNd`9mNNkzFR z0|3>0gG158&kbQzhZiHJdW}h6|E`0jPGc0M%ik&;{+ZJ8$Hg;9!8Pfd^cmv!V}ULE zkm8|3=2Q1l$~FuP73q|EYb2U~Ndq=zneYOG{T=92+8rOGK8bqsibyd~afSm2* z<`nA`0FQB<3b^*Tc&J1+J!LH$?e1scQm+T^kQzK}Jqlf-XHs=*K>k4UlDD zruuxo4Y=)uSH@eL5bCpE;03ZNKL_t*PiQ8*qMp8(P{e&r^W^Rxof4Wl$ z2-?z?)*@}LB8n%`H_rK|SKU^{{0Puz0*t%LyKhB~fs zm6V7l{WY6c#ZO_Vra%{P*96!B6V})vtqRz*D-#LWNJof$uOB94aS8($lrMp58d0zzZ z?6L?f1dS3SV(e}gANX}oU|cMnWiRQP{tbb0&0qD=-rPYIFuyLP%L6-U+DIT#-zUu^ z`m;d9_xavjAO>S)Sd6$oB@ooPet{)qD63eijgIsi_Pd@}Ah08cgf9><_gu4$Pu8fY zmxUS&M~k#Ld??;_w!h2@dAg*DYGtgc*#RAwLLFN7ECY>(p1L0ihYYv4wF(53Q0~V1 z^X(|#X#UsCw+0RTvM)94;o3-;(9OqbfCSQ78+Uk5-K{V6Ai4Fvw#35^td*txR&=#W zOf@7VNa20GHT74wDN~!uw`jSjg+6gnhG+=jE@UWEzFf+y#rJRJX*ODNm%8sBBVw#p z@~_mSBmyiDKwo=;QKiz@k2kVS^(>fRnNw@*=)#NC&m_Y}g5X zY`ln{)H%0&B3&iYWD#r6u;%ZoBmi!4JZFLN_>}QQ2YiU*dk(02v{#0ur;6buA2-C4 zz!d6@Oss8M@t$;;H{h=E0U^@LoIu7AE<{F+GG+s!F?p7`aOvPeP*aG;x^qF~{3LM4 zP^b1xFfJUoK*(^_MbX(8Rcz9~f~u}4<$1;w>d`>haTQ51;1p2HxaQtOvLu+O;7W{2 zU;&L~bkFBLo7hxOH8n+O3oV4Umf$~_^K_la-`e=vQ~n%<-NQjSlSAk#nNGZ%bnQ&b zW`1*^u2d+;;Bur}KZ(%T!Mr|`g7{o64qez*x>94-ui4zue=G0GriFU6%G3xvhRw}) zaf)@am(2nYJs-K4o0h(~CvYOy9JsNLK zdDvKi=A;m!?t%6XbeciiO-M?4-mrnXdKAR;uZ4+Gg+qvaapSIb(y@b-`FTaHR4s>^ zDzgRk3=8%Y5~!+0vm zwuWqVpX7GPYa$`NJ>5=^I!jZF1}W48qoLaYfNXV!43$}oP;ztzz6fX)yp!EY5EnhC zgVVy=814c&VIv0xS(>N$!bh?Iq)FuUmdwUbN+!mlv>(ZUmYvd7PeU97)S1zx?#=Qs zv>iBYVTkNTgtZDE>$F(vXCZIAeDH({f2)DrkP0Z1%hiiiEfHwv0@VbFp22P{Z*T&L zq-J)79c61c-pk6(j^3n&Wl|a&l%ol0c86Y9XHf>)IuZ@^@gO7f5sh>RJu4o9h~%U} zr8y@U{asc?q(f0vDX8acqd*N!O*?^%3KujBj*`w2LZ$I~WYgDtcd-{Rt%sgM6>1rc zJ7g*49r|5XPl27ar#0hd_69y>D(V|l?3PLcv~^2APl0bjKaB@&RLTcQ{W>p39;9k2 zDu46wmN=k5Yq2f}y+b6CM+0-1L5su=FGl@5X|~>G+gg(t(-rF&1hJsFFU@Lk)_C#D zata8m@zt2z>5%@S;FZgBOA>|4mr5H<*5iK8E2D5mY9;(n{*2gL*ckkUgJ@^WbQuLs|F53P}DAbQ|L8soIz) z@xe}mkVVimhoHqRs+5d%{gwW7C^K^!B}Ma3p})yz_gsLg9zR)IjmYLK5}t&Xjx|4p z&;77uN)rg0p>QvTFU1Q0@s8LDpo1IOlmg3v$)@)jmi#wLti42|NRgj1 z`<>Fx>U!p<1u)(n{I|jWB2D&Cl2zKTRvW*0x3R116*aFs^_`$D1x`gJBMEezh2 zYEGK-qT^x4fYzv?WgI$xVqv=4oUw;E=EA9}X(^#R<f>_=_c|{r27Prjh#HC7g>aqu1zg3Q@tclAS0sV(~KTRHSr(@^(zt}#Wo6uPQ^(L7Xdmxct%gRSxjf$ zXF~c)?7fKjU%B#&v+zJnj)`AVU%BPp)UdKSfz0_I%4tf|EO#x}l}HQMYCOhj5a5h@ zR0&86ukxA9A}c73*p{b9o+y_QyG6H3u{$IMKH- zX4tK9wx+N3x40hO>)BcG?;T<94_E*ow}e*xE!7XSU)dLtnKh0@m6cU~s)ED|>fd72 zqsE~m+{=)>B<>2VP&b-XX|Kvr&7_r4QrQsz$|aPM+eXH0J<-U9VN4TK`~`hNWyZ-X zwPifW$wId|Y#(bNva)WBcnNm7}K5T^y*kWq$`-HneJ@`S6qu#j+XJep&k` zk|Kf8y3If}Hbm%Q7ck-0P^Oa+kz5dJ_Nz+BR{QJgNK(ak;?P#%X!F$mwNV$%EpSwj z=u5(_hJ$T}%w}mitFs(W9=I>l&e^+I;%IlwBrCs*jJ_UvcV+H_%txzi8*TKBFRW%O z9d&!G7}JqH0famI*I%GoMXl}kGF|Ju4LBYvBS@W+ zTt!pY7cpPl8WF8CpaaDri}tI!p3P6C8#GqM`YR7d5!s-R8}_N!wTxsuRS?5zcjrXL zP{f-4_LJc#kGEM=-hsHpjcjssXbGqF+KSY1aUw;knFinhCB`-+Fjdr%mXv^wbu~H_ zttzjRdY8@X(R|^172y$Rm5M4qd5AMx`m zA5XWq1U@-GdzSv5iw{#%>qVLAlMI^hKgvhZ4f%(1RejP&LcPYP!lY2?+_a}qwOCXw zhwwS{i^jBjUD|EgQcN-n1EO{qm-(1>Mua!bT7oVQqW3uNF}2 z)O1ec8H0;|tb-?pJS{5bDm8qD$wIC4S6KZ9{bLb?6snHB-mqVXN^a5LdEi7J@W;DT zFOnSw&>hlj=(EBffS#UREt>#?qGpnYq`{D8lQw}rq$g@UXW+wM0)B>{Uz{Rw^SS|* zrWN%tE^Cl4=2Dxt3BEKwj>$iS!mYav@VGj+4DFOl*KPS!0SJnmJq->MmDm-wW`jOT z|FFY61fWB5cBj~{@wCShzf_DLC&X++t(z#(rtLCexwZZTB4|rny7lmZPi!_Ix~I@& z^+1`VChs8!UUSadBmT5;OR`T}agwb|*;I{xYOb@Gp{lBA?bn%BL4(cu>$SeRn4^O8 zKIhS=(v`N`zw0f%puVQ|P+7kOqemvL&BK~N)0dI{vbtEh!jV2i&LzUSFDa25au=Gx z28{RCni$Unmemrg_RH4ATWfcIapa1tb=uSY1K&Uzk&kt6N-Q>->hrbsQ&dH}FVOY> zpC>uw^+Em8)x~vv=24EV7GEA{(Wi-TPLnF0ZeTDHCwknrBu9(nu~k7~gK11}!a?~w z=i_L2#>WR!n5RPR@x=cPLA}M##uzLd_Ct`IFPb-k27RWBTQwXK$?aycDr~SU;xhBa zNm6ZnR16F=wFb!0&;vNpr4oU4DM~6cl*g?=51<`eFB8X&d&P*C2WFw&CP%%Zp>m^a zG8+w|@uq`)1+_j@k{lM)`kN6n=tR057LpD3cS3*37aL=BHnj(#lK5Y)tjmnwbv&eD zRaNm82uhpXT7TcL^ig6YP!)QiV{*trSv7zDIf>cy3E%YGs?*!qa77N%KRmrn=WBk) zj16e`#0HY8KD3jKzKOrc>HL}7?4W0|}d zW;=&^@r6+tNC|(|F?w8#@vaZl7`1A`;I8SM$7ojTc2=Pp^`ka1Ep0C2+T2c~#dT?A zX?2X{Jdk#{{X#Fjj}f|9l>zOb{?f?C%l!V1QUO^=yOp1Rkbcm2lA!WCpx!&?#`O(XHR{Wu9Bkl4f>>EioLfYB6I*m zr0ly}+uvXsL-oSUNO0-xOq3KUf=qE|{?dY1`=Xlb%v~1)Jyp8uVuISWi!L(Y6_p+6 zibc>BkuZH072oy5RT2=N*c1_MbrL2e@gV;9f%}1!Rj52oS=BFHvGRcYqVUHKw-T# z`e&BOIvt!t?%_cl1t>_Wn+3Km-Xc|pUdwj-+ctfYfxNr@qIFBsRvc)*j!&J@>#wKyq=52Rs!KCTFhuVCC)~=9FSo)R1{8WEmG; z*L@Dwv&Q}vtR#^~$*+k!;cSi2lKB*@yN~i2Z(l3*M*Gj~^$aV2K73n!ZUz65mSwws z^=uG9AOSJFohkn^eR)Onsr)rBWP2?Q!fI_1@^Jm0!FE$A#g?%(sYce{q$6c0KbGoD z-Fad~b$!?P0!Y%I>mSSOvBsaRnCKb!skQ>%d1V$uLtEV*)u+M@0`nhnqAClp`VIV4 zdxy0=yL^^>+D4|a13`oMdJdA`qJCxXA`H7-QU?EIy8(w@nyvc(TYuy3y5=frvj>#* z!-vOlXQF>~Z&u_vi~a+xHs!~D7P|jA=rg@^Y)Oy-++(kk)WwwSx&8x1`_}j?nSSGy zv|n+6lF%+#iRjAztUauI1>L5D2eE*MYMcEeCKM?!(2Z%CB0siEb48!pujx3Du+ftK zNq!GfOsAfg>77!Yhh#^aU!{4v5p_E4Tp3u0&h;aDJE{TyMz;5XRbIa;N`PboYDY>= z9T*^d)vAGl*`Lk!)0fg=MgGvL0H{D$zxbK$D6(R{&z4ZGKY_5y&1V8Jc}3aex1!`W ze5{FNrse?EM*8TiR{@JDKW6-kSwEnmgOenq2Qbv`OE}lkJE-y4YxaHIB0rbxhv_pq z4P&cxx<0gOC@CX-hMOs0l!uM>&v1Gce|1vNLelEuDClTL`&(nL)K(d^*`VYkhq1Ka z7@MD)%e()Sfx@5_WB@gKXG4<&;|jrslSUMG?zdG>b9DFNvYY9nhJ{2HbKkJgQlxaPbDL9aPkN-l!;Ah%+VeXe3 zGG4G`m?@2Adr9f4l2ELj03kjzY3U^4>u?NvZOtI;^ntdj3Q<3IPIIiNnkTo2R#IwJ zVH3Y0#AyJe*{x=I$=KA|w&KGWQl%zh^vovxs`fW(yaoHXVTFsRMD?5Y-LJ0dcm3V6 zfkW@cx0YVRX^axpj$&Se?^(Qc{Z0Ch_|WE3(2$&!b&F0)VHT{iiINN`y0I*4a`ostE%~c_48~x9s z<0Gf?#rCuJK!;#?=yN`(fHLn63V`v~;0l+2-H@y{jhIyX7M^14CJ#ZG->9~OUki$v zgEFg6eX9Bj@l^SaBJ%a|LOzMAP`m*J7{}tqtOP_Sfz4fO@w+{*Tncne$){Gu(5iTU zX_arXT&dy)eX@Yir5faN64SvTm2}mUwF)B0T#s-02VDg6KMdhYE4CsQ)A>cK^m?01 z*t7dI@dxRXU}c+v#w`OH>l((V5cD4xdt|$YcgjTnl=VUar_5G2MI^>%ZVc+J^4QgZ~VKAL*mf z$soG|b=|!77@_siL2-{V#wTvqx3rRYR$rZIA*j!RT~aq^Z#aDUAgU;0;v9i`EuYoQ zY~x!Rqpd9jQ8L4OsPpIL9k!WXA#qI8FbbL2#E~@NZq@5eeA|e*&GIu{*F#ht^vjoK zlHTNOjz9okCcQx+egRn_frRm5!^bqK3i!Ut6bwX!c(9B9*08?}7|2AC!ru)}CJ`iE zX!B9L8Z|5AZo0#tVU`Rc&Gs#ISp19;-U?qypSqM|wcF=hd{Bj++Ab1; z5>iBJ$zd^7G_|-o64%O#fdl?Sg2e2J&CM@xlC6?#RFGpjasB|OFb>8Vnju(mNMkQa z^-08$!4AwEY#)p$4rRZRxkMf+S0KzP(dfI!BV>8P-Y>`Mar$v4&qzQp#p0cBGnx_r|9c^{J0l+YY zjP!STwl&;E(}!Nm*2THxkeRtQzsmDUD%7IR^nDfOU}n|sN76axsUt3-!bLi2q}7z+ z=DHIrbS+5EQ&!^j$-XKH0H;sS*^LAE{Ga7#S2cTa3M{G}ShO>LnF6QKrmDU9$!6{a zrp#A}*CJg8WzyhWhM>Xr-8J_GLVbk3Trzr#Ykj)NEd+DPCaTaj9`a+|<{ly6FIPn! z-m^}&Y1bBsGF%Qt^;EDZm?7GMD3~HX;Oamgz8Z1y=-IoA0=x)co3Zz-Us!@?Utu;~0PW5q)#N)$smarp| zlY1t0O4hl-b8^Be_1wNP(EG592H<(t#yJ9)O@j#FHRd<600SVGd3O zrfZuVXm3?~Gp%Ine%Me}yE#iocrrW#h3h(19+VF~K$8tdPVxE?Tlk6|W;) z5p@eh`N^gpw!fo)GG>b!dc97B;DYA^&g#ijnymbv?xw29Pr(OusB({7lpF2ro?2`^ za`P*d?o9n!o2nVfGl^VWJ}>HV@}^5SrfdqBAO~en;efzJRDgTg#k1aF$$}Zk)Uv~S5i_{owcuet>d4= z^qCNvr$5x|S8_2e(*!OEF2vKr zt21YqT942Lk~wLEm<-P&BgR8`R+ z4Q&DDxMuccgSGf}rt z(XGF?BzM9L@ZzK{hQ}RcwfH)0Je((5Oa66o$05G7M3bsJbl8{fn{KuAL57aZ&({hy zs)~CUU#79bB*yLT{Y9=Rf1m9SP7P?Q6rw7k(rqY5;SQbF0_H|5wZ;1~G-hVXE3v<4 zT=dbPK>URCx|KhL9f?M%6`bW)Vj>V(!U7x^oQ@0~LF%>nm`v?i`m1#s?=XE7!Q0tJ z5A}PWhV}F#k>Cc)dLYbkkL+5G-4=T8pMy!Maz8h3y zkw!1~R~32{ZhEqsFsMGWIP zl%J3=hDr$l_F5rA@uWF5;SnDS+9dB1!k--n#KX0wd8qBK_C;FyQ<7IEj)v9nmTs&* zjQ*VEqsRDF1WY;^rpZYlNtb-JsWOi?YO(-?6u+x*(pXEGK2o*JErQCLdIhrC%!;@- zMwyz2efd$QX4SC7IjO>h1IYv$+!Xg5(=Edx?QVL64Ej7%!qq z#`cE_G7c7i6@50EzPd>!``a3HCuq5qF!e)#^;!srdx@Vx?7ZAWByFeD?`#k}=c5O{ zZ~e9aBr=(jU5kKRse>+!001BWNklg#Woks^+Z?druvix$DW|@-;Vr~;hh>Wf^7nAZqRJ|y%5<2K;x;ET&Srqi3;F2 z!$&7cIXNbp+_5^1R1SvqW3N|)%jErK47{ks8=E7(IV!X z9pZnJqrrLd+OPa8wE=_ADH75`pJJs_v>^pKT}ZJmTy0F`qA%gJ%RI3pw0FnZI~cP;WCM?VYCrI`$9eyO&`tj+0ZSB?WhaY{6(%(k)h z)4F9G#OGD_#{%X!l$%=nSU-+DMU2HQu%V}i5LHY?F{Y-+#F>BuO)oqWnesSnBdNy z!Xryt2HD3#%0X_v#d6w5FVfO)u&`X!unucW-+?9ZexND!MB_cNH_8G8r#a^!kuAGkg)1|UOu6XCoveB-ENGL7}o zydHr;nF|+nEkJNfpK5?&D!h7MzAPeST%%ngt8slJ--j8c(eZ79Cem63g_xKJM=UT> zYnNqk)6EcY$r?Lh`38zf00vA9`V4V1-V-o1Sou7@Ih4lGa1D z6X+E43+Mm^+xk0R%qk9*6a*tN)e!ot)Yf#K$XV|rJrxLD-#d?-`WqWJfKVo+7S+=#&`q$>Mtbeu0)#H2tmWB6N83A!(BxF{n) zY1zU$Sl{9XC@}Zrhqsj)mBF6%*$b^d;b*FOxP>+YttRBl=;=rc@6#tr5 z*=i%#u4-zk7_I%!lz-}69{8S$uhG-$mPAF~!Q3g}s#>~7Bo|2fC`@CV*+i#_7esk`ZW3va%Hu zwG{GxF$aT9bvm|^>n*sTP@j=T9$1sHnorvMS}YZZrB$B1VDfQ9)P#rdn?cUI7^xZ++ebi z7}V5MZPYM7eX8~j1%&zxR+4``Gs?`c{wcyALde?}bT4U~gl-&Mlr>U-dS-wT*wmjO zNt&ayR|*jzlSv{5twh?x<+G92rAvn2<< z!O_2t@WkC#?3-yEus}LunsRyiC;c~agRENiv;`oxZ|F=JvRei4@gpzV%twv2{u>U# z$S5WGRAnt@dvBN#&c>@N#%##q7n}z~b)Z52S0GwK#2Yx3R<7&PUB7~oRL8711Av^U zq>)+xAl%e1^ZQ>DvS8;^_ObCKrB&6W0CPDyC!xH^|Ir?;YqIb)w@Ds3g{AW z4+9;)vbPw)>vtM|D;JGd7qnVc`d|urlT}2ZNVc+?B3TYjv_79j$uvE+AM21T7m5q| zUo^TYiGT=tF%l3(nY4*b@?WKo^E-y3{8r8=6Gu`X=c};5Az_h+P-)+FJM+I8>L9o8 zi^?qkQIvWps>^)E!HN9nrUQZs2+%h2QR5g8G1bs3eX^~qvy0kyzw-_`_gj}AS-6G$zi0g=YgR1RadqFzq0U*mPBQW{J;CcxZA+)*DsJ!J_8!N`h=rgDOlGoo*y%Q@86yl4Sv^~w!Cu`e1(xLCWAYH0ztHwgvo6k(RDaU{X7tI{J7|Csa!F9H4OKx9 zNzk+u0Ab{e0;*jw@+sj6kaUUs+?p;Ahg`Pi9kr$>h#)@^h@G@`T}*)gQ@fV2x7vPY zIp@&ds_CoW2}0Fi-;*;1fv*YPD1B%SOIQJzHZFCFN_k1Sq`jnVyG2rPW8A%{{!3Tc zUOCgpy;aD;IS-Z%QzLQ~04tG9qEAgKoDik(DqwIAX*MP6YTkFTTKqrZR|VdwZo6kv zZgFn<*yV&%$hL30fPcR@{dI#<5ouaRBKm3}riIF=v;%MI(_{LdCi>^tMj24Qv+4<4 zO;pvQ%`xgZ=|lOQ>dhqoNM3*SPA<5}rTSgczuKl7H8-C2*R}s5gcu)8WtquIGHB}!b=6|MfMgjuf$cYRnv89gcZJh(dtP%2~Lri{NP@=jI z(Lkv?O#zxL2t>7m+*N}g%0x9hY}K=&dB>X+)~PdpZTZ2oU?m~0P^y8ZY>LxLxTGQ$ zN17ecQig!G^9BIedSFN+bK~%Dr~GGf>&7U*v)W7MP~HB`^ORI>%yTCxbLOEBlLAZ{ z+^8j_^kQd3Bl2v}X^NWe*;9^$*wB=_Q?w}C*$mUvHURB(DQRjQdGMv91t zf%C*957y~FfBz$Aj4F3hH4QOG3(44I{7uL~{zGm6EWBd#9H@o)Wlb@3wgtIiD_`Cjw{e0Ce4AlO;#c_{6{0oBBF^NZ08)94(6piu3xRC!< zDqo1dZ~^*MHU+NQ2f8TvN$qu7s;8k{c2C<;BP~0X*dURE(1W|Enf&_^9$YOFCqq467Ri_IqSTA74-i;`Zr#$ z(8O*cn@0r|n)G9aKe5UwHMoL0Qdnc5h~Z*QC;%Zuq9RC8noTo0^iu0Tf$BzmER9lO z_=<>HiN5+Ks%`>U<+uM8T{W=3Sf{T~%LTt5EBi$qizV3_-s`R!r|S~07pqf{{@2F# z{YAE+bF?23!`$?tMp8JFRA8UMfSc*f5G0Z{Cf6hpv(g;N9sprCz2MYVu} zbSOr~zl3%~D-cDQZPx{@Q7-})#_RMjSKET&EUm!Mb+C~W7G){01PHplc-fXquDyKY zK!0BY9CQY;iNBuy?lepPxRNHQ{P^~RZy}ef1a!>0Tv+~_<+D13yqlG$@_W)#Xn&)- zOa#E?XcbGOmNA1DRB?lv)nSZ`0BNnB)m^xRo}!$r_h2_P(}w^Uj;nszS}gwwh?XVF zlJ5FOvE@@Q7>&~S)yA(k| z8H;+N4cpHIIGXSZYAr&-0*Dx+ZMH_;UM~d1q7Y2wm%BKJS2wDT{^)P;E@M9Wt2Fu` z1oW{)G-!}3PalqbBk{shGD(uZa{Bw;h^XjpR9XKWz5{E`^*{UW>0?4QpZu82r=cX= zCO8Aos)HyJ75raaz)d*;bkOcS>1q9!Kxb?14!9sbNwlBCJ{v_TfwCr5s$B$Z(%;Hk z=a-*K`@XC5(w_jTqy8UY|CRih-#mZ&;FeySNATt^BSlTR-W7eai_m00F;n?D+Rv8Y z&duKo+fO1wz*Z{+gn&?>U?)%v#0i~?r+?*F`NEMB%*Ed%;Z5>iU+kwwm2B#Q9A*RJ z9Z7YkjsWxc7q7GFKL`xNg1LjhyQfNpf}kw7zkZsc#~bA1b~x*F6S zaJomI_V=LPDgvz+Gfwz&KmZ~N7}#nd2DjEU^`XKuFNpk|GqS$_*wlX>09s(7mQmWF zIoe0D$n(<2tZ=$0*gvNaW>7=|B8-6K*5-J62oHS-(`lS)KH1Q9Xl%Gv-41s6yV zLeL7Cs5qk2?VLC@-syC%+<3`Af1hkILDFh~m|>luH~*bp4OIR9wRw@)k)oof|PTMUfC80tIqjQ-jRl)Ut`ctW)4F z2IE4ilh-!YK0w_*So=|5+Ni&XSh0}lPGW6BLb9p6%zoMm`5|1WGqbyP?->~xUa@k;(C`qet~2Tch!lY+@;Yr` zXBjd#i~k2L*NzWY=SgTS!dZXoYp}oSmKRbh=tJP9X%o)09=CO;0oc)20e5MH0jwq4U39o-q+qK}T+%!7w2! zMT~m0NILgya&jsXm1Ugmbf>1LSvR$`iuf?f5X8fpbz~#V;xg7EDD7;J?K*oIpgz9*3i=~iDj#Rs zsB$qmJH6Q0!dxH2M*KbaK{qvJcM9&a4y&(%2$M52FTVQ5H~;ZRr%s<68SEeE?^`;$ z;JWv0B>@-+Afb#Xd-6YQnhALWNW>yw2q6>!ArLP{2PhCw&odJ{XnI`|@s8mU0Aeg7 zM>S=P09ZyMfRaO+7>P>Y8KQv6>;lMJLXZ+51d#58BIvod0SXKOp=hRhpfH8=@AlGIybD1m2D&@b5y}()5Wr`9M(^D;7BPeYj6-v)r_;q43J+901M7 zuJ?QLq+STx&gh{p69%Q9oPJdGiSc|=U*J?@Bf9* ze)fhNu4jVfX0GAG2CLqmgv|WSA_-b5IY*mSCsk)9RDwM8KKZ znImr9w5;h6gEI%m$j!1t5$F5Ti*X)7Mjk%pZ#%q*C;QG;+2%UE)%dAd{m6 z0+~p|Ma2#!sh8!EAaTqAQFl%vP8YWO`c|)8apl%6h!BxlRj88!dFf#ilIR5^FqTuu z?$wg)Le+ChIw5V)ne)1+Bubav+q2y%@)g%XI8$)xMkRCTn zB0+v%1KEiZmD+;Om~sRW-1-O687IG_nr7YL>_XvFQzA*S;HllzjnW((+LlTpnXh{#$~3O4*yb?!<4(C^EHy5BSQg z2w_e1Qx-uavQze+e(uFDf8(FeotwD#gEtKi4({Fm)|xfTKXT8lTuH(aco6Ev$a9(~ z%_4}=;R7xlW+(u$B;-AAd^X5i)e8On6?Ud_N)kkf5g-Pj5Kv1*UDb+;KuL%taw1gM zjoVtwXnfD<6HmNz`k}{Pz`|v2RD3ev43NyF3bnC^`I4u&7r%{Hdp&-$4=?;lHef47QBHY284*RO%4I1ig(#fnB_zr2(lWbQUobPfB+aU5XD~E?RGog{D*%kT5X<~Awu3+ zOl5@t1G(UoSmvV`5y6clpn<8(G(wUA1qhhLhH8>V+Mqe32y(^_zr|jnyZ0eQiZQY? zjQ#!nH{W#K*3BFGiZ%eo2zo{g+6Jb(-G>#4BT zx13fk*J$(?NG*YH0GXSyeZYt)0=67ZAOLIu#zOX2$0VJKshh|X31+)pAURIX!T>FX@>dycQI;YMvCTPsGWqwbP5fBom1-tz0v9Ot3!9$G zrv;>LP5vN@4hbZaX}YJ!o5}KJ3#hdFZ0}L2Dw2*$88!Pn?xk6;Ya zm#W01*Q6olqB!@(#!vQM&2gY^V{`8O`Rz|^UobKIr9}iTEiTL6-+l8NZytK{^Pl_0HLKTzmSkQ*H2SE!IG_+FzWxNe| z`t<4V{L}Xku)p1U;)(4y+;H9K;za-u3T(An?N+PR*9L&TcB`+iJ7NwUUYfU}$f`nriRS^pO|D_$ltFo@#>T!+>igxpYbABt zf3sFaUzI*=l#=|Qx?$@e4;^{W0E7@Q6a|@qBlVnwfwxFRMsgOzav#l5R6h(>cqX|B z1>c-20C-AzC~u*=7-OgPiNEu!$A$GvoU&R%@xq4nNwAP~(>rN|#7Z3y+2e{1E~LM1 z59s;vi6R6|170o0ZzOLbxVJVvk?)YK$c4O9T6df0FEiBau-k$aRk(<6F$Tq0ie_uQ)I_O zLvEYhm)j#JZTTE+Sk896nZiJItB_D%L1VB@>GtWN%(w~0mzfGKbLP1ponm{5I0a<+>K;meXcyq!mt3=!>(5asoq0=kb7-clH1u7!0 zSR+I459;!t zO(*Z1001BWNkl;UNIigdRN+JMgFeiSV&*wxglnqJ{2dR)JW?B3L^3D!u+hIW( zto4)EIdbuvB`K|w%5m+#jgG^)s54g~4OvmOMGa}xT8|)CIT};nX$&?7iT(lxO)RDB zXqwJ`1Z^pxoOh8xp4m?iJ_KA?Ry#B0$#!QtUd1knK8oxU<~3bY1B(EYlaqUP?>cl~ z|4ldFx@Xt!7oLCN1MmL;2R1z9A{70DLx>oP0G5-?2L}g7iV=3Koo@HcnKP}T7#<$!>uXa$KBd=N7zulby4~Kgb;Opw}x5k1oX76(jTk(n_V=Aq(e%S z>)=fX9gXrrdj}(UUgSKvh;Rh@NX=7lTw=Xhcx$KWU24k5o|;>op?s*}FUxBH&~e z5^$;;)xIMK$tb68LgA{-xD_UU!Mh!b&hkV6DnKz(MA1AT6yu{--4jP`vOWa~17jjG zcZ5v0s?`vQ)|C7&IR6`MUfpnu`Ca2NWTrr(f{T(L-6AJvcZzmwbT(45LbXPrGDd1a zT}1*BH1^4#LIA5_NdoK^-qfp?eW>HGDEXGNQ3D`B*(*Dp&fw6%dK z43v2x1*#PZ(V6XR|2<_&G=O0yp(FQ*MPYBfZ10f#@g23@w0cI~iPlM$5+te9GS;hnT040zFlp+puf!vb| z*k^qWn?%0Y0?5{=#YnbVNQqUmx%F=8FV!9^y5`@}EhBZxkm{=KadH^~s4H!{$ParX zlgyigd73u%9d!foJLdvwow}M2M;`|X94V^aYYzIjpXBvfA%vp|X2g(25<4L?gBH=& zKccZRUbJT^Mg*9go_X%YSN`Iw-#&9{{L`QK$h~*nx@zUpq9_0`#z?ViBUGd--Bh9A z&M;3cW@F#V$q51s5~MXaZN1&NMxyr$#3bk^sk{ONo%~VLU=t9^qMo!?o@npr76lIVYw){AC4Y9_LD~+ZM9mz{%gPX*MIfbyLaz8{`QHjmtEc} z1h|HP{e68I$wUw!a`uxT0>1e2jz9a;KYs3+XS?0bfBfPXw?Dr9{!iZjk^4T{neBY@ zTi^Q0qd$K8`0-ZJy7t=l{>(=|_TFo+1?*ucBqB(YNEiu@pE&X4(@#xJP5$yP|H`qW zZ~f?zAMV<{^YSY$U%GTjC_<~9DyY)}5?yp>1j+Up4_I}Fjr7kyRr=RLl**lE0xB)e z?Z3on8Chf6LoB;B^Oq+7iYqmgGe=-fR{aYCxOM6P0QuB&oh*t|J}s=R*k_GEpy|5&nJ}%q zD_f4iH^vm{B|{vk3pw(iz)oI&wToOTw#4sTx4^RYF<_I7|r4k7WUjoG>#jYxwC}e?fdqnwdfco(^yX0X6}H&*dPaA}!|R z7xLRlza|MIA8?bW1Mt?rO?SFbK--mnT~qQb+fd!FLUqgp$oB-Oa#086|MesF-}6~K z5L5?DirP~ogvUDlvAKQ0J>n)4^uYq42;uz11`OM+!cPh$nwU1B4$$0E9$=I2*?rpuX3io$1bMG{Yoe3{Rv+^`|PH zQVE6f@{HYPOw+Oo=MOlAAf!;C)2-0}q}q*3dX18#0<5HFn%h)&s%*gq`q%h7ha@O} z6*=VIf@GFN#hfq@1_((2(1s}jI=`ec+DUSFc%fX8i1z{`#-J_{A?CJ9d2Q*2`9|T=mQ| zPyfY#`AVSYx11Eluf zTSBE|?L>AzQoh*C*17mgYMBI^N*lo9;A?PfALifPyj9bP50x2v;3o zC05oD0Lc-X=Hp29TwE$TU*m8N6D@>L00fyL0RWQ4v{m`6(2a#O88Lu}0H|G%^d}J# zr32MP!fds?N0MC2AoDX|q9Xqic<<(WVi38azr@zh&eJdg8Tldql8EkFv4624sp;Qj zkqqj^%IzslUu{Pio%E{StMo+-7k_t>=kQ|Xwdsmh7E*qKb!tEQm&SQ|qE-q$eJY~@ zsA?MIVw81LSnaNaD*d5Gy2{DoGA)J#fD@Bb&%N~8x4!%Ekt4_Mz5SL?eC(bzt5>w! zZC%u$+D>zm8cf;DGD-l><3m86$P5IWtcVm%#`Ga9OI|vtEanXZEXzU!QDU2ho}kA= zM2>6f>~-QsjiWq;05*k(xz6~crbbnDrr-tmD-4!aa-D?wr}?TIskE8VpPDXKz9&vG zGc8cJi!p!7)JI8~9qvv*rY6Zmi!Q`SRx{_Vl35{yEZbX^f0CQs;Msf_J1;~Z-+z6! zP71Nj_6C+bB@R*Ht6e&|DYFBo2Q=ZEed8Cmf9Yp)(Zc9b6}%g6>ny*=AD>h*e) zlapu8jE|o=d+Ow=lP6A&j*Z=Q_uZ>juU@)h#rto+MA1Ar7Sp-b!7fP_8)YXdauRUD{KTD!YzQ)m~9f1H7Ux_@`9 zvR-9x!kT>}g=!w^K=vhlueon>icfPr1zB1>-QZEYd#Ngh_r-Pka1EhU=8%94HJupH ziiRuGm38};w@=I+wC$%P2r{fAj@V(*)UGmiUil|QUg$<0&C2SPv(nz9n|;Bq9McrgO-)4QV7v7%MRz1 z(IH97`}_asCUYvMq4OgM(8t;k7^?N-#pAG_FSpLM%uFh+Um)wR{P+weAY zZ=3639PfxO2XME#TaDHneY3+>I+WG5e1-GFSV|oQCT{7Mfd? z(w#h%xwhqsI^BKW{@Ks5huX zP4tgC#EQfq0G&Q_=Ez%bO`M-#ErCdg*y(guuUWlx*|L)--+A-Up`s|tvIN8s!s4-| zYgVsbxL~BO-6DWaw|DZLQ~UPp8Xq4Y93I}ZVbki>D~ARK0JD91lO$AN2I;R@M<4*M zM2nGk)m&Dby;_GxHqS5rslK#^$0@(FvHeE*m!HxmX0@@;?G&?j&)P}oQaq551}X|IJe zB^w}1N*LsBlEA71t_(ZfZv{D651T6ViH-CT2EF;_p=X|XIs{y~a@CFF+g-9` z$%pWdC#>cPo6}?l`B_1`Q*L>2M(M+KhfXcDvE#sDV9tDpV9)S zU+74h6Opes@h%Tg&F88pH*=~{e*saBcS-Vz0CRf{H5M6?#OzmZ?kX0H{Ka{(nvGBu zZEpFAgv9^zkv$4Y$TyX>4ZKKAp;)thQvhxBG0R=5jm}omSh;RSRn($J)f55nolTlc8QnT#=mk z&*5MA#TKDps#Ue@jB?9tF=>BMmN5BIq5h$9Z}pUO%IsU-A>Td#0JIW=ZK^2UTwSwU z8Jp!H$Xygt8Mh3Ip_p|htci;i#^_d(>0PKx8-3@^VS`5)r1knHW|-Y+Z6-IvU_FwV z7=Ef=_YbxA+E4ZN>-4EyWIFiLPmj#12^hB}PTj0jbv~V{o*80_L`VS;5vHeSUfA*4 zw}1HP-UElPyXNxyKYq`a&6l#_^ej{Hsf~+xm%OyN&AU<}IB;P9x4-k<0|yWF_4O5q z%$M^M=kL1ro||sE<+*2{`_6a%VPIe&mN7yMfJ>GwzxLYq-gfJ4>o2_|w2DJ-9(m{o z-+$(*C#I*Ti&lHvwr%&^eb?37wk=#R60jPnBBCvkb4VtQ+Kgz8XuLs7B6W(^a{4E9 zcoFpX7rlr=&R?vxUvE6gf2?uYuQbi``daOg7?;VXU}?(QuS-#_ZdD)G6_MU#hF}OO z$cFob#MSlM85e1gX6PCAa6*PBqqV%4d(X9PM2SfmjjH`m21xsl=}#LxX(H1|=W?GG z3ab$&=S8&(q;=I7qrc?$PIZj{PvFO<#kjUC*omEG79iQu6s|M}qILOE#YVuW)h*LA zv%B}~+40Jb+1c4ApLqP0S6*(n+vDSBPM$oud-t9jZ@3A0!vKU>Mnu$dAt0i6-g)PZ zH{LjR?p(LqnVFs*9vU280cJF%o*fBsj`qq*2=g;rj zwe!f4!<#m3WSWd)mvT)j}0@}l(Dvr_qvUa2em1@Dz)i5*r%*Een% z5R==qmyoSHQUmJ;QSpqV_CMGHT=T!~b?(4;fV6aQPWb`ZZWkqhBS6%R)S0}sP>OV6 zWXPx@0LSf3$8XQZg_IGCBz>ZaOayAc!9pQNE+vMHdT#zI4AG0E#?8Vi^S{tUA7B^a z53|o-T9F^?UgqDuQL58lOP770U!Tn$RurZB9Py4686OCIOOSG7TTXDZgCB3Id7>HvrYP_okd`AA-} zqDOJCTR}j3Z%FhHwrUmtMJe=8&_4;~MEk6>nMX?z2#F&W`NVX81VmJ@hbTSbn}tA4 z^r<2g0|LTAwi86Es?}Si2nZbq?OE-h=(Xbc*i`9Y>zZV(Y~EGKIsa`=5&?o8PB{Vk>{V=ZZf(h5CCQo7!vwbvqU5-6Jwn1 zb$7hB>+c`>$)5dhzW2(@?*G_5S8v;@)T;sd3`J=yud)2uWHI@z}AqpLpVl zBX7NR&DHPe?`s2K0O)qRo!QRh#N@&K`(Awh`AgQX-?(98+3U>C&c68kbI(2V^qJGA zKXL#43l}VW>ZvFG<||)bI5K?w^*6kA?C3xK!?#YIdT00xU%2L~tAgy-C>p3RoiUDz zwO~}S=Um4SNLIBbW;u-^+u4f9l-vt5TJ&DCk9Gbw2!lcOIeZ?PYEdGi-5_~8pO`o7 z?u8i=c4tXW@=w{26KG z)KUvx@kF-kCa4itTc7Bo$(Oo)$S7$}A}0C}Fj>;##pFOufK;}lZU9k1uD)OxWliLq ze6L_i)mZ0W(W-7_$uQR2A=kb)!j4qCutyvXh^#Cc)kce8X_RTlnvTog_lM|D(Gc&WZGc&WZ zBf}$|+0y~BEMx4I5V_DHAY!}S#vt0&>2|xl9s(92^tIbV!$W0RQjGlr{iBP=$}%22 zbm-`@qeH{PJ70UPD3B-yZ1wl`@7=xo!2bQ~*RLl6458O6L8P0y-5yt_A^4n*?AGn+ z!^VS^QYpp)nh5Tl9VuGjT_QcF>}E;^m_QHgO7sVv@lF6E?UW#r3Az44cK&AS5@ynp zU=5~_B>_G!KZ<0assB~^!PHSLTag{t1pEUPA*6LYLOwzSB3q`&6<}`O8!e~8mjvx) zcGY!&2mxaj%^{zP10Y9g=_nw2mVu*>s+X@L%IvctU=CDd1z(Lm#LmS0FDsSXsLDl* zl=scy9|vtkQc;!Nm2!F3CsPLbMMz-4(Wj12(k^FJ=7K^dgRpr!m7Dno&~UC1|0wkZ z^%>>?T}Gi73BaUPsL|{nZQCb9IEnte&IGLhnwb$Jg@7dIJhOLWBAaaRVK>tCpwup< zEJ{kpgKVzB79i~|Lqug6JKavZD2jGVxk6>cZa2m32iL98BhWzF&nOk5D@~}hMI_Ib`pUA3%3+R`UXxAQm?pr87l=` zR>>x$L$}>?qbR45aPti*2GX-U{JnKb9?7((RT+WsEe?KX7Jzye!KlOPAbp?|r}h+rQhJot^E> z{`9Ah{=fh6&wlXG_cm|ZxM9Pl7hiau%H9`#_18c3$xj|Ra`^ZE;18a8@`*d%|Nd=P zToJI9@GB+12)Q4(f6ORWSS&hV^uhEHxr*0h?p;++ow=YMv6jS|T6!lN_Ha12?7%@T#0I@75 zrzYBc?eml8r)H*Oq*klFe8ut*inrc6((CrhUI|DiPrN;T=FH;7W81F0a?6&@4?OTm zK0E;s%3j>Fckk|9Z;T8NKk&fM-*($=Ww+aIw zvMkG3dP{3?q@usI^HOcnpblrN6P|VcqULm!{7I6Me*xJk5J}_z9Qm@Z5nBYAko%_W z1)0<(=x1_WwLfISpz}FKhdO=yOPL4^Q7HdGzLHEV1mwhrET%=!U0tJ!qAN{xgUU&Y z0Ei$(k7yP7yvAJZ8;D&)KV-k>T4fGAe+8IFU?v(B!C%k$n-UG)Wm_jmEmNH*e!tm_ zQ^_?jZg#~c)^D}|F(X{EZ*D$JmAFP$3Ob!3)xRfp3c!Yfma=7F?=K0Q4- zI54ne$?`L2PEJfrsyal@_eUxr!r;*0s#UAbjGsMs_H3uq;U&MV{{BTHBMTQU8XO!9 zApk@UVljknw|nBm$=!SRu3xuy-6fY`!EscWnw&gz)QC*IHYIe6iW2B}u4Vh$}l0Bcc z=|n&|zZ3wnrd-@f73^pqtlkM&X(`WZ`k%=13bCn_(3>QefvCg8Wc%vBXacYPBPy^n zcc?8RIQoP%E2<)kSu2%-mg*mb5UE5udhFO=e*HW9_8orz&F}daKXcdRTQ&L_jeXh$ACIYuBtE86Nh0ne9&Z^%t!o4D=O418p1} zph%y%|AD8Tdg9qjq+PXD8#ikt$drXJ;i>E-nDQ4-ebp(wpy*IYia>86h*IBadXYA4SpR?iUw`KB{`=0ym zxa0QWp`jpf9}r-2a`LgqAN#{U{KGH&@-O|yZ~WHKU<(L|;K-39fBB`qd~ri>g#Z8` z07*naROQvze&@G;_hTQu56CJvwMIt)5ds zE0%5Av`K?$Ha|!NQ&Uq z=z7SYQd^Zt?hRzpuXZo<#F%E7?OI4b5^oiO-je3-M|S)Jgy4dGmm_y9Nf)(y&Habw z{#R~je5#>+f%2Kw%MDV)B^6DiM$vk1qps8^c;X4)8t5ZchPm!z)jsB5suvMTiYHH= z{PVy5=H3H`@4n;aPkrJeTQ_eo^W>t002DX?g#gMo9Xnt~pY%1RNqPi8h>>EXR=Y2w zevel&8DoqzG&r(kY{{8Zr@!~T@12`I^Syum?i;V~TspRN z`LgAG?UuP~W0Y0Zle|UahoCd{V6-A{Ln}mMt{KC9$~57L3jNhiRq2m8FPyiiaSFk8 zp#I6_Ut0eENB$Aqw%i1fr|t~^L_lIGK?DjC;E;AULI?!1=L)3!h4^p%PBaJ6WU_)y ze9cME*MFVO&gfGsJ!j5E;=?)A&S;)kED%wSYLn8Tnl;k>rlO67n!*nui(U!Y7C#r( z-;2AL4UWLYf z#_R8#JbCri+g2=J+Ujrj_xJbpw_63g_r32~zI@rvH(uMh^Yu^^v$M0))6-@P&rh74 z?aX+ZG`K2bWXfS-;+l8GYAiw$P^{A*!6qiqtdp5bFLm}Ux1VM}tB4ln(%;dNsjG>7-~gH@MF5$9bm4@Vb%&nB7O}a~=IilE{^ea-lGivOifPg z-Mjn1!Gk|~_=o4uPXQkZO8^9i4T5e*dO60}*WVWqJDtw>+3`b%4(-{y z_ryEzP$)vcso9w>(ZPd<4jecT5mv2Rw`ScXMZ5LzBai&g|I6=u@rz#^KRZsytAfO+ z#ua}Lg{Th^A2dirWsFDOI5FbrZ82YP&pdTXlDKOhu= zH|g;kC|jgXfz3*@2TrR*Bs)Sx!F}fk2_W*hpf+#CLKRCQTNLSRpqnI+ncu9c0-b`Z zs3_>h1hldl8||(5crUeRHHvc5j-1Gj02)%IdH)22c{+#WU!|D8Gb%N8KVp5I@gCLY zS*N{vBW9)W_G`LRlFeDokNoOpirsb5V*sF(A4+yVYeB3vcf*w=Arg^4wt})zgTf8u z;sl+q($^zbpjC@m?YUGJp*2M@rZKvk2@z*IvroVH>Ym+)?!4vt58rX~nw3k7B9Pj8 ze?Je55CdX?g#~ZGo2w!xx^yXoFR*bwJFBH7_I@_}N>hzv%T>YaSrw?}fQ_skYo0 z5h4Z7j1UkS?gt_&iCPdML7_$;Ap{6PH$CIhTRvw52oVASD+QTPOQf(uTO${t7B{0O zNE)^I<|eIdz7uOB)w1l!D{WX2b%F@RCrj=D)V^uz7XUG6hpm3B#$V&G>MtRY6EL%% zwm?q#k{b;~%(z*Jqe%Zx+H{ChD^(`K-Az&xQkAM7U zM}~$Ud+gECg$piSzh-#2|7Sk>v9)Vg|K!m}jvPL`e%-p8Z@K0D@4J1~s?`#F#Yhl& z|A<(YS6_AYvazK%-+W71Qh}jIWveIvaplU@_uv2E(W8e~uUa`cIDF?_cWv3c82|Ke;J z#ebpqLV<8F{DWV9u`B?%$In5$4QfE6DDME~(QJ~Bg@b}J0GKNbHnul%Gej~QW|m};O!RRa&#hlnmm;MK3`^M>C=xSBq#ya!;UC_^O?PrrS6Uynoi7OigB+>rNa2Bv zTtsP2!%s)mNQj}o)fYnR(3^)|-mzoR!uJi23=`t`*|U51?mKe$&_I8GuiKfJocY}6 zKL4pte>wo{-o5*;{_;yZ-+1G5pZ}!?9=IP$2n8-$JQ^_e^%Wog_|HE0z=Qq$?QW;D zd)Kc2^uUe&P zvG{rInE*v{Y1zsrHOLC3stOv#IJl>AAtVni_X4D8P%rw<7FZ9}(=*rAz%kGRG-^>K zaAxd+^zmbA8erq76T-+Y`i9UA%8l2US~Z~ZuTH8f7z2Vxbn4XE5c)#VcjWk~^D~`L z1Pq~7v|6neAM9qceMoTP-$LO(Spw&O24F(Z4DpQ&Zsr7ZJIVt670y>~6TFdY_ViPz? z(CAFxXBd&?AX=s|Q)>X;4?xVSdSNuHDUE5U=SC508Wb0U$uc z7$9JOf8U2b^ucboJ2)@^h`;xHzc)NI1VBTBgP;5CXFJ`_;?YrY;q%bnv*H5wd4Bz! z%PEK&%PK4nNlgqLT9rTDz99Zz%H-Sqgs!XnPnzFQof3y8{nuYFF#I$;MYJ*>&kkCh zBt_y`SuXZ1(#aU${wD$w0z`%G6r;7kC#M4$)RqBqjBDq4LJH0koDo!Q(I6UPNIDUc zRyH_IL_k0}LqG2C8auH5bEwVA`xmU|IHNW~L7x ze)Igf^LM`g1MPMTLr~{u?#xJs=Bgll$;IB|<+PY8sZTOZGTR9xc?XbvcL|UPB2f{z z=pjaGF?jS7#iXmb121F%Ct|2mCmgG!mjg7}XVTO__Q6&bRr@0{K$8ssJ~NRiTZ6>Q zW*wvenADJ`{k7yyF=q@P~t5BSy74=3RF~bO&?=zDWDPjm4H*WgHU;M>|BO{#4Te4*7vL#D< zF-D>?Vz-Plolak0-=fjc4{y8jy6bMZ=IU!l7mvR9!t+1+{`W%&zy6!Qb;XreOifOH zW%$caKmEk_fAIY!OP5@B#TDk(Kty30goE~l0>Si>f{B=RR|NpmoBajJrN z@>CvPn7?&SfzE-L$I_u?@?ViUa(6b$r=T28bLu^$wjf-neH)U>Hk$P@l%VU!B&I+F zbyh^Y5-~-WfJn%@y(%LJK=wjl^8}mRp+8xT48;jI7QK>yHSKRblMfdOv0f3R<5{|XrdW!Yr}3;~G% z3mhF?G&(wpA~r~YMp{D)M}~xfF?ETP`yn6@VF*Qmixw>cVt)`30z$!~D2gyTx>%7q zEL^ydi4j6rwqiMAO6)aqDx>*D+2`mjIY3n}=FxN}W>vpjZl$VUB(fScJkgG!td)O_w5atvE>(V`rkgGMyqyp~fUTM>;mDV3u4WS@Op!ZCQw{!37OG5l?iA57pc z*0o+E)C%9#+9L})=JJ#5KafRO24nI&0l>|`=j_YziMywcuP$K!GklmTbz-%B3DsEY zJmMskK)phw=Q$$q6eYh`4PTVxjGBFd$ZPt*WR(aJmoHy2wq)#u=bt%!=FGC?%Q1ul z`}ZB(zkkh|bxX#U9)J62x7&?CEd)RaMF2$N7n6Dz4pmZd~(ay zt+O*TU;Ep?{m1Wo8-R+UIQz=0yLa!Jn3(wF{h#b~JA3x*{i8qne-0ctIJ$W8{{06! zo!Mi@jxSrj+=$2;$dY^P_|bz04-5_tU3=Yi&?*2}No74!?p+ALcHqz(h(AIKhpQ_{ zhP-sK;|C83KpiI_5c1TI)T{>7`a#Sdhh*MN+PR-dY3nn{qOBY?&2692HV^QfbDd2j zFv#v{r8a?xmpiEmZTiUEJ-Yr`V??^O00yXUmubprUC4D$+EGZF-~zMI@S^wb^0;j9 zn8G$?kLO|IxQLI?G^4@#y z9T^@33=|^)wTjl6Gvh$eKQOrM>T5pz^PegJ+kO2@mM&Sod_{kMKO(+!^2DJ72Uf3L z`@Y+58yp@C5bnPF?pI%V`T6Ibz3HZ#F1z9i@BehJ+vF3pbBRO|Vqh2tDyPu|vWpK5spV|cp3!C> z(ycnS9ON2D5wne4eU}}93@vCI@`xhVIX=zuLvHOTRd1H-dDb=?X-d>6eZct3{h@>} z-Eae7WQW0cRMK)FId5~6&e<^NjYNo4K*7-~zbmQ~5CF)mm?pD>P0c~TKt#HpNfiVE zcsETV#DWM2E7enuDoUlAel;#=z6knf)NH2zJc>?!IVH}oUl{|0w3dL=@71~k`!7j| z==6gcN1Sw6jas9nN?MWG9|#D@K|iaBP0gm;v`U6y4VS@i2^SW!iHkbr5D@bKDnTe~ zSmC;cH`YU_boTsJ*qn$Zsec1*UVbu9q7PN@O=@4H!mWW$v`IFiG70Z6 zNp8R$n{9=3RerLaqq5K3_@w%)zhVBTWFX+oi&{KZe_gZf?o@7^^%J$=~uCd+r+?7<}@n zC!g59eRy#2()H_mo$iiTcC1*w^n8C#abNAnH{SC1!fBfS|_U+prg4W@CP9f~yf8fYlM^>+1wQ0*{2nZ0AO5!;Mwjltd zWDS$mGM?%iy!^YfziK8WxP*a(EVe5WA_QHrkoc#flkl5Ju4#P*517LhQCEbJcO?7M z+y6+^^2+QZB#ZC$2JcJ^Z=_N@yXaNLUxfTTS8Iwa9%9oN%ym^dpq`a#7s1R*rkwJ4 zz^XwqYy_A!m6JkA5B1I9o9L5~*ps4xKDu)yd6$eC^-@!LlE#0L_FdHqqeDY#j#JRz z*LwfWSFKvL0>A(~s&oeXgQ4$0f8T=PAw(dEa*l-#SuIW4{RU+K+PhluMI`O0*Q#qUb_fHp=h^?fW2O??Dde`uIR+! zFXr?olNsPbBUith=nrB&Z3>Z(h%F=OiXEynbpiTcg#3%`Xyh*dxIo2|+C)M`&irDn z=B+{gLtQ?T8q~FZj*F-w!brDzr-=kAgGndHK&Q@x{*h$=6A}Bg9huyd2J0xCPFJSv zjhLAN1RzSOvZTLFgW`sQ=!stB0djeWa!oh8M9TRa9e?@NW{8tkyKY}Vek$^*P{}}T zg+3k=ThP)#pPEz{Ng|mnF;0{jc0|zr6W3=a50{3O>@#vruP!JF7)b1VRNu$<%ap0Z z&T6;Se0eu2cM<`H=vFzh~yPOW&zq7q(1b66D>pRLa&B(p#IYZjH|BPG%IY!oQL49eUL0bqbsM}#0p))4Ok zBV_fo0uL=2dcy`f_2EB+UK!Yyfyz;3Y*IeikLh=>r2iuE@7 zIRA_D%MZFxliUgPYbdGqFhfdSC!E+$7=#)AhBb-L3FMuy8W64BK3%+%D>(9i%OOwUe#`@7#h zb?VeF{nD>|@Qx2H7+EkpH1d_d`Riw&d-mtvKE8Bp>4)#VcWm+K=8an*go(-XFTeca zvE#>%A3wHWWZ_SK^2oAf%N}^}!7DGjtle&Rd!1docL7kV)$;fY0m@!)@7{eA6O*^x zd<)NzD?KB_2}~=UsODeHE~RQuwGS2hmVp)w2qkMJ$jQ9`02DCjuFWVrZsZ@(YtnQ; zsh9{@nOkC>IQ;?utoDB{`>Yr98JjsY!DE8-8>?sLm9kGlD0ZdZG9(ydDIl@Rc8l5e z{ORN#1nsd?cKxUP&T8+Va-07V6DG?Z^i9<@^THpj{E@x8c}r_`E?(f~=s9VB^U)D+ zRn=O)WbvhISA?RdzCl4AxcXHR+oW~F2qY8Z$rFR6YLo?Kj3sY!7GtZ`A|eVglI~%H zKon!yr3g?^2n83n0TD%7wtU&DRcnqMKK$?xfAF5`-h1xcnk#>cxu_ z&)QS6WD(JV2>`GPX!$ep9+lj`8ir{w59IXQ-)gf`?nlK>ZjuUeOT70~K#*|bEsR+mQ#6#(>!X2^mCdfb(V+=*Xm-M0^X6my2ndnDoV{^IuDk0kJ82qPN?T;tg?EPN z9NRO!vn%t~rzZYx}s&x*{{WwMsZ;!mvYegW?apUm@=GNsZK8c+1O)PHj3ziOWwg^&~F z1}m+J6eYU;@xHA@6t`{LwszemFTV8R-S>QW&+gqv-+Jq+tFPI#apMnq-56u9n-09f zfB?bUGX$VO0kPZd?%uWY)H^5o`}roQstoODl`d+@>6r6jv6+yF^7g zqy;%)hMCZ0^#gJ-xC|&NZ8?3zFXB%4f!k4ZHXygLy;3gk>RkPoUy}>XvsTL1l9jtnw2SX4J&t>7-uT!hsmWvAYbME-8t!43m>XE*Bxq?%DRe z$Px+!2$9I(BZu;C@WQEfyFENS+~3#7PFgGzA_28pMSp)^e}B7G6h%=)EQtU^;1kXe zal`tJAN=5*U;Ep?`RZ4`^5RP`9zT9^_nw^_*KfG*zK>jX`DG!yL^SzxbLUK92z zk=dSdHTtv5{r@O??=ZQJ>rV8Xy0_Eh!Q?@lgfsw=6O%+jA|*j$4hpm=Tfvfp)+<|H zf6w;&-tOnm*4wAF&+E6lvaHWa7D+D^tw4eqB*7>Il1L-~k{D#n0F!6B`__4XoO7z` zb`L1IB?67x_tvdCb;9q2s`5z#lI=-#_i;BD&iJL{bHB~}UhZPZb$))0l)R>th_bp{ zFvg}r_#a4C)hkqD&e5!tObP_HCum=lAo;GQSsj@yY(H3?F(=5h^JPE+NR;fTz{WX4 zAa$l1T_#Ue3{n;jx&=B;05GDUZ7x#~BS!%#s{5}2LI`68K>*c)MTJPttpU})php=R z&6}e{e*wY+Nzo9H0abc}uLAiE)`bW0Gganv8CFpm%)vkEcPKLi5{yW461U&>0CeX- zgNCds2qI-Ll7$^WDzA)4oqm=tR`;QT6#sZ$0pz<-)edu)1whgTi_#?`K(wlVlf)7b zSj)SZkXxDph5}=u)C(m@hRo_EeJ~3;@{o!EYOp+1q!siJlt#dx6?X<1k}!xMM!_Wt zbim}I{^2H^jz67~hRDb9TCfK6)FyBfl?tMhZVr*^zl(IFOo>*Y{YL$RmHNjid7dll zd9x?O$8_^C>U}zXi3q>QIz}=Aa`zNXv)C`tTnyo+5i9`07*naRN{n{D(`d} zK&DR_!0R_xX$BsOIFW>aEvoSeR@KE+;FFYn32s33%;gV)?XLQO+s^OD6&7B5}^30O=( znGg+-wMjXNjM=bZ{qKF^_iAfvd9IHfIdc5i@l&UUk|aqInwpp@&K940?zyJMdO}Eu zGP&G9UtdijUnS7^w)y9sq2?- z-;3fL^9w=yinJ;jN!d~^rb=Lg7W_%jfh}~EwiNQeJfrxiCuBr_L9k*Nf(<~p|H$S2DSmZU(|0wUs#UKK!r3DA<%F2zY%?Ju zlCH=CKzHJws(KwJ1W|3NHL(b~IWPIG4$*lKfZz`K0~b*A#=2N%cDb5?QNj zhm@I#FiDcB+1X1Y!_Cd}3N?i|(hQFpI?eDf5e)nBMm7iqE`*c%Fau_(s^vFSY|egJ1-94VweQVw|Ud%JMXxQ$D`3Bu0FM(og`K} zh?zJ7=YJB}a@pDvB7&vZn0z5$S6Bb(&wO_E;7SWnvNo5?HPqJD)f8TThF+xojqT>C$MqT(XuBsaPr%izOp&hzyvu>AwB1 zOifMq_4T&4wz8jrYQIPn6A=fb$PkEE=H&zZh9B~vspJ`MBsD3isE4X8=WzlNSp=;K zV$dopwXL3t>o<+GKote#0mEV7hIvs9+3OwfRUAIxqX^2Wp$rCM4T!p?Ef@g=u}sg+ zVOGc8>8-qIe*!=xbn6OPBRGW!fOCz_S-_CibND|MQc4OVNRSPXcN0S_h~b@K`;I;- z1sL>8ZUN`to*vZJuWf9W3t^n}ClUSI0*_`q8`Yy6fV|NE}Cvb@g#JLnr{!!%`FE8bZp5y>TkBa&jCo!>T_t zhzX!rRYdUikngMj0^om~Yu3_V0BG*{HNl+}Fo_x$M{1ABLjHGxN9jA=gyP)x$v=ga zTs?<;0V5kEi$uhSN}y*JBI^<~f@KN$U>lTU!gxkJ*Rhup&;$WPZ@~a0N@5h9D2I~q zgd#}j)KqFM-~T{0rABJ`6)KoQNWMD+``|zfzd)xb6`pr9i6)e36(fgs(Vt7^yqXkC zg!;jx--JpbopeOB)|N^oYb_!~QOvd1E`|!8A}S!nLm+VmIDF}S05Y!NX zb`&j=AqeoG_^Z}O@(_4!kMxBjf>27zqZh|cojy5#es^uqm!Md5ZozP#gR@`m zJ67Kb%0Hnda`JmQ`ASP=ifPIHlf4qT-vxA>ISbiCzW7y>0{bAazX|x|yp{du!(S@a z^0;1+;&dYRSFPe&N;G%^V2L6?OB99LgFue5@_o@^TEa*UKi#~ zr^e1l*nGf1eoMKUuBFtdgCA6G3H)4EYx2;RIO|MqpL<~Kr5P9u%1{Ht>V)0f-?`2e zsMh~#+MIqCA1-vrLwo`NZn1u4+i573=1(AkxjDhzsa)t?&5Gt_bX8*}Rh6I<;=;Ay zFkwJp_eJ=?@sT*0iKSwxYzz|7`gQBOJG=M2vX{tifA98zWy{Z;JQYW=LCBa`@e2`I zJ}WjdenVg+Flb_93;|h-k!WlpO9(cxY7P*Av*PUQCKfE?bs}3T6^pY)A~I2w&1crE zTl1qI{d972qM@Owv8j1{a(rT96rfDTzJBz`^z_7a+qbV>v#!3O>D<}#mqtbkg}gy* zZfxxC?t0~weQys96>4iEgRj5w#>t_RU7cN4c926fh7vn^@#4{=M;jXI`}_M$6k*`f z3r8UD#O!#JiK!F7w`i8$&zzvBD*fQ$BmlUCvuA9PVMO3lEfvc1DorAb7=c;lL7m;8Ov1MgDJ_hMDV~>T1Q^u_q!-=D`EJWcd9vyuFRr3} zWF+9$hNJ7NFmbBim42K4uhQZ3cTzZ2d?h$Z6DmYM?Vf_;{<+)d?X!5SX$_US--$L@ zIpZ=6rBs~C$JEqVT?)vOW4u&c&Hd?uJ;751HbFw7M5~saY6DTZ6lTi+bOTl)4WD*t z*>eW=cigZvC_^mb2!K9w!ux{)0z`5|Bf~4;-Ew5DF}oD9un!5qWa75A7J~p7AtIs_ zw8&~7w*v=x6zk?Upugq)yFH1($CddcV064nK;=NC2N{6M0ETiCk*x+i8o^4X=KLOO zC@51&9?TR4=p@kffI*@|hk054PxCUm>KCUhJUdnB1??8=?hvOLIlveZ!9)lMl9!Nv zDo06V1$2*R-N7C8VMiYfh^nRv#cm5bax}+vLt@&l&m<)ZzW;u95Q8m)6gMECEJLrL zPY@l{iWK>`t5y%2_rY)Vt0Q;OrJTu+6dk2Nt%Db|w0=++r;yZ7g5^*&K}49?l^H5FH*=Bom+NEbJ)MjVu`h8mVyj+XHCOpbR1(Jf|uL zDPp;ylzwKZ#t4Ckt)|XwU_coM_%t|xIIGh$Ge_Qd{jtX%zx|H8dwP4anK+7MgKn-2 zB|uA-1w%roh?W4*VqyWfDha_DaU0?`E}kGx#T3KBOorPl|t6w5_2RO z2H`YQX}qiSo1ZwAWm!Q6h`d(-m#nZmD|*#1{@^}nHCH%T@zTBhC8K#8xAuzM%qZbB zBqD^6jb&ZlNfkP3fuqiyQR5<4Y;Zu35>;8<3m67M?ONs_G;deU@Etv}P7x42&D3hM z9FTrGbz3oF3fM`UkSMZN@?CO6Co8~($3srjK#ME6bYhKNtDSZ`0 z!3YRF#A9CacrutYPk*jZb)-OaBhsPEbO(e)=@tR-YFtue;hq*N}Jl9DAGaq3sSdSi@^ z0D%B4B1PpS0W`!XG*QCmE+LvY&SZ1Bnta`-KK1EcyPp2~*S@@e|Gv(y?pI%X_5AsB zx7_rhkAM8$`STa&YHEJ+v!7=3xyiAKUAuO@arE_-gDWnLjjpI!aoe4D{hxpJSAX%v zKfUIfYtEcK`|7K&ojHA`qob2a>y86HcpyaAlyhen_)Es9Pj2B_OrPmINjMGSEdda=!n5snZ%v2#pmGs%y8 zRi&ZH5L<_LDryYcSMaJaEDDblAOiL;$fC7Y_cUh0rmBO2f=LNFfhcqX0Fglrun@;B z^hUnfpdsWV*$~*{5a>Y2{6z-9l}G^r5E?|QA#9fwCDv&XU)G~(#QTx66Xaxvs+n?H zl}Qa6B2}y@3QKStf`&*fNkWTnU`bE8`c9UxivCWy3;UXKUgQ@m4ubtvm z4CXzMOAo@(vwiAf$e)$`@0=Jn7H^}WQWX$rhzw8=4-y!Ix|u!7joO2JMn^@ z{Dy}oxJUWd@C*=NUE^W0XTQ1S7c}$XnaU$o2R<)dy!7~!Pki-jUp;^JY)zpiq2znt zGaEN-{^-X(cI~y-Ce{LyK|nA>49yJ@Az45REQuC@l0duVqgr%C0+U#> zVB;u?01~aw@mWxlV;D=Cm&wEEiGoP3fHuU1$ZD z=r?D8)vi|e%12M$q5nV*I?LN-=Sz~nqfD9}T*F}|u@s1inwtj%Ku-D_LvlrKmInd6 za0Gb(bvC?b=hYHdGO45sg;5}y?8L;3%PJhDK3>O*)$$wChsc#YNry<6-vn4a)n}{O zP7LTTZwBu_d+LHW6QO-~d=&r;aTG=1kew*iB~k;6DgH6&P7%}@Wl0RoR=7SeZOgm? z6&VSXs@|MtTJjeW82kG-r|hHU=gE>FY9cP~vO;%H6$o@`%5?;g17A_#1tK&zHLYE{ zzP7dyXJRsV-F4TWIyuzS)3bK%x+pSrHTgwLmedq#n(FIfV-jmI!urPgzJY;KQeL=V zfzTff0JL{@_VxF-wzfu+{b06^5Xo9z18l^O$AB^B3pLqn9A%79A;Mg~P+M15Q(N2E z)R@g@dVBl-%YXU1Z-4t+yPtcuSS+`-x4!><@4M+kH)pfCRjXG2`Ct6y5B}x*_uYSA zcW2joZ@gjc`E@7WertMiYFlF4K{I@;Nb zuw==YJ^S`jk}O-gw5zKdTzml(U=RUC0wd89DqEv*_=IhQtF$0bXVnkZuUk&d5V|M^ zfNXE(j)KI1JuI52611qMV0MfU$O1$th^(RyDfwjq;XOUQ3vb<>ptaJ(v*f6emXna7 zb!cFHNxuh>Dk_Q*=KeUX9NZKVi=^ACNYl6ar}dwH7Qvwm!FEg#kjCbCJTPOZ7CLo{ zx`k;%1kf_dm6hJ^tO!bx=z;VyoRIj9cO5GX0i>Ex|AbX-g-0)6s zM8#A{55yF5p2f2qh?x#*cbLx!Beyh%00;~ZU7aT%bwVX47kp~_t~%MX3aL>9(P_Yc zd6<#X$lPCo5UF_jyOEPCh4$DQz*1tR;LUY1MM>?FA3-?3o|A*`sVBK`FW@s2B_8r# zdN=?SaGkeE%8ZnHIK_%;jYB`jeYENV!fJ9Asq~#9kh0W4FB(Tb# zmANXR)E1lqBx%hFy8~T1G@(y8K&2)Me-e25Q3x5qlZVM50;1b#nRoI?%E^I)2mbp%{QcPI=;uE7hpSf(7R#k4 zpM2uk=XQVlJKxS^Gh4Q7VOm%XK#=8HL5(MYRAUI%L;{;w0AnKEpi?J2h_XkVi~#_* z#XGuNS=pL|C?kNvIwi~RV3n&j(V7DQfTlRW)qy&o=etP&Mx#-H#9l9=l@p07_&J*hSh>Eo~jy7~8d zm~;`8cW*#>%m(d{YRA--pr_7fsPJGVGv!Ao-wc{SGb&TK(}uubPB}jwumpDn08fT+ zIja$bS)CJu!2m?(T5`WoAnyPKmc0@ptvKe^MD2kVtvc3C*k+PMA1 zQrRXS{_uyVr>EOnTM>=uM;9(On!N-dfP6l`W%K5*edVhQ=XYc?F@Uv}fS{wJ{bL`! z=R+U7skgTWfTGB3-n_M|t8-}R#LUceYfDS-k|pi!?NKIfYG}CSgEy@hST-{=(c03| z*4{BaGc!Fk(b3TnXX2*DraSMrYxBm9)+S9&O-+qW<)mzk>FVl4V{DQTB3V0rX?)3| zp2dq6$C->rKS;cjEs^2)jRh-;oLoM{*Odv_b0DxnLIr@SfLiwmK&lB+YX%Zele2ET zb!0X-5)f0)WD{zzLIZ_LwgNwEiz(Dv(Ph5sHV;U1akYL65P}dY9q8qMR0M%4?$jl@ z+EE{w5uiq*cSwKnc-_ZK0uZkdL}=`yH9@abXsQayn6jW8oEoT*d@e1oq_`}d=7PeN zk3%}kHkajL%~IjU%ypkma>#F0%|=l)omEgBZL@_puyJ>SyGw9)4eoA16WrYroZ!Ko z;O-C{g1fuByZf2%RQ*$2QE&k>yQkmowVnl*n5WKpgh%fVXu#UP^)(<;t{$l)VWM}3 z#xbDCLGlut4PO;3$;XlXrJ2ZN!GUqys#xOQOhSa7c~eT;D?D1tNy~_0Bej_SY&Xs= zLfqOA&aS9&{w?g=^xs?)zk>uKxNlG$zOh6TL7LcuFSXg1cVFugI=)tp%hz>5Y_>l;fi_y*i7#AvKzq4#(T=!7$dVZK_|e&o55R z`n)x!J3FmeKDA9v_BsAJg^kAHz<%8Ca5A^Bu)jjDaXcvkSevVAExZQ88VdS6DiaQr zX&TkIjkOdOhV(82Xqfg|_l*|U6U1hL*iS?R{&#a)*=uj3Pg{tHEeG~%UVG1XC%?j@ zc8ay>iFkb;=d|vlq71BTZCC0Y<@}XU$$+Wy|W zJmBZ&Cq!!Z_7o3^Gp^ zDkt`9*L$2v%n_kl<|H3YgI^65vGc2V0*(%ZUEN~?<~H;n2`6I>Erq5sjeU|S1$JY< zxn>Zn+7==gM+OF%#*8EH3QDZ*tQJCZ%wuGulk>7Rbkd<;ZaAX+!PEGZvxHe3-_$H$ z_2-XlxMu$G%kYlG9Jh0QxjI6(4_Y6D21Wu3g%`?~_HwHPCm(g=d5Lg!GOk%g&*86q zOmx)kNLD3wu9X|gWXJj>X*XkjAm#5!bbl$6y4!;T2CZsUQqp$01}aCLh>4HOcu7_C z)bQ}|HV?JHi<8P5i;KqKH8ll=ua0N`BE!p}AxEr7YaI*g>VAXBO=C|U{+o=uH$g;_ zQ+&=&#%%qAteXD=YrxUn-QC;UI|xmhHGs+*0=UI)OVPQ1AR^lv{B&;7c(Yp8w6Kz|e{qCOTlkiB_0q{rw`nb4MU~cS!ono_QwTO5 zIw$Y<@iq%>#DI!p9bi&jvio%#Eu%U?08El60b^&j8H`-5-;-@n0EajO_Y z!J-jj`L}EpHGbkey6>4MZRmoZN*U~}fnXL+wgY7wo;UT7;e28p2PXt1LLN*`Uyo`; zF2}EY8jcGKbMcnn-dDpEZwcoh4?95#(#P&$@M-J7^tPfz6v_F)snd5*QdW;JudFeh zZD03eY8O6vY-iHJHxb7aoQR}2N8YycI?lSIadVOocE#`gr_?Wi{$0LvL~Z#KW@!s% zDKpFu6#I=*H^f*y28rklza33%^w@o^9z2A?Sg)LIP(D_j>hEF%q?xIImO=?xN#|D3 zdvi5)lF6;?e^ajMrZFi67nb3wK6xx~!g@!kA-_AS%6dK_dqQ~zLvHJ@Vw^^W&_dt< zp+BPxFAuJ6E&w*GVr^}$A5ZM?z^UQCj=xg7UuqPyKGdoV>Ek zSJ~U4o*_Vg&k0Sf`9o&!8iS+celE{hk^|u`o!N=#R1$M_a|BI|- zK;!3r-fX#3IM!@)zLkl3=JS|acQB5T7{0leZ1_G}WjS=uiD0hKkPZ+gQ2 zd8Q^VE-^5WMiJ?)%KkNG7n6n)l1C9)Qv5dgr=e=z7M~vZ}OZCEGkyXU79b z@`FexHqJjKA5PLHOt3z$IG_k3q4j!AeJ2H6jF3NOB*H{p_SvyCSX}~gyCARQ;=&+! zGohiCK?uzE`#bYnUl@h(%f9jj4EtnyeM>`!XEBLj@(4;q?B8$EmMr6ilYw|GR!w|w zNcJS;xc^S52-j2@;zE%Uablf=#Ng7f9^m!N>?juSw|p+1Z0!p^x(ec4m*moFGv{}Y zz9vUx!J&A1NOJ-A$10aV%u{e!%9t5jD$?M*G)D2SJh*LbQv$Pav>`k-J-v}k5ruQS zz_3EJgQW&)CA8@o`lxVcJGp8Q6M{d*zhllabg~E&5*#e*d3|K+pdc(yQ4pufIC?XH zR8Z#ssuQkdZa2P*_&pc%$s<4}Z28)_7>q+!xj9i^Aprtm76(m+n0QEL-^!xe>lrkvyC%&e3A!8yB zFND`4)8R$0-1n0T<{DX6>TU90vd^%}Fw@!12i#+lCH!UFM{XJAO|6WC8mFwvb4~?5 z-^mn$6|QaHHDALwaRS3bL@7m_n5hR8|EC2w;0m1;0do{iS8$f@rVLg}Ad8SIb$me3 z)lb;yfou}X3&>=SON?jj!akVa3N0umcMLzkn`(!rj^V8h5%J&ORWiiJ6_D=I%Rr(K z@(9&%p#Fh@@hsj}v#WS$L|k%6N0m?Az>u}ZX)nTti=R#4wJ!VZ7WTo{zwa90)!V0M z5N^D^o|C*?cKa{`$O>+NsB-3SzA?MI+j2*+P_gz|fbIJAd=2&e0`}$k8HdTR-t5Z+ zKn;5cB=Px3b8m00>^Pqu90aJV(o$0M6?)v&E=!Y>L(;@cyj*-O`AwEf*NNid;+=j+ z1?)wNz>Cg)wSEXgAzBGwv|eF~ix;TI!K%_@Ph|3Q3ktFevOAxzHXmefWR96__^#cJ zB(V_T<9pdXoJ*7VnvKje5a8pFXYl%NHY`X1n!_(o6-`Qj-MtEhh_}rGkUeOX6Z7%j zcD+4F%K*?VSI&ki5+Pv4$ZfwqJv-}g8pWOEb9|MQlqCEZD4f!1EP5fb0rXr1_~}Vi z`ZZ=l@lGW^Tdz;;-uD?k7s`%}9_OdcM(^9Ws24teYf8S~9Wzk(4;cC!72M!5*EiN9 z-9E(V6Z+jHzV-A_1O_sAzdT3O)lCSszVHupuFTY#-RzG6#pQyOF7Y~jzYJ>u`k9@o zQ`y{TdVVf9iPv_FP`^SJ3ThV}p|5kokIakAa;00$7!(!6`I?>>3jauZT^+2B{3(v` zGd3Rb1{1lzEJ7Dy{CRZ_;SLoRN{dSFd3}zEKgNj3q}Hdai{0o3m5THHwZ}7}Y$wvZYsKfYM;ZxpYV`mR z<~>CDVtCIodX<8RG4vm?;;A;hRLl}iSV*)DD!5J_zT<-fx{)potk9-psDFApK%ike zW`rE=`7l~hV@irV2(70cCI=nP>t%JVRi@8F#Q~z#6>So{SpLDcLl*2|QWM72COmK+ zDs1-cc?@u9lSI5b^*Azivaqha3Xr;?gyRj+Cw;%ea2XKnZg~`molOE{>Eh6qbk{l; z=pr|Fz`Zpw2kq`6Y>uT-^j*7zng{~y#gAZvgO^oAj`<@{)Hh@r;&a{~$g&Uty!kcT zzs0n)h#E&LatleVNA)^)+c;?d}-P!vEI4&#jk~nqUiz=G zy5NY(MmNkoWb<+V3G3-$M`htwS{#iVT~%% zQw@Al4B^x7|3)?r@MI3T9*>ehD})IyA^4C>(^l@Vbe^F?@?H3r93o$Wzslh)l701f z+Ao)a{`gi3tC3!K>*|o44ln2CQ|Pj%Vvw3z8i zzSr#OTsHJLMd=+gx4*OBcT+9hY*+urX+(sNuDNft+pZ+yvKZhKee!&NTJwE-%YGld zu-q;#DS5L0xVMic5^nLDGZr!$GzShoxBvqy6Bx;?*B$Ixx(S-wKCQ?2n*c{5Sd_N= zhyQval!k@|;G~@??B2nl$GCd~P$|B|SDQ(zQ2!p%P6y)4kc{x@66M|iKlj_*?ZDc+C}sbTkrjrgn!7G`jhAijFtX_Mj5+Rl67;wUd1Grp=WpW%IfMU6pEyHghuL zWMNc#WygNnddK^qggqf|oBKvqCO=8b!@75qL9=ZmhxhJfT<|X(-&@m%TV@?Lem(i& z0vqZP>5=o5rh_HR?8=go<^$7C=e5?VR-jXYLS20rv-jI6G0`qUS|%0{7#IeGW|K+A=A|0#GOtfM8Y_9qec&! zW+V+wf@uQ&${hPcFF9vkiy@F+DgJO5LHukiD$TVDtSq0oBYBk=`>0>&@YoM8Q)<|k zS5dzJyH%`H%P>Y8w>R2C!0fI%hKQ@e-`%VH5sEh0@>TW@RM21a20L47+>|r;x9OPP zWYz{elLBk|LpkuNQlxv0^b9H|16B9vM7^7{cv2V7?t+@(DM0H0T!fmdS`{DfjCOzh zjM@8s{e+zO?8zKTbH}G1;W?4-bn+`8z;D!vhT!Fv7pIsT>_f-uz|lg2J3-14Q{HQ| z`DwvBgp9>R(iZ|1#+>|h9X*nCl{X4(HzEjO0tZruiS+E~mb72u(XQAdTS5zDP-a}( zJi|*d&cu?GUe6Ja-Gqj0x{V9s(`>`1=r)qi7;@DAs)4KRUynw+YJw{?7zQL&Uu+k! z#hR_zkX~ZLfizOuW_GZn732BhH%HL@8D3*R90!?>a}{)*Mb{>-H^Xiv)IWV1!b`DdF|^=7IZS`Kfl6G%+uhLw;kb7EhYSnxr53!#$u_8|p#1w5x`fN1=AJEk3 z(8cdSYCcqa%yt_tQ^-b2W@2(?Q}WpE|I`hDFa#fb$_rexZW_eMf3fTf7?Q7Hfqx8I zsiRBkdoCOde+i;bbv#8Iw=XVPQ&VKQJSsR1&2QI-J7}M7b|_vbTi{qo#ai&xoE1={wea6FDPl_=!yKn^Pw~z>t@EpU_-g8qcZv z{siq1nMlm}opSVVg(-Z-;-i8(6u!g6ZkG^pO$%;t)F3q)#C_t2jqbQajKx#UgJtt3~D5}#l8FaLLH>7~%;N}9(UfKaiOP?Ug878#<&TAJ;9sve4X8?Eg;bM*5$| z+9g%q@SQ=t$el$zpS{F4T;_~UyBF^!n>AfES}cTS{)@Joj+gg6z+LG*akqj_#-_3{ z4Zs);msV%>)oAqrrpLxdw{H(F^JO;{l3tB0YFJQ6gIOYdPMR=lTUezkYl4deB~zhB ze~Ox4!`Nm{g~;hp<|`cCm(-CCc1@H5qb_8$lAz&%c;O&!Yj8R;A0FDYSWs zo?O7&8PVnou@nvp$L$Z?bR#Kb#V6gxuK4*VNJsxOKk>#|pd2-mbuKOIZ2zB@@g?^) zDjz<7r@_4V^QQcyP_dr{MIP=_-;GOIHOuPQWJ|zD(JH*ASn{R~q)h$qC{W1QuRQZT z9!g}D;Kv9RPBC%;!pdj~5M@0M;{~RP$JelAs1hAVuk8AqtguK6()JGN-Ar2vA1Eq_ zu255$cv?sr{ezxaHX<=@*rybscsi5eR!oE0IAcmGZyL|4e%Z;M2x9msED(lgQJ?#= zXLU4Y2ChQ)NAM>BgJ$=z^+V@O7;B7MUAU>WT&Gl&>*5=B8msF%7RpK~w)GnL*e3UR zhJ;#;s2y==ufUmH^(FXDj^*!)vu28$A3={Y2W-mzYyr3H(^xKZg0iL6V9sTZ?u0PI z1qL%Dn7wG{2m1%r;98EK(}-zMOd=vE<$>PA3IX?q*neV@n%}L}QsQBsG_3CE2lnuh zEtku9MZ;`L;us^NqJ=w7N?3fK7z&oQyQcRVIGY!Y{CywJ4-7r;8kFA8+sk0J5^OOn z$tNBZWmUZGRDS%K_qqzg)MXI07wVQROex1DaDdndW!*YH9;gU>V)pZ{gU{j1IB za6jpy<-yg@I}!;|vK8xfsQ95vG2*Qnj7|bZG}E_;z{4|zP$0B))&YqdlVh3&QM1+c znUtsxo7FbBHYA+_amJuxzzykTLQa>M`mTbNN-H}IPX&VG%H0^r>8uUDl;J0mO0^Ym)q{mfFB$m_*6(88;chgWcF za?+TJ`hs47EaDF>n=$#oH&fHRu*v25`Ni39Cg8A><8~i&tG+B$7OH^^w(3Xx2^H5gy8q^hubMCrnVS#5F9rBzME?P=BfRhUn}-7 z&E=mstZ4Pds^ZDARM{71T25b-_%Obol zC%F6L`RMhix%s`-DS&qzNrAz3x4jO1mI2z~

kJqbk0|Oz%~o9@i{^x@e?{eDnqg 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%0Aqd=^01-ayhQZA4%5UGRiXnqMSB#*Ge!BA* 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

G$BvWIL3I-Xs5dOVN%{{6vIPeKyR$&NKeS#1 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

^djtgUiLOMKYaiu%}U}6&X-vVHsD2RUu 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Ü+WS__8ioI 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 String(value).replace(/[&<>"']/g, c => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } [c])); + + function render(s, m, v, lang) { + const t = (en, zh) => lang === 'zh' ? zh : en; + const chip = (text, color = '') => `${esc(text)}`; + const arrow = ''; + const row = (...items) => `

`; + const gpu = count => + ``; + const trainer = + `
Megatron${s.precision === 'fp8train' ? 'FP8' : 'BF16'} · ${v.total} GPU${gpu(v.total)}
`; + const precision = s.precision === 'bf16' ? 'BF16' : s.precision === 'int4' ? 'INT4' : 'FP8'; + const serving = (label, count) => + `
${esc(label)}vLLM · ${precision}${count ? ' · '+count+' GPU' : ''}${s.pd ? '
PrefillDecode
' : gpu(count || m.engine)}${s.hicache ? '
↳ HiCache · CPU
' : ''}
`; + let topology; + if (s.layout === 'external') { + topology = + `
${trainer}${t('slime training job', 'slime 训练任务')}
${serving(t('Cluster A', '集群 A'))}
${serving(t('Cluster B', '集群 B'))}
${serving(t('Cluster …', '集群 …'))}

${t('Independently managed fleets, connected by endpoint. Locations are illustrative; external engines may also be in one datacenter.', '通过 endpoint 连接独立管理的 GPU 集群。位置仅为示意;external 引擎也可以位于同一机房。')}

`; + } else { + topology = + `
${trainer}
${s.schedule === 'async' ? '⇄' : '⇢'}
${serving('Rollout', v.rollout)}
${s.layout === 'colocate' ? t('one GPU pool · alternating phases', '同一 GPU 池 · 交替工作') : t('slime manages two dedicated GPU pools', 'slime 统一管理 · 两个独立 GPU 池')}
`; + } + let lesson; + let title; + switch (s.step) { + case 0: + title = t('Where does the learning signal come from?', '训练信号从哪里来?'); + lesson = s.task === 'math' ? + `
${row(chip('Prompt', 'blue'), chip('8 × '+t('answer', '回答')), chip(t('Math verifier', '数学验证器'), 'yellow'))}
${[1,0,1,1,0,0,1,0].map(n => chip(n, n ? 'green' : '')).join('')}
${row(chip('reward'), chip(s.sc ? 'REINFORCE + SC' : 'GRPO', 'blue'))}

${t('One prompt, eight answers. Compare verifier scores within the group to compute advantages.', '同一个 prompt 生成八个回答;比较组内验证器得分,计算相对优势。')}

` : + `
${row(chip('Agent', 'blue'), chip(t('Tool / environment', '工具 / 环境'), 'yellow'))}${row(chip(t('Trajectory + loss mask', '轨迹 + loss mask')), chip(t('Your reward function', '自定义奖励'), 'green'))}

${t('Collect tool results, verifier feedback, or environment rewards. Train on model-generated tokens in the trajectory.', '收集工具结果、验证器反馈或环境奖励;对轨迹中模型生成的 token 进行训练。')}

`; + lesson += + `
${chip(m.moe ? 'MoE · router' : 'Dense', 'blue')}${m.moe ? ' ↗ '+chip('E₁')+' '+chip('E₂')+' '+chip('…') : ' → '+chip(t('all layers', '完整网络'))}${esc(m.size)}
`; + break; + case 1: + title = t('Follow a sample across updates', '跟着样本走过权重更新'); + lesson = + `
rollout${chip('v₀', 'blue')}${chip(s.partial ? t('save prefix', '保存前缀') : t('ready', '就绪'), 'yellow')}${s.schedule === 'async' ? chip('v₀ / v₁', 'blue') : chip(t('wait', '等待'))}
train${chip(s.schedule === 'async' ? t('warm queue', '预热队列') : t('wait', '等待'))}${chip('update → v₁', 'green')}${chip(s.partial ? t('resume', '续跑') : t('next batch', '下一批'), 'blue')}

${s.schedule === 'async' ? t('Rollout stays in flight during training. Samples in the queue may use older weights.', '训练时 rollout 持续进行,队列中的样本可能来自旧权重。') : t('Complete groups train together; synchronize weights before the next generation phase.', '完整样本组一起训练;同步权重后再进入下一段生成。')}

${s.partial ? `
${chip('prefix · v₀', s.maskPartial ? 'masked' : 'yellow')}${chip('continuation · v₁', 'blue')}${s.maskPartial ? t('Old tokens masked in loss; still used as context.', '旧 token 不计 loss,仍作为上下文。') : t('Keep old tokens and their rollout log-probs.', '保留旧 token 及其 rollout log-prob。')}
` : ''}${row(chip('Adam m + v'), chip(s.cpuAdam ? 'CPU RAM' : 'GPU HBM', s.cpuAdam ? 'green' : 'yellow'))}

${s.recompute ? t('Recompute activations during backward to save GPU memory.', '反向传播时重新计算激活,节省 GPU 显存。') : t('Keep activations for backward; this uses more GPU memory.', '保留激活供反向传播使用,需要更多 GPU 显存。')}

`; + break; + case 2: { + title = t('Different tensors, different precision', '不同张量,各选精度'); + const bar = (label, bits, format, cache = '') => + `
${label}
${''.repeat(bits)}
${format}
`; + const ssm = s.mambaDtype === 'auto' ? m.hybrid?.ssmDtype : s.mambaDtype; + lesson = + `
${bar('Train', s.precision === 'fp8train' ? 8 : 16, s.precision === 'fp8train' ? 'FP8' : 'BF16')}${bar('Rollout', precision === 'BF16' ? 16 : precision === 'FP8' ? 8 : 4, precision)}${bar('Attn KV', s.kv === 'fp8' ? 8 : 16, s.kv === 'fp8' ? 'FP8' : 'auto*', 'attention')}${m.hybrid ? bar('SSM', ssm === 'float32' ? 32 : 16, ssm === 'float32' ? 'FP32' : 'BF16', 'ssm') : ''}

${t('Each cell represents one bit per element, before scales and metadata. *Auto KV follows the serving stack; the 16-bit row is illustrative. These rows show dtype, not total pool memory.', '每格表示每元素一 bit,不含 scale 和元数据。*Auto KV 由推理栈决定,图中以 16 bit 为例。各行展示 dtype,不代表整个缓存池大小。')}

`; + if (m.hybrid) lesson += + `
${m.hybrid.attentionLayers} × Attention${t('More tokens → more KV entries', 'token 增多 → KV 条目增多')}
${m.hybrid.linearLayers} × ${esc(m.hybrid.linear)}hₜ₋₁ → hₜ${t('Recurrent state + convolution state', '递归状态 + 卷积状态')}

${t('Mamba cache keeps recurrent states and snapshots, with a fixed state shape per slot. Its SSM dtype does not change with FP8 attention KV; convolution state has its own dtype.', 'Mamba cache 保存递归状态与快照,每个 slot 的状态形状固定。SSM 精度不跟随 attention KV 的 FP8 开关改变;卷积状态也有自己的 dtype。')}

${t('State / attention KV memory ratio', '状态 / attention KV 内存比例')}: ${esc(s.mambaRatio || t('serving default', '推理栈默认'))}

`; + break; + } + case 3: { + title = t('Make a mismatch visible', '让训推差异看得见'); + const weight = s.correction === 'tis' ? 2 : s.correction === 'icepop' ? 0 : 1; + lesson = + `
q(a) = 0.1
p(a) = 0.4
${row(chip('p / q = 4'), chip(s.correction === 'none' ? t('no correction', '无修正') : s.correction.toUpperCase(), 'blue'), chip('w = '+weight, weight ? 'green' : 'masked'))}

${t('Illustrative probabilities: TIS clips to 2; ICE-POP drops ratios outside [0.5, 2]; no correction uses weight 1.', '示例概率:TIS 裁剪至 2;ICE-POP 丢弃 [0.5, 2] 外的比率;无修正使用权重 1。')}

`; + if (m.moe) lesson += + `
Rollout → ${chip('E₁', 'blue')} ${chip('E₃', 'yellow')}
Train → ${chip('E₁', 'blue')} ${chip(s.r3 ? 'E₃' : 'E₂', s.r3 ? 'yellow' : '')}
${s.r3 ? t('R3 replays the recorded expert IDs.', 'R3 重放记录的 expert IDs。') : t('Without replay, router rounding can choose different experts.', '未重放时,router 数值误差可能选到不同专家。')}
`; + lesson += + `
${s.sc ? row(chip('score'), chip('− E_q[score]', 'yellow'), chip(t('zero mean', '均值为零'), 'green')) : row(chip('PPO ratio'), chip(t('clipped objective', '裁剪目标'), 'blue'))}

${s.sc ? t('SC centers the weighted score per prefix; it uses REINFORCE and a top-128 sampler approximation.', 'SC 在每个 prefix 中心化加权 score,使用 REINFORCE 与 sampler top-128 近似。') : t('PPO clipping controls policy changes; TIS / ICE-POP correct a different ratio, between trainer and sampler.', 'PPO clipping 控制策略更新幅度;TIS / ICE-POP 修正训练器与采样器之间的另一种比率。')}

${chip(s.deterministic ? 'run A = run B*' : 'run A ≈ run B', s.deterministic ? 'green' : '')}${t('*Repeatability is scoped to supported kernels; it does not imply equality across engines.', '*可重复性取决于支持的 kernel,不代表跨引擎数值相等。')}
`; + break; + } + case 4: { + title = t('Inside the serving fleet', '走进推理集群内部'); + const cache = `
${chip('GPU', 'blue')}${t('Prefix cache', '前缀缓存')}
${s.hicache ? `
${chip('CPU', 'yellow')}HiCache
` : ''}
${s.hicache ? (s.pd ? t('Enabled on prefill workers only.', '仅在 prefill worker 启用。') : t('Reuse prefixes through GPU ↔ host RAM.', '通过 GPU ↔ 主机内存复用前缀。')) : t('GPU prefix cache only; no host cache tier.', '只有 GPU 前缀缓存,无主机缓存层。')}`; + const prefill = `
Prefill

${t('Read the prompt; reuse cached prefixes.', '读取 prompt,复用已缓存的前缀。')}

${cache}
`; + const shownDrafts = Math.min(s.specSteps, 3); + const proposed = Array.from({length: shownDrafts}, (_, i) => chip('d' + (i + 1), 'yellow')).join(' '); + const accepted = Array.from({length: Math.max(0, shownDrafts - 1)}, (_, i) => chip('✓ d' + (i + 1), 'green')).join(' '); + const decoding = s.speculative ? + `
${s.draftSource === 'external' ? t('Separate EAGLE head', '独立 EAGLE 头') : t('Checkpoint MTP head', 'Checkpoint MTP 头')}${t('Propose', '提出候选')} · ${s.specSteps} ${t('steps', '步')} · top-k 1
${proposed}${s.specSteps > shownDrafts ? chip('…') : ''}
${t('Target model · batch verification', '目标模型 · 批量验证')}
${accepted} ${chip('× d' + shownDrafts, 'rejected')}
${t('Commit accepted prefix + corrected token', '提交接受的前缀 + 修正 token')}
${accepted} ${chip('c', 'blue')}
${t('Illustrative rejection round; acceptance varies by request. All-accepted rounds can emit a bonus token.', '图示为一次拒绝示例,接受长度随请求变化;全部接受时可多输出一个 token。')}
` : + `
${t('Target model · one token at a time', '目标模型 · 逐 token 解码')}${row(chip('t₁', 'green'), chip('t₂', 'green'), chip('t₃', 'green'))}${t('One target decoding step for each new token.', '每产生一个新 token,执行一次目标模型解码。')}
`; + const decode = `
Decode ${s.speculative ? '· EAGLE' : ''}
${decoding}
`; + const pools = s.pd ? + `
${t('Prefill engine pool', 'Prefill 引擎池')}
${prefill}
↓${m.hybrid ? t('KV + recurrent state transfer', 'KV + 递归状态传输') : 'KV transfer'}Mooncake · RDMA
${t('Decode engine pool', 'Decode 引擎池')}
${decode}
` : + `
${t('One engine · prefill + decode', '同一引擎 · prefill + decode')}
${prefill}
↓ ${t('Cache stays in the engine', '缓存在引擎内复用')}
${decode}
`; + lesson = + `
${chip('Prompt', 'blue')} ↓
${pools}

${t('Concurrency', '并发上限')}: ${s.concurrency} · ${t('Response limit', '回复上限')}: ${s.response} tokens

`; + break; + } + default: + title = t('Your path to a running experiment', '让实验真正跑起来'); + lesson = row(chip('prepare', 'blue'), chip('convert', 'yellow'), chip('check'), chip('train', + 'green')) + + `

${v.errors.length ? t('Complete the highlighted settings before exporting the shell.', '补齐标出的配置后即可导出 shell。') : t('Export experiment.sh, then run these stages in your prepared environment.', '导出 experiment.sh,在准备好的环境中依次运行这些步骤。')}

${chip('experiment.sh')}${s.pd ? chip('vllm.yaml') : ''}${s.layout === 'external' ? chip('serving-reference.sh') : ''}${chip('experiment.json')}
`; + } + const explanation = `

${title}

${lesson}
`; + const placement = `${topology}${s.straw ? `
straw · ${s.schedule === 'async' ? 'worker 0 / 1 / … → ' : ''}partial → ready → batch JuiceFS · ${t('persistent payloads + queues', '持久数据 + 队列')}
` : ''}`; + return `
${esc(m.name)} · ${esc(m.size)}
${s.step === 4 ? explanation + placement : placement + explanation}`; + } + const api = { + render + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.VimeDiagrams = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/docs/_static/js/experiment-engine.js b/docs/_static/js/experiment-engine.js new file mode 100644 index 000000000..662a5756c --- /dev/null +++ b/docs/_static/js/experiment-engine.js @@ -0,0 +1,621 @@ +/* Configuration and shell generation. No DOM, network, or runtime dependencies. */ +(function(root) { + 'use strict'; + const models = { + qwen38: { + name: 'Qwen3.8-27B', + mtp: true, + size: '27B · Hybrid Dense', + repo: 'Qwen/Qwen3.8-27B', + model: 'qwen3.5-27B', + recipe: 'qwen3.5-27B', + nodes: 4, + engine: 2, + tp: 4, + pp: 2, + cp: 4, + ep: 1, + dp: 1, + moe: false, + hybrid: { + linear: 'Gated DeltaNet', + linearLayers: 48, + attentionLayers: 16, + ssmDtype: 'float32' + }, + maxTokens: 8192, + extra: ['--decoder-last-pipeline-num-layers 30'] + }, + deepseek: { + name: 'DeepSeek-R1', + mtp: true, + size: '671B · A37B · MoE', + repo: 'deepseek-ai/DeepSeek-R1', + model: 'deepseek-v3', + recipe: 'deepseek-r1', + nodes: 16, + engine: 64, + tp: 8, + pp: 4, + cp: 4, + ep: 32, + dp: 8, + moe: true, + maxTokens: 8192, + extra: ['--decoder-last-pipeline-num-layers 13'] + }, + flash: { + name: 'GLM-4.7-Flash', + mtp: true, + size: '30B · A3B · MoE', + repo: 'zai-org/GLM-4.7-Flash', + model: 'glm4.7-30B-A3B', + recipe: 'glm4.7-30B-A3B', + nodes: 1, + engine: 8, + tp: 2, + pp: 2, + cp: 2, + ep: 4, + dp: 8, + moe: true, + maxTokens: 8192, + extra: ['--decoder-last-pipeline-num-layers 23'] + }, + dense: { + name: 'GLM-4-9B', + size: '9B · Dense', + repo: 'THUDM/GLM-Z1-9B-0414', + model: 'glm4-9B', + recipe: 'glm4-9B', + nodes: 1, + engine: 2, + tp: 2, + pp: 1, + cp: 2, + ep: 1, + dp: 1, + moe: false, + maxTokens: 4608, + extra: [] + }, + glm47: { + name: 'GLM-4.7', + mtp: true, + size: '355B · A32B · MoE', + repo: 'zai-org/GLM-4.7', + model: 'glm4.5-355B-A32B', + recipe: 'glm4.7-355B-A32B', + nodes: 8, + engine: 32, + tp: 8, + pp: 4, + cp: 2, + ep: 16, + dp: 4, + moe: true, + maxTokens: 8192, + extra: [] + }, + glm5: { + name: 'GLM-5.3', + mtp: true, + size: '744B · A40B · MoE', + repo: 'zai-org/GLM-5.3', + model: 'glm5.2-744B-A40B', + recipe: 'glm5.2-744B-A40B', + nodes: 32, + engine: 64, + tp: 4, + pp: 8, + cp: 8, + ep: 32, + dp: 64, + moe: true, + maxTokens: 8192, + extra: ['--decoder-first-pipeline-num-layers 14', '--decoder-last-pipeline-num-layers 16', + '--data-pad-size-multiplier 1024', '--log-probs-chunk-size 16384' + ] + }, + }; + const defaults = { + straw: false, + partial: false, + maskPartial: false, + queueDir: '', + queueRun: '', + declaration: '', + cpuAdam: true, + recompute: true, + model: 'flash', + task: 'math', + layout: 'colocate', + schedule: 'sync', + precision: 'bf16', + kv: 'auto', + mambaDtype: 'auto', + mambaRatio: '', + correction: 'tis', + r3: false, + sc: false, + deterministic: false, + pd: false, + hicache: false, + speculative: false, + draftSource: 'builtin', + draft: '', + specSteps: 3, + nodes: 1, + rollout: 8, + prefill: 8, + concurrency: 128, + response: 4096, + rounds: 3, + transport: 'nccl', + hf: '/data/GLM-4.7-Flash', + train: '/data/GLM-4.7-Flash_torch_dist', + data: '/data/dapo-math-17k/dapo-math-17k.jsonl', + save: '/data/vime-experiment', + megatron: '/root/Megatron-LM', + endpoints: '', + shared: '/shared/vime-weights', + local: '/local/vime-checkpoint', + ib: '', + generate: '', + reward: '', + step: 0 + }; + const enums = { + model: Object.keys(models), + task: ['math', 'custom'], + layout: ['colocate', 'separate', 'external'], + schedule: ['sync', 'async'], + precision: ['bf16', 'fp8', 'fp8train', 'int4'], + kv: ['auto', 'fp8'], + mambaDtype: ['auto', 'float32', 'bfloat16'], + draftSource: ['builtin', 'external'], + correction: ['none', 'tis', 'icepop'], + transport: ['nccl', 'disk', 'delta'] + }; + const numberLimits = { + nodes: [1, 512], + rollout: [1, 8192], + prefill: [1, 8192], + concurrency: [1, 8192], + response: [128, 131072], + rounds: [1, 100000], + specSteps: [1, 8], + step: [0, 5] + }; + const textKeys = ['hf', 'train', 'data', 'save', 'megatron', 'endpoints', 'shared', 'local', 'ib', + 'generate', + 'reward', 'queueDir', 'queueRun', 'declaration', 'mambaRatio', 'draft' + ]; + + function sanitize(input) { + const s = { + ...defaults + }; + if (input && Object.hasOwn(models, input.model)) { + const m = models[input.model]; + Object.assign(s, { + nodes: m.nodes, + rollout: m.nodes * 8, + prefill: m.engine, + draftSource: m.mtp ? 'builtin' : 'external', + specSteps: input.model === 'glm5' ? 4 : 3, + hf: '/data/' + m.name, + train: '/data/' + m.name + '_torch_dist' + }); + } + if (!input || typeof input !== 'object') return s; + for (const [key, values] of Object.entries(enums)) + if (values.includes(input[key])) s[key] = input[key]; + for (const key of Object.keys(defaults).filter(k => typeof defaults[k] === 'boolean')) + if (typeof input[key] === 'boolean') s[key] = input[key]; + for (const [key, [lo, hi]] of Object.entries(numberLimits)) + if (Number.isInteger(Number(input[key])) && Number(input[key]) >= lo && Number(input[key]) <= hi) s[key] = + Number(input[key]); + for (const key of textKeys) + if (typeof input[key] === 'string' && input[key].length <= 2048) s[key] = input[key].replace(/[\x00-\x1f\x7f]/g, + '').trim(); + if (!models[s.model].hybrid) { + s.mambaDtype = 'auto'; + s.mambaRatio = ''; + } + return s; + } + + function change(state, key, value) { + const s = { + ...state, + [key]: value + }; + if (key === 'model') { + const m = models[value]; + s.nodes = m.nodes; + s.rollout = m.nodes * 8; + s.prefill = m.engine; + s.cpuAdam = m.moe || value === 'qwen38'; + s.hf = '/data/' + m.name; + s.train = s.hf + '_torch_dist'; + s.kv = 'auto'; + s.mambaDtype = 'auto'; + s.mambaRatio = ''; + s.draftSource = m.mtp ? 'builtin' : 'external'; + s.draft = ''; + s.specSteps = value === 'glm5' ? 4 : 3; + if (!m.moe) s.r3 = false; + if (value === 'glm5' || value === 'deepseek') { + s.precision = 'fp8'; + s.kv = 'fp8'; + } + } + if (key === 'partial' && !value) s.maskPartial = false; + // Dependency changes remain one undoable action, announced by the UI. + if (key === 'schedule' && value === 'async' && s.layout === 'colocate') s.layout = 'separate'; + if (key === 'layout' && value === 'colocate') { + s.schedule = 'sync'; + s.transport = 'nccl'; + } + if (key === 'layout' && value === 'external') s.transport = 'disk'; + return sanitize(s); + } + + function preset(name) { + if (name === 'scale') return { + ...change(defaults, 'model', 'glm5'), + pd: true, + precision: 'fp8', + kv: 'fp8', + correction: 'icepop' + }; + if (name === 'agent') return { + ...defaults, + task: 'custom', + layout: 'separate', + schedule: 'async', + straw: true, + partial: true, + precision: 'fp8', + pd: true, + hicache: true, + rollout: 16 + }; + return { + ...defaults + }; + } + // Always quote user supplied strings as shell literals, never executable substitutions. + const quote = value => "'" + String(value).replace(/'/g, "'\"'\"'") + "'"; + + function validate(s, lang = 'en') { + const m = models[s.model], + errors = [], + notes = []; + const t = (en, zh) => lang === 'zh' ? zh : en; + const total = s.nodes * 8, + rollout = s.layout === 'colocate' ? total : s.rollout; + if (total % (m.tp * m.pp * m.cp) !== 0 || total % (m.ep * m.pp) !== 0) errors.push(t( + 'Training GPUs must be divisible by TP × PP × CP and EP × PP for this recipe.', + '此模型的训练 GPU 数必须同时整除 TP × PP × CP 和 EP × PP。')); + if (s.layout !== 'external' && rollout % m.engine !== 0) errors.push(t( + `Rollout GPUs must be a multiple of ${m.engine} (GPUs per engine).`, + `Rollout GPU 数必须是 ${m.engine}(每个引擎的 GPU 数)的倍数。`)); + if (s.layout !== 'external' && s.pd && (s.prefill % m.engine !== 0 || s.prefill >= rollout)) errors.push(t( + `PD needs at least one prefill and one decode engine. Prefill GPUs must be a multiple of ${m.engine} and less than the rollout pool.`, + `PD 至少需要一个 prefill 和一个 decode 引擎;prefill GPU 数须为 ${m.engine} 的倍数,且小于 rollout 总 GPU 数。`)); + if (s.layout === 'colocate' && s.schedule === 'async') errors.push(t( + 'Fully async requires separate training and rollout GPUs.', 'Fully async 需要独立的训练与 rollout GPU。')); + if (s.layout === 'colocate' && s.transport !== 'nccl') errors.push(t( + 'This colocated recipe uses full weight updates through CUDA IPC; choose the default transport.', + '此同卡配方使用 CUDA IPC 全量更新,请选择默认传输。')); + if (s.r3 && !m.moe) errors.push(t('R3 requires a MoE model.', 'R3 仅适用于 MoE 模型。')); + if (s.deterministic && s.schedule === 'async') errors.push(t( + 'A fully async queue does not reproduce the same sample ordering. Use synchronous execution for this reproducibility recipe.', + 'Fully async 队列不能复现相同的样本顺序;此可复现配方请选择同步训练。')); + for (const [key, en, zh] of [ + ['hf', 'HF checkpoint', 'HF checkpoint'], + ['train', 'training checkpoint', '训练 checkpoint'], + ['data', 'prompt data', 'prompt 数据'], + ['save', 'output directory', '输出目录'], + ['megatron', 'Megatron directory', 'Megatron 目录'] + ]) + if (!s[key]) errors.push(t(`Enter the ${en} path.`, `请填写${zh}路径。`)); + if (s.task === 'custom') { + if (!/^\w+(?:\.\w+)+$/.test(s.generate)) errors.push(t( + 'Enter an importable generation hook, e.g. my_agent.generate.', '请填写可导入的生成 hook,例如 my_agent.generate。')); + if (!/^\w+(?:\.\w+)+$/.test(s.reward)) errors.push(t('Enter an importable reward hook, e.g. my_reward.reward.', + '请填写可导入的奖励 hook,例如 my_reward.reward。')); + } + if (s.layout === 'external') { + if (!s.endpoints || !s.endpoints.split(/\s+/).every(v => /^(?:https?:\/\/)?[a-zA-Z0-9._-]+:\d{1,5}$/.test(v) && + Number(v.split(':').at(-1)) > 0 && Number(v.split(':').at(-1)) < 65536)) errors.push(t( + 'Enter engine addresses as host:port separated by spaces (not a router URL).', + '填写以空格分隔的引擎 host:port(不是 router 地址)。')); + notes.push(t( + 'External vLLM is configured by its owner. Use the generated serving reference on every engine; vime discovers existing regular/prefill/decode roles and cannot switch them from the training CLI.', + '外部 vLLM 由部署方配置。请将生成的 serving 参考应用到各引擎;vime 发现已有 regular/prefill/decode 角色,无法通过训练 CLI 改变它们。')); + } + if (s.transport !== 'nccl' && !s.shared) errors.push(t( + 'Disk transport needs a shared path visible to both clusters.', '磁盘传输需要两个集群都可见的共享路径。')); + if (s.transport === 'delta' && !s.local) errors.push(t( + 'Delta transport needs a local checkpoint directory on every serving host.', + 'Delta 传输需要每台推理主机上的本地 checkpoint 目录。')); + if (s.pd && !s.ib) errors.push(t( + 'Enter the RDMA device(s) for this Mooncake PD recipe, e.g. mlx5_0. Check these names on every host.', + '此 Mooncake PD 配方需要 RDMA 网卡名,例如 mlx5_0;请核对每台主机。')); + if (s.speculative) { + if (s.draftSource === 'builtin' && !m.mtp) errors.push(t( + 'This model has no built-in MTP recipe here. Select a separate EAGLE head and provide its checkpoint.', + '此模型没有内置 MTP 配方,请选择独立 EAGLE 头并填写 checkpoint。')); + if (s.draftSource === 'external' && !s.draft) errors.push(t( + 'Enter the separate EAGLE head checkpoint path or Hugging Face model ID.', + '请填写独立 EAGLE 投机头的 checkpoint 路径或 Hugging Face 模型 ID。')); + notes.push(t( + 'EAGLE proposes tokens, then the target verifies them. Use a compatible draft head, with its weights available on every serving host. The builder configures inference only; separate draft-head training is not enabled. Track spec_accept_rate, spec_accept_length and rollout time as RL changes the target.', + 'EAGLE 先提出候选 token,再由目标模型验证。投机头须与目标模型匹配,且权重需供所有推理主机访问。向导只配置推理,不开启独立投机头训练。随 RL 更新,观察 spec_accept_rate、spec_accept_length 和 rollout 耗时。')); + } + if (s.straw) { + if (!s.queueDir || !s.queueDir.startsWith('/')) errors.push(t( + 'straw needs an absolute shared JuiceFS data directory.', 'straw 需要绝对路径的共享 JuiceFS 数据目录。')); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(s.queueRun)) errors.push(t( + 'Enter a straw run ID using letters, digits, dots, underscores or hyphens.', + '填写 straw run ID,使用字母、数字、点、下划线或连字符。')); + if (!s.declaration || !s.declaration.startsWith('/')) errors.push(t( + 'Enter the absolute path to your verified JuiceFS deployment declaration.', '填写已验证的 JuiceFS 部署声明文件绝对路径。')); + notes.push(t( + 'straw persists payloads and queue state on shared JuiceFS. Every node needs the same mount and straw-queue package. With fully async it activates one generation process per eligible Ray node; reserve CPU resources and keep topology stable for recovery. The short recipe saves every round.', + 'straw 在共享 JuiceFS 保存数据和队列。各节点需相同挂载与 straw-queue 依赖;与 fully async 组合后,每个符合条件的 Ray 节点启动生成进程,需预留 CPU 并保持恢复时拓扑一致。短实验每轮保存。' + )); + } + if (s.partial) notes.push(t( + 'Partial rollout preserves unfinished prefixes and resumes them later; the synchronous recipe oversamples 16 groups for a target of 8. A response can contain multiple weight versions. Masking old prefix tokens does not make the remaining prefixes on-policy.', + 'Partial rollout 保留未完成前缀,之后续跑;同步配方超采样 16 组、目标 8 组。一个回复可能混合权重版本;mask 旧 token 也不会让后续 prefix 自动变成 on-policy。')); + if (s.maskPartial && !s.partial) errors.push(t('Prefix masking requires partial rollout.', + '旧前缀 masking 需要启用 partial rollout。')); + if (s.schedule === 'async') notes.push(t( + 'Rollouts continue across weight updates. Monitor staleness/mean and staleness/max; run evaluation separately from this continuous queue.', + 'Rollout 跨权重更新持续生成。监控 staleness/mean、staleness/max;在此连续队列之外单独运行评估。')); + if (s.correction === 'none' && (s.schedule === 'async' || s.precision !== 'bf16')) notes.push(t( + 'You chose stale or quantized rollouts without importance correction. Track train_rollout_logprob_abs_diff and reward before scaling.', + '当前使用过期或量化 rollout,但未启用重要性修正。扩容前检查 train_rollout_logprob_abs_diff 和 reward。')); + if (s.model === 'qwen38') notes.push(t( + 'Qwen3.8-27B uses the qwen3_5 architecture and matches the existing 27B model dimensions. This text-RL recipe reuses that backend; the checkpoint still needs GPU validation.', + 'Qwen3.8-27B 使用 qwen3_5 架构,尺寸匹配现有 27B 配置。此文本 RL 配方复用该后端,checkpoint 仍需 GPU 验证。')); + if (m.hybrid) { + notes.push(t( + 'Hybrid cache contains attention KV and recurrent state managed by a shared paged cache. KV dtype applies only to attention layers; Mamba SSM dtype controls recurrent state separately. Check backend support and log-prob accuracy for each change.', + 'Hybrid cache 的 attention KV 和递归状态由共享分页缓存管理。KV dtype 只作用于 attention 层;Mamba SSM dtype 单独控制递归状态精度。每项调整都需核对后端支持与 log-prob 精度。' + )); + if (s.mambaRatio && (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(s.mambaRatio) || + !Number.isFinite(Number(s.mambaRatio)) || Number(s.mambaRatio) <= 0)) errors.push(t( + 'Mamba / attention KV memory ratio must be a positive number, or left blank for the serving default.', + 'Mamba / attention KV 内存比例须为正数,或留空沿用推理栈默认值。')); + else if (s.mambaRatio) errors.push(t( + 'vLLM uses a shared paged cache rather than independently sized Mamba and attention pools. Clear the imported pool-split ratio; use the total GPU cache budget.', + 'vLLM 使用共享分页缓存,而非独立定容的 Mamba 与 attention 池。请清除导入的分池比例,使用总 GPU 缓存预算。')); + } + if (s.sc) notes.push(t( + 'SC selects REINFORCE with GRPO advantages and no standard-deviation normalization. Sampling is fixed to temperature=1, top_p=1, top_k=-1, without penalties, constraints, or streaming.', + 'SC 使用 REINFORCE、GRPO advantage,关闭标准差归一化。采样固定为 temperature=1、top_p=1、top_k=-1,不使用惩罚、约束解码或 streaming。')); + if (s.hicache) notes.push(t( + 'HiCache consumes host RAM as well as GPU cache. The recipe uses a host/device cache ratio of 2; PD enables it on prefill only. Cache reuse is limited by weight-update invalidation.', + 'HiCache 还会占用主机内存;配方的 host/device cache 比例为 2,PD 时只在 prefill 启用。权重更新后的缓存失效会限制复用收益。')); + if (s.deterministic) notes.push(t( + 'Deterministic execution is not cross-engine equality. Install the deterministic vLLM/Megatron stack and remove FlashAttention 3 as documented. Exact GLM-5 alignment has a separate six-layer EP8 regression gate.', + '确定性执行不等于训推数值相等。按文档安装 deterministic vLLM/Megatron 依赖并移除 FlashAttention 3。GLM-5 精确对齐另有六层 EP8 回归验证。')); + if (s.cpuAdam) notes.push(t( + 'CPU Adam offloads optimizer work and state to host memory. Budget host RAM and CPU↔GPU bandwidth; it does not offload activations or KV cache.', + 'CPU Adam 将优化器计算与状态移到主机,需预算 CPU 内存和 CPU↔GPU 带宽;它不会卸载激活或 KV cache。')); + if (!s.recompute) notes.push(t( + 'Activation recomputation is disabled. Retaining intermediate activations increases GPU memory use, especially for long sequences.', + '已关闭激活重计算。保留中间激活会增加显存,长序列尤其明显。')); + if (s.precision === 'fp8train') notes.push(t( + 'FP8 training is experimental. This recipe omits fp8-param-gather to keep the CPU Adam offload path compatible.', + 'FP8 训练为实验性功能;此配方不启用 fp8-param-gather,以兼容 CPU Adam offload。')); + if (s.precision === 'int4') notes.push(t( + 'INT4 rollout is beta and has no maintained GLM end-to-end recipe here. Validate your model and installed kernels with a short run before scaling; training remains BF16.', + 'INT4 rollout 为 beta,仓库尚无维护中的 GLM 端到端配方。扩容前先用短实验验证模型和当前 kernel;训练仍为 BF16。')); + return { + errors, + notes, + total, + rollout, + localGpus: s.layout === 'colocate' ? total : s.layout === 'external' ? total : total + rollout + }; + } + + function kvTransferConfig(s, role) { + const host = {kv_connector: 'SimpleCPUOffloadConnector', kv_role: 'kv_both', + kv_connector_extra_config: {cpu_to_gpu_ratio: 2}}; + if (!s.pd) return host; + const transfer = {kv_connector: 'MooncakeConnector', + kv_role: role === 'prefill' ? 'kv_producer' : 'kv_consumer', + kv_connector_extra_config: {mooncake_protocol: 'rdma', device_name: s.ib}}; + return s.hicache && role === 'prefill' ? { + kv_connector: 'MultiConnector', kv_role: 'kv_producer', + kv_connector_extra_config: {connectors: [transfer, host]} + } : transfer; + } + + function servingArgs(s) { + const m = models[s.model], + args = [`--rollout-num-gpus-per-engine ${m.engine}`, '--vllm-gpu-memory-utilization 0.7']; + if (m.dp > 1) args.push(`--vllm-data-parallel-size ${m.dp}`); + if (m.moe) args.push('--vllm-enable-expert-parallel'); + if (m.hybrid && s.mambaDtype !== 'auto') + args.push(`--vllm-mamba-ssm-cache-dtype ${s.mambaDtype}`); + if (s.speculative) args.push(`--vllm-speculative-config ${quote(JSON.stringify( + s.draftSource === 'builtin' ? {method: 'mtp', num_speculative_tokens: s.specSteps} : + {method: 'eagle', model: s.draft, num_speculative_tokens: s.specSteps} + ))}`); + if (!s.pd && (s.model === 'deepseek' || s.model === 'glm5')) + args.push('--vllm-all2all-backend deepep_auto'); + if (s.model === 'glm5') args.push('--no-vllm-async-scheduling'); + if (s.kv === 'fp8') args.push('--vllm-kv-cache-dtype fp8_e4m3'); + if (s.deterministic) { + args.push('--vllm-enable-deterministic-inference'); + if (s.model === 'dense') args.push('--vllm-attention-backend FLASHINFER'); + } + if (s.hicache && !s.pd) args.push('--vllm-enable-prefix-caching', + `--vllm-kv-transfer-config ${quote(JSON.stringify(kvTransferConfig(s)))}`); + return args; + } + + function yaml(s) { + if (!s.pd || s.layout === 'external') return ''; + const v = validate(s), + m = models[s.model]; + let out = `vllm:\n - name: default\n update_weights: true\n server_groups:\n`; + for (const [role, count] of [ + ['prefill', s.prefill], + ['decode', v.rollout - s.prefill] + ]) { + out += ` - worker_type: ${role}\n num_gpus: ${count}\n num_gpus_per_engine: ${m.engine}\n`; + const overrides = [`kv_transfer_config: ${JSON.stringify(kvTransferConfig(s, role))}`]; + if (s.hicache && role === 'prefill') overrides.push('enable_prefix_caching: true'); + if (s.model === 'glm5' || s.model === 'deepseek') + overrides.push(`all2all_backend: deepep_${role==='decode'?'low_latency':'high_throughput'}`); + if (s.model === 'glm5' && role === 'decode') overrides.push('kernel_config: {"moe_backend":"deep_gemm"}', + 'async_scheduling: false'); + if (overrides.length) out += ' overrides:\n' + overrides.map(a => ' ' + a).join('\n') + '\n'; + } + return out; + } + + function generate(s, lang = 'en') { + s = sanitize(s); + const m = models[s.model], + v = validate(s, lang), + t = (en, zh) => lang === 'zh' ? zh : en; + const arg = [`--actor-num-nodes ${s.nodes}`, '--actor-num-gpus-per-node 8']; + if (s.pd) { + arg.push('--router-kv-connector mooncake'); + if (m.dp > 1) arg.push(`--router-intra-node-data-parallel-size ${m.dp}`); + if (s.model === 'glm5') arg.push('--router-prefill-policy round_robin', + '--router-decode-policy round_robin'); + } + if (s.layout === 'colocate') arg.push('--colocate'); + if (s.layout === 'external') arg.push('--rollout-external-engine-addrs ' + s.endpoints.split(/\s+/).filter( + Boolean).map(quote).join(' ')); + else arg.push(`--rollout-num-gpus ${v.rollout}`); + arg.push('--hf-checkpoint "$ROLLOUT_MODEL"', '--ref-load "$TRAIN_CHECKPOINT"', '--save "$SAVE_DIR"', + `--save-interval ${s.straw?1:20}`, '--prompt-data "$PROMPT_DATA"', '--input-key prompt', '--label-key label', + '--apply-chat-template', '--rollout-shuffle'); + if (s.task === 'math') arg.push('--rm-type deepscaler'); + else arg.push('--custom-generate-function-path ' + quote(s.generate), '--custom-rm-path ' + quote(s.reward)); + arg.push(`--num-rollout ${s.rounds}`, '--rollout-batch-size 8', '--n-samples-per-prompt 8', + '--global-batch-size 64', `--rollout-max-response-len ${s.response}`, '--rollout-temperature 1', + '--rollout-top-p 1', '--rollout-top-k -1'); + if (s.schedule === 'async') arg.push( + '--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async', + '--skip-eval-before-train'); + if (s.straw) arg.push('--rollout-data-transport straw', `--rollout-data-dir ${quote(s.queueDir)}`, + `--rollout-queue-run-id ${quote(s.queueRun)}`, '--rollout-storage-profile juicefs', + `--rollout-storage-declaration ${quote(s.declaration)}`); + if (s.partial) { + arg.push('--partial-rollout'); + if (s.schedule === 'sync') arg.push('--over-sampling-batch-size 16'); + } + if (s.maskPartial) arg.push('--mask-offpolicy-in-partial-rollout'); + arg.push(`--vllm-server-concurrency ${s.concurrency}`, '--advantage-estimator grpo', '--kl-coef 0', + '--entropy-coef 0'); + if (s.sc) arg.push('--use-score-centering', '--score-centering-top-k 128', '--pg-loss-type reinforce', + '--disable-grpo-std-normalization', '--calculate-per-token-loss'); + else arg.push('--eps-clip 0.2', '--eps-clip-high 0.28'); + if (s.correction !== 'none') arg.push('--use-tis', `--tis-clip-low ${s.correction==='icepop'?0.5:0}`, + '--tis-clip 2'); + if (s.correction === 'icepop') arg.push( + '--custom-tis-function-path vime.backends.megatron_utils.loss.icepop_function'); + if (s.r3) arg.push('--use-rollout-routing-replay'); + if (s.deterministic) arg.push('--deterministic-mode'); + arg.push(`--tensor-model-parallel-size ${m.tp}`, '--sequence-parallel', `--pipeline-model-parallel-size ${m.pp}`, + `--context-parallel-size ${m.cp}`, `--expert-model-parallel-size ${m.ep}`, '--expert-tensor-parallel-size 1', + ...m.extra, ...(s.recompute ? ['--recompute-granularity full', '--recompute-method uniform', + '--recompute-num-layers 1' + ] : []), '--use-dynamic-batch-size', `--max-tokens-per-gpu ${m.maxTokens}`); + arg.push('--optimizer adam', '--lr 1e-6', '--lr-decay-style constant', '--weight-decay 0.1', '--adam-beta1 0.9', + '--adam-beta2 0.98'); + if (s.cpuAdam) arg.push('--optimizer-cpu-offload', '--overlap-cpu-optimizer-d2h-h2d', + '--use-precision-aware-optimizer'); + if (s.precision === 'fp8train') arg.push('--fp8-format e4m3', '--fp8-recipe blockwise'); + arg.push('--attention-dropout 0', '--hidden-dropout 0', '--accumulate-allreduce-grads-in-fp32', + '--attention-softmax-in-fp32', '--attention-backend flash'); + if (m.moe) arg.push(s.model === 'glm5' ? '--moe-token-dispatcher-type alltoall' : + '--moe-token-dispatcher-type flex'); + if (m.moe && s.model !== 'glm5') arg.push('--moe-enable-deepep'); + arg.push(...servingArgs(s)); + if (s.pd && s.layout !== 'external') arg.push('--vllm-config "$LAB_CONFIG/vllm.yaml"'); + if (s.transport !== 'nccl') { + arg.push(`--update-weight-mode ${s.transport==='delta'?'delta':'full'}`, '--update-weight-transport disk', + `--update-weight-disk-dir ${quote(s.shared)}`); + if (s.transport === 'delta') arg.push(`--update-weight-local-checkpoint-dir ${quote(s.local)}`); + } + const env = { + PYTHONPATH: s.megatron, + CUDA_DEVICE_MAX_CONNECTIONS: '1', + NVSHMEM_DISABLE_NCCL: '1', + NCCL_NVLS_ENABLE: '0', + PYTHONUNBUFFERED: '1' + }; + if (s.deterministic) Object.assign(env, { + NCCL_ALGO: 'Ring', + NVTE_ALLOW_NONDETERMINISTIC_ALGO: '0', + CUBLAS_WORKSPACE_CONFIG: ':4096:8' + }); + if (s.precision === 'fp8train') env.NVTE_FP8_BLOCK_SCALING_FP32_SCALES = '1'; + const config = yaml(s); + const header = + `#!/usr/bin/env bash\n# Generated by vime experiment lab. Run from the vime repository root.\n# Recipe basis: scripts/run-${m.recipe}.sh\n# ${m.name} | ${s.layout} | ${s.schedule} | ${s.precision} | ${s.correction}\n# Allocated Ray GPUs: ${v.localGpus}. Memory fit depends on your GPU and context.\n# Usage: bash experiment.sh prepare | convert | check | train\nset -euo pipefail\n\nHF_MODEL=${quote(s.hf)}\nTRAIN_CHECKPOINT=${quote(s.train)}\nPROMPT_DATA=${quote(s.data)}\nSAVE_DIR=${quote(s.save)}\nMEGATRON_DIR=${quote(s.megatron)}\nROLLOUT_MODEL="$HF_MODEL${s.precision==='bf16'?'':s.precision==='int4'?'-int4':'-fp8'}"\n# Same repository and data paths must be accessible on every participating node.\nexport PYTHONPATH="$PWD:$MEGATRON_DIR\${PYTHONPATH:+:$PYTHONPATH}"\nsource ${quote('scripts/models/'+m.model+'.sh')}\n\n`; + const conversionNodes = ['glm5', 'glm47', 'deepseek'].includes(s.model) ? 4 : 1; + const conversionArgs = s.model === 'glm5' ? + '--tensor-model-parallel-size 8 --pipeline-model-parallel-size 2 --decoder-last-pipeline-num-layers 40 --expert-model-parallel-size 16 --expert-tensor-parallel-size 1' : + s.model === 'deepseek' ? + '--tensor-model-parallel-size 1 --pipeline-model-parallel-size 8 --decoder-first-pipeline-num-layers 7 --decoder-last-pipeline-num-layers 6 --expert-model-parallel-size 4 --expert-tensor-parallel-size 1' : + s.model === 'glm47' ? + '--tensor-model-parallel-size 8 --pipeline-model-parallel-size 4 --expert-model-parallel-size 8 --expert-tensor-parallel-size 1' : + ''; + const prep = + `if [[ "\${1:-check}" == prepare ]]; then\n # Run once on the shared filesystem. Conversion is a separate distributed step.\n${['deepseek','glm5'].includes(s.model)?` hf download ${quote(m.repo)} --local-dir "$HF_MODEL-source-fp8"\n python tools/fp8_cast_bf16.py --input-fp8-hf-path "$HF_MODEL-source-fp8" --output-bf16-hf-path "$HF_MODEL"\n python - "$HF_MODEL/config.json" <<'VIME_BF16_CONFIG'\nimport json, sys\nfrom pathlib import Path\np = Path(sys.argv[1])\ncfg = json.loads(p.read_text())\ncfg.pop('quantization_config', None)\np.write_text(json.dumps(cfg, indent=2))\nVIME_BF16_CONFIG\n`:` hf download ${quote(m.repo)} --local-dir "$HF_MODEL"\n`}${s.task==='math'?' hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir "$(dirname -- "$PROMPT_DATA")"\n':' # Supply your prompt JSONL and importable custom hooks before training.\n'}${s.precision==='bf16'?'':s.precision==='int4'?' python tools/convert_hf_to_int4_direct.py --model-dir "$HF_MODEL" --save-dir "$ROLLOUT_MODEL"\n':' python tools/convert_hf_to_fp8.py --model-dir "$HF_MODEL" --save-dir "$ROLLOUT_MODEL" \\\n --strategy block --block-size 128 128 --max-workers 4\n'} printf '%s\\n' 'Next: run bash experiment.sh convert on ${conversionNodes} node(s), 8 GPUs each.'\n exit 0\nfi\n\nif [[ "\${1:-check}" == convert ]]; then\n${conversionNodes>1?' : "\${CONVERT_MASTER_ADDR:?Set the conversion head IP on all 4 nodes}"\n : "\${CONVERT_NODE_RANK:?Set this node rank: 0, 1, 2, or 3}"\n':''} torchrun --nproc-per-node 8 ${conversionNodes===1?'--standalone':'--nnodes 4 --node-rank "$CONVERT_NODE_RANK" --master-addr "$CONVERT_MASTER_ADDR" --master-port 12345'} \\\n tools/convert_hf_to_torch_dist.py "\${MODEL_ARGS[@]}" \\\n ${conversionArgs?conversionArgs+' \\\n ':''}--hf-checkpoint "$HF_MODEL" --save "$TRAIN_CHECKPOINT"\n exit 0\nfi\n\nfor path in "$ROLLOUT_MODEL/config.json" "$TRAIN_CHECKPOINT/latest_checkpointed_iteration.txt" "$PROMPT_DATA"${s.straw?' '+quote(s.declaration):''}; do\n if [[ ! -f "$path" ]]; then\n printf 'Missing input: %s\\nRun prepare and convert, or edit the paths at the top of this script.\\n' "$path" >&2\n exit 1\n fi\ndone\n\nif [[ "\${1:-check}" == check ]]; then\n printf '%s\\n' 'Input files exist. Verify Ray capacity, serving dependencies, and the selected model recipe before training.'\n exit 0\nfi\nif [[ "\${1:-}" != train ]]; then\n printf '%s\\n' 'Usage: bash experiment.sh prepare | convert | check | train' >&2\n exit 1\nfi\n\n`; + const runtime = JSON.stringify({ + env_vars: env + }, null, 2); + const launch = + `# Ray must already be running. Join workers before submitting this job.\n# RAY_DASHBOARD can point at an existing head (default: localhost).\nLAB_CONFIG=$(mktemp -d "$PWD/.vime-lab.XXXXXX")\ntrap 'rm -rf -- "$LAB_CONFIG"' EXIT\ncat > "$LAB_CONFIG/runtime-env.json" <<'VIME_RUNTIME'\n${runtime}\nVIME_RUNTIME\npython - "$LAB_CONFIG/runtime-env.json" "$PWD" <<'VIME_PATH'\nimport json, sys\nfrom pathlib import Path\np = Path(sys.argv[1])\ncfg = json.loads(p.read_text())\ncfg['env_vars']['PYTHONPATH'] = sys.argv[2] + ':' + cfg['env_vars']['PYTHONPATH']\np.write_text(json.dumps(cfg))\nVIME_PATH\n${config?`cat > "$LAB_CONFIG/vllm.yaml" <<'VIME_VLLM'\n${config}VIME_VLLM\n`:''}\nray job submit --address="\${RAY_DASHBOARD:-http://127.0.0.1:8265}" \\\n --runtime-env "$LAB_CONFIG/runtime-env.json" -- python train.py \\\n "\${MODEL_ARGS[@]}" \\\n ${arg.join(' \\\n ')}\n`; + const external = s.layout === 'external' ? + `# External vLLM launch reference — apply before submitting experiment.sh.\n# One command per engine; multi-node engines also need --nnodes, --node-rank\n# and --master-addr and --master-port. Use each engine's real host, ports and visible devices.\n# Model, dtype, TP/EP/DP, cache and routed-expert reporting must match the trainer.\n# Pass engine host:port addresses, not a router URL, to vime.\n\nVLLM_SERVER_DEV_MODE=1${s.deterministic?' VLLM_BATCH_INVARIANT=1':''} vllm serve \\\n ${quote(s.hf+(s.precision==='bf16'?'':s.precision==='int4'?'-int4':'-fp8'))} \\\n --tensor-parallel-size ${m.engine / m.dp} \\\n ${servingArgs(s).filter(a=>!a.startsWith('--rollout-') && !a.startsWith('--vllm-disaggregation-') && a !== '--vllm-enable-deterministic-inference').map(a=>a.replace(/^--(no-)?vllm-/, '--$1')).concat(['--logprobs-mode processed_logprobs', '--enable-scale-out', '--enable-prompt-tokens-details', '--enable-server-load-tracking', '--enable-per-request-metrics', `--max-logprobs ${s.sc?129:1}`, `--weight-transfer-config ${quote(JSON.stringify({backend:'nccl'}))}`], s.r3?['--enable-return-routed-experts']:[], s.pd?[...(s.hicache?['--enable-prefix-caching']:[]), `--kv-transfer-config ${quote(JSON.stringify(kvTransferConfig(s, 'prefill')))}`]:[]).join(' \\\n ')}\n\n${s.pd?'# Launch a matching decode group with kv_role=kv_consumer.\n# Keep host KV offloading on prefill only.\n# Configure connector handshake ports, RDMA routing, and multi-node engine ranks.\n':''}# Disk sync: expose ${s.shared} at the SAME path on trainer and serving hosts.\n${s.transport==='delta'?'# Delta requires vime-patched vLLM /pull_weights and local checkpoint paths.\n':''}` : + ''; + const plan = { + schema: 1, + choices: s, + model: m.name, + validation: v + }; + return { + validation: v, + files: { + 'experiment.sh': header + prep + launch, + ...(config ? { + 'vllm.yaml': config + } : {}), + ...(external ? { + 'serving-reference.sh': external + } : {}), + 'experiment.json': JSON.stringify(plan, null, 2) + '\n' + } + }; + } + root.VimeExperiment = { + models, + defaults, + enums, + numberLimits, + sanitize, + change, + preset, + validate, + generate, + quote + }; +})(typeof window === 'undefined' ? globalThis : window); diff --git a/docs/_static/js/experiment-reader.js b/docs/_static/js/experiment-reader.js new file mode 100644 index 000000000..8e5e6910d --- /dev/null +++ b/docs/_static/js/experiment-reader.js @@ -0,0 +1,201 @@ +/* Read the existing Sphinx guides in place, keeping the builder and its history alive. */ +(function() { + 'use strict'; + const dialog = document.getElementById('learn-dialog'); + if (!dialog) return; + const content = document.getElementById('learn-content'); + const title = document.getElementById('learn-title'); + const zh = document.documentElement.lang.startsWith('zh'); + const base = new URL('./', location.href); + let previousFocus, request = 0, + mathReady, mermaidReady; + const cache = new Map(); + + function isGuide(url) { + return url.origin === base.origin && url.pathname.startsWith(base.pathname) && + url.pathname.endsWith('.html') && !/\/(?:index|library|search|genindex)\.html$/.test(url.pathname); + } + + async function typeset(element) { + if (element.querySelector('.math')) { + if (!mathReady) { + mathReady = new Promise((resolve, reject) => { + window.MathJax = { + startup: { + typeset: false + }, + tex: { + inlineMath: [ + ['\\(', '\\)'] + ] + }, + options: { + // MyST skips article prose, then opts formula nodes back into processing. + ignoreHtmlClass: 'tex2jax_ignore|mathjax_ignore', + processHtmlClass: 'tex2jax_process|mathjax_process|math' + } + }; + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'; + script.onload = () => resolve(window.MathJax.startup.promise); + script.onerror = reject; + document.head.appendChild(script); + }); + } + try { + await mathReady; + if (element.isConnected) await window.MathJax.typesetPromise([element]); + } catch (_) { + /* Keep readable TeX if the CDN cannot be reached. */ + } + } + if (element.querySelector('.mermaid')) { + if (!mermaidReady) mermaidReady = import( + 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'); + try { + const { + default: mermaid + } = await mermaidReady; + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'neutral' + }); + if (element.isConnected) await mermaid.run({ + nodes: element.querySelectorAll('.mermaid') + }); + } catch (_) { + /* The source diagram is still available. */ + } + } + } + + async function open(href, source, info) { + const url = new URL(href, base); + if (!isGuide(url)) return; + const ticket = ++request; + if (!dialog.open) { + previousFocus = source || document.activeElement; + dialog.showModal(); + } + title.textContent = info?.title || (zh ? '页内阅读' : 'Read in place'); + window.MathJax?.typesetClear?.([content]); + content.replaceChildren(); + const status = document.createElement('p'); + status.setAttribute('role', 'status'); + status.textContent = zh ? '正在加载完整说明与推导…' : 'Loading the full guide and derivation…'; + content.appendChild(status); + dialog.scrollTop = 0; + const documentUrl = new URL(url); + documentUrl.hash = ''; + try { + let html = cache.get(documentUrl.href); + if (!html) { + const response = await fetch(documentUrl); + if (!response.ok) throw new Error(response.status); + html = await response.text(); + cache.set(documentUrl.href, html); + } + if (ticket !== request || !dialog.open) return; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const article = doc.querySelector('article.bd-article'); + if (!article) throw new Error('Missing article'); + const anchor = url.hash ? doc.getElementById(decodeURIComponent(url.hash.slice(1))) : null; + const section = anchor?.closest('section') || article; + const body = document.createElement('div'); + body.className = 'reader-article'; + body.innerHTML = section.innerHTML; + body.querySelectorAll('script, .headerlink').forEach(node => node.remove()); + body.querySelectorAll('[href], [src]').forEach(node => { + for (const attr of ['href', 'src']) { + if (node.hasAttribute(attr)) node.setAttribute(attr, new URL(node.getAttribute(attr), + documentUrl).href); + } + if (node.tagName === 'A' && new URL(node.href).origin !== base.origin) { + node.target = '_blank'; + node.rel = 'noopener'; + } + }); + title.textContent = info?.title || body.querySelector('h1, h2, h3')?.textContent.trim() || doc + .title; + const heading = body.querySelector('h1, h2, h3'); + if (heading?.textContent.trim() === title.textContent.trim()) heading.remove(); + const links = document.createElement('p'); + links.className = 'reader-sources'; + const full = document.createElement('a'); + full.href = url.href; + full.target = '_blank'; + full.rel = 'noopener'; + full.textContent = zh ? '单独打开本节 ↗' : 'Open this section separately ↗'; + links.append(full); + if (info?.paper) { + const paper = document.createElement('a'); + paper.href = info.paper; + paper.target = '_blank'; + paper.rel = 'noopener'; + paper.textContent = zh ? '原论文 / 技术文章 ↗' : 'Original paper / article ↗'; + links.append(paper); + } + content.replaceChildren(links); + if (info?.text) { + const summary = document.createElement('p'); + summary.className = 'reader-summary'; + summary.textContent = info.text; + content.append(summary); + } + content.append(body); + void typeset(body); + } catch (_) { + if (ticket !== request) return; + status.textContent = zh ? '加载失败,请重试或直接打开原文。' : + 'Could not load the guide. Retry or open the original page.'; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.textContent = zh ? '重试' : 'Retry'; + retry.onclick = () => open(href, source, info); + const link = document.createElement('a'); + link.href = url.href; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = zh ? '打开原文 ↗' : 'Open guide ↗'; + content.append(retry, link); + } + } + + document.addEventListener('click', event => { + const a = event.target.closest('a[href]'); + if (!a || a.target || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event + .button !== 0) return; + const url = new URL(a.href); + if (a.closest('.reader-article') && url.origin === base.origin && + url.pathname === new URL('index.html', base).pathname) { + event.preventDefault(); + dialog.close(); + document.getElementById('lab').scrollIntoView(); + return; + } + if (!isGuide(url)) return; + event.preventDefault(); + if (a.closest('.reader-article') && url.hash) { + const target = content.querySelector('#' + CSS.escape(decodeURIComponent(url.hash.slice(1)))); + if (target && new URL(a.href).pathname === new URL(content.querySelector('.reader-sources a') + .href).pathname) { + target.scrollIntoView({ + block: 'start' + }); + return; + } + } + void open(url, a); + }); + document.getElementById('close-learn').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => { + request++; + previousFocus?.focus({ + preventScroll: true + }); + }); + window.VimeReader = { + open + }; +})(); diff --git a/docs/_static/js/experiment-ui.js b/docs/_static/js/experiment-ui.js new file mode 100644 index 000000000..214c5d6c0 --- /dev/null +++ b/docs/_static/js/experiment-ui.js @@ -0,0 +1,815 @@ +(function() { + 'use strict'; + const E = window.VimeExperiment, + $ = id => document.getElementById(id); + if (!E || !$('interactive-lab')) return; + const lang = document.documentElement.lang.startsWith('zh') ? 'zh' : 'en', + t = (en, zh) => lang === 'zh' ? zh : en; + const esc = s => String(s).replace(/[&<>"']/g, c => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } [c])); + const steps = [ + [t('Model', '模型'), t('Choose your starting character.', '选一个你的初始角色。'), t( + 'Start with a model and a task. Everything else follows.', '先选模型和任务,后面的配方会随之变化。')], + [t('Placement', '训推布局'), t('Give your model a home.', '让训练与推理各就各位。'), t( + 'GPU ownership and scheduling are two separate choices.', 'GPU 如何分配,和任务如何调度,是两个独立选择。')], + [t('Precision', '精度'), t('Find your precision sweet spot.', '找到精度与效率的平衡。'), t( + 'Choose precision for weights, attention KV, and hybrid recurrent state separately.', + '分别选择权重、attention KV 与 hybrid 递归状态的精度。')], + [t('Consistency', '训推一致'), t('Keep the two worlds in tune.', '让两个世界保持合拍。'), t( + 'Reduce numerical mismatch, correct its effect, or combine the two.', '可以减少数值差异,也可以修正它对训练的影响。')], + [t('Serving', '推理引擎'), t('Build your rollout engine.', '搭好你的 rollout 引擎。'), t( + 'Tune for your workload. More switches do not always mean a faster run.', '根据负载调优。开关越多,并不总是越快。')], + [t('Launch', '启动'), t('Connect the last few pieces.', '接好最后几块积木。'), t( + 'Use paths visible to your cluster. The shell includes preparation and a short three-round run by default.', + '填写集群可见的路径。shell 默认包含准备步骤和三轮短实验。')], + ]; + const theory = { + grpo: { + title: 'GRPO', + formula: 'Aᵢ = (Rᵢ − mean(R)) / (std(R) + ε)', + text: t( + 'Compare multiple answers to the same prompt instead of training a critic. The recipe uses 8 answers per prompt; Score Centering switches off standard-deviation normalization and uses REINFORCE.', + '用同一 prompt 的多次回答估计相对优势,不额外训练 critic。配方每个 prompt 采样 8 次;启用 SC 时改用 REINFORCE 并关闭标准差归一化。'), + paper: 'https://arxiv.org/abs/2402.03300', + page: 'advanced/policy-mismatch.html#grpo' + }, + tis: { + title: 'TIS · Truncated Importance Sampling', + formula: 'w = clip(p_old(a | h) / q(a | h), L, U)', + text: t( + 'Rollout samples come from q, while PPO uses the old trainer p_old. Importance weights bridge that gap; clipping limits extreme weights but introduces bias. This recipe uses L=0, U=2. With SC the numerator is the current trainer and the weight is detached.', + '样本来自 rollout 分布 q,PPO 的旧策略来自训练器 p_old。重要性权重连接两者;裁剪限制极端权重,同时引入偏差。配方使用 L=0、U=2。与 SC 组合时,分子改为当前训练策略,并对权重停止梯度。' + ), + paper: 'https://openreview.net/forum?id=8MHqvb4lK9', + page: 'advanced/policy-mismatch.html#tis' + }, + icepop: { + title: 'ICE-POP', + formula: 'w = (p / q) · 1{ L ≤ p / q ≤ U }', + text: t( + 'vime’s built-in IcePop callback masks weights outside the interval instead of clipping them to its boundary. The recipe uses [0.5, 2]. Track the masked fraction: excessive masking can discard the learning signal.', + 'vime 内置 IcePop callback 将区间外的权重置零,而不是裁剪到边界。配方使用 [0.5, 2]。需要观察被 mask 的比例,避免丢掉太多学习信号。'), + paper: 'https://arxiv.org/abs/2510.18855', + page: 'advanced/policy-mismatch.html#icepop' + }, + r3: { + title: 'R3 · Rollout Routing Replay', + formula: 'MoE(x) = Σᵢ∈TopK_rollout gᵢ(x) · Expertᵢ(x)', + text: t( + 'Tiny router differences can select different experts. Replay the expert IDs recorded during rollout on the training side. This aligns the discrete route, while numerical differences inside experts can remain. Extra route tensors cost memory and transfer bandwidth.', + '微小的 router 差异可能选中不同专家。R3 在训练时重放 rollout 记录的 expert IDs,对齐离散路由;专家内部仍可能有数值差异。保存路由张量会增加内存和传输开销。'), + paper: 'https://arxiv.org/abs/2510.11370', + page: 'advanced/policy-mismatch.html#r3' + }, + sc: { + title: 'SC · Score Centering', + formula: 'g = A · [wₐ ∇ log pₐ − Σᵥ qᵥ wᵥ ∇ log pᵥ]', + text: t( + 'Subtract the sampler’s expected (possibly importance-weighted) score at each prefix. The centered score has zero mean, removing a drift term. This is not an unbiased on-policy gradient. vime stores the sampler top-128 head and approximates the tail; it requires REINFORCE, not PPO clipping.', + '逐个 prefix 减去采样器下(可加重要性权重的)score 期望,使中心化后的 score 均值为零,消除漂移项;这不等于无偏 on-policy 梯度。vime 保存 sampler top-128 head 并近似 tail,使用 REINFORCE,而非 PPO clipping。' + ), + paper: 'https://arxiv.org/abs/2609.20807', + page: 'advanced/policy-mismatch.html#sc' + }, + deterministic: { + title: t('Deterministic execution', '确定性执行'), + formula: 'repeatable(p) ∧ repeatable(q) ⇏ p = q', + text: t( + 'Fixing each engine’s execution order improves reproducibility. Cross-engine log-prob equality also requires aligned kernels, precision, reductions, and routers. The maintained GLM-5 exact-alignment gate is six-layer EP8; do not extrapolate that guarantee to a full model.', + '固定各引擎的执行顺序可以改善可复现性。要让训推 log-prob 相等,还需要对齐 kernel、精度、归约和路由。现有 GLM-5 精确对齐验证为六层 EP8,不能直接推广为全模型保证。' + ), + paper: 'https://lmsys.org/blog/2025-09-22-sglang-deterministic/', + page: 'advanced/rl-systems.html#deterministic' + }, + precision: { + title: t('Precision & quantization', '精度与量化'), + formula: 'q(a | h) = softmax(f_quantized(h))ₐ', + text: t( + 'Lower precision changes the rollout distribution. Weight precision, attention KV dtype and hybrid recurrent-state dtype are independent choices. FP8 attention KV requires model/backend support; Mamba SSM state and convolution state have separate storage. Compare the two cache pools below.', + '低精度会改变 rollout 分布。权重、attention KV 和 hybrid 递归状态分别选择精度。FP8 attention KV 需模型与后端支持;Mamba SSM 和卷积状态各有独立存储。下面分别说明两类缓存。' + ), + paper: 'https://arxiv.org/abs/2209.05433', + page: 'advanced/rl-systems.html#precision' + }, + async: { + title: t('Synchronous vs fully async', '同步与 Fully async'), + formula: 'T_sync ≈ T_rollout + T_train + T_sync_weights', + text: t( + 'Synchronous execution makes experiment ordering easier to understand. Fully async keeps a warm queue of generations across steps, reducing waits for long tails but mixing weight versions. The animation is explanatory, not a performance prediction. Track queue age and measured throughput.', + '同步执行更容易理解实验顺序。Fully async 跨 step 保持生成队列,减少长尾等待,但会混合不同权重版本。右侧动画用于解释流程,不预测性能;请测量队列年龄和实际吞吐。'), + paper: 'https://arxiv.org/abs/2509.19128', + page: 'advanced/rl-systems.html#async' + }, + placement: { + title: t('GPU placement & weight updates', 'GPU 布局与权重更新'), + formula: 'separate GPUs = training pool + rollout pool', + text: t( + 'Colocated engines take turns on one GPU pool. Disaggregated engines own separate pools; external engines are also managed outside vime. External weight updates can use NCCL or a shared filesystem; delta updates require disk transport and patched serving endpoints.', + 'Colocated 在同一组 GPU 上轮流工作;分离布局各占一组 GPU;external 还把引擎生命周期交给外部系统。外部权重更新可使用 NCCL 或共享文件系统,delta 需要磁盘传输和带补丁的推理接口。' + ), + paper: 'https://arxiv.org/abs/2409.19256', + page: 'advanced/rl-systems.html#placement' + }, + pd: { + title: 'PD · Prefill / Decode', + formula: 'throughput ≤ min(nₚ μₚ, n_d μ_d, B_network / KV_bytes)', + text: t( + 'Separate prompt processing from token generation and size each pool independently. This can reduce interference on long prompts, but adds KV-transfer and deployment costs. It is a serving split, independent of the training/rollout GPU split.', + '将 prompt 处理与逐 token 生成拆开,分别扩容。长 prompt 下可能减少干扰,但增加 KV 传输与部署成本。它是推理内部拆分,独立于训练 / rollout 的 GPU 布局。' + ), + paper: 'https://arxiv.org/abs/2401.09670', + page: 'advanced/rl-systems.html#pd' + }, + eagle: { + title: 'EAGLE · ' + t('Speculative decoding', '投机采样'), + text: t( + 'A draft head proposes candidates; the target model verifies them and commits an accepted prefix. A separate head must match the target model. Monitor acceptance and rollout time as RL updates the target.', + '投机头提出候选,目标模型验证并提交接受的前缀。独立投机头必须匹配目标模型;随 RL 更新,观察接受率和 rollout 耗时。'), + paper: 'https://arxiv.org/abs/2401.15077', + page: 'advanced/speculative-decoding.html' + }, + hicache: { + title: 'HiCache', + formula: 'saved time ≈ hit_rate × prefill_cost − cache_IO', + text: t( + 'Reuse prefixes through a hierarchy of GPU and host memory, optionally storage. It helps repeated prefixes, not unrelated prompts. This recipe configures host memory only, and prefill only under PD; model-weight updates limit how long cache entries remain valid.', + '在 GPU、主机内存乃至存储之间复用 prefix。它适合重复前缀,不一定帮助无关 prompt。此配方只配置主机缓存,PD 下仅在 prefill 启用;模型权重更新会限制缓存有效期。' + ), + paper: 'https://docs.sglang.io/docs/advanced_features/hicache_best_practices', + page: 'advanced/rl-systems.html#hicache' + }, + }; + theory.parallel = { + title: t('Parallelism & memory', '并行与内存'), + formula: 'N = TP × PP × CP × DP; EP uses an expert process-group factorization', + text: t( + 'TP splits a layer, PP splits layers, EP splits experts, CP splits tokens. DP replicates work across different samples; the distributed optimizer shards its state. CPU Adam moves optimizer work to host memory. These save different kinds of memory and introduce different communication.', + 'TP 拆层内张量,PP 拆模型层,EP 拆专家,CP 拆序列。DP 用副本处理不同样本,distributed optimizer 对状态分片。CPU Adam 将优化器工作移到主机。它们节省的内存和引入的通信各不相同。' + ), + paper: 'https://arxiv.org/abs/2104.04473', + page: 'advanced/parallelism-memory.html' + }; + theory.partial = { + title: 'Partial rollout', + formula: 'q(y | x) = ∏ₜ q_version(t)(yₜ | x, y₍ $('toast').classList.remove('visible'), 4500); + } + + function persist() { + try { + localStorage.setItem('vime-experiment-v1', JSON.stringify(state)); + } catch (_) {} + } + + function commit(next, message) { + if (JSON.stringify(next) === JSON.stringify(state)) return; + history.push({ + ...state + }); + if (history.length > 100) history.shift(); + future.length = 0; + state = next; + stop(); + render(); + persist(); + if (message) toast(message); + } + + function options(key, title, items, single = false) { + return `
${title}
${items.map(([value,label,description])=>``).join('')}
`; + } + + function toggle(key, label, desc) { + return ``; + } + + function input(key, label, type = 'text', help = '', wide = false) { + return ``; + } + + function form() { + const m = E.models[state.model]; + switch (state.step) { + case 0: + return options('model', t('Your model', '你的模型'), Object.entries(E.models).sort(([a], [b]) => Number( + ['qwen38', + 'deepseek' + ].includes(a)) - Number(['qwen38', 'deepseek'].includes(b))).map(([k, v]) => [k, v.name, v + .size + ])) + + options('task', t('Your learning signal', '你的学习信号'), [ + ['math', t('Verifiable math', '可验证数学'), t('Prompt + label JSONL, built-in deepscaler reward.', + 'Prompt + label JSONL,内置 deepscaler 奖励。')], + ['custom', t('My agent / reward', '我的 Agent / 奖励'), t( + 'Plug in your generation and reward functions.', + '接入自己的生成和奖励函数。')] + ]) + + `

${t('The compact starting recipe uses GRPO, 8 responses per prompt, and 3 rounds.','起步配方使用 GRPO,每个 prompt 8 次采样,先训练 3 轮。')}

`; + case 1: + return options('layout', t('Where do the engines live?', '引擎部署在哪里?'), [ + ['colocate', 'Colocated', t( + 'One pool. Rollout and training take turns; weights use CUDA IPC.', + '同一组 GPU,训推轮流执行;CUDA IPC 同步权重。')], + ['separate', 'Disaggregated', t('Separate GPU pools, both managed by vime.', + '各自独立 GPU 池,由 vime 管理。')], + ['external', 'External vLLM', t('Attach engines already running in another cluster.', + '连接已部署的外部 vLLM 引擎。')] + ], true) + options('schedule', t('How does work progress?', '任务如何推进?'), [ + ['sync', t('Synchronous', '同步训练'), t('Finish a batch, train, update weights.', + '完成一批 rollout,再训练并更新权重。')], + ['async', 'Fully async', t('A warm queue across steps. Add straw for distributed workers.', + '跨 step 持续生成;搭配 straw 启用分布式 workers。')] + ]) + + `
${input('nodes',t('Training nodes × 8 GPUs','训练节点数 × 8 GPU'),'number')}${state.layout!=='colocate'?input('rollout',state.layout==='external'?t('External GPUs (planning only)','外部 GPU 数(规划用)'):t('Rollout GPUs','Rollout GPU 数'),'number'):''}
` + + schedulingPanel() + resourcePanel() + toggle('cpuAdam', 'CPU Adam · ' + t('save GPU memory', + '节省显存'), t( + 'Offload optimizer states and compute; uses host RAM and transfer bandwidth.', + '优化器状态与计算 offload 到 CPU,需要主机内存与传输带宽。')) + toggle('recompute', t('Recompute activations', + '激活重计算'), t( + 'Keep fewer intermediates; recompute them during backward.', '少存中间激活,在 backward 时重新计算。')) + + parallelPanel(); + case 2: + return options('precision', t('Training → rollout weights', '训练 → 推理权重'), [ + ['bf16', 'BF16 → BF16', t('An easy baseline for measuring mismatch.', '适合建立基线,先测量训推差异。')], + ['fp8', 'BF16 → FP8', t('Maintained large-MoE path. Convert the HF checkpoint.', + '大 MoE 维护路径,需转换 HF checkpoint。')], + ['fp8train', 'FP8 → FP8', t('Experimental. Changes the training numerical path.', + '实验性,改变训练侧数值路径。')], + ['int4', 'BF16 → INT4', t('Beta. Validate GLM kernel support first.', + 'Beta,先验证 GLM kernel 支持。')] + ]) + (m.hybrid ? + `

${t( + 'This hybrid model has 16 attention layers and 48 Gated DeltaNet layers. Their caches use separate settings.', + '这个 hybrid 模型有 16 层 attention 和 48 层 Gated DeltaNet,两类缓存分别配置。')} + ${t('How the two caches work →', '看懂两类缓存 →')}

` : '') + + options('kv', t('Rollout attention KV cache', 'Rollout attention 层 KV cache'), [ + ['auto', t('Model default', '模型默认'), t('Use the serving stack’s default KV dtype.', + '沿用推理栈默认 KV dtype。')], + ['fp8', 'FP8 E4M3', t( + 'Attention KV only. Requires support from this model’s attention backend and GPU.', + '只作用于 attention KV,需当前模型的 attention 后端与 GPU 支持。')] + ]) + (m.hybrid ? options('mambaDtype', t('Mamba / linear-attention recurrent state', + 'Mamba / 线性注意力递归状态'), [ + ['auto', t('Model default · FP32', '模型默认 · FP32'), t( + 'Qwen3.8-27B config selects FP32 SSM state. Independent of attention KV.', + 'Qwen3.8-27B 配置采用 FP32 SSM 状态,与 attention KV 独立。')], + ['float32', 'FP32', t( + 'Explicit 32-bit recurrent state; convolution state keeps its own dtype.', + '显式使用 32 bit 递归状态;卷积状态保留自身的 dtype。')], + ['bfloat16', 'BF16', t( + 'Smaller recurrent state. Validate long-sequence accuracy and kernel support.', + '减少递归状态存储;验证长序列精度与 kernel 支持。')] + ]) + `

${t( + 'vLLM manages attention KV and recurrent state in a shared paged cache. Both use the total GPU cache budget; no separate pool-ratio setting is needed.', + 'vLLM 在共享分页缓存中管理 attention KV 与递归状态,两者受总 GPU 缓存预算约束,无需设置独立池比例。')}

` : ''); + case 3: + return options('correction', t('Importance correction', '重要性修正'), [ + ['none', t('Observe only', '暂不修正'), t('Baseline. Watch log-prob mismatch and reward.', + '作为对照,观察 log-prob 差异与 reward。')], + ['tis', 'TIS', t('Clamp weights to [0, 2].', '将权重裁剪到 [0, 2]。')], + ['icepop', 'ICE-POP', t('Zero weights outside [0.5, 2].', '将 [0.5, 2] 之外的权重置零。')] + ]) + toggle('r3', 'R3 · ' + t('Replay MoE routes', '重放 MoE 路由'), m.moe ? t( + 'Align expert selection; can combine with correction.', '对齐专家选择,可与重要性修正组合。') : t( + 'Requires MoE; this dense model has no expert routes.', '仅适用于 MoE;当前 dense 模型没有专家路由。')) + + toggle('sc', + 'SC · Score Centering', t('Center weighted scores; selects REINFORCE and saves sampler top-k.', + '中心化加权 score,改用 REINFORCE 并保存 sampler top-k。')) + toggle('deterministic', t( + 'Deterministic execution', + '确定性执行'), t('Control execution order. Requires the deterministic dependency stack.', + '控制执行顺序,需要 deterministic 依赖栈。')); + case 4: + return toggle('pd', t('Split prefill & decode', '分离 prefill 与 decode'), t( + 'Separate serving stages. Useful when prompt processing delays decoding.', + '拆分推理阶段,适合 prefill 干扰 decode 的场景。')) + toggle('hicache', 'HiCache', t( + 'Reuse repeated prefixes in host RAM. Extra memory and I/O budget.', + '在主机内存复用重复前缀,需要额外内存与 I/O 预算。')) + + toggle('speculative', 'EAGLE · ' + t('Speculative decoding', '投机采样'), t( + 'Draft several candidates, then verify them together with the target model.', + '先提出多个候选 token,再由目标模型一起验证。')) + + (state.speculative ? options('draftSource', t('Draft head', '投机采样头'), [ + ...(m.mtp ? [['builtin', t('Checkpoint MTP head', 'Checkpoint 内置 MTP 头'), t( + 'Use the target checkpoint’s supported prediction layers.', '使用目标 checkpoint 自带的预测层。')]] : []), + ['external', t('Separate EAGLE head', '独立 EAGLE 头'), t( + 'Load a compatible head from a local path or Hugging Face model ID.', + '从本地路径或 Hugging Face 模型 ID 加载匹配的投机头。')] + ]) + (state.draftSource === 'external' ? input('draft', t('EAGLE head checkpoint', 'EAGLE 投机头 checkpoint'), + 'text', t('Path or model ID, available on every serving host. The head is loaded for inference.', + '路径或模型 ID,需供每台推理主机访问;投机头用于推理加载。'), true) : '') + + input('specSteps', t('Draft steps', '投机步数'), 'number', t( + 'Top-k is fixed to 1; verification budget is steps + 1. Start small and measure acceptance.', + 'Top-k 固定为 1,验证 token 预算为步数 + 1;先从小值开始测量接受率。')) : '') + + `
${state.pd&&state.layout!=='external'?input('prefill',t(`Prefill GPUs (engine = ${m.engine})`,`Prefill GPU(每引擎 ${m.engine})`),'number'):''}${state.pd?input('ib',t('RDMA devices','RDMA 网卡'),'text','mlx5_0,mlx5_1'):''}${input('concurrency',t('Concurrency per engine (global budget basis)','每引擎并发(全局预算基数)'),'number')}${input('response',t('Max response tokens','最大回复 token 数'),'number')}
` + + (state.layout !== 'colocate' ? options('transport', t('Weight updates', '权重更新'), [ + ['nccl', 'NCCL', t('Reachable, compatible GPU communication group.', '可建立兼容的 GPU 通信组。')], + ['disk', t('Full · shared disk', '全量 · 共享盘'), t('A complete checkpoint every sync.', + '每次同步传输完整 checkpoint。')], + ['delta', t('Delta · shared disk', '增量 · 共享盘'), t( + 'Changed bytes + local checkpoint. Patched serving required.', + '变化字节 + 本地 checkpoint,需要推理补丁。')] + ]) : ''); + default: + return `
${input('hf',t('Original BF16 HF checkpoint','原始 BF16 HF checkpoint'),'text',t('prepare downloads here; FP8 / INT4 gets its own sibling directory.','prepare 下载到此处;FP8 / INT4 使用旁边的独立目录。'),true)}${input('train',t('Megatron checkpoint','Megatron checkpoint'),'text','',true)}${input('data',t('Prompt JSONL','Prompt JSONL'),'text',t('Use prompt and label keys for math.','数学数据使用 prompt 和 label 字段。'),true)}${input('save',t('Output directory','输出目录'),'text')}${input('megatron',t('Megatron-LM repository','Megatron-LM 仓库'),'text')}${input('rounds',t('Training rounds','训练轮数'),'number')}${state.layout==='external'?input('endpoints',t('External engine addresses','外部引擎地址'),'text','host1:30000 host2:30000',true):''}${state.transport!=='nccl'?input('shared',t('Shared weight directory','共享权重目录'),'text','',true):''}${state.transport==='delta'?input('local',t('Serving host local checkpoint','推理主机本地 checkpoint'),'text','',true):''}${state.task==='custom'?input('generate',t('Generation hook','生成 hook'),'text','my_agent.generate')+input('reward',t('Reward hook','奖励 hook'),'text','my_reward.reward'):''}
`; + } + } + const helpKeys = [ + ['grpo'], + ['placement', 'async', 'partial', 'straw', 'parallel'], + ['precision'], + ['tis', 'icepop', 'r3', 'sc', 'deterministic'], + ['pd', 'hicache', 'eagle'], + ['placement'] + ]; + + function schedulingPanel() { + const async = state.schedule === 'async', distributed = async && state.straw; + return toggle('partial', 'Partial rollout', t( + 'Keep unfinished prefixes and resume them after an interruption.', + '保留未完成前缀,在中断后继续生成。')) + (state.partial ? toggle('maskPartial', t('Mask earlier prefix tokens', + 'Mask 已有前缀 token'), t('Exclude their loss; does not remove prefix-distribution mismatch.', + '不计算旧 token 的 loss;不会消除 prefix 分布差异。')) : '') + toggle('straw', 'straw · ' + t( + 'persistent rollout storage', + '持久 rollout 存储'), t( + 'Shared JuiceFS queue + tensor packs. With fully async: distributed generation workers.', + '共享 JuiceFS 队列与张量 packs;搭配 fully async:分布式生成 workers。')) + (state.straw ? + `
${input('queueDir',t('Shared JuiceFS rollout directory','共享 JuiceFS rollout 目录'),'text','/shared/juicefs/jobs/my-run/rollout_data',true)}${input('queueRun','Run ID','text','my-run')}${input('declaration',t('JuiceFS declaration JSON','JuiceFS 声明 JSON'),'text','/shared/juicefs/deployment.json')}
` : + '') + + `
${distributed?'Distributed fully async':async?'Fully async':state.partial?t('Synchronous + partial rollout','同步 + partial rollout'):t('Synchronous baseline','同步基线')}
${t('Generation','生成')}${distributed?'worker 0 / 1 / …': 'vLLM'}${async?t('continues during training','训练时持续生成'):t('pause for update','暂停并更新')}
${t('Training','训练')}${t('ready batch','就绪 batch')}Megatronweights ↺
${state.partial?`
${t('Long response','长回复')}prefix · v₀${t('save','保存')}suffix · v₁
`:''}

${distributed?t('Autonomous producers feed one global queue; the trainer need not wait for every worker. Admission pauses and in-flight results are persisted around weight updates.','多个 producer 向全局队列供数,训练不必等待每个 worker。权重更新时暂停接收新任务,并保存进行中的结果。'):async?t('A manager-local background worker keeps the queue warm across rounds. Select straw to distribute the producer processes.','管理进程内的后台 worker 跨轮次维持队列;选择 straw 可将 producer 进程分布到多机。'):state.partial?t('Oversample 16 groups, train with 8 accepted complete groups, save unfinished groups for continuation. Partial responses are not directly used as completed training examples.','超采样 16 组,用 8 组已完成且被接受的样本训练;未完成组留待续跑,不直接当作完整训练样本。'):t('Complete the selected batch before the training phase. Long responses can hold the batch open.','先完成当前 batch,再进入训练阶段;长回复可能让整批等待。')}

${t('Conceptual timeline; widths are not measured durations.','概念时间线,宽度不代表实际耗时。')}${t('Compare scheduling, continuation & recovery →','对比调度、续跑与恢复 →')}
`; + } + + function resourcePanel() { + const m = E.models[state.model], + gpus = m.nodes * 8, + total = E.validate(state).localGpus; + return `
${t('RECIPE STARTING POINT','配方起步参考')}${gpus} GPU / ${m.nodes} × 8

${t(`Train TP${m.tp} · PP${m.pp} · EP${m.ep} · CP${m.cp}. Serving: ${m.engine} GPUs per engine.`,`训练 TP${m.tp} · PP${m.pp} · EP${m.ep} · CP${m.cp}。推理每引擎 ${m.engine} 张卡。`)}

${t('Based on repository H100/H200 recipes, with a short response budget; not a minimum or a capacity guarantee.','基于仓库 H100/H200 配方,以短回复起步;不是最小卡数或容量保证。')} ${t('See assumptions →','看推荐依据 →')}

${t('Your local reservation','当前本地资源总量')} ${total} GPU
`; + } + + function parallelPanel() { + const m = E.models[state.model]; + return `
${t('What are TP / PP / EP / CP / ZeRO?','TP / PP / EP / CP / ZeRO 是什么?')}
${['tp','pp','ep','cp','zero','memory'].map(k=>``).join('')}
${parallelDiagram()}

${t('Current recipe','当前配方')}: TP${m.tp} · PP${m.pp} · EP${m.ep} · CP${m.cp} · ${state.model==='glm5'?'allgather CP':'zigzag CP'} · ${t('distributed optimizer enabled','distributed optimizer 已开启')}

${t('Diagrams, formulas & papers ↗','图示、公式与论文 ↗')}
`; + } + + function parallelDiagram() { + const block = (text, cls = '') => `${text}`; + let caption = '', + diagram = ''; + if (parallelView === 'tp') { + caption = t( + 'Split one matrix across GPUs. Every GPU works on the same tokens, then communicates partial results. Useful when individual layers are large; fast interconnect matters.', + '把同一个矩阵拆给多张 GPU。各卡处理同一批 token,再通信合并部分结果。适合单层很大,需要高速互联。'); + diagram = + `
${block('X')} →
${block('W₀','blue')}${block('W₁','yellow')}${block('W₂','pink')}${block('W₃','green')}
→ ${block('Y')}
`; + } + if (parallelView === 'pp') { + caption = t( + 'Split model layers into stages. Microbatches move through stages; each stage holds fewer layers, but an empty stage is a pipeline bubble.', + '按模型层分成多个 stage。Microbatch 依次流过,每卡保存更少的层;等待数据的空闲阶段就是 pipeline bubble。'); + diagram = + `
${block('Layers 0–3','blue')} → ${block('4–7','yellow')} → ${block('8–11','pink')}
${t('Illustrative stages; actual layer counts follow the selected model.','示意分层;实际层数按当前模型配置。')}`; + } + if (parallelView === 'ep') { + caption = t( + 'Place different MoE experts on different GPUs. The router sends tokens to selected experts through all-to-all dispatch, then combines their outputs. EP is not another independent factor to multiply into TP × PP × CP × DP.', + '将不同 MoE 专家放在不同卡上。Router 通过 all-to-all 将 token 发给选中专家,再合并输出。EP 不是在 TP × PP × CP × DP 之外再乘一次的独立维度。' + ); + diagram = + `
${block('router')} ⇄
${block('Expert 0','blue')}${block('Expert 1','yellow')}${block('Expert 2','pink')}${block('Expert 3','green')}
`; + } + if (parallelView === 'cp') { + caption = cpView === 'allgather' ? t( + 'vime allgather CP splits a packed token stream into contiguous rank chunks. The DSA path gathers K/V or index data as needed. With CP > 1 this mode is supported only by the guarded DSA architectures, not every model.', + 'vime 的 allgather CP 将打包 token 流连续切给各 rank;DSA 路径按需收集 K/V 或 index 数据。CP > 1 时仅支持代码允许的 DSA 架构,不能给所有模型随意打开。' + ) : t( + 'The default zigzag layout gives each rank one early and one late chunk per sequence, balancing causal attention work. The attention backend exchanges K/V; zigzag describes the token layout, not a universal communication algorithm.', + '默认 zigzag 布局把每条序列的一段前部、一段后部分给同一 rank,平衡 causal attention 计算量。Attention backend 交换 K/V;zigzag 描述 token 布局,不等于某一种固定通信算法。' + ); + const ids = cpView === 'allgather' ? [ + [0, 1, 2, 3], + [4, 5, 6, 7] + ] : [ + [0, 1, 6, 7], + [2, 3, 4, 5] + ]; + diagram = + `
${Array.from({length:8},(_,i)=>block(i,'token')).join('')}
${ids.map((v,i)=>`
Rank ${i}${v.map(n=>block(n,i?'yellow':'blue')).join('')}
`).join('')}
${t('Illustration: one sequence, CP=2. These buttons explore the layout; the model recipe selects its supported mode.','示意:单序列 CP=2。按钮仅切换图示;实际配方采用模型支持的模式。')}`; + } + if (parallelView === 'zero') { + caption = t( + 'Data-parallel replicas process different samples. Shard optimizer state between them rather than storing every state on every GPU. vime enables Megatron’s distributed optimizer by default; this is not automatically ZeRO-3 parameter sharding.', + '数据并行副本处理不同样本。优化器状态分片保存,避免每卡存一整份。vime 默认开启 Megatron distributed optimizer;这不自动等于 ZeRO-3 参数分片。'); + diagram = + `
${[0,1].map(i=>`
DP ${i}${block('model','blue')}${block('state '+i,'yellow')}
`).join('')}
reduce-scatter → optimizer → all-gather`; + } + if (parallelView === 'memory') { + const p = { + flash: 30, + dense: 9, + glm47: 355, + glm5: 744, + deepseek: 671, + qwen38: 27 + } [state.model]; + caption = t( + 'A whole-model tensor ledger before sharding: BF16 weights use about 2 bytes/parameter; two FP32 Adam moments use 8. CPU Adam moves optimizer work to the host. Gradients, master weights, activations, buffers and KV cache are additional. This is not a per-GPU memory estimate.', + '分片前的全模型张量账本:BF16 权重约 2 字节/参数,两份 FP32 Adam moments 共 8 字节/参数。CPU Adam 将优化器工作移到主机。梯度、master weights、激活、buffers 和 KV cache 还需另算;这不是每卡显存估计。' + ); + diagram = + `
${block('BF16 weights','blue')}≈ ${2*p} GBGPU · ${t('before TP / PP / EP','TP / PP / EP 分片前')}
${block('Adam m + v',state.cpuAdam?'green':'pink')}≈ ${8*p} GB${state.cpuAdam?'CPU':'GPU'} · ${t('before optimizer sharding','优化器分片前')}
${t('Approximate decimal GB; master-weight dtype and optimizer implementation can change storage.','十进制 GB 近似;master weight 精度和优化器实现会影响实际存储。')}`; + } + return `${diagram}

${caption}

`; + } + + function drawWorld() { + const m = E.models[state.model], + v = generated.validation; + $('world').innerHTML = window.VimeDiagrams.render(state, m, v, lang); + $('world').classList.remove('enter'); + void $('world').offsetWidth; + $('world').classList.add('enter'); + $('simulation-status').textContent = t('Click “Play a round” to follow the data.', '点击「演示一轮」,跟着数据走一遍。'); + $('summary').innerHTML = [ + [t('Local Ray GPUs', '本地 Ray GPU'), v.localGpus], + [t('Schedule', '调度'), state.schedule === 'async' ? (state.straw ? 'Distributed async' : + 'Fully async') : state + .partial ? t('Sync + partial', '同步 + partial') : t('Synchronous', '同步') + ], + [t('Correction', '修正'), [state.correction === 'none' ? '—' : state.correction.toUpperCase(), state + .r3 ? 'R3' : + '', state.sc ? 'SC' : '' + ].filter(Boolean).join(' + ')] + ].map(([k, v]) => `
${k}${esc(v)}
`).join(''); + const notes = []; + if (state.layout === 'colocate') notes.push(t( + 'One GPU pool, two phases. Training and rollout release memory for each other.', + '同一组 GPU,两段流程;训练和 rollout 交替释放显存。')); + else if (state.schedule === 'async') notes.push(t( + 'Generation stays in flight while training progresses. Keep an eye on stale samples.', + '训练推进时生成仍在继续,需要关注样本的过期程度。')); + else notes.push(t( + 'Two GPU pools, synchronous steps. Switch to fully async when long-tail waits matter.', + '两个 GPU 池,同步执行。长尾等待明显时,可尝试 fully async。')); + if (generated.validation.errors.length) notes.push( + `${t(`${generated.validation.errors.length} setup item(s) to resolve before export.`, `导出前还有 ${generated.validation.errors.length} 项配置需要补齐。`)}` + ); + else notes.push(t('The pieces fit. Review your paths and export the recipe below.', + '配置组合检查通过。核对路径,即可导出下方配方。')); + $('advice').innerHTML = notes.map(n => `

${n}

`).join(''); + } + + function showArtifact() { + if (!generated.files[artifact]) artifact = 'experiment.sh'; + $('artifact-tabs').innerHTML = Object.keys(generated.files).map(name => + `` + ).join(''); + $('artifact-panel').setAttribute('aria-labelledby', 'tab-' + artifact.replace(/\W/g, '-')); + const blocked = generated.validation.errors.length > 0 && artifact !== 'experiment.json'; + $('artifact-code').textContent = blocked ? t( + '# Complete the setup items above to generate an executable artifact.\n# You can still download experiment.json to save your choices.', + '# 补齐上方配置后生成可执行文件。\n# 仍可下载 experiment.json 保存当前选择。') : generated.files[artifact]; + $('download').disabled = blocked; + $('copy').disabled = blocked; + } + + function render() { + generated = E.generate(state, lang); + $('steps').innerHTML = steps.map((step, i) => + `` + ).join(''); + $('step-number').textContent = String(state.step + 1).padStart(2, '0'); + $('step-title').textContent = steps[state.step][1]; + $('step-description').textContent = steps[state.step][2]; + $('choices').innerHTML = form(); + $('step-help').innerHTML = helpKeys[state.step].map(key => + `` + ).join(''); + $('previous').disabled = state.step === 0; + $('next').textContent = state.step === 5 ? t('See my script ↓', '查看启动脚本 ↓') : t('Next →', '下一步 →'); + $('step-count').textContent = `${state.step+1} / 6`; + $('undo').disabled = !history.length; + $('redo').disabled = !future.length; + drawWorld(); + const v = generated.validation; + $('issues').innerHTML = (v.errors.length ? + `
${t('Finish these settings to export','补齐这些配置即可导出')}
    ${v.errors.map(e=>`
  • ${esc(e)}
  • `).join('')}
` : + '') + (v.notes.length ? + `
${t('Recipe notes','配方说明')} · ${v.notes.length}
    ${v.notes.map(n=>`
  • ${esc(n)}
  • `).join('')}
` : + ''); + showArtifact(); + $('run-steps').innerHTML = + `
  • ${t('Prepare weights & data.','准备模型与数据。')} ${t('Use the','先准备')} ${t('documented environment','文档中的运行环境')}${t(', then run ', ',然后运行 ')}bash experiment.sh prepare → bash experiment.sh convert${t('. Existing BF16 / torch_dist paths can be used directly.', '。也可直接填写已有 BF16 / torch_dist 路径。')}
  • ${t('Connect the cluster.','连接集群。')} ${t(`Make ${v.localGpus} Ray GPUs available. Start the head with ray start --head; join workers with ray start --address=HEAD:6379. Use the same repo and paths on all nodes.`, `准备 ${v.localGpus} 张 Ray GPU。主节点执行 ray start --head;worker 使用 ray start --address=HEAD:6379 加入。各节点保持相同仓库和路径。`)} ${state.layout==='external'?t('Deploy engines using serving-reference.sh first.','先参照 serving-reference.sh 部署引擎。'):''}
  • ${t('Run & inspect.','启动并观察。')} bash experiment.sh check → bash experiment.sh train. ${t('Then check reward and train_rollout_logprob_abs_diff. Add a held-out evaluation before a long run.','观察 reward 与 train_rollout_logprob_abs_diff;长时间训练前添加独立验证集。')} ${t('Recipe source ↗','配方源码 ↗')}
  • `; + } + + function navigate(step) { + commit({ + ...state, + step + }); + $('step-title').focus({ + preventScroll: true + }); + } + + function stop() { + clearInterval(timer); + timer = null; + $('world').classList.remove('running'); + $('play').setAttribute('aria-pressed', 'false'); + $('play').textContent = '▶ ' + t('Play a round', '演示一轮'); + } + $('play').addEventListener('click', () => { + if (timer) { + stop(); + $('simulation-status').textContent = t('Paused. You can change any piece.', '已暂停,可以修改任何一块。'); + return; + } + phase = 0; + $('world').classList.add('running'); + $('play').setAttribute('aria-pressed', 'true'); + $('play').textContent = 'Ⅱ ' + t('Pause', '暂停'); + const advance = () => { + if (state.step === 4) { + const stages = [ + ['prefill', state.hicache ? t('Look up prefixes in GPU / CPU cache, then process uncached tokens.', + '在 GPU / CPU cache 查找前缀,再处理未命中的 token。') : t('Read the prompt using the engine’s GPU cache.', + '读取 prompt,使用引擎内的 GPU cache。')], + ...(state.speculative ? [ + ['draft', t('The EAGLE head proposes candidate tokens.', 'EAGLE 投机头提出候选 token。')], + ['verify', t('The target model verifies the candidate block.', '目标模型批量验证候选 token。')], + ['commit', t('Commit the accepted prefix and a corrected or bonus token, then continue.', + '提交接受的前缀与修正或额外 token,然后继续生成。')] + ] : [['decode', t('The target model decodes one token, then repeats.', '目标模型解码一个 token,再继续下一步。')]]) + ]; + if (state.pd) stages.splice(1, 0, ['transfer', t('Transfer the prefill cache to the decode engine.', + '将 prefill 缓存传输给 decode 引擎。')]); + const [stage, label] = stages[phase % stages.length]; + $('simulation-status').textContent = label; + document.querySelectorAll('[data-serving-phase]').forEach(node => + node.classList.toggle('active', node.dataset.servingPhase === stage)); + phase++; + return; + } + const async = state.schedule === 'async'; + const labels = async ? [t('Rollouts enter the warm queue; new generations keep running.', + 'Rollout 进入预热队列;新的生成持续运行。'), t( + 'Megatron trains a ready batch while vLLM keeps generating.', + 'Megatron 训练就绪 batch,vLLM 继续生成。'), t( + 'Updated weights reach vLLM; queued samples can be older.', '新权重到达 vLLM;队列里可能仍有旧样本。' + )] : [t( + 'vLLM generates a batch; rewards are evaluated.', 'vLLM 生成一批样本,并计算奖励。'), state + .partial ? t( + 'Save unfinished prefixes, then train on complete groups.', '保存未完成前缀,再用已完成组训练。') : t( + 'Megatron trains on the completed batch.', 'Megatron 使用完成的 batch 训练。'), state.partial ? + t( + 'Sync weights; resume saved prefixes with the new version.', '同步权重,用新版本续跑已保存的前缀。') : t( + 'New weights are synchronized before the next batch.', '同步新权重,然后进入下一批。') + ]; + $('simulation-status').textContent = labels[phase % 3]; + document.querySelector('.trainer').classList.toggle('active', async || phase % 3 === 1); + document.querySelectorAll('.sampler').forEach(node => node.classList.toggle('active', async || + phase % 3 === 0)); + phase++; + }; + advance(); + timer = setInterval(advance, 2000); + }); + $('choices').addEventListener('submit', e => e.preventDefault()); + $('choices').addEventListener('click', e => { + const b = e.target.closest('[data-parallel], [data-cp]'); + if (!b) return; + if (b.dataset.parallel) parallelView = b.dataset.parallel; + if (b.dataset.cp) cpView = b.dataset.cp; + const host = document.querySelector('.parallel-explorer'); + host.outerHTML = parallelPanel(); + document.querySelector(b.dataset.parallel ? `[data-parallel="${parallelView}"]` : + `[data-cp="${cpView}"]`) + .focus({ + preventScroll: true + }); + }); + $('choices').addEventListener('change', e => { + const input = e.target; + if (!input.name) return; + const value = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input + .value) : + input.value; + let message; + if (input.type === 'number' && (!Number.isInteger(value) || value < E.numberLimits[input.name][0] || + value > E + .numberLimits[input.name][1])) { + toast(t( + `Use a whole number from ${E.numberLimits[input.name][0]} to ${E.numberLimits[input.name][1]}.`, + `请输入 ${E.numberLimits[input.name][0]} 到 ${E.numberLimits[input.name][1]} 的整数。`)); + render(); + return; + } + if (input.name === 'schedule' && value === 'async' && state.layout === 'colocate') message = t( + 'Moved to separate GPUs to keep rollout alive. Undo restores both choices.', + '已切换到独立 GPU 以持续 rollout;撤销会同时恢复两个选择。'); + if (input.name === 'layout' && value === 'colocate' && state.schedule === 'async') message = t( + 'Colocation uses synchronous execution. Undo restores the async layout.', + '同卡布局采用同步执行;撤销可恢复异步布局。'); + const focusName = input.name, + focusValue = input.value; + commit(E.change(state, input.name, value), message); + const next = Array.from($('choices').elements).find(el => el.name === focusName && (el.type !== + 'radio' || el + .value === focusValue)); + if (next) next.focus({ + preventScroll: true + }); + }); + $('steps').addEventListener('click', e => { + const b = e.target.closest('[data-step]'); + if (b) navigate(Number(b.dataset.step)); + }); + $('previous').addEventListener('click', () => navigate(Math.max(0, state.step - 1))); + $('next').addEventListener('click', () => { + if (state.step < 5) navigate(state.step + 1); + else $('recipe').scrollIntoView({ + behavior: 'smooth' + }); + }); + document.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => commit(E.preset( + b.dataset + .preset), t('Starting point loaded. Your previous choices are one undo away.', + '已载入起点;撤销即可恢复之前的选择。')))); + $('undo').addEventListener('click', () => { + if (!history.length) return; + future.push({ + ...state + }); + state = history.pop(); + stop(); + render(); + persist(); + toast(t('Choice undone.', '已撤销。')); + }); + $('redo').addEventListener('click', () => { + if (!future.length) return; + history.push({ + ...state + }); + state = future.pop(); + stop(); + render(); + persist(); + toast(t('Choice restored.', '已重做。')); + }); + $('reset').addEventListener('click', () => commit({ + ...E.defaults + }, t('Reset to the starting experiment. Undo is available.', '已重置为起步实验,可以撤销。'))); + document.addEventListener('keydown', e => { + if (/input|textarea|select/i.test(e.target.tagName) || $('learn-dialog').open) return; + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + (e.shiftKey ? $('redo') : $('undo')).click(); + } + }); + $('artifact-tabs').addEventListener('click', e => { + const b = e.target.closest('[data-artifact]'); + if (b) { + artifact = b.dataset.artifact; + showArtifact(); + document.querySelector('[role=tab][aria-selected=true]').focus(); + } + }); + $('artifact-tabs').addEventListener('keydown', e => { + const keys = Object.keys(generated.files); + let i = keys.indexOf(artifact); + if (e.key === 'ArrowRight') i = (i + 1) % keys.length; + else if (e.key === 'ArrowLeft') i = (i - 1 + keys.length) % keys.length; + else if (e.key === 'Home') i = 0; + else if (e.key === 'End') i = keys.length - 1; + else return; + e.preventDefault(); + artifact = keys[i]; + showArtifact(); + document.querySelector('[role=tab][aria-selected=true]').focus(); + }); + async function copy(text) { + try { + await navigator.clipboard.writeText(text); + toast(t('Copied.', '已复制。')); + } catch (_) { + const area = document.createElement('textarea'); + area.value = text; + area.style.position = 'fixed'; + area.style.left = '-9999px'; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + toast(ok ? t('Copied.', '已复制。') : t('Clipboard unavailable. Select and copy the text manually.', + '剪贴板不可用,请选中文字手动复制。')); + } + } + $('copy').addEventListener('click', () => copy(generated.files[artifact])); + $('download').addEventListener('click', () => { + const url = URL.createObjectURL(new Blob([generated.files[artifact]], { + type: artifact.endsWith('.json') ? 'application/json' : 'text/plain;charset=utf-8' + })); + const a = document.createElement('a'); + a.href = url; + a.download = artifact; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(t('Downloaded.', '已下载。')); + }); + $('share').addEventListener('click', () => { + const shared = { + ...state + }; + for (const key of ['hf', 'train', 'data', 'save', 'megatron', 'endpoints', 'shared', 'local', 'ib', + 'generate', 'reward', 'queueDir', 'queueRun', 'declaration', 'draft' + ]) delete shared[key]; + const url = new URL(location.href); + url.hash = new URLSearchParams({ + experiment: JSON.stringify(shared) + }).toString(); + copy(url.href); + toast(t('Share link copied. Machine paths and addresses are excluded.', '分享链接已复制,不包含机器路径与地址。')); + }); + $('step-help').addEventListener('click', e => { + const b = e.target.closest('[data-learn]'); + if (!b) return; + const info = theory[b.dataset.learn]; + window.VimeReader.open(info.page, b, info); + }); + const preview = document.querySelector('.preview'); + const previewPosition = document.createComment('preview position'); + preview.before(previewPosition); + const mobile = matchMedia('(max-width: 720px)'); + let labVisible = false; + const updatePreviewButton = () => { + $('mobile-preview').hidden = !mobile.matches || !labVisible; + }; + new IntersectionObserver(entries => { + labVisible = entries[0].isIntersecting; + updatePreviewButton(); + }).observe($('lab')); + mobile.addEventListener('change', () => { + if (!mobile.matches && $('mobile-preview-dialog').open) $('mobile-preview-dialog').close(); + updatePreviewButton(); + }); + $('mobile-preview').addEventListener('click', () => { + $('mobile-preview-host').append(preview); + $('mobile-preview-dialog').showModal(); + }); + $('close-preview').addEventListener('click', () => $('mobile-preview-dialog').close()); + $('mobile-preview-dialog').addEventListener('close', () => { + previewPosition.after(preview); + stop(); + $('mobile-preview').focus({ + preventScroll: true + }); + }); + $('interactive-lab').hidden = false; + $('recipe').hidden = false; + render(); +})(); diff --git a/docs/_templates/experiment.html b/docs/_templates/experiment.html new file mode 100644 index 000000000..707e13b26 --- /dev/null +++ b/docs/_templates/experiment.html @@ -0,0 +1,121 @@ +{% set zh = language.startswith('zh') %} +{% macro t(en, cn) %}{{ cn if zh else en }}{% endmacro %} + + + + + + + {{ t('vime · Build your next experiment', 'vime · 搭建你的下一个实验') }} + + + + + + + + + + + +
    +
    +
    +

    A LITTLE SLIME. A LOT OF POSSIBILITY.

    +

    {{ t('Built for', '为规模化') }}
    {{ t('scaling RL.', 'RL 而生。') }}

    +

    {{ t('vime is an LLM post-training framework for RL scaling, providing two core capabilities:', 'vime 是为 RL scaling 设计的 LLM 后训练框架,提供两大核心能力:') }}

    +
    1. {{ t('High-Performance Training', '高性能训练') }} ↘
    2. {{ t('Flexible Data Generation', '灵活的数据生成') }} ↘
    + {{ t('Build your first experiment', '搭建第一个实验') }} ↗ + {{ t('Explore. Undo. Try again.', '随时切换,随时回退。') }} +
    + +
    +
    +
    01 / ↔

    {{ t('High-Performance Training', '高性能训练') }}

    {{ t('Supports efficient training in various modes by connecting Megatron with vLLM.', '通过连接 Megatron 与 vLLM,支持各种模式的高效训练。') }}

    Megatron⇄vLLM
    +
    02 / ⌁

    {{ t('Flexible Data Generation', '灵活的数据生成') }}

    {{ t('Enables arbitrary training data generation workflows through custom data generation interfaces and server-based engines.', '通过自定义数据生成接口以及 server-based engines,实现任意训练数据生成流程。') }}

    {{ t('Your data & environments', '你的数据与环境') }}→Data Buffer
    +

    {{ t('Training, rollout, custom generation, rewards, verifiers, and environment interaction flow through the same training / rollout / Data Buffer path.', '训练、rollout、自定义数据生成、奖励、验证器与环境交互,都流经同一条 training / rollout / Data Buffer 路径。') }}

    +
    +
    +

    BUILT IN THE OPEN. USED AT SCALE.

    {{ t('From GLM-4.5 to GLM-5.3 & GLM-5.3-Flash.', '从 GLM-4.5,一直到 GLM-5.3 与 GLM-5.3-Flash。') }}

    {{ t('slime has powered RL post-training across Z.ai’s GLM generations. Explore the model releases, then use the same core capabilities in your own experiment.', 'slime 持续支撑 Z.ai 历代 GLM 的 RL 后训练。了解模型的发布历程,再用同样的核心能力搭建你的实验。') }}

    +
    Z.ai × slime
    {% for version in ['4.5', '4.6', '4.7', '5', '5.1', '5.2', '5.3', '5.3-flash'] %}GLM-{{ '5.3-Flash' if version == '5.3-flash' else version }}{% if not loop.last %}→{% endif %}{% endfor %}
    + {{ t('Now, see how an RL experiment fits together', '现在,亲手搭起一个 RL 实验') }} ↓ +
    +
    +
    +

    01 / THE EXPERIMENT BUILDER

    {{ t('Make it yours.', '来,搭一个你的实验。') }}

    +
    + + + +
    +
    +
    + {{ t('A starting point?', '选一个起点?') }} + + + +
    + + +
    + +
    +

    03 / FOLLOW YOUR CURIOSITY

    {{ t('Go a little deeper.', '好奇,就再往前一步。') }}

    {{ t('The ideas, equations, and engineering behind your choices.', '每个选择背后的论文、公式和工程细节。') }}

    + 01{{ t('The experiment field guide', '实验配置指南') }}{{ t('From checkpoint to first reward', '从 checkpoint 到第一个 reward') }} ↗ + 02{{ t('Inside the algorithms', '拆开算法看一看') }}GRPO · TIS · ICE-POP · R3 · SC ↗ + 03{{ t('Inside the system', '看懂系统的取舍') }}{{ t('Precision, async, PD & HiCache', '精度、异步、PD 与 HiCache') }} ↗ + 04{{ t('The documentation library', '完整文档资料库') }}{{ t('Organized guides, APIs & debugging', '分类指南、接口与调试') }} ↗ +
    +
    +

    04 / WHEN YOU WANT TO GO FURTHER

    {{ t('A good experiment asks good questions.', '好的实验,也要会问问题。') }}

    {{ t('Open a question. Find the next measurement, then the right tool.', '展开一个问题,找到下一次测量和趁手的工具。') }}

    +
    01 / DEBUG{{ t('The reward looks wrong. Where do I start?', 'Reward 不对,先从哪里查?') }}+

    {{ t('Inspect generated text, checkpoint loading, and the chat template first. Save a rollout, replay a fixed batch on the training side, then compare log-probs before changing the algorithm.', '先检查生成文本、checkpoint 加载和 chat template。保存一轮 rollout,用固定 batch 重放训练,再比较 log-prob,最后才调整算法。') }}

    {{ t('Debug & replay →', 'Debug 与重放 →') }}{{ t('Understand mismatch →', '理解训推差异 →') }}
    +
    02 / PROFILE{{ t('Training is slow. What am I waiting for?', '训练太慢,时间究竟花在哪里?') }}+

    {{ t('Separate rollout, reward, weight transfer, and training time. Trace the critical path, then profile the busy engine. Use tools/profile_rollout.py to collect vLLM traces and inspect prefill/decode behavior.', '区分 rollout、reward、权重传输和训练耗时。先用 trace 找关键路径,再 profile 瓶颈引擎。使用 tools/profile_rollout.py 收集 vLLM trace,观察 prefill / decode。') }}

    {{ t('Profiling →', '性能剖析 →') }}{{ t('Timeline & tracing →', '时间线与追踪 →') }}{{ t('Metrics →', '指标与观测 →') }}
    +
    03 / SCALE{{ t('How do I grow beyond one cluster?', '如何从一台机器走向多个集群?') }}+

    {{ t('Split GPU ownership, then choose a weight transport that matches the network. Add PD when prefill and decode need different resources; use persistent rollout storage when jobs need recovery.', '先划分 GPU 的归属,再根据网络选择权重传输。Prefill / decode 资源需求不同时考虑 PD;需要恢复长任务时接入持久 rollout 存储。') }}

    External engines →Delta sync →Partial / distributed async →straw →{{ t('Recovery →', '故障恢复 →') }}
    +
    04 / EXTEND{{ t('My agent needs its own world.', '我的 Agent,需要自己的环境。') }}+

    {{ t('Start with a custom per-sample generation function and reward. Replace rollout orchestration only when your data scheduling or cross-step state needs it.', '从自定义逐样本生成函数和奖励开始。需要改变数据调度或跨 step 状态时,再替换完整 rollout 编排。') }}

    Agent RL →{{ t('Extension interfaces →', '扩展接口 →') }}vLLM topology →
    +
    +
    + +
    +

    LEARN WITHOUT LOSING YOUR PLACE

    + +
    + + diff --git a/docs/build.sh b/docs/build.sh index 4e5bbd510..493b6e740 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -10,6 +10,20 @@ if [ "$DOC_LANG" != "en" ] && [ "$DOC_LANG" != "zh" ]; then fi shift +SPHINX_BUILD="$(command -v sphinx-build || true)" +if [ -z "$SPHINX_BUILD" ] && [ -x "$SCRIPT_DIR/../.venv-docs/bin/sphinx-build" ]; then + SPHINX_BUILD="$SCRIPT_DIR/../.venv-docs/bin/sphinx-build" +fi +if [ -z "$SPHINX_BUILD" ]; then + cat >&2 <<'EOF' +[slime-docs] sphinx-build was not found. From the repository root, run: + python3 -m venv .venv-docs + .venv-docs/bin/python -m pip install -r docs/requirements.txt +Then retry: bash docs/build_all.sh +EOF + exit 1 +fi + cd "$SCRIPT_DIR" -VIME_DOC_LANG="$DOC_LANG" sphinx-build -b html -D language="$DOC_LANG" --conf-dir . \ +VIME_DOC_LANG="$DOC_LANG" "$SPHINX_BUILD" -b html -D language="$DOC_LANG" --conf-dir . \ "$@" "./$DOC_LANG" "./build/$DOC_LANG" diff --git a/docs/build_all.sh b/docs/build_all.sh index fece56deb..674ecc672 100755 --- a/docs/build_all.sh +++ b/docs/build_all.sh @@ -27,7 +27,7 @@ cat > "$ROOT_INDEX" <<'EOF' (function(){ var stored = null; try{stored = localStorage.getItem('vime-doc-lang');}catch(e){} - var path = (stored === 'zh') ? 'zh/' : (stored === 'en') ? 'en/' : null; + var path = (stored === 'zh' || (!stored && navigator.language.startsWith('zh'))) ? 'zh/' : 'en/'; if(path){ window.location.replace(path); } })(); diff --git a/docs/conf.py b/docs/conf.py index e888588eb..01774a59e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -72,7 +72,7 @@ myst_ref_domains = ["std", "py"] -templates_path = [] +templates_path = ["_templates"] source_suffix = { ".rst": "restructuredtext", @@ -99,7 +99,7 @@ "repository_url": "https://github.com/vllm-project/vime", "repository_branch": "main", "path_to_docs": f"docs/{language}", - "show_navbar_depth": 3, + "show_navbar_depth": 1, "max_navbar_depth": 4, "collapse_navbar": True, "use_edit_page_button": True, @@ -161,6 +161,13 @@ def setup(app): # ensure examples are synced before reading source files app.connect("builder-inited", _sync_examples) app.connect("html-page-context", _example_source_context, priority=499) + app.connect("html-page-context", _experiment_home) + + +def _experiment_home(app, pagename, templatename, context, doctree): + if pagename == "index" and app.builder.format == "html": + context["doc_layout"] = os.environ.get("VIME_DOC_LAYOUT", "root") + return "experiment.html" def _example_source_context(app, pagename, templatename, context, doctree): diff --git a/docs/en/advanced/arch-support-beyond-megatron.md b/docs/en/advanced/arch-support-beyond-megatron.md index e6391fb80..37c87da28 100644 --- a/docs/en/advanced/arch-support-beyond-megatron.md +++ b/docs/en/advanced/arch-support-beyond-megatron.md @@ -10,7 +10,7 @@ The Qwen3-Next integration has three parts: 2. **Run GDN within Megatron.** The GDN implementation adapts the Hugging Face model layout and uses FLA or the optional FlashQLA backend for the recurrent attention kernel. `HuggingfaceAttention` gathers sequence and context partitions before the replicated computation, then returns the local output partition. See [hf_attention.py](https://github.com/vllm-project/vime/blob/main/vime_plugins/models/hf_attention.py). 3. **Load the model weights.** The checkpoint loader maps Hugging Face parameter names and tensor layouts to the custom modules and the retained Megatron layers. See [the Qwen3-Next weight loader](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/hf_to_megatron/qwen3_next.py). -The [Qwen3-Next example](../examples/qwen3-next-80B-A3B.md) contains the launch configuration and backend options. +The [experiment tutorial](../get_started/experiment-guide.md) explains model selection and launch configuration. Existing architecture-specific launchers remain in `scripts/`. ## Current Limitations diff --git a/docs/en/advanced/fault-tolerance.md b/docs/en/advanced/fault-tolerance.md index 31873159f..b604ab0ee 100644 --- a/docs/en/advanced/fault-tolerance.md +++ b/docs/en/advanced/fault-tolerance.md @@ -1,6 +1,6 @@ # Fault Tolerance -vime checks vLLM engines, removes failed engines from service, and restarts them before the next weight update. When you use Straw transport or save rollout debug data, you can also retain the serving cluster after a Megatron failure, adjust the training configuration, and resume training. +vime checks vLLM engines, removes failed engines from service, and restarts them before the next weight update. When you use straw transport or save rollout debug data, you can also retain the serving cluster after a Megatron failure, adjust the training configuration, and resume training. These features are enabled by default for serving clusters started by vime. You do not need `--use-fault-tolerance`; the flag remains for compatibility with older commands. External serving clusters retain their existing policy. @@ -12,7 +12,7 @@ This recovery workflow requires the **Ray cluster to remain running**. Losing th Choose either option below. vime automatically persists recovery data in these modes, independently of `--use-fault-tolerance`. -Use Straw to store rollout and training data: +Use straw to store rollout and training data: ```bash --rollout-data-transport straw --rollout-data-dir /shared/my-run/queue @@ -60,7 +60,7 @@ vime manages the serving cluster through a named Ray actor. A new job looks up t Set `--rollout-session-id` to choose a session identity explicitly. Otherwise, vime selects an identity in the following order and hashes it to produce a stable name: -1. With Straw, use the absolute storage directory path and `--rollout-queue-run-id`. +1. With straw, use the absolute storage directory path and `--rollout-queue-run-id`. 2. Otherwise, use the absolute path template from `--save-debug-rollout-data`. 3. If debug data is not saved, use the absolute `--save` directory path. 4. If none of these is configured, use the model and rollout configuration. @@ -69,9 +69,9 @@ Use the same identity for both attempts of a training run. Independent runs shou Three components handle recovery: -- `ServingCluster` manages routers, vLLM engines, GPU resources, the Straw queue controller, and the weight-update lock. It is a named, detached Ray actor that survives the job that created it. +- `ServingCluster` manages routers, vLLM engines, GPU resources, the straw queue controller, and the weight-update lock. It is a named, detached Ray actor that survives the job that created it. - `RolloutManager` handles generation, data reading, sample conversion, and training-data partitioning. It can be reused or recreated after it exits; recreating it does not destroy resources held by `ServingCluster`. -- `TrainingRecovery` owns the model checkpoint boundary and retains the batches needed after it. The Straw accepted log owns raw and converted batch receipts; recovery reconciles those receipts if a manager or RPC fails before the journal is updated. A converted batch is stored once, and each DP rank receives an index view that can be rebuilt for a new parallel configuration. +- `TrainingRecovery` owns the model checkpoint boundary and retains the batches needed after it. The straw accepted log owns raw and converted batch receipts; recovery reconciles those receipts if a manager or RPC fails before the journal is updated. A converted batch is stored once, and each DP rank receives an index view that can be rebuilt for a new parallel configuration. If `RolloutManager` survives, it pauses admission of new generation tasks. If it exits, its replacement reconnects to `ServingCluster`, restores data-source progress, and uses the queue controller to prevent old readers from taking more tasks. Completed prefetch results that have not reached training remain available. Trainer processes are also registered with `ServingCluster`, so it can clean up old trainers even after the manager exits. @@ -120,7 +120,7 @@ For slow generation requests, use [Trace Viewer](../developer_guide/trace.md) to ## Recovery Scope and Limitations -Without Straw or rollout debug dumps, you can still retain the serving cluster, but you cannot replay training batches after the last checkpoint. +Without straw or rollout debug dumps, you can still retain the serving cluster, but you cannot replay training batches after the last checkpoint. Recreating `RolloutManager` supports the built-in data sources. Custom sources need compatible `state_dict` / `load_state_dict` methods and queue controllers that survive independently of the manager. Data-source constructors and rollout hook signatures are unchanged. diff --git a/docs/en/advanced/low-precision.md b/docs/en/advanced/low-precision.md index 08aba5d8b..a8d6e3c89 100644 --- a/docs/en/advanced/low-precision.md +++ b/docs/en/advanced/low-precision.md @@ -11,7 +11,7 @@ Megatron keeps the trainable checkpoint in BF16/torch_dist format. vLLM serves a | Feature | Status | Recommended Use | |---|---|---| | BF16 training + FP8 rollout/inference | Stable | Default path for large MoE RL recipes. Keeps training stable while reducing rollout memory and bandwidth. | -| FP8 KV cache in vLLM rollout | Stable when supported by your vLLM version/GPU stack | Increase KV cache capacity for long-context or agentic rollout by passing `--vllm-kv-cache-dtype fp8_e4m3`. | +| FP8 attention KV cache in vLLM rollout | Requires model, attention backend and GPU support | Increase attention KV capacity with `--vllm-kv-cache-dtype fp8_e4m3`; hybrid recurrent state is configured separately. | | INT4 rollout / INT4 QAT | Beta | Use when rollout memory/throughput pressure is high and the model path has been validated. | | FP8 training + FP8 rollout | Experimental | Useful for research on training/inference mismatch and throughput, but still has optimizer and checkpointing caveats. | @@ -51,6 +51,8 @@ For long-context, multi-turn, or agentic workloads, KV cache capacity is often t This is a rollout-side setting. It does not change Megatron training precision; it increases effective vLLM KV cache capacity and can allow longer contexts or higher concurrency, subject to the accuracy/performance behavior of your vLLM version and GPU stack. +For hybrid models such as Qwen3.8-27B and GLM-5.3-Flash, this flag applies only to attention-layer KV. Linear-attention recurrent state uses `--vllm-mamba-ssm-cache-dtype`, while vLLM manages attention KV and recurrent state through a shared paged-cache budget rather than independently sized pools. FP8 KV does not imply FP8 recurrent or convolution state. The builder leaves these overrides unset by default, and offers FP32/BF16 recurrent state for Qwen3.8-27B. See [hybrid cache settings, architecture sources and memory derivation](rl-systems.md#hybrid-cache). + ## FP8 Training with FP8 Rollout vime also supports experimental FP8 training paths. We observed that FP8 training plus FP8 inference can improve inference throughput and reduce training/inference mismatch in some settings. More details are available in [this blog](https://lmsys.org/blog/2025-11-25-fp8-rl/). diff --git a/docs/en/advanced/parallelism-memory.md b/docs/en/advanced/parallelism-memory.md new file mode 100644 index 000000000..4f90701c9 --- /dev/null +++ b/docs/en/advanced/parallelism-memory.md @@ -0,0 +1,117 @@ +# GPUs, memory, and parallelism + +Open **Placement** in the {ref}`interactive lab ` to explore the diagrams. First establish that each phase fits and learns correctly, then optimize useful samples per second. + +## Starting GPU allocations + +The lab assumes eight GPUs per training node and starts from repository recipes for H100/H200-class servers with fast interconnects. These are recipe-based starting points, not measured minimums or guarantees for every GPU in that class. The short experiment uses a 4,096-token response limit, eight prompts and eight responses per prompt. Prompt length still counts toward memory. MoE and Qwen profiles enable CPU Adam and recomputation. + +| Model | Training GPUs | TP / PP / CP / EP | GPUs per serving engine | Source | +|---|---:|---|---:|---| +| GLM-4-9B | 8 | 2 / 1 / 2 / 1 | 2 | [9B](https://github.com/vllm-project/vime/blob/main/scripts/run-glm4-9B.sh) | +| GLM-4.7-Flash | 8 | 2 / 2 / 2 / 4 | 8 | [Flash](https://github.com/vllm-project/vime/blob/main/scripts/run-glm4.7-30B-A3B.sh) | +| GLM-4.7 | 64 | 8 / 4 / 2 / 16 | 32 | [355B](https://github.com/vllm-project/vime/blob/main/scripts/run-glm4.7-355B-A32B.sh) | +| GLM-5.3 | 256 | 4 / 8 / 8 / 32 | 64 | [744B](https://github.com/vllm-project/vime/blob/main/scripts/run-glm5.2-744B-A40B.sh) | +| DeepSeek-R1 | 128 | 8 / 4 / 4 / 32 | 64 | [R1](https://github.com/vllm-project/vime/blob/main/scripts/run-deepseek-r1.sh) | +| Qwen3.8-27B | 32 | 4 / 2 / 4 / 1 | 2 | [Matching architecture](https://github.com/vllm-project/vime/blob/main/scripts/run-qwen3.5-27B.sh) | + +The Flash lab profile uses EP4 so EP × PP fits eight GPUs. Qwen3.8 reuses a matching architecture configuration; this documentation change has not GPU-validated its checkpoint. Start with 9B or Flash to learn the loop, then measure peak memory on your target model before reserving a long job. + +Colocation reserves one pool; separate placement reserves training plus rollout GPUs. PD divides the rollout pool into whole prefill/decode engines. External serving capacity is outside the trainer's Ray allocation. Adding GPUs without changing model parallelism can simply add replicas rather than reducing per-GPU model memory. + +## The memory ledger + +For an illustrative mixed-precision Adam with $P$ parameters, BF16 weights take $2P$ bytes, FP32 gradients $4P$, FP32 master weights $4P$, and two FP32 moments $8P$: + +$$M_{\rm states}=(2+4+4+8)P=18P\ \text{bytes}.$$ + +Actual precision-aware optimizer state dtypes can differ. Activations, buffers, temporary casts, allocator reserve, reference-policy copies and KV cache are additional. A 30B model has about 60 GB of BF16 weights and 240 GB of FP32 moments before sharding. For MoE storage use total parameters, not only active parameters. + +```{mermaid} +flowchart TB + T[Training memory] --> W[Weights + gradients] + T --> O[Optimizer states] + T --> A[Saved activations] + T --> B[Buffers + temporaries] + O --> C[CPU Adam: host memory and compute] + A --> R[Recomputation: save less, compute again] + S[Serving memory] --> SW[Weights] + S --> KV[KV cache + workspace] +``` + +With ideal phase offload, colocated GPU demand approaches $\max(M_{\rm train},M_{\rm rollout})$; resident buffers and transitions add overhead. Separate pools have independent capacity constraints. + +## TP: split one layer + +Split a matrix by columns: + +$$W=[W_0\;W_1],\qquad Y=XW=[XW_0\;XW_1].$$ + +Each GPU computes a slice. A following row-partitioned layer needs $Z=Y_0V_0+Y_1V_1$, a reduction of partial outputs. This explains both memory savings and frequent communication. Use fast links for TP. Sequence parallelism partitions additional activation operations; it does not replace CP. See the [Megatron paper](https://arxiv.org/abs/2104.04473) and [parallelism guide](https://docs.nvidia.com/megatron-core/developer-guide/latest/user-guide/parallelism-guide.html). + +## PP: split model layers + +```{mermaid} +flowchart LR + A[Early layers] -->|activations| B[Middle layers] + B -->|activations| C[Late layers] + C -->|gradients| B + B -->|gradients| A +``` + +Each stage stores its layers. For $p$ equal-cost stages, $m$ microbatches and stage time $\tau$, a simple forward pipeline takes $(m+p-1)\tau$. Utilization is $m/(m+p-1)$ and the fill/drain bubble fraction is $(p-1)/(m+p-1)$. Backward scheduling, communication and unequal layer costs change actual training efficiency. More microbatches amortize bubbles but each must still fit. See the [Megatron paper](https://arxiv.org/abs/2104.04473). + +## EP: split MoE experts + +For selected experts $K(x)$: + +$$y(x)=\sum_{e\in K(x)}g_e(x)E_e(x).$$ + +GPUs hold different experts. All-to-all dispatch sends tokens to their owners; combine returns weighted outputs. Uneven expert traffic can bottleneck an otherwise balanced weight allocation. See [GShard](https://arxiv.org/abs/2006.16668). + +Dense-layer rank accounting commonly uses $N=TP\cdot PP\cdot CP\cdot DP$. Experts use another grouping with expert TP and expert DP: do not multiply EP on top of the dense identity. With expert TP=1, the lab checks divisibility by both $TP\cdot PP\cdot CP$ and $EP\cdot PP$. Model dimensions and Megatron impose further constraints. + +## CP: split tokens + +CP distributes queries by token position. Attention still needs remote keys/values, introducing communication. CP reduces local sequence activations; it does not itself shard weights. + +| Layout: 8 tokens, CP2 | Rank 0 | Rank 1 | +|---|---|---| +| Default zigzag | 0, 1, 6, 7 | 2, 3, 4, 5 | +| vime allgather CP | 0, 1, 2, 3 | 4, 5, 6, 7 | + +Zigzag splits each padded sequence into $2c$ chunks. Rank $r$ gets chunks $r$ and $2c-r-1$. Pairing early and late positions balances causal attention: for eight tokens, contiguous ranks process 10 and 26 query-key pairs, while these zigzag ranks each process 18. This counts pairs, not sparse-kernel runtime. + +vime `--allgather-cp` packs sequences and assigns contiguous slices of the padded stream. The DSA backend gathers needed remote attention/index data; loss processing restores sample ownership. This is different from zigzag per-sequence splitting. A backend-incompatible flag can scramble token order. + +```{mermaid} +flowchart LR + A[Rank 0 local queries] --> C[Attention over required keys] + B[Rank 1 keys / values] -->|backend communication| C + C --> D[Rank 0 local outputs] +``` + +Zigzag describes placement, not one universal ring algorithm. The attention backend chooses communication. Allgather can materialize large tensors, so CP does not divide every memory allocation equally. + +At CP > 1, vime permits allgather CP only for `DeepseekV32ForCausalLM` and `GlmMoeDsaForCausalLM`. The GLM-5.3 lab profile enables it through its model config. Other profiles retain their supported layout; diagram buttons do not change runtime flags. See [validation](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/arguments.py), [packing](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/data.py), [CP indexing](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/cp_utils.py), and the [Megatron guide](https://docs.nvidia.com/megatron-core/developer-guide/latest/user-guide/parallelism-guide.html). + +## ZeRO and distributed optimizer + +Data-parallel replicas process different samples. ZeRO progressively shards optimizer states (stage 1), gradients (stage 2), then parameters (stage 3). For $D$ replicas and the illustrative 18-byte state budget: + +$$M_0=18P,\quad M_1=6P+12P/D,\quad M_2=2P+16P/D,\quad M_3=18P/D.$$ + +Derive these by first dividing master weights plus moments by $D$, then gradients, then BF16 weights. These ideal persistent-state formulas omit transient communication buffers. $P$ may be a local model-parallel shard and $D$ its applicable replica group; expert groups can differ. See [ZeRO](https://arxiv.org/abs/1910.02054). + +vime enables Megatron's `use_distributed_optimizer` by default: shard optimizer state/work, then gather updated parameters. This does not imply full ZeRO-3 parameter sharding or a DeepSpeed runtime. See [backend defaults](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/arguments.py). + +## CPU Adam and recomputation + +Adam maintains moments and uses bias-corrected estimates: + +$$m_t=\beta_1m_{t-1}+(1-\beta_1)g_t,\quad v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2,$$ +$$\hat m_t=m_t/(1-\beta_1^t),\quad\hat v_t=v_t/(1-\beta_2^t),\quad\theta_{t+1}=\theta_t-\eta\hat m_t/(\sqrt{\hat v_t}+\epsilon).$$ + +CPU Adam keeps optimizer state and computes updates on the host. The switch exports `--optimizer-cpu-offload`, `--overlap-cpu-optimizer-d2h-h2d` and `--use-precision-aware-optimizer`. Sharding decides which rank owns state; offload decides where that rank stores/updates it. Host RAM, CPU throughput and PCIe bandwidth replace part of the GPU burden; activations and KV cache are unaffected. See [Adam](https://arxiv.org/abs/1412.6980) and [ZeRO-Offload](https://arxiv.org/abs/2101.06840) for background; vime uses Megatron's implementation. + +The independent recomputation switch saves boundaries and repeats forward work during backward, trading FLOPs for activation memory. See [activation checkpointing](https://arxiv.org/abs/1604.06174). For training OOM, identify whether states or activations dominate before choosing offload, more model parallelism, or smaller token microbatches. For serving OOM, examine weights, KV dtype, concurrency, response length and engine size. Measure each change with [profiling](../developer_guide/profiling.md). diff --git a/docs/en/advanced/policy-mismatch.md b/docs/en/advanced/policy-mismatch.md new file mode 100644 index 000000000..c1e511882 --- /dev/null +++ b/docs/en/advanced/policy-mismatch.md @@ -0,0 +1,207 @@ +# From policy gradients to train–rollout consistency + +[Return to the experiment lab](../index.rst). Read [the systems companion](rl-systems.md) for precision, determinism, asynchronous execution, PD, and HiCache. + +The equations below are a tutorial derivation of the objectives implemented in this repository. They distinguish identities, biased surrogates, and engineering approximations. Paper links identify the original work; the implementation links explain the particular variant exported by the lab. + +## A common language + +Let $x$ be a prompt, $y=(a_1,\ldots,a_T)$ a response, and $h_t=(x,a_{0$ wherever $p_0(a)f(a)\ne0$, change of measure gives + +$$ +\mathbb E_{a\sim p_0}[f(a)] +=\sum_a q(a)\frac{p_0(a)}{q(a)}f(a) +=\mathbb E_{a\sim q}[\rho_a f(a)],\qquad +\rho_a=\exp(\log p_0(a)-\log q(a)). +$$ + +The PPO policy-update ratio $r=p_\theta/p_0$ and behavior-correction ratio $\rho=p_0/q$ serve different purposes. Before clipping, their product is $p_\theta/q$. vime multiplies the PPO loss by detached $w(\rho)=\operatorname{clip}(\rho,L,U)$; the lab uses $L=0,U=2$. + +Derive its bias by subtracting the exact expression: + +$$ +\mathbb E_q[w(\rho)f]-\mathbb E_{p_0}[f] +=\mathbb E_q[(w(\rho)-\rho)f]. +$$ + +For $L=0$, this becomes $-\mathbb E_q[(\rho-U)f\,\mathbf1_{\rho>U}]$. Capping weights limits outliers but does not give unbiased importance sampling. With nonzero $L$, small weights are also increased. + +This fixed-prefix identity does not correct the distribution of prefixes. Full-sequence change of measure would use $p_0(y)/q(y)=\prod_t\rho_t$, often with high variance. Token-wise TIS is a practical surrogate, not an exact replacement for that product. + +Implementation: [`vanilla_tis_function`](https://github.com/vllm-project/vime/blob/main/vime/backends/megatron_utils/loss.py), [`importance_weights`](https://github.com/vllm-project/vime/blob/main/vime/utils/ppo_utils.py). REINFORCE, including SC, uses detached **current** trainer probabilities in the correction numerator instead of $p_0$. + +## ICE-POP + +Original work: [Every Step Evolves: Scaling Reinforcement Learning for Trillion-Scale Thinking Model](https://arxiv.org/abs/2510.18855). + +The lab selects vime's built-in `icepop_function`. Its behavior-correction rule is + +$$ +w_{\rm ice}(\rho)=\rho\,\mathbf1_{L\le\rho\le U},\qquad L=0.5,\quad U=2. +$$ + +Inside the interval this equals importance sampling. Outside it, the contribution is zero. Its bias relative to the same fixed-prefix target is therefore + +$$ +\mathbb E_q[w_{\rm ice}(\rho)f]-\mathbb E_{p_0}[f] +=-\mathbb E_q[\rho f\,\mathbf1_{\rhoU}]. +$$ + +A token with $\rho=4$ receives weight 2 under the lab's TIS rule and weight 0 under IcePop. These are distinct estimators. The callback sets weights to zero; it does **not** renormalize the loss by the number of retained tokens. PPO clipping can still act on $r$ independently. The paper's complete training recipe contains other components; selecting this callback does not reproduce the whole paper. + +Exported settings: `--use-tis --custom-tis-function-path vime.backends.megatron_utils.loss.icepop_function --tis-clip-low 0.5 --tis-clip 2`. Observe `tis`, `tis_clipfrac`, reward, and effective signal after masking. + +## R3 + +Original work: [Stabilizing MoE Reinforcement Learning by Aligning Training and Inference Routers](https://arxiv.org/abs/2510.11370). + +Write a MoE layer as $F(x)=\sum_{i\in I(x)}g_i(x)E_i(x)$, with discrete expert set $I=\operatorname{TopK}(u(x))$. A perturbation near a top-k boundary can swap an expert even when its magnitude is tiny. Decompose the output difference by adding and subtracting the same intermediate term: + +$$ +F_{I_q}(x_q)-F_{I_p}(x_p) +=\underbrace{F_{I_q}(x_q)-F_{I_q}(x_p)}_{\text{same-route numerical/input difference}} ++\underbrace{F_{I_q}(x_p)-F_{I_p}(x_p)}_{\text{expert-selection difference}}. +$$ + +Replay sets the trainer route to recorded $I_q$, eliminating the second term **at this layer for this comparison**. It does not eliminate the first. For a softmax-gated example, + +$$ +g_i=\frac{\mathbf1_{i\in I_q}\exp u_i}{\sum_{j\in I_q}\exp u_j},\qquad +\frac{\partial g_i}{\partial u_j}=g_i(\mathbf1_{i=j}-g_j),\quad i,j\in I_q. +$$ + +Replay expert identities, not detached expert outputs. Gradients can still flow through selected gates and experts. GLM uses sigmoid-based routing and its own normalization/scaling, so this softmax example is illustrative; keep the architecture's actual gate rule. + +The lab emits `--use-rollout-routing-replay`; vime-managed vLLM requests routed experts automatically. External engines must be launched with `--enable-return-routed-experts`. Storage grows with tokens × MoE layers × selected experts. R3 is independent of TIS/SC and is not meaningful for a dense model. Implementation: [`vllm_engine.py`](https://github.com/vllm-project/vime/blob/main/vime/backends/vllm_utils/vllm_engine.py) and the replay code in [`megatron_utils`](https://github.com/vllm-project/vime/tree/main/vime/backends/megatron_utils). + +## SC + +Original work: [Score Centering Stabilizes Off-policy Reinforcement Learning](https://arxiv.org/abs/2609.20807). The derivation here follows the algebra of vime's [`score_centering_correction`](https://github.com/vllm-project/vime/blob/main/vime/utils/score_centering.py) and its loss implementation. + +### From an unwanted mean to a centered score + +Fix a prefix and let $\mu=\sum_a q_a s_a$. In general $\mu\ne0$, even with the same checkpoint on both sides. For a detached advantage $A$, covariance decomposition gives + +$$ +\mathbb E_q[A s]=\mathbb E_q[A]\mu+\operatorname{Cov}_q(A,s). +$$ + +Use $\tilde s_a=s_a-\mu$. Then $\mathbb E_q[\tilde s]=0$, and + +$$ +\mathbb E_q[A\tilde s]=\operatorname{Cov}_q(A,s). +$$ + +The prefix-conditional mean term vanishes. The covariance is still under $q$, so this does not establish an unbiased gradient under $p_\theta$. Group-centered rewards across full responses do not ensure $\mathbb E[A\mid h]=0$ at every prefix. + +### Compose with a correction weight + +Let $w_a=f(p_a/q_a)$, with $f$ either identity, TIS, masking, or 1. Define $\mu_w=\sum_a q_aw_as_a$ and center the **weighted** score: + +$$ +\tilde s^w_a=w_as_a-\mu_w,\qquad +\mathbb E_q[\tilde s^w]=0. +$$ + +An implementable minimization loss with exactly this local gradient direction is + +$$ +\ell=-\operatorname{sg}(A)\left[ +\operatorname{sg}(w_a)\log p_a +-\sum_v\operatorname{sg}(q_vw_v)\log p_v\right]. +$$ + +Differentiate only $\log p$: $-\nabla\ell=A(w_as_a-\mu_w)$. Differentiating through $w$ would add unwanted derivatives. With exact $w=p/q$ and full support, $\mu_w=\sum p s=0$; clipped or masked weights generally make it nonzero. + +### Why top-k does not require storing the full vocabulary + +vime records the sampler's head $H$ (128 tokens in the lab). Its probabilities retain **full-vocabulary mass**, not a head-only renormalization. Set + +$$ +P_H=\sum_{v\in H}p_v,\quad Q_H=\sum_{v\in H}q_v,\quad +\alpha=\frac{1-Q_H}{1-P_H},\quad +\hat q_v=\begin{cases}q_v&v\in H\\\alpha p_v&v\notin H.\end{cases} +$$ + +This approximation sums to one. Since $\sum_v p_vs_v=0$, the tail score can be replaced algebraically: + +$$ +\mu_{\hat q}=\sum_{H}q_vs_v+\alpha\sum_{v\notin H}p_vs_v +=\sum_H(q_v-\alpha p_v)s_v. +$$ + +In the tail $p_v/\hat q_v=1/\alpha$, so with $w=f(p/q)$: + +$$ +\mu_{\hat q,w}=\sum_{v\in H} +\left[q_v f(p_v/q_v)-\alpha f(1/\alpha)p_v\right]s_v. +$$ + +This is the head-only residual used in the implementation. Full-distribution centering is exact; centering with the reconstructed tail is an approximation, and small tail masses need numerical protection. A minimal logit check uses $p=(0.6,0.4)$, $q=(0.5,0.5)$ and $s_a=e_a-p$: the uncentered mean is $q-p=(-0.1,0.1)$; subtracting this mean makes the expected centered score zero. + +### Match the sampler's actual distribution + +The lab fixes temperature 1, top-p 1, top-k -1 and selects `--pg-loss-type reinforce`, `--disable-grpo-std-normalization`, `--calculate-per-token-loss`. SC cannot be substituted into GSPO/CISPO/PPO clipping. Streaming, constrained decoding, sampling penalties, and per-request temperature changes are unsupported here. + +vime also supports recorded top-p support $S$: normalize trainer probabilities on $S$ and use all recorded $q_v$ on that support. Then $\mu_w=\sum_{v\in S}q_vw_v\nabla\log p^S_v$ needs no tail approximation. This is exact relative to the recorded support, not to an untruncated policy; storage can greatly exceed top-128. See [sampling and compatibility details](../get_started/usage.md#score-centering). + +## What to measure + +Hold prompt data, seed, response budget, and evaluation fixed. Begin with the short baseline, change one mechanism at a time, and record reward alongside `train_rollout_logprob_abs_diff`, `tis_clipfrac` when applicable, and async `staleness/mean` / `staleness/max`. Smaller mismatch alone does not prove better learning. Save a fixed batch for [debug replay](../developer_guide/debug.md) to distinguish optimizer behavior from sampling variation. diff --git a/docs/en/advanced/rl-systems.md b/docs/en/advanced/rl-systems.md new file mode 100644 index 000000000..7b62fbb92 --- /dev/null +++ b/docs/en/advanced/rl-systems.md @@ -0,0 +1,139 @@ +# Understand the RL system you are building + +[Open the experiment lab](../index.rst). These are independent dimensions of an experiment: **placement** owns GPUs, **scheduling** orders work, **precision** changes arithmetic, and **serving topology** divides inference work. The simple cost models below are explanatory estimates, not benchmark predictions. + +## Placement + +A synchronous round generates responses, computes rewards, trains, and distributes new weights. With colocation, rollout and training share GPUs and alternate residency. With disaggregation they own separate GPU pools, even when scheduling is synchronous. External vLLM adds independent lifecycle ownership; it does not by itself imply asynchronous training. + +If training needs $N_T$ GPUs and rollout needs $N_R$, separate pools reserve $N_T+N_R$ GPUs. Colocation reuses a pool, but each phase still needs enough devices and host memory for its own parallelism/offload. The lab assumes 8 GPUs per training node and checks divisibility by TP × PP × CP and expert-TP × EP × PP. Divisibility is necessary, not a memory-capacity proof. + +For full weight size $S_W$, transfer bandwidth $B_W$, and control/load overhead $t_0$, a rough sync cost is + +$$t_W\approx S_W/B_W+t_0.$$ + +Delta transfer substitutes changed bytes $S_\Delta$ but adds snapshot, diff, encoding, and reconstruction time. It wins only when the bytes saved outweigh those costs. vime uses CUDA IPC for colocation; disaggregated full updates can use NCCL or disk. Delta requires disk transport, a shared directory, host-local checkpoint directories, and patched vLLM `/pull_weights` endpoints. + +Related systems research: [HybridFlow](https://arxiv.org/abs/2409.19256). vime's actual interfaces: [external engines](external-rollout-engines.md), [delta sync](delta-weight-sync.md), and [training topology](megatron-config.md). + +## Async + +Let $t_R$ be rollout plus reward time, $t_T$ training time, and $t_W$ weight-update time. A serial round takes approximately + +$$t_{\rm sync}=t_R+t_T+t_W.$$ + +If independent resources and a sufficiently full queue overlap generation and training perfectly, the optimistic steady-state lower bound becomes + +$$t_{\rm async}\gtrsim\max(t_R,t_T)+t_W,\quad +\text{speedup}\lesssim\frac{t_R+t_T+t_W}{\max(t_R,t_T)+t_W}. +$$ + +Weight-update stalls, sampling dependencies, reward latency, queue warmup, and GPU contention weaken this bound. It does not predict a measured speedup. Variable-length samples make synchronous barriers especially costly. + +The cost of overlap is staleness. If a sample was generated at weight version $v_s$ and consumed at $v_t$, define age $d=v_t-v_s$. At a prefix $h$, mismatch can be split into + +$$ +\log p_{v_t}(a\mid h)-\log q_{v_s}(a\mid h) +=\underbrace{\log p_{v_t}-\log p_{v_s}}_{\text{parameter staleness}} ++\underbrace{\log p_{v_s}-\log q_{v_s}}_{\text{same-version engine mismatch}}. +$$ + +Deterministic kernels can reduce the second term, not the first. A long trajectory may contain several versions; a single age statistic is only a diagnostic. TIS/SC can be useful, but do not justify an unlimited stale queue. + +vime uses `train.py` with `--rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async`. It preserves in-flight generations across calls and needs separate GPUs for meaningful overlap. Monitor `staleness/mean` and `staleness/max`. The generated recipe omits evaluation on that continuous queue; use a separate evaluation path/job. See the [fully async implementation guide](../_examples_synced/fully_async/README.md). Related work: [PipelineRL](https://arxiv.org/abs/2509.19128); this citation is conceptual context, not a claim that vime implements the paper's entire algorithm. + +## Precision + +Suppose serving logits are $z+\delta$ while trainer logits are $z$. For a fixed token, differentiate log-softmax: + +$$ +\frac{\partial\log p_a}{\partial z_j}=\mathbf1_{a=j}-p_j. +$$ + +A first-order expansion gives + +$$\log q_a-\log p_a\approx\delta_a-\sum_jp_j\delta_j.$$ + +A common shift cancels, while token-dependent perturbations change probabilities. Quantization, differing kernels, and accumulation order all contribute; this approximation can fail near discontinuities such as expert selection. + +A tensor with $P$ elements needs approximately $Pb/8$ bytes at $b$ bits, excluding scales, metadata, padding, and replicated copies. Halving the bit width halves the raw tensor storage, not necessarily total GPU memory. Optimizer state, activations, and KV cache remain separate budgets. + +The lab offers BF16 training + BF16 or FP8 rollout, experimental FP8 training, and beta INT4 rollout. BF16 training uses a converted `torch_dist` checkpoint. FP8 rollout uses a separate block-quantized HF checkpoint whose `quantization_config` controls online weight conversion. Attention KV precision and hybrid recurrent-state precision are independent of weight precision and of each other. FP8 training uses blockwise TE settings; it omits `fp8-param-gather` because of CPU Adam incompatibility. + +Format reference: [FP8 Formats for Deep Learning](https://arxiv.org/abs/2209.05433). See [vime precision support and caveats](low-precision.md). The lab's INT4 options require model/kernel validation; no GLM INT4 performance claim is made. + +### Hybrid cache + +Hybrid models have two different kinds of serving cache. [Qwen3.8-27B's config](https://huggingface.co/Qwen/Qwen3.8-27B/blob/main/config.json) specifies 48 linear-attention layers and 16 full-attention layers, with FP32 recurrent state. [GLM-5.3-Flash's config](https://huggingface.co/zai-org/GLM-5.3-Flash/blob/main/config.json) combines 34 KDA linear-attention layers with 11 DSA layers. These are different attention architectures; the shared vLLM **Mamba cache** name describes their recurrent-state storage, not an identical model architecture. See the [Gated DeltaNet paper](https://arxiv.org/abs/2412.06464) for the recurrent update mechanism. + +| Cache | What it stores | Independent vime setting | +|---|---|---| +| Attention KV | Per-token cache for attention layers | `--vllm-kv-cache-dtype`; FP8 requires the selected model, backend and GPU to support it | +| Mamba / linear-attention state | Recurrent state and cached snapshots | `--vllm-mamba-ssm-cache-dtype`, separately from KV dtype | +| Convolution state | Short local history in linear-attention layers | Separate convolution dtype; the SSM dtype flag does not change it | + +The builder exposes model default, FP32 and BF16 for recurrent state. Default leaves the SSM flag unset; Qwen3.8-27B's published config selects FP32. Selecting FP8 attention KV never turns recurrent state into FP8. The [SGLang argument reference](https://docs.sglang.io/docs/advanced_features/server_arguments#mamba-cache) documents the independent controls; [state dtype and size implementation](https://github.com/sgl-project/sglang/blob/v0.5.15.post1/python/sglang/srt/configs/mamba_utils.py) separates recurrent and convolution tensors. + +To account for memory, let $N$ be stored token positions and $K$ be allocated recurrent-state slots, including snapshots and scheduler buffers. With $c_A$ bytes per attention token and $c_R$ bytes per recurrent slot, + +$$B_{\rm cache}\approx N c_A + K c_R.$$ + +For conventional GQA attention layers $\mathcal A$, count K and V elements at each token. For linear layers $\mathcal R$, count recurrent and convolution elements per slot: + +$$ +c_A=\sum_{\ell\in\mathcal A}2H^{KV}_\ell d_\ell\frac{b_{KV}}8, +\qquad +c_R=\sum_{\ell\in\mathcal R}\left(P^{SSM}_\ell\frac{b_{SSM}}8+P^{conv}_\ell\frac{b_{conv}}8\right). +$$ + +Use tensor dimensions after sharding for a per-GPU estimate. DSA/MLA uses different cache tensors: measure their bytes instead of substituting GQA dimensions. A recurrent slot has a fixed shape, but the **number of slots** can grow with concurrency, prefix snapshots and scheduler buffering. + +Changing only BF16 attention KV to FP8 yields $B'\approx Nc_A/2+Kc_R$, so total cache memory does not generally halve. Similarly, BF16 SSM affects only the recurrent term, leaving convolution state untouched. + +SGLang’s separate-pool memory control sets $r=B_R/B_A$ through `--sglang-mamba-full-memory-ratio`. For an ideal two-pool budget $B$, solve $B_A+B_R=B$ and $B_R=rB_A$: + +$$B_A=\frac{B}{1+r},\qquad B_R=\frac{rB}{1+r}.$$ + +This describes SGLang’s separate-pool allocator, not vLLM’s shared paged cache. vLLM has no equivalent pool-split ratio; the builder uses the total GPU cache budget and rejects imported nonempty pool ratios. Use allocation logs and workload measurements to assess actual capacity. External deployments receive dtype options in `serving-reference.sh`; managed engines receive the `--vllm-` flags. + +The runnable Flash preset is **GLM-4.7-Flash**. GLM-5.3-Flash's hybrid design above does not make it interchangeable with that preset or with the GLM-5.3 DSA recipe. + +## Deterministic + +Floating-point addition is not associative. Changing a batch shape or reduction tree can change a result even without any random sampling. Reproducibility asks whether repeated execution of one configuration produces the same output; alignment asks whether two **different** implementations produce the same probabilities: + +$$\text{repeatable}(p)\land\text{repeatable}(q)\not\Rightarrow p=q.$$ + +The lab's deterministic switch enables Megatron deterministic mode, deterministic vLLM inference, and the documented NCCL/TE/cuBLAS environment settings. It is a reproducibility configuration, not a proof of cross-engine equality. The dependency stack must support the selected model and kernels; follow the [reproducibility guide](reproducibility.md), including its FlashAttention 3 preparation. + +Exact GLM-5 alignment additionally uses aligned DSA attention, batch-invariant DeepGEMM forward, matching router and head precision, and the patched Megatron/DeepEP path. Slime’s maintained regression is **six-layer GLM-5.2, EP8**, with `train_rollout_logprob_abs_diff < 1e-6`; a separate layerwise gate checks exact equality. This evidence does not establish full 744B, arbitrary topology, async, or PD alignment. + +Original technical work: [SGLang deterministic inference](https://lmsys.org/blog/2025-09-22-sglang-deterministic/). Run the documented gate before expanding the claim. + +## PD + +Prefill consumes the prompt; decode produces tokens iteratively. PD disaggregation splits these **serving** phases, independently of whether the trainer shares GPUs with serving. See [DistServe](https://arxiv.org/abs/2401.09670) for the research motivation, and [vime's PD guide](pd-disaggregation.md) for its vLLM/Mooncake implementation. + +Let $n_P,n_D$ be counts of prefill/decode engines and $\mu_P,\mu_D$ their measured requests/second under the target workload. With KV payload $S_{KV}$ per request and network bandwidth $B_{KV}$, flow conservation gives the bottleneck bound + +$$\lambda\le\min(n_P\mu_P,n_D\mu_D,B_{KV}/S_{KV}).$$ + +Ignoring transfer, balancing $n_P\mu_P\approx n_D\mu_D$ prevents a growing interstage queue. The correct split follows measurements of your prompt/response distribution, not a universal 1:1 ratio. + +For a conventional GQA/MHA cache, a rough unsharded payload for a prompt of length $L$ is $S_{KV}\approx2L N_{\rm layers}N_{\rm KVheads}d_{\rm head}b/8$. The factor 2 accounts for K and V. MLA/DSA and compressed or paged layouts need their actual representation instead of this formula. + +The lab generates `vllm.yaml` with prefill and decode groups. Group GPU counts must be multiples of the model's GPUs-per-engine and sum to the rollout pool. The selected Mooncake recipe needs working RDMA devices and corresponding dependencies. External deployments apply PD in their own launcher; `--vllm-config` must not be combined with `--rollout-external-engine-addrs`. + +## HiCache + +The [official HiCache design and usage guide](https://docs.sglang.io/docs/advanced_features/hicache_best_practices) describes a cache hierarchy extending prefix reuse beyond GPU memory. A simple per-request model compares reused computation with I/O: + +$$\Delta t\approx h\,t_{\rm avoided\ prefill}-t_{\rm cache\ IO}-t_{\rm bookkeeping},$$ + +where $h$ is the reusable-prefix hit rate. A positive hit rate alone does not prove speedup; large transfer cost can dominate. Useful metrics include prefix hit rate, host RAM, cache I/O time, and TTFT. + +The lab uses `--vllm-enable-prefix-caching` and a `SimpleCPUOffloadConnector` configured with `cpu_to_gpu_ratio=2`, configuring host cache without an L3 storage service. Under managed PD, it enables HiCache on prefill only, matching the engine builder's behavior. It does not configure decode offload or distributed cache storage. KV represents activations under specific weights; reuse across actor weight updates needs correct invalidation. Measure useful hits within that validity window. + +## Debug before optimizing + +Start with a three-round run and held-out evaluation. If generations are wrong, check the input template, checkpoint conversion, and weight update first. [Debug replay](../developer_guide/debug.md) freezes the sampled batch. [Tracing](../developer_guide/trace.md) identifies which phase waits. [Profiling](../developer_guide/profiling.md) then inspects the expensive kernels or engine stage. Optimize the measured bottleneck while comparing reward and mismatch; throughput alone is not the RL objective. diff --git a/docs/en/advanced/rollout-scheduling.md b/docs/en/advanced/rollout-scheduling.md new file mode 100644 index 000000000..d8200062c --- /dev/null +++ b/docs/en/advanced/rollout-scheduling.md @@ -0,0 +1,98 @@ +# Synchronous, partial, and distributed fully async rollout + +These are three separate decisions: **when training consumes a batch**, **whether unfinished generation can continue**, and **where rollout state lives**. The {ref}`lab ` shows their combined timeline and exports their actual flags. + +| Choice | Generation at a training boundary | Where unfinished work goes | +|---|---|---| +| Synchronous baseline | Finish the target batch, train, update weights | No retained partial work in the lab baseline | +| Synchronous + partial rollout | Oversample; once enough complete groups are accepted, stop the remaining work and train | Save prefixes, resume them in a later rollout | +| Fully async + object-store | A manager-local background worker keeps a queue across calls | Returned aborted groups go back to the data buffer | +| Fully async + straw | Independent generation processes on eligible Ray nodes feed the global batch collector | Persistent queue tasks, partial groups, ready groups and packed tensors | + +Separate training/serving GPUs permit overlap. Fully async still pauses admission around weight publication and coordinates in-flight work; it does not remove every synchronization point. straw can also be used with synchronous execution. + +(partial)= +## What partial rollout saves + +Suppose group A finishes quickly, while group B has generated only a prefix. Once the target number of **complete accepted groups** is ready, the synchronous sampler can abort remaining requests, train, update weights, and later continue B from its prefix. Partial rollout preserves generated work; it does not make an unfinished response a complete rewarded training sample. + +```{mermaid} +sequenceDiagram + participant S as vLLM + participant Q as Buffer / straw + participant T as Trainer + S->>S: Generate oversampled groups under v0 + S->>T: Target batch of complete accepted groups + S->>Q: Unfinished B: tokens + sampler metadata + T->>S: Train, then publish v1 + Q->>S: Resume B from its saved prefix + S->>T: Completed B, possibly spanning v0 and v1 +``` + +The lab exports `--partial-rollout`; for the synchronous recipe it also sets `--over-sampling-batch-size 16` with a target `--rollout-batch-size 8`. Oversampling supplies spare groups so slow unfinished groups can be retained. The standard sampler checks completion at group boundaries; a slow member can keep its group unfinished. Without excess in-flight work there may be little partial work to save. + +For a response whose first $k$ tokens were generated under version $v_0$ and remaining tokens under $v_1$, its behavior distribution is + +$$ +q(y\mid x)=\prod_{t=1}^{k}q_{v_0}(y_t\mid x,y_{ W0[Node 0 producer] + Q --> W1[Node 1 producer] + Q --> WN[Node N producer] + W0 --> S[vLLM GPU engines] + W1 --> S + WN --> S + S --> R[Persist completed groups + tensors] + R --> B[Global batch collector] + B --> T[Megatron] + T -->|weights + admission coordination| S +``` + +Fast workers can provide the next batch without a barrier requiring every worker to finish. Bounded completed prefetch applies backpressure when training falls behind. Weight-update coordination pauses admission and preserves results before resuming. Aborted groups are rebuffered rather than treated as completed training data. Storage and continuation remain distinct: the synchronous partial flag controls retaining unfinished work at its batch boundary; the async workers have their own aborted-group handling. + +Let $C$ be `vllm_server_concurrency`, $E$ the number of logical rollout engines, $G$ responses per prompt, and $W$ eligible worker nodes. The distributed implementation divides + +$$K=\left\lfloor\frac{CE}{G}\right\rfloor$$ + +group slots across workers, with quotient/remainder allocation. Each worker must receive at least one group, so $CE\ge WG$. This is a concurrency budget, not throughput; engine latency, agent tools, reward work and storage matter. Engine counting follows vime's serving configuration, including PD groups, rather than raw GPU count. See [worker selection](https://github.com/vllm-project/vime/blob/main/vime/rollout/fully_async_rollout.py) and [distributed scheduler](https://github.com/vllm-project/vime/blob/main/vime/rollout/fully_async_distributed.py). + +## Staleness and throughput + +If version $v$ is currently served and a group's oldest generated token used version $u$, its staleness is $v-u$. A freshly completed response can still be stale if its prefix began earlier. straw prioritizes completed groups, then partial groups, then fresh prompts; within a stage, older generated-token versions come first, with FIFO ties. Ordering does not automatically discard stale samples. + +If producers sustainably generate $\lambda$ accepted groups/s and training consumes $\mu$, throughput cannot exceed $\min(\lambda,\mu)$. With $\lambda>\mu$, an unbounded backlog grows at roughly $\lambda-\mu$; backpressure is needed. In a stable queue, Little's-law accounting gives $L=\lambda W_q$ for mean queued group count $L$ and time in that queue $W_q$. More producers are useful only while the rest of the system can consume their output. + +Watch `staleness/mean`, `staleness/max`, accepted-group throughput, reward, masking/clipping rates and training/rollout log-prob difference. Off-policy corrections do not guarantee that arbitrary lag is harmless. The [systems cost derivation](rl-systems.md#async) and [PipelineRL paper](https://arxiv.org/abs/2509.19128) provide related pipelining background; vime's implementation above defines the actual behavior. + +## Persistence and recovery with straw + +The lab asks for a shared JuiceFS directory, run ID, and deployment declaration, and exports: + +```bash +--rollout-data-transport straw \ +--rollout-data-dir /shared/juicefs/jobs/my-run/rollout_data \ +--rollout-queue-run-id my-run \ +--rollout-storage-profile juicefs \ +--rollout-storage-declaration /shared/juicefs/deployment.json +``` + +Install the same `straw-queue` version on all nodes; mount the verified storage at the same absolute path. The declaration records mount/durability properties, it does not create the mount. straw stores prompt tasks, partial/ready groups, training batches and packed tensors. Leases allow unfinished work from lost workers to become available again. R3 and SC tensor records can be shared across stages without copying their bytes. + +The short straw recipe saves every round. Recovery needs a completed **model/optimizer plus rollout-state checkpoint** and the referenced storage pool. It does not restore GPU KV cache or guarantee identical future random generations. Keep dataset, tokenizer, run ID, storage settings and fully async worker topology compatible. Stop the previous job before a whole-job restart; coordinator failover is not automatic. For retained serving, follow [fault tolerance](fault-tolerance.md). Full setup, leases, GC and branch/rollback semantics are in [the straw guide](straw.md) and [straw's filesystem contract](https://github.com/zhuzilin/straw/blob/main/docs/FILESYSTEM.md). + +The continuous async path does not support inline evaluation or `--rollout-all-samples-process-path`; evaluate separately. straw does not support `--buffer-filter-path`, and uses its own persisted ordering. See [debugging](../developer_guide/debug.md) for replay and [profiling](../developer_guide/profiling.md) before changing concurrency. diff --git a/docs/en/advanced/speculative-decoding.md b/docs/en/advanced/speculative-decoding.md index e475fe830..48a017353 100644 --- a/docs/en/advanced/speculative-decoding.md +++ b/docs/en/advanced/speculative-decoding.md @@ -30,6 +30,42 @@ For the full list of `SpeculativeConfig` fields (including `num_speculative_toke and `draft_tensor_parallel_size`), see vLLM's speculative-decoding [documentation](https://docs.vllm.ai/en/latest/features/speculative_decoding/). +## Configure EAGLE in the interactive builder + +In the homepage's **Serving** step, enable **EAGLE · Speculative decoding**. Choose the checkpoint's built-in MTP head where supported, or **Separate EAGLE head** and enter its checkpoint directory or Hugging Face model ID. The separate head must be compatible with the target model; an arbitrary smaller language model is not an EAGLE head. Make its weights accessible to every serving host. + +The builder exports one vLLM `SpeculativeConfig`: `method="mtp"` for a supported built-in head or `method="eagle"` with `model` for a separate EAGLE head. Drafting depth $\gamma$ maps to `num_speculative_tokens=γ`. The default depth is 3 (4 for the GLM-5.3 recipe). These are starting settings, not measured optimal values. Attention backend selection follows the model and vLLM configuration. + +The **Your little RL world** diagram shows all eight PD / HiCache / EAGLE combinations. PD separates prefill and decode engine pools and adds cache transfer. HiCache adds a CPU cache tier; in this recipe, PD attaches it only to prefill. EAGLE adds a draft head, candidate tokens, target verification and committed output. With EAGLE off, the target decodes one token per step. **Play a round** highlights these stages in order; the acceptance example is illustrative. + +Managed deployments receive `--vllm-speculative-config` arguments in `experiment.sh`. External deployments must apply the corresponding `--speculative-config` arguments from `serving-reference.sh` when launching their engines. Training-side flags also enable vime's speculation metrics; they cannot reconfigure a running external engine. The draft path is retained locally and in the downloaded configuration, but omitted from share links. Switching target models clears it so a head is not accidentally reused for another target. + +The builder configures inference. It does not enable online MTP or separate-head training. As the target changes during RL, monitor `spec_accept_rate`, `spec_accept_length` and rollout latency. Extra draft weights, verification buffers and hybrid recurrent-state snapshots also consume memory. + +## Why verification is needed + +[EAGLE](https://arxiv.org/abs/2401.15077) uses a lightweight head to propose candidates using target-model features. The target then verifies them. The probability argument below is the standard single-path rejection-sampling construction from [Accelerating Large Language Model Decoding with Speculative Sampling](https://arxiv.org/abs/2302.01318); it describes the ideal sampler, not a claim that every backend uses identical kernels. + +At a fixed prefix, let $p(a)$ be the target distribution and $q(a)$ the draft distribution. For a proposed $a\sim q$, accept with probability + +$$\alpha(a)=\min\left(1,\frac{p(a)}{q(a)}\right).$$ + +The accepted probability mass for token $a$ is $q(a)\alpha(a)=\min(p(a),q(a))$. Total rejection probability is + +$$Z=1-\sum_a\min(p(a),q(a))=\sum_a[p(a)-q(a)]_+.$$ + +After rejection, draw a correction from $r(a)=[p(a)-q(a)]_+/Z$. Combining accepted and corrected mass gives + +$$\Pr(\text{output}=a)=\min(p(a),q(a))+Zr(a)=p(a).$$ + +When $Z=0$, rejection never occurs. Apply the construction at successive accepted prefixes; after the first rejection, discard the remaining candidates and continue from the corrected prefix. If all candidates pass, an additional target token can be emitted. Thus the diagram marks an accepted prefix and a correction, rather than training directly on unchecked draft tokens. + +For a simple cost model, let $A$ be the number of accepted draft tokens, $t_d$ the per-step draft cost, $t_v$ the batched target verification cost, and $t_o$ cache/scheduling overhead. If an ordinary target step costs $t_t$, + +$$\text{speedup}\approx\frac{(\mathbb E[A]+1)t_t}{\gamma t_d+t_v+t_o}.$$ + +More draft steps help only when the additional accepted tokens repay the extra work. This is why the builder exposes depth and acceptance metrics rather than promising a fixed speedup. + ## Online SFT for the Draft Model As RL progresses, the sampling distributions of the draft and target models can drift apart. Fewer draft tokens pass verification, and speculative decoding can even yield negative returns. diff --git a/docs/en/advanced/straw.md b/docs/en/advanced/straw.md index 4cc02944b..1bb89bf49 100644 --- a/docs/en/advanced/straw.md +++ b/docs/en/advanced/straw.md @@ -89,9 +89,9 @@ for custom rollout functions and queue readers. ## Packed storage and supported data -Packing is enabled whenever Straw transport is selected. Each writer appends +Packing is enabled whenever straw transport is selected. Each writer appends multiple samples and tensors to the same file (a pack), reducing small-file -metadata overhead on shared storage. vime uses Straw's default target size, +metadata overhead on shared storage. vime uses straw's default target size, currently **1 GiB**. Use `--rollout-queue-segment-mib` to override it in MiB. The target size controls file rotation; readers can access each publication @@ -116,7 +116,7 @@ and ordinary fields still occupy manager memory. | Option | Default | Purpose | |---|---|---| -| `--rollout-queue-segment-mib` | Unset; uses Straw's default (currently `1024`) | Override the target pack rotation size in MiB; explicit values must be positive. | +| `--rollout-queue-segment-mib` | Unset; uses straw's default (currently `1024`) | Override the target pack rotation size in MiB; explicit values must be positive. | | `--rollout-io-concurrency` | `4` | Bound concurrent serialization and filesystem I/O submissions | | `--rollout-queue-lease-seconds` | `300` | Worker lease duration; active readers renew it | diff --git a/docs/en/blogs/index.rst b/docs/en/blogs/index.rst new file mode 100644 index 000000000..ebf917be0 --- /dev/null +++ b/docs/en/blogs/index.rst @@ -0,0 +1,10 @@ +Design notes & releases +================================================== + +Read the framework design and release history. + +.. toctree:: + :maxdepth: 1 + + ../blogs/release_v0.1.0.md + ../blogs/introducing_slime.md diff --git a/docs/en/blogs/introducing_slime.md b/docs/en/blogs/introducing_slime.md new file mode 100644 index 000000000..c7feb3867 --- /dev/null +++ b/docs/en/blogs/introducing_slime.md @@ -0,0 +1,126 @@ +> Historical Slime article mirrored from THUDM/slime; model claims and release versions below refer to Slime, not Vime. + +# slime: An SGLang-Native Post-Training Framework for RL Scaling + +> This article was first released in [lmsys.org](https://lmsys.org/blog/2025-07-09-slime/). + +## Vision That Drives slime + +We believe in RL. We believe RL is the final piece toward AGI. + +If you feel the same way, you'll share our vision: + +- Every field should be end-to-end RLed and every task should become an agent environment. +- Every RL run should last longer, and every model should scale larger. +- RL systems should integrate seamlessly with existing infrastructure, letting us focus on new ideas instead of boilerplate engineering. + +That's why we present [slime](https://github.com/THUDM/slime), a post-training framework designed to be: + +- **Versatile** – with a fully customizable rollout interface and flexible training setups (colocated or decoupled, standard or fully-async rollout, RL or SFT cold start). +- **Performant** - integrating SGLang for inference and Megatron-LM for training, natively. +- **Maintainable** - with a lightweight codebase and smooth transition from Megatron pretraining to SGLang deployment. + +In short, a post-training framework for RL scaling. + +Here’s how we made it happen. + +## Customizability Brings Freedom + +> We should stop trying to find simple ways to think about the contents of minds, such as simple ways to think about space, objects, multiple agents, or symmetries. +> +> +> — *The Bitter Lesson* +> + +A prevailing misconception within the RL community is the need for separate frameworks for different tasks: one for plain math, one for multi-turn tool calling, one for fully-async rollout, one for agentic tasks, and so on. Forking and maintaining multiple frameworks is dreadful, leading to time-wasting bugfix cherry-picking, or worse, training crashes by missing patches. + +It wasn’t always like this: no one forks PyTorch just for a new dataloader. We believe the current chaos stems from the trap of dictating how people should build their applications. If we insist on defining a universal template for every rollout scenario, we’ll inevitably create an RL framework that meets only a fraction of real-world needs. + +slime views the data sampling in RL differently. We manage all SGLang servers within slime with [sgl-router](https://github.com/sgl-project/sglang/tree/main/sgl-router) and provide an interface for the data generation component, **allowing users to inject custom logic and freely interact with SGLang servers**. Unleash their creativity. + +![slime architecture](../../_static/image/arch.png) + +With the sgl-router, users only need to send HTTP requests to a single endpoint. By exposing this endpoint, complex agent environments can directly interact with slime through an OpenAI-compatible API — no need to modify the environment, and training-deployment consistency is preserved. + +Regarding training schemes, slime uses Ray for resource management, enabling **colocated** (same GPUs) or **decoupled** (separate GPUs) setups with a single flag (`--colocate`). + +slime keeps a single training entrypoint, `train.py`. Fully-async behavior lives in the rollout implementation instead of a separate training loop: select `slime.rollout.fully_async_rollout.generate_rollout_fully_async` with `--rollout-function-path`. Its background worker keeps individual generations in flight and carries a warm queue across training steps. + +## Built for Performance + +**A decent RL framework must be fast and consistently fast.** + +**Fast** means leveraging the fastest inference and training frameworks. + +Unlike pre-training, RL workloads involve tons of online sampling during training, which makes the inference performance crucial. Therefore, slime exclusively integrates SGLang, and deliberately delivers an SGLang-native experience. + +So what does ‘SGLang-native’ mean? It means you can take full advantage of all SGLang optimizations — using SGLang inside slime feels just like using it standalone. To make that possible: + +- slime internally launches SGLang servers in a **server-based mode**. +- slime implements **seamless pass-through** for all SGLang parameters (with a `--sglang` prefix), ensuring that all optimization options can be enabled. For instance, you can pass `--sglang-enable-ep-moe`, `--sglang-enable-dp-attention` and `--sglang-enable-deepep-moe` for the powerful multi-node MoE inference capabilities. +- slime provides an **SGLang-only debug mode** (`--debug-rollout-only`) for easy performance tuning. + +Together, we can reproduce the standalone performance of SGLang within slime. Even the base image of slime is built on `lmsysorg/sglang:dev`. + +For training, slime integrates the battle-tested Megatron-LM, aiming for a similarly native pre-training experience: + +- slime also implements **seamless pass-through** for all Megatron parameters. +- slime supports **all Megatron parallelisms** (TP, PP, EP, CP) and monitors training MFU. +- slime offers a **Megatron-only debug mode** (`--debug-train-only`) and supports storing sampling data for reproducibility. + +Megatron can be notoriously complex, so we also provide checkpoint conversion tools to simplify its use. + +**Consistently fast** means keeping pace with the evolving inference and training frameworks. + +If you ever followed the [SGLang PR list](https://github.com/sgl-project/sglang/pulls), you will be astonished by its rapid evolution. Megatron, on the other hand, is often heavily customized, with every organization maintaining its own fork. slime is designed to keep pace with upstream changes in SGLang and adapt to optimizations in in-house Megatron variants. This is another reason why we pursue native support for SGLang and Megatron. The parameter pass-through makes upgrading effortless. + +Beyond optimizing inference and training frameworks, we also tackled RL-specific workloads. When SGLang needs changes to support these workflows, we work closely with the SGLang team to upstream patches—so slime can stay native, even as RL logic evolves. Examples include: + +**Optimizing weight updates**: Unlike inference tasks, RL training involves frequent updates to model weights. To address this, we’ve introduced several optimizations in SGLang: + + - Parameter updates for MoE models under various parallelism strategies ([#6265](https://github.com/sgl-project/sglang/pull/6265), [#6308](https://github.com/sgl-project/sglang/pull/6308), [#6311](https://github.com/sgl-project/sglang/pull/6311)). + - Bucketed parameter update support to reduce overhead ([#7292](https://github.com/sgl-project/sglang/pull/7292)). + +**`/abort_request` for dynamic sampling**: In RL algorithms that require oversampling, such as [DAPO](https://arxiv.org/abs/2503.14476), some requests may continue running even after sufficient data has been collected. In collaboration with the [AReal](https://github.com/inclusionAI/AReaL) team, we designed an new endpoint: `/abort_request`. This endpoint enables: + + - Immediate termination of on-going requests. + - Reclaiming partially generated content, which enables partial rollouts. + +Implemented in [#6698](https://github.com/sgl-project/sglang/pull/6698), [#6855](https://github.com/sgl-project/sglang/pull/6855), [#6184](https://github.com/sgl-project/sglang/pull/6184), [#5966](https://github.com/sgl-project/sglang/pull/5966). + + +## Lightweight and Extensible + +Focusing on customization and performance, slime: + +1. Provides a customizable rollout interface. +2. Uses Ray for GPU management and asynchronous execution. +3. Integrates SGLang for inference and Megatron for training. +4. Provides weight updates between training and inference. + +Pretty straightforward, right? slime transfers complexity from the framework to user-defined pipelines and core libraries (SGLang and Megatron), resulting in a lightweight, easily maintainable codebase. + +But it doesn’t stop at RL. + +Thanks to its modular design and powerful backends, slime can naturally extend to other post-training workflows with minimal extra code: + +- **SFT**: Load Megatron and use token prediction loss. +- **Rejection Sampling**: Use SGLang for filter, followed by Megatron SFT. + +*(Note that SFT feature is now in experimental state.)* + +Beyond that, slime's native integration **seamlessly bridges pre-training to online services**. We can use Megatron for pre-training, switch to slime (which integrates both Megatron and SGLang) for post-training, and finally use SGLang directly for evaluation and deployment. This eliminates the cumbersome and error-prone steps of converting checkpoint formats and aligning precision between frameworks. + +The unified pipeline saves us from tedious glue code, freeing us to focus on what really matters: better RL. Hurray! + +## Roadmap + +The journey of RL scaling has just begun, and slime is continuously evolving. In the next phase, we will focus on: + +1. Collaborating with the SGLang team to explore optimal RL training strategies for large-scale MoE models. +2. Supporting broader post-training workflows, strengthening the pre-training-to-production bridge. +3. Adding native PyTorch training backend support to lower the entry barrier. + +We hope slime accelerates your RL scaling journey and turns your innovative ideas into reality. Contributions and conversations are always welcome! + +Special thanks to the AMD GenAI - Foundation Model Team for Day-1 AMD hardware support. diff --git a/docs/en/blogs/release_v0.1.0.md b/docs/en/blogs/release_v0.1.0.md new file mode 100644 index 000000000..e1a45a34c --- /dev/null +++ b/docs/en/blogs/release_v0.1.0.md @@ -0,0 +1,160 @@ +> Historical Slime article mirrored from THUDM/slime; model claims and release versions below refer to Slime, not Vime. + +# v0.1.0: Redefining High-Performance RL Training Frameworks + +> The origin version of this article is in Chinese and was first released in [zhihu](https://zhuanlan.zhihu.com/p/1945237948166547268). + +With the help of the community, we've finally released the first version of **slime**, **v0.1.0**, just two months after it was open-sourced. + +In a nutshell, this version can be summarized as follows: + +> **slime v0.1.0 provides all the essential performance optimizations needed for large-scale MoE RL training.** + +Specifically, this version brings the following improvements: + + - **Performance**: + - Provides **efficient inference for MoE models**, especially with **fp8 rollout + deepep + mtp**. + - Designed a generic **training framework memory offload solution** to save more KV Cache space, thus increasing inference concurrency. + - **Faster parameter updates**. + - Achieves more training with fewer GPUs through **CPU Adam**. + - Supports **all of Megatron's parallel strategies** as well as **deepep**. + - **Features**: + - Added support for **GSPO** for MoE model training. + - Added support for **TIS** for fp8 rollout. + - **Correctness**: + - Implemented **Dense and MoE model CI** to strictly check metrics like kl. + +We hope to use slime v0.1.0 to demonstrate our understanding of high-performance RL training frameworks and have it become a baseline for future performance comparisons. + +Next, I'll elaborate on the design philosophy behind these features. + +----- + +## Performance Optimization: Pushing the Limits of RL Training Speed + +In traditional deep learning training, there's a universal solution for speedup: **add more GPUs**. By reducing the amount of data processed per GPU, you can significantly lower end-to-end training latency. + +However, this method doesn't work for RL training because **inference latency cannot be reduced by adding more GPUs**. Even with more GPUs, we still have to wait for the longest sample to finish decoding. While increasing throughput can improve the amount of training data per rollout, the off-policy issues caused by an excessively large inference batch size still have some limitations. + +I believe this is the biggest challenge for infrastructure under the current RL paradigm, which is: + +> **We want to scale inference compute, but we cannot scale inference latency.** + +The decoding speed of a single data point determines the upper limit of RL training speed. For larger MoE models, there are currently three common optimization methods to push this limit, and we've tried all of them: + +1. **Reduce memory access through quantization**: Considering that long calibration is not feasible in RL training, slime opts for fp8 quantization. +2. **Use deepep low-latency mode to reduce all2all latency across machines**: To work with deepep, slime recommends using blockwise quantization with fp8 to enable related SGLang configurations. +3. **Enable Speculative Sampling**: slime allows loading any draft model for the inference part (currently, it doesn't support updating the draft model during training). + +![](../../_static/image/blogs/release_v0.1.0/overrall.png) + +By using the three optimizations mentioned above, we can increase a model like **GLM4.5 355B-A32B** from less than 10 tokens/s for a single data point to **60-70 tokens/s**, which significantly raises the upper limit of RL training speed. + +In addition to monitoring inference throughput, slime also monitors `perf/longest_sample_tokens_per_sec` to better understand the potential for performance optimization in the inference part. + +----- + +## Doing More Experiments with Fewer GPUs: Fully Offloading Megatron + +After optimizing the upper limit, we noticed another characteristic of RL training: as long as the **KV Cache doesn't overflow**, increasing the inference batch size doesn't significantly affect training latency. + +**KV Cache overflow** occurs during inference when the response lengths of the data are all very long, leading to insufficient KV Cache space. This requires kicking out some half-generated data from the queue and then re-running prefill and subsequent inference steps after other data has been processed and freed up space. If a data point with a response length of 64k has to wait for 32k tokens to be decoded by other data during its inference, its total time is equivalent to decoding 96k tokens. This greatly impacts the RL training speed. + +Therefore, a more suitable training configuration is to calculate the minimum number of GPUs needed to prevent KV Cache overflow based on the inference batch size, the average response length, and the available KV Cache space on a single server. A group of these GPUs is then used for training. For example, if we have 512 GPUs and the calculation shows that 256 GPUs provide enough KV Cache, we should run two experiments in parallel instead of launching one experiment with all 512 GPUs. + +Based on this consideration, we noticed two points for optimization: + +1. **The optimal number of GPUs may not be sufficient to load the training part**. Inference only needs to load the fp8 parameters, while training generally requires more than 18 times the parameter size of GPU memory (bf16 param, fp32 grad, fp32 master param, fp32 m and v). To solve this, slime uses **Megatron's built-in CPU Adam** to save GPU memory for the training part. This strategy allowed us to provide solutions for training GLM 4.5 355B-A32B with 8 nodes and DeepSeek R1 with 16 nodes. +2. **Increase the KV Cache space available per SGLang Server**, which means increasing `mem_fraction`. For the more common integrated training and inference tasks, the main limitation for a larger `mem_fraction` is the residual GPU memory after offloading the training part to the CPU. Therefore, we need to find a generic way to offload the GPU memory used by the Megatron part. + +### How to Offload GPU Tensors Generically + +One crude approach is to find all the GPU Tensors allocated by Megatron and call `.to("cpu")` on all of them. This method has three difficulties: + + - It's hard to capture all GPU Tensors allocated by Megatron. + - Because Megatron's distributed optimizer reorganizes all parameters into some contiguous GPU buffers and then divides them with various slices, it's difficult to properly handle all references to correctly free the GPU Tensors. + - It requires checking the source code again with every new Megatron version, which is hard to maintain. + +Is there a more generic solution? + +We noticed that SGLang's `torch_memory_saver` and VLLM's `cumem_allocator` provide a more general offload solution. Their principle is that CUDA 10.2 provides a **series of Virtual Memory Management APIs**, similar to an operating system's virtual and physical addresses (VA and PA). When allocating GPU memory, they return a handle to a memory mapping instead of the actual physical address. Therefore, when offloading, we only need to "secretly" release the memory corresponding to this mapping and reallocate it when this memory is needed. The upper-level application doesn't need to be aware of this. + +![](../../_static/image/blogs/release_v0.1.0/cuda_vmm.png) + +A natural idea is to use this method to take over the entire training process in RL. However, this prevents the reuse of PyTorch's `CUDACachingAllocator`, and without the cache, memory fragmentation becomes more pronounced, easily leading to **OOM** during training. + +To continue reusing the native, cached allocator, we cannot use `CUDAPluggableAllocator`. Noticing again that slime's architecture has training and inference in different processes, we only need to **directly replace `cudaMalloc` and `cudaFree` used by `CUDACachingAllocator` in the training process with VMM APIs via `LD_PRELOAD`**. This allows us to completely and generically offload all GPU Tensors allocated by PyTorch. + +At the same time, we must also note one detail: VMM APIs and cudaIPC APIs (such as `cudaIpcGetMemHandle`) are incompatible. Therefore, for integrated training and inference tasks and DeepEP, we need to disable the `LD_PRELOAD` replacement and switch back to `cudaMalloc`. + +With help from the SGLang community, we updated `torch_memory_saver` for slime's needs, implementing this offload solution. + +### How to Offload NCCL + +After thoroughly offloading the GPU Tensors in Megatron, we found that a large amount of GPU memory still remained, which was caused by **NCCL**. In PyTorch, each NCCL group involved in communication allocates a substantial buffer. This issue is particularly noticeable for larger MoE models due to the various parallel strategies, potentially taking up **more than 10GB**. + +The `LD_PRELOAD` solution mentioned above doesn't handle the NCCL issue well, and we don't want to modify the NCCL source code to avoid having to maintain a separate NCCL fork in addition to slime. So, slime's approach is to use `destroy_process_group` to destroy the NCCL group when offloading Megatron and then recreate it before loading Megatron. To do this, we mimicked the VMM API and monkey patched `dist.new_group` to add a layer of `ReloadableProcessGroup`. + +In this way, we achieved a generic **NCCL offload**. However, because we need to rebuild the NCCL group, this operation has a slight impact on the speed of the first communication in each training iteration. But we believe this approach offers a significant advantage in terms of maintainability and the GPU memory it saves. + +Combining these two optimizations, we reduced Megatron's residual GPU memory from around **15-18GB** to **3-5GB**, which allows us to increase the `mem_fraction` for MoE models to **0.7-0.8**. This significantly boosts the available KV Cache, increases the concurrency each server can support, and allows us to launch more training tasks with fewer GPUs. + +----- + +## Parameter Update Optimization + +Parameter update is another special step in RL training. For this, slime v0.1.0 provides the best optimization solution for scenarios where training and inference are in different processes. This work was heavily optimized by Biao He. I recommend reading his blog post: + + - [Efficient Reinforcement Learning Training - Optimizing Weight Synchronization in slime](https://hebiao064.github.io/rl-weight-sync) + +Currently, slime can complete weight synchronization for a GLM4.5 355B-A32B model with bf16 weights in **48s** and complete fp8 blockwise quantization + parameter update in **100s** (the fp8 branch is still being optimized). + +----- + +## Training Optimization + +For the pure training part of slime, we believe Megatron already provides ample optimizations, so our main focus was to **ensure compatibility with all of Megatron's parallel strategies**. + +During this adaptation, we found an interesting bugfix: we discovered that when SGLang enabled mtp, the Megatron part couldn't start DeepEP. It turned out that when mtp is enabled, SGLang disables the overlap schedule, which causes a certain metadata communication to use nccl instead of gloo after being offloaded to the CPU, and this conflicts with DeepEP. + +----- + +## Performance Optimization Check List + +Since the release of slime, I've often been asked about its performance comparison with other frameworks. + +My understanding of benchmarks is that they should not be used as a weapon for frameworks to attack each other, but rather as a **tool for identifying gaps**. To that end, we will gradually release performance benchmarks that slime focuses on to improve ourselves. + +I also believe that before running benchmarks, you can analyze a framework's focus on performance from a qualitative perspective. Here's a basic feature check list for optimizations: + + - Does it support MoE training? (Currently, large-scale experiments are focused on MoE) + - Can the internal sglang `mem_fraction` or vllm `gpu_utilization` be adjusted to over 0.7? (Ensures KV Cache space) + - Does it support fp8 or lower precision inference? (Reduces inference memory access, boosts speed) + - Does it support enabling deepep for both training and inference? (Optimizes MoE all2all communication) + - Does it support speculative sampling? (Improves inference latency and throughput) + - Does it have an efficient training backend, such as Megatron or torchtitan, and support all necessary parallelization strategies? (Reuses mature training optimizations) + +slime v0.1.0 has made preliminary attempts at all the above optimizations, and there's still a lot of room for improvement. We hope this version can serve as a baseline for future slime versions or for performance comparisons between different frameworks. We also welcome all friends who share our pursuit of performance to try out slime and join the slime community\! + +----- + +## New Algorithm Support + +To better train MoE models and perform fp8 rollouts, we implemented **GSPO** and **TIS**. Additionally, community experts have helped implement algorithms like reinforce++ and reinforce++ baseline. + +----- + +## Correctness Verification + +slime v0.1.0 adds **end-to-end CI**: we run single-machine GLM4 9B and Qwen3 30B-A3B training for each PR, ensuring correctness through strict checks. For example, we explicitly require: + + - The recomputed log prob of the first rollout must be exactly equal to the log prob of the reference model. + - The ppo_kl of the first training step within each rollout must be exactly 0. + +Such precise verification is rarely achieved in training frameworks, and it's something we are very proud of. + +----- + +This is a brief introduction to slime v0.1.0. I hope it sparks your curiosity about slime and that it can be helpful in your work. + +Everyone is welcome to join the slime community. Let's work together to build an open RL Infra and contribute to RL scaling\! diff --git a/docs/en/developer_guide/ci.md b/docs/en/developer_guide/ci.md index dec3a5786..b4742c193 100644 --- a/docs/en/developer_guide/ci.md +++ b/docs/en/developer_guide/ci.md @@ -53,12 +53,12 @@ The test checks that healthy vLLM processes, routers, and GPU placements are reu | Data storage | RolloutManager state | Checks | |---|---|---| -| Straw with online GC | Remains alive | Reconnect trainers and replay completed training batches that were not checkpointed. | -| Straw with online GC | Killed after failure | Reconnect a new manager to the original serving cluster and replay the same batches. | -| Straw with a model/optimizer checkpoint and Megatron YAML configuration | Killed during training | Restore from the checkpoint and check configuration and recovery state. | +| straw with online GC | Remains alive | Reconnect trainers and replay completed training batches that were not checkpointed. | +| straw with online GC | Killed after failure | Reconnect a new manager to the original serving cluster and replay the same batches. | +| straw with a model/optimizer checkpoint and Megatron YAML configuration | Killed during training | Restore from the checkpoint and check configuration and recovery state. | | Rollout debug files | Killed after failure | Restore data from debug files and reconnect a new manager to the original serving cluster. | -| Straw with disk-delta weight synchronization | Killed after failure | Publish restored weights as a new full baseline, then continue delta updates. | -| Straw with PD/NIXL serving | Killed after failure | Wedge the prefill actor, replace it within the reset timeout, and retain the healthy decode actor. | +| straw with disk-delta weight synchronization | Killed after failure | Publish restored weights as a new full baseline, then continue delta updates. | +| straw with PD/NIXL serving | Killed after failure | Wedge the prefill actor, replace it within the reset timeout, and retain the healthy decode actor. | `test_qwen3_30B_A3B_training_recovery.py` uses 8 GPUs for the same OOM/checkpoint/manager-loss workflow with a MoE model, R3, and stateless Adam. It omits optimizer tensors while checking scheduler progress, compares persisted routing bytes across the TP/DP change, and completes training after recovery. The dense cases cover ordinary Adam with optimizer checkpoints. diff --git a/docs/en/developer_guide/index.rst b/docs/en/developer_guide/index.rst new file mode 100644 index 000000000..7c056e85d --- /dev/null +++ b/docs/en/developer_guide/index.rst @@ -0,0 +1,10 @@ +Development & CI +================================================== + +Test changes and configure development dependencies. + +.. toctree:: + :maxdepth: 1 + + ../developer_guide/ci.md + ../developer_guide/install_flashqla.md diff --git a/docs/en/examples/agents/index.rst b/docs/en/examples/agents/index.rst new file mode 100644 index 000000000..b36bfd765 --- /dev/null +++ b/docs/en/examples/agents/index.rst @@ -0,0 +1,11 @@ +Agents & environments +================================================== + + + +.. toctree:: + :maxdepth: 1 + + ../../_examples_synced/multi_agent/README.md + ../../_examples_synced/coding_agent_rl/README.md + ../../_examples_synced/tau-bench/README.md diff --git a/docs/en/examples/index.rst b/docs/en/examples/index.rst new file mode 100644 index 000000000..e923877e6 --- /dev/null +++ b/docs/en/examples/index.rst @@ -0,0 +1,11 @@ +Task tutorials +================================================== + +Explore complete workflows by task, from agents to multimodal training. + +.. toctree:: + :maxdepth: 1 + + ../examples/agents/index + ../examples/multimodal/index + ../examples/workflows/index diff --git a/docs/en/examples/multimodal/index.rst b/docs/en/examples/multimodal/index.rst new file mode 100644 index 000000000..42a45630b --- /dev/null +++ b/docs/en/examples/multimodal/index.rst @@ -0,0 +1,10 @@ +Multimodal tasks +================================================== + + + +.. toctree:: + :maxdepth: 1 + + ../../_examples_synced/geo3k_vlm/README.md + ../../_examples_synced/geo3k_vlm_multi_turn/README.md diff --git a/docs/en/examples/workflows/index.rst b/docs/en/examples/workflows/index.rst new file mode 100644 index 000000000..d80da17ba --- /dev/null +++ b/docs/en/examples/workflows/index.rst @@ -0,0 +1,14 @@ +Training & systems workflows +================================================== + + + +.. toctree:: + :maxdepth: 1 + + ../../examples/qwen3-4b-base-openhermes.md + ../../_examples_synced/fully_async/README.md + ../../_examples_synced/delta_weight_sync/README.md + ../../_examples_synced/eval_multi_task/README.md + ../../_examples_synced/on_policy_distillation/README.md + ../../_examples_synced/train_infer_mismatch_helper/README.md diff --git a/docs/en/get_started/agent.md b/docs/en/get_started/agent.md index 5ad5a59b8..2e6555c54 100644 --- a/docs/en/get_started/agent.md +++ b/docs/en/get_started/agent.md @@ -8,8 +8,8 @@ This page is a roadmap: use it to decide which docs and examples to read when pl | Goal | Recommended entry point | | :--- | :--- | -| Run a custom agent loop, tool calls, RAG, browser/terminal/sandbox interaction for each sample | [`--custom-generate-function-path`](customization.md#--custom-generate-function-path), [writing a custom generation function](quick_start.md#writing-custom-generation-function) | -| Implement verifier rewards, test-based rewards, environment success checks, or an external reward service | [`--custom-rm-path`](customization.md#--custom-rm-path), [writing a custom reward function](quick_start.md#writing-custom-reward-function) | +| Run a custom agent loop, tool calls, RAG, browser/terminal/sandbox interaction for each sample | [Writing a custom generation function](customization.md#--custom-generate-function-path) (`--custom-generate-function-path`) | +| Implement verifier rewards, test-based rewards, environment success checks, or an external reward service | [Writing a custom reward function](customization.md#--custom-rm-path) (`--custom-rm-path`) | | Return multiple training samples from one prompt, such as subagent, multi-agent, or context-compaction segments | [fan-out return from custom generate](customization.md#returning-multiple-training-samples-for-one-prompt), [`examples/multi_agent`](../_examples_synced/multi_agent/README.md) | | Avoid blocking training on long-tail agent rollouts | [`examples/fully_async`](../_examples_synced/fully_async/README.md) | | Study a full end-to-end agent example with sandboxing, real code edits, and test-based grading | [`examples/coding_agent_rl`](../_examples_synced/coding_agent_rl/README.md) | diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index b9c8b65f6..a748da9b9 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -44,7 +44,7 @@ For most agentic use cases, **start with `--custom-generate-function-path` plus | Attach custom loss masks, metadata, or convert agentic outputs into training data | [`--rollout-data-postprocess-path`](#rollout-data-postprocess-path), [`--custom-convert-samples-to-train-data-path`](#custom-convert-samples-to-train-data-path) | | Debug long-running custom generation, verifier calls, tool calls, or sandbox steps | trace utilities in [`vime.observability.trace_utils`](../developer_guide/trace.md) | -Native examples of this pattern: [`examples/multi_agent`](../../../examples/multi_agent/README.md) (a `--rollout-function-path`-based multi-agent pattern) and [`examples/fully_async`](../../../examples/fully_async/README.md) (long-tail agentic generation), both keeping vime's default `vllm_rollout` outer loop. +Native examples of this pattern: [`examples/multi_agent`](../_examples_synced/multi_agent/README.md) (a `--rollout-function-path`-based multi-agent pattern) and [`examples/fully_async`](../_examples_synced/fully_async/README.md) (long-tail agentic generation), both keeping vime's default `vllm_rollout` outer loop. ## Detailed Interface Reference diff --git a/docs/en/get_started/experiment-guide.md b/docs/en/get_started/experiment-guide.md new file mode 100644 index 000000000..775b6e71e --- /dev/null +++ b/docs/en/get_started/experiment-guide.md @@ -0,0 +1,124 @@ +# Tutorial: build and understand an RL experiment + +{ref}`Open the interactive Quick Start `. Choose a model, watch the GPU layout change, try alternatives, and download the resulting shell. The same tutorial applies across GLM, Qwen3.8-27B, and DeepSeek-R1; model dimensions come from the repository's model configurations. + +## The loop you are building + +```{mermaid} +flowchart LR + A[Prompts / environment] --> B[vLLM: sample responses] + B --> C[Reward / verifier] + C --> D[Batch or persistent queue] + D --> E[Megatron: policy update] + E -->|updated weights| B + E --> F[Checkpoint + held-out evaluation] +``` + +A training example is more than a prompt and an answer: it carries tokens, rewards, masks, and the probabilities or replay data required by your objective. vLLM generates experience; Megatron computes gradients. Changing precision, kernels, or weight versions can make their distributions differ. Scaling RL means managing that difference while using compute effectively. + +The lab's **Play a round** animation illustrates this loop. It is not a simulation of GPU speed or a training run. Every choice is reversible: use the previous step, any step label, undo/redo, or reset. Choices survive reload locally. A share link carries architecture choices but excludes machine paths, engine addresses, and custom hook paths. `experiment.json` preserves the complete local configuration. + +## 1. Choose the task and model + +The initial experiment uses verifiable math, JSONL keys `prompt` and `label`, a chat template, and the built-in `deepscaler` reward. It samples eight responses per prompt, uses GRPO, and runs three rounds. This is a correctness run, not a claim of capability improvement. + +For an agent or a different reward, choose **My agent / reward**, then provide importable generation and reward functions. Those hooks must exist in the same environment on workers. The lab does not invent a reward function for your task. See [agent workflows](agent.md) and [hook contracts](customization.md). + +Model selection changes architecture, TP/PP/CP/EP, conversion strategy, and serving-engine size together. These are starting configurations, not a memory-fit calculator. Default training node counts are inherited from repository recipes: GLM-4.7-Flash and GLM-4-9B use one 8-GPU node; GLM-4.7 uses 8; GLM-5.3 uses 32; DeepSeek-R1 uses 16. Qwen3.8-27B uses the four-node 27B hybrid-attention configuration. + +[Qwen3.8-27B's official config](https://huggingface.co/Qwen/Qwen3.8-27B/blob/main/config.json) identifies `qwen3_5` with dimensions matching `scripts/models/qwen3.5-27B.sh`. The text-RL path reuses this model spec and its HF conversion support. This architecture check is not GPU validation of the new checkpoint or a multimodal RL recipe. FLA/Gated Delta Net dependencies still apply; see [architecture support](../advanced/arch-support-beyond-megatron.md). + +[GLM-5.3’s official config](https://huggingface.co/zai-org/GLM-5.3/blob/main/config.json) matches the 78-layer, 256-expert DSA architecture and cross-layer index-sharing schedule in the existing `glm5.2-744B-A40B` model specification. The lab reuses that architecture source with the GLM-5.3 checkpoint; the older six-layer deterministic regression is not a validation of this new checkpoint. + +## 2. Choose GPU ownership, then scheduling + +The lab shows starting GPU counts, total allocation, and clickable TP/PP/EP/CP/ZeRO diagrams. CPU Adam and activation recomputation switches change the exported shell. See [memory accounting and parallelism](../advanced/parallelism-memory.md), including allgather/zigzag CP. + +| Choice | What happens | What you must provide | +|---|---|---| +| Colocated | Training and rollout alternate on one GPU pool | Enough memory for each phase and offload | +| Disaggregated | vime manages separate training and serving pools | Ray capacity for both pools | +| External vLLM | vime connects to engines owned by another deployment | Engine addresses and compatible weight transport | +| Synchronous | Finish a rollout batch, train, sync weights | A straightforward ordering baseline | +| Fully async | Keep generation in flight across updates | Separate pools and monitoring of sample age | + +Selecting fully async from colocation moves the layout to separate GPUs in the same undoable action. Selecting colocation restores synchronous execution. Other incompatibilities remain visible until fixed, so the lab never silently exports a different algorithm. + +External engines are not a generic text-completion API: vime needs vLLM server information, weight-update endpoints, and sampler metadata. The generated `serving-reference.sh` shows the required settings. Deployment-specific hosts, ports, multi-node ranks, and RDMA configuration belong to that external launcher. A trainer CLI cannot reconfigure an already-running engine. + +Partial rollout keeps unfinished prefixes for later continuation, including under synchronous training. straw persists payloads and queue state; combined with fully async it selects distributed producer processes across Ray nodes. The lab exposes both choices independently. Compare their timelines, mixed-version probabilities, and checkpoint boundaries in [rollout scheduling](../advanced/rollout-scheduling.md). + +## 3. Choose numerical behavior + +Start with BF16 on both sides to establish a baseline, or the maintained BF16-training / FP8-rollout path for large MoE. The latter keeps a BF16 training checkpoint and uses a separately quantized HF checkpoint for serving. Attention KV precision is another independent choice. Hybrid models also need a separate recurrent-state dtype: Qwen3.8-27B exposes attention KV, Mamba SSM state, and an optional state/KV memory ratio. The diagram shows the two pools separately. See [hybrid cache accounting](../advanced/rl-systems.md#hybrid-cache), including GLM-5.3-Flash's distinct architecture. + +FP8 training is experimental; INT4 rollout is beta. Every lower-precision path needs a compatible GPU/kernel stack. See [precision and memory derivation](../advanced/rl-systems.md#precision). + +Then choose corrections deliberately. TIS clips importance weights; IcePop masks out-of-range weights. R3 replays MoE routes. SC centers weighted scores and uses REINFORCE. Deterministic execution addresses reproducibility but does not automatically make two engines identical. The **why & math** buttons lead to [full derivations](../advanced/policy-mismatch.md) and the original papers. + +## 4. Size serving for the workload + +Keep ordinary serving as a baseline. Enable PD when measurements show prefill and decode need independent resources. The lab requires both groups to contain whole engines and uses Mooncake/RDMA; fill in actual device names. `vllm.yaml` is embedded inside `experiment.sh`, so a separate manual file copy is unnecessary. + +Enable HiCache for reused prefixes after budgeting host RAM and I/O. Under PD this recipe enables it on prefill only. Tune concurrency and response-token limits against actual memory and latency. See [the serving cost models](../advanced/rl-systems.md#pd). + +Enable **EAGLE** to add speculative decoding. Select a checkpoint MTP head or provide a separate compatible EAGLE head, then choose the draft depth. The serving diagram changes with PD, HiCache and EAGLE, showing candidate generation and target verification when enabled. See [configuration, acceptance derivation and metrics](../advanced/speculative-decoding.md). + +## 5. Prepare once, convert, then run + +First [install the environment](quick_start.md). Keep the same checkout, dependencies, model/data paths, and configuration paths on every participating host. Multi-node runs need a shared filesystem or equivalent identical local copies; a path inside only the head container is insufficient. Make the generated `.vime-lab.*` configuration directory visible to Ray workers through the same repository path. + +Download `experiment.sh` from the lab into the vime repository root. Review its paths and notes, then run: + +```bash +# Once, on the shared filesystem: download source weights and data, +# and prepare selected FP8/INT4 rollout weights. +bash experiment.sh prepare + +# Convert the BF16 source into a reshardable Megatron checkpoint. +bash experiment.sh convert +``` + +Small/medium recipes convert on one node with 8 GPUs. For GLM-4.7, GLM-5.3, and DeepSeek-R1, the generated conversion is a four-node job with 8 GPUs per node. Run `convert` on **all four nodes** with the same `CONVERT_MASTER_ADDR` and a different `CONVERT_NODE_RANK` in 0–3. Conversion parallelism can differ from training because `torch_dist` is reshardable. Conversion itself needs sufficient GPU and host memory. + +GLM-5.3 and DeepSeek-R1 publish FP8 weights, so `prepare` downloads them into a separate source directory, converts them to BF16, and then prepares the selected serving format. An existing BF16 HF checkpoint and `torch_dist` checkpoint can be used directly by editing paths and skipping preparation. + +Start the Ray head and join workers using the number of GPUs reported by the lab: + +```bash +# Head node +ray start --head --node-ip-address HEAD_IP --num-gpus 8 \ + --dashboard-host 0.0.0.0 --dashboard-port 8265 + +# Each worker node +ray start --address HEAD_IP:6379 --num-gpus 8 +``` + +With external engines, deploy and verify those engines first; their GPUs are outside the trainer's Ray capacity. For disk transport, mount the weight directory at the same path on both sides. Delta additionally needs the patched serving endpoints and local checkpoint directories. + +On the head, from the repository root: + +```bash +bash experiment.sh check +bash experiment.sh train +``` + +`check` verifies the presence of input files; it does not load the model or certify capacity. `train` submits the job with explicit runtime environment settings. Set `RAY_DASHBOARD` if using another Ray head. The shell does not kill processes or start a cluster implicitly. + +The shell defaults to a short training run. Use a fresh output directory and straw run ID for a new experiment; existing save directories follow the backend’s recovery rules. To resume a later run, add `--load` for the saved checkpoint and follow the [checkpoint/recovery requirements](../advanced/fault-tolerance.md). Decide a save interval appropriate for your experiment; the short default may finish before the interval is reached. + +## 6. Decide whether the experiment worked + +Read generated responses first. Check reward and the reference/trainer log-probs; then inspect training/rollout mismatch and correction clipping/masking. With fully async, inspect staleness. A fast run with meaningless rewards is not progress. + +Add a held-out dataset before a long run. A synchronous example after downloading AIME data is: + +```bash +--eval-interval 20 \ +--eval-prompt-data aime /data/aime-2024/aime-2024.jsonl \ +--n-samples-per-eval-prompt 8 +``` + +The fully async queue does not implement evaluation itself; schedule a separate supported evaluation path or job. See [evaluation configuration](usage.md) and the [fully async guide](../_examples_synced/fully_async/README.md). + +If output is wrong, use [debug and replay](../developer_guide/debug.md). If an otherwise correct run is slow, use [trace](../developer_guide/trace.md), [profiling](../developer_guide/profiling.md), and [observability](../advanced/observability.md) to identify the next change. The homepage's expandable advanced sections follow the same diagnostic order. diff --git a/docs/en/get_started/index.rst b/docs/en/get_started/index.rst new file mode 100644 index 000000000..f791e3b05 --- /dev/null +++ b/docs/en/get_started/index.rst @@ -0,0 +1,14 @@ +Start here +================================================== + +Build, launch, and customize your first experiment. + +.. toctree:: + :maxdepth: 1 + + ../get_started/experiment-guide.md + ../get_started/quick_start.md + ../get_started/usage.md + ../get_started/customization.md + ../get_started/agent.md + ../get_started/qa.md diff --git a/docs/en/index.rst b/docs/en/index.rst index 916d5e90b..65fc3eb73 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -1,3 +1,5 @@ +.. _lab: + Vime Documentation =================== @@ -85,13 +87,14 @@ Start by Use Case examples/qwen3-4b-base-openhermes.md _examples_synced/fully_async/README.md _examples_synced/multi_agent/README.md + _examples_synced/dspark/README.md + _examples_synced/mem_agent/README.md _examples_synced/coding_agent_rl/README.md _examples_synced/delta_weight_sync/README.md _examples_synced/eval_multi_task/README.md _examples_synced/geo3k_vlm/README.md _examples_synced/geo3k_vlm_multi_turn/README.md _examples_synced/on_policy_distillation/README.md - _examples_synced/strands_vllm/README.md _examples_synced/tau-bench/README.md _examples_synced/train_infer_mismatch_helper/README.md @@ -110,3 +113,8 @@ Start by Use Case :caption: Hardware Platforms platform_support/amd_tutorial.md + +.. toctree:: + :maxdepth: 2 + + library diff --git a/docs/en/library.rst b/docs/en/library.rst new file mode 100644 index 000000000..e643d1399 --- /dev/null +++ b/docs/en/library.rst @@ -0,0 +1,18 @@ +Documentation library +================================================== + +Learn while building in the :doc:`interactive lab `, or browse complete guides by topic below. + +.. container:: library-categories + + .. toctree:: + :maxdepth: 1 + + get_started/index + training/index + rollout/index + operations/index + examples/index + developer_guide/index + platform_support/index + blogs/index diff --git a/docs/en/operations/index.rst b/docs/en/operations/index.rst new file mode 100644 index 000000000..a83c35c33 --- /dev/null +++ b/docs/en/operations/index.rst @@ -0,0 +1,13 @@ +Debugging, profiling & recovery +================================================== + +Find correctness issues, measure bottlenecks, and recover a run. + +.. toctree:: + :maxdepth: 1 + + ../developer_guide/debug.md + ../developer_guide/trace.md + ../developer_guide/profiling.md + ../advanced/observability.md + ../advanced/fault-tolerance.md diff --git a/docs/en/platform_support/index.rst b/docs/en/platform_support/index.rst new file mode 100644 index 000000000..123abab17 --- /dev/null +++ b/docs/en/platform_support/index.rst @@ -0,0 +1,9 @@ +Hardware platforms +================================================== + +Platform-specific installation and execution guides. + +.. toctree:: + :maxdepth: 1 + + ../platform_support/amd_tutorial.md diff --git a/docs/en/rollout/index.rst b/docs/en/rollout/index.rst new file mode 100644 index 000000000..ac7dcdd34 --- /dev/null +++ b/docs/en/rollout/index.rst @@ -0,0 +1,16 @@ +Rollout & scheduling +================================================== + +Place engines, schedule generation, and move data and weights. + +.. toctree:: + :maxdepth: 1 + + ../advanced/rl-systems.md + ../advanced/rollout-scheduling.md + ../advanced/straw.md + ../advanced/pd-disaggregation.md + ../advanced/external-rollout-engines.md + ../advanced/delta-weight-sync.md + ../advanced/vllm-config.md + ../advanced/speculative-decoding.md diff --git a/docs/en/training/index.rst b/docs/en/training/index.rst new file mode 100644 index 000000000..1d3290cfb --- /dev/null +++ b/docs/en/training/index.rst @@ -0,0 +1,15 @@ +Training & algorithms +================================================== + +Understand policy objectives, precision, parallelism, and memory. + +.. toctree:: + :maxdepth: 1 + + ../advanced/policy-mismatch.md + ../advanced/parallelism-memory.md + ../advanced/low-precision.md + ../advanced/reproducibility.md + ../advanced/on-policy-distillation.md + ../advanced/megatron-config.md + ../advanced/arch-support-beyond-megatron.md diff --git a/docs/requirements.txt b/docs/requirements.txt index 76ec912a2..e67e73669 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,7 @@ gguf>=0.10.0 ipykernel ipywidgets +jieba jupyter_client markdown>=3.4.0 matplotlib @@ -11,7 +12,8 @@ nbstripout pandoc pillow pydantic -sphinx +# Sphinx 9.1 emits an undefined ChineseStemmer in the Chinese search bundle. +sphinx>=8.2,<9 sphinx-autobuild sphinx-book-theme sphinx-copybutton diff --git a/docs/tests/experiment.test.cjs b/docs/tests/experiment.test.cjs new file mode 100644 index 000000000..62fffec0a --- /dev/null +++ b/docs/tests/experiment.test.cjs @@ -0,0 +1,575 @@ +// Documentation generator tests; no GPU or training dependencies required. +const { + test +} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { + spawnSync +} = require('node:child_process'); +require('../_static/js/experiment-engine.js'); +const E = globalThis.VimeExperiment; +const repo = path.resolve(__dirname, '../..'); +const shell = state => E.generate(state).files['experiment.sh']; + +test('memory controls alter optimizer and activation settings independently', () => { + const cpu = shell({ + ...E.defaults, + cpuAdam: true, + recompute: false + }); + assert.match(cpu, /--optimizer-cpu-offload/); + assert.doesNotMatch(cpu, /--recompute-granularity/); + const gpu = shell({ + ...E.defaults, + cpuAdam: false, + recompute: true + }); + assert.doesNotMatch(gpu, /--optimizer-cpu-offload|--overlap-cpu-optimizer-d2h-h2d/); + assert.match(gpu, /--recompute-granularity full/); +}); + +test('straw storage and partial continuation compose with either scheduling mode', () => { + assert.equal(E.validate({ + ...E.defaults, + straw: true + }).errors.length, 3); + for (const schedule of ['sync', 'async']) { + const state = { + ...E.defaults, + schedule, + layout: 'separate', + straw: true, + partial: true, + maskPartial: true, + queueDir: '/shared/juicefs/my run', + queueRun: 'run-1', + declaration: '/shared/juicefs/deployment.json' + }; + assert.deepEqual(E.validate(state).errors, []); + const script = shell(state); + assert.match(script, /--rollout-data-transport straw/); + assert.match(script, /--rollout-storage-profile juicefs/); + assert.match(script, /--rollout-data-dir '\/shared\/juicefs\/my run'/); + assert.match(script, /--save-interval 1(?:\s|\\)/); + assert.match(script, /--partial-rollout/); + assert.match(script, /--mask-offpolicy-in-partial-rollout/); + assert.equal(script.includes('--over-sampling-batch-size 16'), schedule === 'sync'); + assert.equal(script.includes('generate_rollout_fully_async'), schedule === 'async'); + assert.equal(spawnSync('bash', ['-n'], { + input: script + }).status, 0); + assert.equal(E.change(state, 'partial', false).maskPartial, false); + } + assert.doesNotMatch(shell(E.defaults), /--rollout-data-transport straw|--partial-rollout/); +}); + +test('the default and every model baseline have valid parallel layouts and existing architecture sources', +() => { + assert.deepEqual(E.validate(E.defaults).errors, []); + assert.deepEqual(Object.keys(E.models).filter(k => k.startsWith('qwen')), ['qwen38']); + for (const model of Object.keys(E.models)) { + const s = E.change(E.defaults, 'model', model), + m = E.models[model]; + assert.deepEqual(E.validate(s).errors, [], model); + assert.ok(fs.existsSync(path.join(repo, 'scripts/models', m.model + '.sh'))); + assert.ok(fs.existsSync(path.join(repo, 'scripts', 'run-' + m.recipe + '.sh'))); + } + }); + +test('layout transitions enforce GPU residency in one reversible state change', () => { + const old = { + ...E.defaults + }; + const async = E.change(old, 'schedule', 'async'); + assert.equal(async.layout, 'separate'); + assert.equal(old.layout, 'colocate'); + assert.equal(E.change(async, 'layout', 'colocate').schedule, 'sync'); + const external = E.change(old, 'layout', 'external'); + assert.equal(external.transport, 'disk'); + assert.ok(E.validate(external).errors.some(s => s.includes('addresses'))); +}); + +test('PD partitions whole engines and external ownership excludes managed YAML', () => { + const invalid = { + ...E.defaults, + pd: true, + prefill: 8, + ib: 'mlx5_0' + }; + assert.ok(E.validate(invalid).errors.some(s => s.includes('PD needs'))); + const pd = { + ...invalid, + layout: 'separate', + rollout: 16 + }; + const generated = E.generate(pd); + assert.deepEqual(generated.validation.errors, []); + assert.match(generated.files['vllm.yaml'], /worker_type: prefill\n\s+num_gpus: 8/); + assert.match(generated.files['vllm.yaml'], /worker_type: decode\n\s+num_gpus: 8/); + assert.match(generated.files['vllm.yaml'], /"kv_role":"kv_producer"/); + assert.match(generated.files['vllm.yaml'], /"kv_role":"kv_consumer"/); + assert.match(generated.files['vllm.yaml'], /"device_name":"mlx5_0"/); + for (const model of ['deepseek', 'glm5']) { + const state = E.change(E.defaults, 'model', model); + assert.match(shell(state), /--vllm-all2all-backend deepep_auto/); + assert.doesNotMatch(shell(state), /--vllm-moe-a2a-backend|--vllm-deepep-mode/); + const engine = E.models[model].engine; + const topology = E.generate({...state, pd: true, layout: 'separate', + rollout: 2 * engine, prefill: engine, ib: 'mlx5_0'}); + assert.match(topology.files['vllm.yaml'], /all2all_backend: deepep_high_throughput/); + assert.match(topology.files['vllm.yaml'], /all2all_backend: deepep_low_latency/); + assert.doesNotMatch(topology.files['experiment.sh'], /--vllm-moe-a2a-backend|--vllm-deepep-mode/); + assert.doesNotMatch(topology.files['vllm.yaml'], /deepep_mode:/); + } + const external = E.generate({ + ...pd, + layout: 'external', + endpoints: 'worker-a:30000 worker-b:30001' + }); + assert.ok(!external.files['vllm.yaml']); + assert.doesNotMatch(external.files['experiment.sh'], /--vllm-config /); + assert.match(external.files['serving-reference.sh'], /--kv-transfer-config .*"kv_role":"kv_producer"/); +}); + +test('SC uses current-policy REINFORCE with compatible sampling and composes with built-in corrections', +() => { + for (const correction of ['none', 'tis', 'icepop']) { + const generated = shell({ + ...E.defaults, + sc: true, + correction + }); + assert.match(generated, /--pg-loss-type reinforce/); + assert.match(generated, /--disable-grpo-std-normalization/); + assert.match(generated, /--rollout-top-p 1/); + assert.match(generated, /--rollout-top-k -1/); + assert.doesNotMatch(generated, /--eps-clip /); + } + assert.doesNotMatch(shell({ + ...E.defaults, + correction: 'none' + }), /--use-tis/); + assert.match(shell({ + ...E.defaults, + correction: 'icepop' + }), /--tis-clip-low 0.5/); + assert.match(shell({ + ...E.defaults, + correction: 'icepop' + }), /loss.icepop_function/); +}); + +test('dense R3, async determinism, missing hooks and transport requirements are explained', () => { + assert.ok(E.validate({ + ...E.change(E.defaults, 'model', 'dense'), + r3: true + }).errors.some(s => s.includes('MoE'))); + assert.ok(E.validate({ + ...E.defaults, + layout: 'separate', + schedule: 'async', + deterministic: true + }).errors.some(s => s.includes('ordering'))); + assert.equal(E.validate({ + ...E.defaults, + task: 'custom' + }).errors.length, 2); + assert.ok(E.validate({ + ...E.defaults, + layout: 'external', + transport: 'delta', + endpoints: 'node:30000', + local: '' + }).errors.some(s => s.includes('local checkpoint'))); + assert.ok(E.validate({ + ...E.defaults, + pd: true, + ib: '' + }).errors.some(s => s.includes('RDMA'))); +}); + +test('cache, routing replay, and precision settings survive export at the right ownership boundary', () => { + const s = { + ...E.defaults, + layout: 'external', + endpoints: 'node:30000', + transport: 'disk', + r3: true, + hicache: true, + kv: 'fp8', + precision: 'fp8' + }; + const files = E.generate(s).files; + assert.match(files['serving-reference.sh'], /--enable-return-routed-experts/); + assert.match(files['serving-reference.sh'], /--enable-prefix-caching/); + assert.match(files['serving-reference.sh'], /"kv_connector":"SimpleCPUOffloadConnector"/); + assert.match(files['serving-reference.sh'], /"cpu_to_gpu_ratio":2/); + assert.match(files['serving-reference.sh'], /--kv-cache-dtype fp8_e4m3/); + assert.match(files['experiment.sh'], /--update-weight-transport disk/); + assert.match(files['experiment.sh'], /ROLLOUT_MODEL="\$HF_MODEL-fp8"/); + const pd = E.generate({ + ...E.defaults, + layout: 'separate', + rollout: 16, + pd: true, + ib: 'mlx5_0', + hicache: true + }); + assert.match(pd.files['vllm.yaml'], /"kv_connector":"MultiConnector"/); + assert.match(pd.files['vllm.yaml'], /enable_prefix_caching: true/); + const decode = pd.files['vllm.yaml'].split('worker_type: decode')[1]; + assert.match(decode, /"kv_connector":"MooncakeConnector"/); + assert.doesNotMatch(decode, /SimpleCPUOffloadConnector|cpu_to_gpu_ratio/); +}); + +test('hybrid attention KV and recurrent state export independently to each engine owner', () => { + const hybrid = E.change(E.change(E.defaults, 'model', 'glm5'), 'model', 'qwen38'); + assert.equal(hybrid.kv, 'auto'); + assert.equal(hybrid.mambaDtype, 'auto'); + assert.doesNotMatch(shell(hybrid), /--vllm-(kv-cache-dtype|mamba-ssm-cache-dtype|mamba-full-memory-ratio)/); + for (const layout of ['colocate', 'separate', 'external']) { + for (const kv of E.enums.kv) { + for (const mambaDtype of E.enums.mambaDtype) { + const state = { + ...hybrid, + layout, + kv, + mambaDtype, + mambaRatio: '', + endpoints: 'node:30000', + transport: layout === 'external' ? 'disk' : 'nccl' + }; + const generated = E.generate(state); + assert.deepEqual(generated.validation.errors, []); + const file = generated.files[layout === 'external' ? 'serving-reference.sh' : 'experiment.sh']; + const prefix = layout === 'external' ? '--' : '--vllm-'; + assert.equal(file.includes(`${prefix}kv-cache-dtype fp8_e4m3`), kv === 'fp8'); + assert.equal(file.includes(`${prefix}mamba-ssm-cache-dtype`), mambaDtype !== 'auto'); + if (mambaDtype !== 'auto') assert.ok(file.includes(`${prefix}mamba-ssm-cache-dtype ${mambaDtype}`)); + assert.doesNotMatch(file, /mamba-full-memory-ratio/); + assert.equal(spawnSync('bash', ['-n'], { + input: file + }).status, 0); + } + } + } + for (const ratio of ['0', '-1', 'NaN', 'Infinity', '0x10', '$(false)']) { + assert.ok(E.generate({ + ...hybrid, + mambaRatio: ratio + }).validation.errors.some(e => e.includes('ratio'))); + } + assert.ok(E.generate({ + ...hybrid, + mambaRatio: '1.5' + }).validation.errors.some(e => e.includes('shared paged cache'))); + assert.equal(E.sanitize({ + ...hybrid, + mambaDtype: 'fp8' + }).mambaDtype, 'auto'); + const conventional = E.sanitize({ + ...hybrid, + model: 'dense', + mambaDtype: 'bfloat16', + mambaRatio: '1.5' + }); + assert.equal(conventional.mambaDtype, 'auto'); + assert.equal(conventional.mambaRatio, ''); + assert.doesNotMatch(shell(conventional), /--vllm-mamba/); +}); + +test('EAGLE exports a separate draft head or a supported checkpoint MTP head', () => { + const separate = {...E.defaults, speculative: true, draftSource: 'external', draft: '/models/my eagle head', specSteps: 5}; + for (const layout of ['colocate', 'separate', 'external']) { + const s = {...separate, layout, transport: layout === 'external' ? 'disk' : 'nccl', endpoints: 'node:30000'}; + const result = E.generate(s); + assert.deepEqual(result.validation.errors, []); + for (const [name, prefix] of [['experiment.sh', '--vllm-'], ...(layout === 'external' ? [['serving-reference.sh', '--']] : [])]) { + const output = result.files[name]; + assert.ok(output.includes(`${prefix}speculative-config`)); + assert.ok(output.includes('"method":"eagle"')); + assert.ok(output.includes('"model":"/models/my eagle head"')); + assert.ok(output.includes('"num_speculative_tokens":5')); + assert.doesNotMatch(output, /speculative-eagle-topk/); + assert.doesNotMatch(output, /speculative-num-draft-tokens/); + assert.doesNotMatch(output, /--enable-mtp-training/); + assert.equal(spawnSync('bash', ['-n'], {input: output}).status, 0); + } + assert.doesNotMatch(shell({...s, speculative: false}), /--vllm-speculative-/); + } + assert.ok(E.generate({...separate, draft: ''}).validation.errors.some(e => e.includes('EAGLE head'))); + const glm = {...E.change(separate, 'model', 'glm5'), speculative: true}; + assert.equal(glm.draft, ''); + assert.equal(glm.draftSource, 'builtin'); + assert.match(shell(glm), /"method":"mtp"/); + assert.doesNotMatch(shell(glm), /"model":|speculative-draft-model-path/); + assert.doesNotMatch(shell({...glm, draftSource: 'external', draft: 'org/compatible-eagle'}), /--vllm-speculative-draft-attention-backend nsa/); + const dense = E.change(separate, 'model', 'dense'); + assert.equal(dense.draftSource, 'external'); + assert.ok(E.generate({...dense, draftSource: 'builtin'}).validation.errors.some(e => e.includes('MTP'))); + assert.deepEqual(E.generate({...separate, pd: true, rollout: 16, layout: 'separate', ib: 'mlx5_0'}).validation.errors, []); +}); + +test('all eight serving combinations have different PD, cache and decoding structures', () => { + const D = require('../_static/js/experiment-diagrams.js'); + const scenes = new Set(); + for (const pd of [false, true]) for (const hicache of [false, true]) for (const speculative of [false, true]) { + const s = {...E.defaults, step: 4, pd, hicache, speculative}; + const picture = D.render(s, E.models[s.model], E.validate(s), 'en'); + scenes.add(picture); + assert.equal(picture.includes('class="serving-pool combined-pool"'), !pd); + assert.equal(picture.includes('class="serving-pool prefill-pool"'), pd); + assert.equal(picture.includes('class="serving-pool decode-pool"'), pd); + assert.equal(picture.includes('data-serving-phase="transfer"'), pd); + assert.equal(picture.includes('data-cache-tier="cpu"'), hicache); + assert.equal(picture.includes('data-serving-phase="draft"'), speculative); + assert.equal(picture.includes('data-serving-phase="verify"'), speculative); + assert.equal(picture.includes('data-serving-phase="commit"'), speculative); + assert.equal(picture.includes('data-serving-phase="decode"'), !speculative); + assert.ok(picture.indexOf('serving-scene') < picture.indexOf('world-node trainer')); + } + assert.equal(scenes.size, 8); +}); +test('all generated recipe variants are syntactically valid bash', () => { + let count = 0; + for (const model of Object.keys(E.models)) + for (const precision of E.enums.precision) + for (const layout of E.enums.layout) + for (const correction of E.enums.correction) { + const s = { + ...E.change(E.defaults, 'model', model), + precision, + layout, + correction, + endpoints: 'worker-a:30000', + pd: layout === 'separate', + rollout: E.models[model].engine * 2, + ib: 'mlx5_0', + hicache: true, + sc: true + }; + for (const [name, body] of Object.entries(E.generate(s).files)) + if (name.endsWith('.sh')) { + const result = spawnSync('bash', ['-n'], { + input: body, + encoding: 'utf8' + }); + assert.equal(result.status, 0, `${model} ${precision} ${layout}: ${result.stderr}`); + count++; + } + } + assert.equal(count, 288); +}); + +test('shared choices are schema-limited and restore paths for the selected model', () => { + const s = E.sanitize({ + model: 'qwen38', + evil: '