diff --git a/.github/actions/gpu-smoke-prepare/action.yml b/.github/actions/gpu-smoke-prepare/action.yml index b3b416a28..4e610f60a 100644 --- a/.github/actions/gpu-smoke-prepare/action.yml +++ b/.github/actions/gpu-smoke-prepare/action.yml @@ -20,7 +20,7 @@ runs: export UV_CACHE_DIR="${UV_CACHE_DIR:-${HOME}/.cache/uv}" git config --global http.postBuffer 524288000 || true # Base image PyPI mirror may lag pypi.org (e.g. kernels, fa3-fwd). - uv pip install --system --break-system-packages ".[gpu,dev,audio]" + uv pip install --system --break-system-packages ".[gpu,dev,audio,omni]" uv pip install --system --break-system-packages "vllm-omni @ git+https://github.com/vllm-project/vllm-omni.git@$(cat .github/vllm_omni_pin.txt)" uv pip install --system --break-system-packages --no-deps --reinstall "verl @ git+https://github.com/verl-project/verl.git@$(cat .github/verl_pin.txt)" uv pip install --system --break-system-packages TransferQueue==0.1.8 @@ -30,7 +30,10 @@ runs: # TODO: rm --no-deps when VeOmni supports the vLLM torch pin (torch 2.13 as of vLLM 0.28) uv pip install --system --break-system-packages veomni==0.1.11 --no-deps uv pip install --system --break-system-packages torchcodec librosa soundfile av audioread - uv pip install --system --break-system-packages "transformers[mistral-common]==5.14.1" + # The released qwen-tts package targets Transformers 4.57.3. Install the + # tested upstream Transformers 5 source without changing this repo's stack. + uv pip install --system --break-system-packages --no-deps --reinstall \ + "qwen-tts @ git+https://github.com/QwenLM/Qwen3-TTS.git@$(cat .github/qwen_tts_pin.txt)" # NCCL checkpoint engine (diffusion v1 separate_async weight sync). uv pip install --system --break-system-packages pyzmq uv pip install --system --break-system-packages cupy-cuda12x || uv pip install --system --break-system-packages cupy-cuda13x diff --git a/.github/qwen_tts_pin.txt b/.github/qwen_tts_pin.txt new file mode 100644 index 000000000..c0af4ea17 --- /dev/null +++ b/.github/qwen_tts_pin.txt @@ -0,0 +1 @@ +00969daa8064e23adc9e5f52cdf20cf247f94159 diff --git a/.github/workflows/cpu_unit_tests.yml b/.github/workflows/cpu_unit_tests.yml index 4dd2b7485..8ee56e0ae 100644 --- a/.github/workflows/cpu_unit_tests.yml +++ b/.github/workflows/cpu_unit_tests.yml @@ -10,6 +10,7 @@ on: - "tests/**/*_on_cpu.py" - "pyproject.toml" - .github/workflows/cpu_unit_tests.yml + - .github/qwen_tts_pin.txt - .github/vllm_omni_pin.txt - .github/verl_pin.txt pull_request: @@ -22,6 +23,7 @@ on: - "tests/**/*_on_cpu.py" - "pyproject.toml" - .github/workflows/cpu_unit_tests.yml + - .github/qwen_tts_pin.txt - .github/vllm_omni_pin.txt - .github/verl_pin.txt @@ -56,6 +58,7 @@ jobs: cache: pip cache-dependency-path: | pyproject.toml + .github/qwen_tts_pin.txt .github/vllm_omni_pin.txt .github/verl_pin.txt - name: Install dependencies @@ -64,7 +67,9 @@ jobs: pip install "vllm-omni @ git+https://github.com/vllm-project/vllm-omni.git@$(cat .github/vllm_omni_pin.txt)" pip install TransferQueue==0.1.8 pip install --no-deps "verl @ git+https://github.com/verl-project/verl.git@$(cat .github/verl_pin.txt)" - pip install ".[dev]" + pip install ".[omni,dev]" + pip install --no-deps \ + "qwen-tts @ git+https://github.com/QwenLM/Qwen3-TTS.git@$(cat .github/qwen_tts_pin.txt)" pip install --no-deps -e . - name: Verify the documented train install resolves run: | diff --git a/.github/workflows/gpu_smoke.yml b/.github/workflows/gpu_smoke.yml index 06888ce8d..a386adeb3 100644 --- a/.github/workflows/gpu_smoke.yml +++ b/.github/workflows/gpu_smoke.yml @@ -14,6 +14,7 @@ on: - "tests/special_e2e/**" - "pyproject.toml" - .github/workflows/gpu_smoke.yml + - .github/qwen_tts_pin.txt - .github/vllm_omni_pin.txt - .github/verl_pin.txt - .github/actions/gpu-smoke-prepare/** @@ -32,6 +33,7 @@ on: - "tests/special_e2e/**" - "pyproject.toml" - .github/workflows/gpu_smoke.yml + - .github/qwen_tts_pin.txt - .github/vllm_omni_pin.txt - .github/verl_pin.txt - .github/actions/gpu-smoke-prepare/** diff --git a/README.md b/README.md index 6c944e3aa..74491be69 100644 --- a/README.md +++ b/README.md @@ -199,16 +199,20 @@ Visit our [documentation](https://verl-omni.readthedocs.io/en/latest/index.html) ✅ - Qwen3-TTS - Audio-modality - Text → Audio + Qwen3-TTS + Audio-modality + Text → Audio DPO WIP - + GSPO WIP + + GRPO + ✅ + diff --git a/docs/examples/qwen3_tts/grpo_trainer_qwen3_tts.md b/docs/examples/qwen3_tts/grpo_trainer_qwen3_tts.md new file mode 120000 index 000000000..3bffcd1a3 --- /dev/null +++ b/docs/examples/qwen3_tts/grpo_trainer_qwen3_tts.md @@ -0,0 +1 @@ +../../../examples/grpo_trainer/qwen3_tts/README.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index ebb122c13..f9f360b22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,7 @@ examples/mixgrpo_trainer.md examples/diffusionopd_trainer.md examples/flowgrpo_trainer_sd35_drm.md examples/bagel/flowgrpo_trainer_bagel.md +examples/qwen3_tts/grpo_trainer_qwen3_tts.md examples/qwen_image_edit/flowgrpo_trainer_qwen_image_edit.md examples/ltx2/flowgrpo_trainer_ltx2.md examples/minimax_h3/diffusionnft_trainer_minimax_h3.md diff --git a/docs/start/models.md b/docs/start/models.md index 277329538..7b7ba11f5 100644 --- a/docs/start/models.md +++ b/docs/start/models.md @@ -235,6 +235,21 @@ parquet pairs and does not start rollout or reward workers. --- +### Qwen3-TTS-12Hz-0.6B Base + +| Property | Detail | +|----------|--------| +| **Hugging Face ID** | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | +| **Trainable component** | Talker codec-0 policy, full-parameter example | +| **Rollout** | Two-stage vLLM-Omni Talker + code2wav pipeline | +| **Algorithm** | Stock GRPO, vanilla PPO loss, optional direct KL | +| **Reward** | Generic decoded-audio reward; SpeechJudge-BTRM external scorer example | + +The example uses two training GPUs and an independently deployed audio scorer. +See [Qwen3-TTS GRPO with an audio reward](../../examples/grpo_trainer/qwen3_tts/README.md). + +--- + ## Model Architecture Summary | Model | Architecture | Text encoder | @@ -248,6 +263,7 @@ parquet pairs and does not start rollout or reward workers. | MiniMax-H3 | MiniMax H3 transformer | H3 text encoder | | BAGEL | Unified MM | — | | Qwen3-Omni-30B | Omni MoE | Qwen3 | +| Qwen3-TTS-12Hz-0.6B | Talker + code2wav | Qwen3 | --- @@ -262,7 +278,8 @@ parquet pairs and does not start rollout or reward workers. | CLAP | `laion/larger_clap_general` | Audio | LTX-2.3 (Flow-GRPO), MiniMax-H3 (DiffusionNFT) | Local transformers load | | ImageBind | Local `.pth` | Audio + Video | LTX-2.3 (Flow-GRPO), MiniMax-H3 (DiffusionNFT) | Local ImageBind package (CC-BY-NC-SA 4.0) | | DiNa-LRM | HTTP latent scorer | Diffusion latents | SD3.5 (Flow-GRPO DRM) | Separate `diffusion-rm` process, safetensors HTTP | -| HTTP scorer | External HTTP service | Any | Any model | Gunicorn/Flask, pickle protocol | +| HTTP scorer | External HTTP service | Image/audio | Any model | Pickle image or JSON audio protocol | +| SpeechJudge-BTRM | `RMSnow/SpeechJudge-BTRM` | Audio quality | Qwen3-TTS example | External service; CC-BY-NC-4.0 | | JPEG incompressibility | Rule-based | Image stats | Any diffusion model | No model process needed | For end-to-end instructions on setting up each reward, see the respective @@ -272,18 +289,20 @@ trainer's README in `examples/`. ## Which Trainer for Which Model? -| Algorithm | Qwen-Image | Qwen-Image-Edit | SD3.5 | Wan2.2 | LTX-2.3 | MiniMax-H3 | BAGEL | Qwen3-Omni | -|-----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| Flow-GRPO | ✅ | ✅ | ✅ | — | ✅ | WIP | ✅ | — | -| Flow-DPPO | ✅ | — | — | — | — | — | — | — | -| GRPO-Guard | ✅ | — | — | — | — | — | — | — | -| Mix-GRPO | ✅ | — | — | — | — | — | — | — | -| DanceGRPO | — | — | — | ✅ | — | — | — | — | -| DPO | ✅ | — | ✅ | — | — | — | — | ✅ | -| DiffusionNFT | ✅ | — | — | — | — | ✅ | — | — | -| [DiffusionOPD](../algo/diffusion_opd.md) (incl. MOPD) | — | — | ✅ | — | — | — | — | — | -| GSPO (incl. OPD) | — | — | — | — | — | — | — | ✅ | - -HunyuanImage-3.0 (MixGRPO / SRPO) and Qwen3-TTS (DPO / GSPO) appear on the -project README as Planned or WIP and do not yet have a ready-to-run recipe, so -they are omitted from the catalogue above. +| Algorithm | Qwen-Image | Qwen-Image-Edit | SD3.5 | Wan2.2 | LTX-2.3 | MiniMax-H3 | BAGEL | Qwen3-Omni | Qwen3-TTS | +|-----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| GRPO | — | — | — | — | — | — | — | — | ✅ | +| Flow-GRPO | ✅ | ✅ | ✅ | — | ✅ | WIP | ✅ | — | — | +| Flow-DPPO | ✅ | — | — | — | — | — | — | — | — | +| GRPO-Guard | ✅ | — | — | — | — | — | — | — | — | +| Mix-GRPO | ✅ | — | — | — | — | — | — | — | — | +| DanceGRPO | — | — | — | ✅ | — | — | — | — | — | +| DPO | ✅ | — | ✅ | — | — | — | — | ✅ | WIP | +| DiffusionNFT | ✅ | — | — | — | — | ✅ | — | — | — | +| [DiffusionOPD](../algo/diffusion_opd.md) (incl. MOPD) | — | — | ✅ | — | — | — | — | — | — | +| GSPO (incl. OPD) | — | — | — | — | — | — | — | ✅ | WIP | + +HunyuanImage-3.0 (MixGRPO / SRPO) appears on the project README as Planned or +WIP and does not yet have a ready-to-run recipe, so it is omitted from the +catalogue above. Qwen3-TTS DPO and GSPO remain WIP; its ready-to-run GRPO recipe +is listed above. diff --git a/examples/grpo_trainer/qwen3_tts/README.md b/examples/grpo_trainer/qwen3_tts/README.md new file mode 100644 index 000000000..57ef3b4be --- /dev/null +++ b/examples/grpo_trainer/qwen3_tts/README.md @@ -0,0 +1,160 @@ +# Qwen3-TTS GRPO with an audio reward + +Last updated: 09/04/2026. + +This example full-parameter tunes the codec-0 policy of +`Qwen/Qwen3-TTS-12Hz-0.6B-Base`. It uses verl's stock GRPO advantage, +vanilla PPO policy loss, and optional direct reference-model KL. The other +15 codec codebooks and code2wav stage remain frozen but are retained so every +candidate can be decoded and scored as audio. + +The launcher follows the V1 omni-model integration guide: it calls +`verl_omni.trainer.main_omni` and expresses the recipe as CLI overrides on the +standard `omni_trainer` config, without a model-specific Trainer or config tree. + +SpeechJudge-BTRM is one possible pointwise scorer. SpeechJudge's published vLLM +entry point targets the pairwise generative GRM, while BTRM uses a scalar reward +head with Transformers. This example therefore keeps reward inference behind the +generic audio HTTP protocol instead of adding a SpeechJudge-specific Trainer +path. The scorer may run in a separate environment from the Transformers 5.x +vLLM training stack. + +## Algorithm background + +This recipe applies the paper's TTS GRPO flow to Qwen3-TTS: grouped codec-token +rollouts are decoded, scored, converted to group-relative advantages, and +replayed with optional reference KL. It optimizes codec-0, the autoregressive +policy sequence described by the Qwen3-TTS architecture, while retaining all 16 +codebooks for replay and waveform decoding. The HTTP scorer is configurable, so +this is not an exact reproduction of the paper's CER-and-NLL reward. See the +references below for the algorithm and multi-codebook design details. + +## Install + +Install the engine before the training stack: + +```bash +uv pip install -e ".[gpu]" --torch-backend=auto +uv pip install "vllm-omni @ git+https://github.com/vllm-project/vllm-omni.git@$(cat .github/vllm_omni_pin.txt)" +uv pip install -e ".[omni,train,dev]" +uv pip install --no-deps --reinstall \ + "qwen-tts @ git+https://github.com/QwenLM/Qwen3-TTS.git@$(cat .github/qwen_tts_pin.txt)" +``` + +The pinned Qwen3-TTS revision is the upstream Transformers 5 support change +from Qwen3-TTS PR #360. Its package metadata requires Transformers 5.15.1 or +newer, while this repository intentionally caps Transformers at 5.14.1. The +`--no-deps` flag preserves that repository-wide cap; the `omni` extra owns the +runtime dependencies, including `torchaudio==2.11.0` to match vLLM's Torch pin, +and CI tests the exact Qwen3-TTS revision from `.github/qwen_tts_pin.txt` on this +stack. The released `qwen-tts==0.1.1` source targets Transformers 4.57.3 and +cannot be imported unchanged here. The adapter registers the upstream config +and model with `AutoConfig` and `AutoModelForTextToWaveform`; it does not carry +a local Transformers compatibility layer. The system `sox` executable is also +required by qwen-tts. + +## Data + +Training and validation parquet rows use the normal verl format: + +```python +{ + "data_source": "tts", + "prompt": [{"role": "user", "content": "Text to synthesize"}], + "reward_model": {"style": "model", "ground_truth": "Text to synthesize"}, + "extra_info": {"id": "stable-id", "split": "train"}, +} +``` + +Use disjoint prompts. The default recipe evaluates the same complete 100-row +validation parquet at step 0 and every 20 updates. It uses the rollout engine's +global seed; model-specific per-request seed derivation is intentionally outside +this integration. + +The concatenated replay layout also requires one fixed speaker embedding JSON. +Generate it once with the official Qwen3-TTS Base model's +`extract_speaker_embedding` API from a 24 kHz reference recording, then reuse +the same file for the entire run. + +## Audio scorer protocol + +The configured endpoint receives one JSON request per candidate: + +```json +{ + "protocol_version": "1", + "waveform_f32_base64": "...", + "num_samples": 24000, + "sample_rate": 24000, + "prompt": "Text to synthesize", + "metadata": {"id": "stable-id"} +} +``` + +It must return `{"score": 1.25}` and may include additional scalar metrics. +The client retries only transient network, timeout, HTTP 408/429, and 5xx +failures. Missing, malformed, or non-finite results stop the run instead of +being converted to a valid zero reward. + +For SpeechJudge-BTRM, deploy the official +[`AmphionTeam/SpeechJudge`](https://github.com/AmphionTeam/SpeechJudge) code and +[`RMSnow/SpeechJudge-BTRM`](https://huggingface.co/RMSnow/SpeechJudge-BTRM) +checkpoint in a separate environment, then expose its pointwise score through +this protocol. The official [`main_grm_vllm.py`](https://github.com/AmphionTeam/SpeechJudge/blob/master/infer/main_grm_vllm.py) +runs a different, pairwise generative GRM path; the BTRM entry point is +[`main_btrm.py`](https://github.com/AmphionTeam/SpeechJudge/blob/master/infer/main_btrm.py). +Pin the SpeechJudge source revision and runtime versions in the service +deployment. SpeechJudge-BTRM is licensed CC-BY-NC-4.0. + +## Train + +```bash +MODEL_PATH=/path/to/Qwen3-TTS-12Hz-0.6B-Base \ +TRAIN_FILE=/path/to/train.parquet \ +VAL_FILE=/path/to/fixed-validation-100.parquet \ +SPK_EMBED_PATH=/path/to/speaker.json \ +SCORER_URL=http://scorer-host:18080/score \ +OUTPUT_DIR=/path/to/output \ +bash examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh +``` + +The example defaults are `B=4`, `G=8`, `lr=1e-6` with 10 warmup steps and a +constant schedule, direct `low_var_kl` with coefficient `0.12`, two GPUs, and +500 updates. These are recipe values, not algorithm requirements. +`norm_adv_by_std_in_grpo` remains at the upstream default. The actor and +reference keep persistent parameters in FP32, while FSDP uses BF16 parameters +for forward and backward computation with FP32 gradient reduction and buffers. +The actor's AdamW state therefore remains FP32, and rollout inference remains +BF16. + +For a two-update implementation smoke test: + +```bash +TOTAL_TRAINING_STEPS=2 TEST_FREQ=-1 SAVE_FREQ=-1 RESUME_MODE=disable \ +OUTPUT_DIR=outputs/qwen3_tts_grpo_smoke \ +bash examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh \ + trainer.val_before_train=false trainer.log_val_generations=0 +``` + +This smoke proves rollout, finite audio reward, optimizer update, and +post-update weight sync only. It is not evidence that GRPO improves held-out +speech quality; that requires the complete fixed-validation curve and paired +human listening evaluation. + +The CI-oriented wrapper at +[`tests/special_e2e/run_qwen3_tts_grpo_smoke.sh`](../../../tests/special_e2e/run_qwen3_tts_grpo_smoke.sh) +creates deterministic fixtures, uses an in-process CPU duration reward, and runs +two updates with a pinned tiny-random checkpoint rebuilt for the 16-codebook +actor contract. + +## References + +- Chang Liu, Ya-Jun Hu, Ying-Ying Gao, Shi-Lei Zhang, and Zhen-Hua Ling. + [Group Relative Policy Optimization for Text-to-Speech with Large Language + Models](https://arxiv.org/abs/2509.18798), 2025. +- Hangrui Hu et al. [Qwen3-TTS Technical + Report](https://arxiv.org/abs/2601.15621), 2026. +- QwenLM. [Qwen3-TTS PR #360: Support Transformers + 5](https://github.com/QwenLM/Qwen3-TTS/pull/360), 2026. +- Dong Zhang et al. [SpeechAlign: Aligning Speech Generation to Human + Preferences](https://arxiv.org/abs/2404.05600), 2024. diff --git a/examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh b/examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh new file mode 100755 index 000000000..1a42a6b1d --- /dev/null +++ b/examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# Licensed under the Apache License, Version 2.0 + +set -euo pipefail + +MODEL_PATH="${MODEL_PATH:?Set MODEL_PATH to a local Qwen3-TTS Base directory}" +TRAIN_FILE="${TRAIN_FILE:?Set TRAIN_FILE to the training parquet}" +VAL_FILE="${VAL_FILE:?Set VAL_FILE to the fixed validation parquet}" +SPK_EMBED_PATH="${SPK_EMBED_PATH:?Set SPK_EMBED_PATH to a speaker embedding JSON file}" +SCORER_URL="${SCORER_URL:?Set SCORER_URL to an audio scorer /score endpoint}" +OUTPUT_DIR="${OUTPUT_DIR:-outputs/qwen3_tts_grpo}" +NUM_GPUS="${NUM_GPUS:-2}" +SEED="${SEED:-42}" +TOTAL_TRAINING_STEPS="${TOTAL_TRAINING_STEPS:-500}" +TEST_FREQ="${TEST_FREQ:-20}" +SAVE_FREQ="${SAVE_FREQ:-20}" +RESUME_MODE="${RESUME_MODE:-auto}" +PYTHON_BIN="${PYTHON_BIN:-python3}" +for path in "${MODEL_PATH}" "${TRAIN_FILE}" "${VAL_FILE}" "${SPK_EMBED_PATH}"; do + [[ -e "${path}" ]] || { printf 'Missing required path: %s\n' "${path}" >&2; exit 2; } +done + +mkdir -p "${OUTPUT_DIR}" +export PYTHONHASHSEED="${SEED}" +export TOKENIZERS_PARALLELISM=false +export VERL_USE_EXTERNAL_MODULES=verl_omni +export VLLM_USE_FLASHINFER_SAMPLER=0 + +"${PYTHON_BIN}" -m verl_omni.trainer.main_omni \ + "data.train_files=${TRAIN_FILE}" \ + "data.val_files=${VAL_FILE}" \ + "data.seed=${SEED}" \ + data.train_batch_size=4 \ + data.dataloader_num_workers=0 \ + data.max_prompt_length=512 \ + data.max_response_length=360 \ + data.val_max_samples=100 \ + data.validation_shuffle=false \ + data.filter_overlong_prompts=true \ + data.truncation=error \ + "actor_rollout_ref.model.path=${MODEL_PATH}" \ + actor_rollout_ref.model.model_stage=talker \ + actor_rollout_ref.model.hf_config_name=talker_config \ + actor_rollout_ref.model.trust_remote_code=false \ + actor_rollout_ref.model.use_remove_padding=false \ + actor_rollout_ref.model.enable_gradient_checkpointing=true \ + +actor_rollout_ref.model.override_config.attn_implementation=eager \ + +actor_rollout_ref.model.override_config.tts_language=Auto \ + "+actor_rollout_ref.model.override_config.tts_spk_embed_path=${SPK_EMBED_PATH}" \ + actor_rollout_ref.actor.strategy=fsdp \ + actor_rollout_ref.actor.ppo_mini_batch_size=4 \ + actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=1 \ + actor_rollout_ref.actor.use_dynamic_bsz=false \ + actor_rollout_ref.actor.use_kl_loss=true \ + actor_rollout_ref.actor.kl_loss_coef=0.12 \ + actor_rollout_ref.actor.kl_loss_type=low_var_kl \ + actor_rollout_ref.actor.ppo_epochs=1 \ + actor_rollout_ref.actor.shuffle=false \ + actor_rollout_ref.actor.clip_ratio_low=0.2 \ + actor_rollout_ref.actor.clip_ratio_high=0.2 \ + actor_rollout_ref.actor.entropy_coeff=0.0 \ + actor_rollout_ref.actor.loss_agg_mode=seq-mean-token-mean \ + actor_rollout_ref.actor.use_torch_compile=false \ + actor_rollout_ref.actor.policy_loss.loss_mode=vanilla \ + actor_rollout_ref.actor.optim.lr=1.0e-6 \ + actor_rollout_ref.actor.optim.lr_warmup_steps=10 \ + actor_rollout_ref.actor.optim.lr_scheduler_type=constant \ + actor_rollout_ref.actor.optim.weight_decay=0.0 \ + actor_rollout_ref.actor.optim.clip_grad=1.0 \ + "actor_rollout_ref.actor.data_loader_seed=${SEED}" \ + "actor_rollout_ref.actor.fsdp_config.fsdp_size=${NUM_GPUS}" \ + "actor_rollout_ref.actor.fsdp_config.seed=${SEED}" \ + actor_rollout_ref.actor.fsdp_config.model_dtype=float32 \ + actor_rollout_ref.actor.fsdp_config.dtype=bfloat16 \ + '+actor_rollout_ref.actor.fsdp_config.mixed_precision={param_dtype:bf16,reduce_dtype:fp32,buffer_dtype:fp32}' \ + actor_rollout_ref.actor.fsdp_config.param_offload=false \ + actor_rollout_ref.actor.fsdp_config.optimizer_offload=false \ + actor_rollout_ref.actor.fsdp_config.use_orig_params=true \ + actor_rollout_ref.actor.fsdp_config.use_torch_compile=false \ + actor_rollout_ref.actor.fsdp_config.wrap_policy.min_num_params=0 \ + actor_rollout_ref.rollout.name=vllm_omni \ + actor_rollout_ref.rollout.mode=async \ + actor_rollout_ref.rollout.n=8 \ + actor_rollout_ref.rollout.temperature=1.0 \ + actor_rollout_ref.rollout.top_p=1.0 \ + actor_rollout_ref.rollout.top_k=-1 \ + actor_rollout_ref.rollout.dtype=bfloat16 \ + actor_rollout_ref.rollout.tensor_model_parallel_size=1 \ + actor_rollout_ref.rollout.gpu_memory_utilization=0.20 \ + actor_rollout_ref.rollout.max_num_seqs=8 \ + actor_rollout_ref.rollout.max_num_batched_tokens=1024 \ + actor_rollout_ref.rollout.free_cache_engine=false \ + actor_rollout_ref.rollout.calculate_log_probs=true \ + actor_rollout_ref.rollout.logprobs_mode=processed_logprobs \ + actor_rollout_ref.rollout.load_format=safetensors \ + actor_rollout_ref.rollout.layered_summon=true \ + actor_rollout_ref.rollout.enable_prefix_caching=false \ + actor_rollout_ref.rollout.enforce_eager=true \ + actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=1 \ + actor_rollout_ref.rollout.agent.num_workers=8 \ + actor_rollout_ref.rollout.agent.default_agent_loop=omni_single_turn_agent \ + "+actor_rollout_ref.rollout.engine_kwargs.vllm_omni.seed=${SEED}" \ + +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.output_mode=ar \ + +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_name=qwen3_tts_rl \ + +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.pipeline_mode=full \ + +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.async_chunk=false \ + +actor_rollout_ref.rollout.engine_kwargs.vllm_omni.attention_config.backend=TRITON_ATTN \ + actor_rollout_ref.rollout.val_kwargs.n=1 \ + actor_rollout_ref.rollout.val_kwargs.do_sample=true \ + actor_rollout_ref.rollout.val_kwargs.temperature=1.0 \ + actor_rollout_ref.rollout.val_kwargs.top_p=1.0 \ + actor_rollout_ref.rollout.val_kwargs.top_k=-1 \ + actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=1 \ + actor_rollout_ref.ref.strategy=fsdp \ + "actor_rollout_ref.ref.fsdp_config.fsdp_size=${NUM_GPUS}" \ + "actor_rollout_ref.ref.fsdp_config.seed=${SEED}" \ + actor_rollout_ref.ref.fsdp_config.model_dtype=float32 \ + actor_rollout_ref.ref.fsdp_config.dtype=bfloat16 \ + '+actor_rollout_ref.ref.fsdp_config.mixed_precision={param_dtype:bf16,reduce_dtype:fp32,buffer_dtype:fp32}' \ + actor_rollout_ref.ref.fsdp_config.param_offload=false \ + actor_rollout_ref.ref.fsdp_config.use_orig_params=true \ + actor_rollout_ref.ref.fsdp_config.wrap_policy.min_num_params=0 \ + algorithm.adv_estimator=grpo \ + algorithm.norm_adv_by_std_in_grpo=true \ + algorithm.use_kl_in_reward=false \ + reward.num_workers=1 \ + reward.custom_reward_function.path=pkg://verl_omni.utils.reward_score.audio_http_scorer_client \ + reward.custom_reward_function.name=compute_score \ + "+reward.custom_reward_function.reward_kwargs.server_url=${SCORER_URL}" \ + +reward.custom_reward_function.reward_kwargs.timeout=120.0 \ + +reward.custom_reward_function.reward_kwargs.max_retries=2 \ + +reward.custom_reward_function.reward_kwargs.retry_backoff=0.5 \ + reward.reward_manager.source=importlib \ + reward.reward_manager.name=AudioRewardManager \ + reward.reward_manager.module.path=pkg://verl_omni.reward_loop.reward_manager \ + trainer.val_before_train=true \ + trainer.balance_batch=true \ + trainer.critic_warmup=0 \ + trainer.logger='["console"]' \ + trainer.project_name=qwen3_tts_grpo \ + trainer.experiment_name=qwen3_tts_0_6b_audio_reward \ + "trainer.n_gpus_per_node=${NUM_GPUS}" \ + trainer.nnodes=1 \ + trainer.total_epochs=100 \ + trainer.log_val_generations=100 \ + "trainer.total_training_steps=${TOTAL_TRAINING_STEPS}" \ + "trainer.test_freq=${TEST_FREQ}" \ + "trainer.save_freq=${SAVE_FREQ}" \ + "trainer.resume_mode=${RESUME_MODE}" \ + trainer.max_actor_ckpt_to_keep=26 \ + "trainer.default_local_dir=${OUTPUT_DIR}/checkpoints" \ + "trainer.validation_data_dir=${OUTPUT_DIR}/validation" \ + "trainer.rollout_data_dir=${OUTPUT_DIR}/rollout" \ + "$@" 2>&1 | tee "${OUTPUT_DIR}/train.log" diff --git a/pyproject.toml b/pyproject.toml index 39af3a4f1..08fbe060f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,16 @@ audio = [ "qwen-omni-utils>=0.0.9", "audioread", ] +# Install qwen-tts separately from the pinned upstream Transformers 5 support +# revision. The released 0.1.1 source and metadata target Transformers 4.57.3. +omni = [ + "einops>=0.8.0", + "librosa>=0.10.2", + "onnxruntime>=1.20.0", + "soundfile>=0.12.1", + "sox>=1.5.0", + "torchaudio==2.11.0", +] # CUDA rollout backend (vllm) + actor FA3 (kernels) + liger-kernel. Install in step 1 on GPU. gpu = [ "vllm==0.28.0", diff --git a/tests/gpu_smoke/run_gpu_smoke_omni_e2e.sh b/tests/gpu_smoke/run_gpu_smoke_omni_e2e.sh index 979213742..dd8d5431d 100644 --- a/tests/gpu_smoke/run_gpu_smoke_omni_e2e.sh +++ b/tests/gpu_smoke/run_gpu_smoke_omni_e2e.sh @@ -21,4 +21,8 @@ run_test 1 "Qwen3-Omni multimodal offline MLLM DPO LoRA e2e" \ env CUDA_VISIBLE_DEVICES="${CUDA_DEVICE_LIST}" NUM_GPUS=2 \ bash tests/special_e2e/run_qwen3_omni_multimodal_offline_mllm_dpo_lora_smoke.sh "${omni_trainer_args[@]}" +run_test 2 "Qwen3-TTS Talker full-parameter GRPO e2e" \ + env CUDA_VISIBLE_DEVICES="${CUDA_DEVICE_LIST}" NUM_GPUS=2 \ + bash tests/special_e2e/run_qwen3_tts_grpo_smoke.sh "${omni_trainer_args[@]}" + gpu_smoke_summary diff --git a/tests/gpu_smoke/select_gpu_smoke_groups.py b/tests/gpu_smoke/select_gpu_smoke_groups.py index 17c980338..0e5de914f 100644 --- a/tests/gpu_smoke/select_gpu_smoke_groups.py +++ b/tests/gpu_smoke/select_gpu_smoke_groups.py @@ -66,9 +66,14 @@ class SmokeGroup: "verl_omni/workers/**", ), "ci-e2e-omni": ( + ".github/qwen_tts_pin.txt", + "examples/grpo_trainer/qwen3_tts/**", "tests/gpu_smoke/run_gpu_smoke_omni_e2e.sh", + "tests/pipelines/test_qwen3_tts*", "tests/special_e2e/*omni*", + "tests/special_e2e/*qwen3_tts*", "verl_omni/models/transformers/qwen3_omni_thinker.py", + "verl_omni/pipelines/qwen3_tts/**", "verl_omni/trainer/config/omni/**", "verl_omni/trainer/omni/**", ), diff --git a/tests/pipelines/test_qwen3_tts_on_cpu.py b/tests/pipelines/test_qwen3_tts_on_cpu.py new file mode 100644 index 000000000..fc00b3488 --- /dev/null +++ b/tests/pipelines/test_qwen3_tts_on_cpu.py @@ -0,0 +1,167 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Dependency-light Qwen3-TTS actor and rollout contract tests.""" + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + + +def _load(name: str, relative_path: str): + spec = importlib.util.spec_from_file_location(name, Path(__file__).parents[2] / relative_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +forward = _load("qwen3_tts_forward_test", "verl_omni/pipelines/qwen3_tts/talker_forward.py") +rollout = _load("qwen3_tts_rollout_test", "verl_omni/pipelines/qwen3_tts/rollout_utils.py") + +TOKENS = forward.TalkerTokens(900, 901, 902, 4196, 4197, 4198, 4203, 4204, 4205) + + +def test_talker_batch_matches_auto_language_teacher_forcing_layout(): + text_ids = torch.tensor([1, 2, 3, 4, 5, 6]) + codes = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) % 128 + codes[:, 0] = torch.tensor([10, 11, 12]) + + batch = forward.build_talker_batch([text_ids], [codes], TOKENS, sub_codebook_vocab=2048) + + speaker_slot = 6 + codec_start = 8 + text_ids.numel() - 1 + assert batch.input_ids[0, 3:speaker_slot, 1].tolist() == [4203, 4204, 4205] + assert not batch.codec_embedding_mask[0, speaker_slot] + assert batch.input_ids[0, speaker_slot + 1, 1] == TOKENS.codec_pad + torch.testing.assert_close(batch.codec_ids[0, codec_start : codec_start + 3], codes) + assert batch.logit_start == [codec_start - 1] + assert batch.codec_lens == [3] + + +def test_codec0_mask_matches_rollout_vocabulary(): + masked = forward.mask_codec0_logits(torch.zeros((1, 2, 4300)), 2048, TOKENS.codec_eos) + + assert (masked[..., 0] < -1e3).all() + assert torch.isfinite(masked[..., 1:2048]).all() + assert (masked[..., 2048 : TOKENS.codec_eos] < -1e3).all() + assert torch.isfinite(masked[..., TOKENS.codec_eos]).all() + assert (masked[..., TOKENS.codec_eos + 1 :] < -1e3).all() + + +def test_only_validated_auto_language_layout_is_accepted(): + assert forward.require_auto_language("auto") == "Auto" + with pytest.raises(ValueError, match="supports only tts_language=Auto"): + forward.require_auto_language("Chinese") + with pytest.raises(ValueError, match="supports only tts_language=Auto"): + forward.require_auto_language(None) + + +def test_actor_logits_align_to_effective_codec0_response(monkeypatch): + class CodePredictor: + @staticmethod + def get_input_embeddings(): + return [torch.nn.Embedding(2048, 4)] + + talker = SimpleNamespace(code_predictor=CodePredictor()) + model = SimpleNamespace( + talker=talker, + config=SimpleNamespace( + tts_pad_token_id=TOKENS.tts_pad, + tts_bos_token_id=TOKENS.tts_bos, + tts_eos_token_id=TOKENS.tts_eos, + talker_config=SimpleNamespace( + codec_pad_id=TOKENS.codec_pad, + codec_bos_id=TOKENS.codec_bos, + codec_eos_token_id=TOKENS.codec_eos, + codec_nothink_id=TOKENS.codec_nothink, + codec_think_bos_id=TOKENS.codec_think_bos, + codec_think_eos_id=TOKENS.codec_think_eos, + ), + ), + ) + + def fake_logits(_talker, batch, _speaker): + vocab = torch.arange(1, 4301, dtype=torch.float32).reshape(1, 1, -1) + return vocab.expand(1, batch.input_ids.shape[1] - 1, -1) + + monkeypatch.setattr(forward, "codec0_logits", fake_logits) + input_ids = torch.zeros((1, 9), dtype=torch.long) + input_ids[0, -3:] = torch.tensor([10, 11, 12]) + codes = torch.zeros((1, 6, 16), dtype=torch.long) + codes[0, :3, 0] = torch.tensor([10, 11, 12]) + + logits = forward.tts_actor_logits( + model, + input_ids, + torch.ones_like(input_ids), + torch.tensor([[1, 2, 3, 4, 5, 6]]), + codes, + torch.tensor([3]), + torch.tensor([6]), + torch.zeros((1, 4)), + ) + + assert torch.nonzero(logits.abs().sum(dim=-1)[0], as_tuple=False).reshape(-1).tolist() == [5, 6, 7] + assert logits[0, 5, 0] == -1e4 + assert logits[0, 5, 1] == 2 + assert logits[0, 5, 2047] == 2048 + assert logits[0, 5, 2048] == -1e4 + assert logits[0, 5, TOKENS.codec_eos] == TOKENS.codec_eos + 1 + + +@pytest.mark.parametrize("response_length", [2, 14, 15, 16, 17, 32]) +def test_codec_alignment_recovers_exact_prefix_without_final_residual_row(response_length): + token_ids = list(range(100, 100 + response_length - 1)) + [2150] + generated = torch.arange((response_length - 1) * 16, dtype=torch.long).reshape(response_length - 1, 16) + 1 + generated[:, 0] = torch.tensor(token_ids[:-1]) + raw = torch.cat((torch.zeros(12, 16, dtype=torch.long), generated)) + + aligned = rollout.align_audio_codes(raw, token_ids) + + assert aligned[:, 0].tolist() == token_ids + torch.testing.assert_close(aligned[:-1, 1:], generated[:, 1:]) + assert not aligned[-1, 1:].any() + + +def test_codec_alignment_preserves_final_row_and_rejects_heuristic_match(): + token_ids = [101, 102, 103, 2150] + generated = torch.arange(4 * 16, dtype=torch.long).reshape(4, 16) + 1 + generated[:, 0] = torch.tensor(token_ids) + aligned = rollout.align_audio_codes(torch.cat((torch.zeros(12, 16, dtype=torch.long), generated)), token_ids) + torch.testing.assert_close(aligned, generated) + + malformed = torch.zeros(15, 16, dtype=torch.long) + malformed[12:, 0] = torch.tensor([101, 999, 103]) + with pytest.raises(RuntimeError, match="Could not exactly align"): + rollout.align_audio_codes(malformed, token_ids) + + +def test_codec_alignment_rejects_ambiguous_exact_matches(): + raw = torch.zeros(2, 16, dtype=torch.long) + raw[:, 0] = 101 + + with pytest.raises(RuntimeError, match="Ambiguous Qwen3-TTS codec alignment"): + rollout.align_audio_codes(raw, [101, 2150]) + + +def test_talker_batch_and_logit_mask_reject_contract_mismatches(): + with pytest.raises(ValueError, match="matching non-empty"): + forward.build_talker_batch([], [], TOKENS, sub_codebook_vocab=2048) + with pytest.raises(ValueError, match="codebook_vocab"): + forward.mask_codec0_logits(torch.zeros((1, 2, 100)), 101, TOKENS.codec_eos) diff --git a/tests/pipelines/test_qwen3_tts_package_on_cpu.py b/tests/pipelines/test_qwen3_tts_package_on_cpu.py new file mode 100644 index 000000000..a6c6fa069 --- /dev/null +++ b/tests/pipelines/test_qwen3_tts_package_on_cpu.py @@ -0,0 +1,185 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Check the pinned upstream qwen-tts TF5 source on the repository stack.""" + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + + +def _load_smoke_helper(filename: str): + path = Path(__file__).parents[1] / f"special_e2e/{filename}.py" + spec = importlib.util.spec_from_file_location(f"{filename}_under_test", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_smoke_speaker_dimension_matches_model_config(tmp_path): + builder = _load_smoke_helper("create_dummy_qwen3_tts_grpo_data") + model_config_path = tmp_path / "config.json" + model_config_path.write_text( + json.dumps( + { + "talker_config": {"hidden_size": 128}, + "speaker_encoder_config": {"enc_dim": 128}, + } + ), + encoding="utf-8", + ) + + assert builder._speaker_dimension(model_config_path) == 128 + + model_config_path.write_text( + json.dumps( + { + "talker_config": {"hidden_size": 128}, + "speaker_encoder_config": {"enc_dim": 1024}, + } + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="speaker encoder output must match"): + builder._speaker_dimension(model_config_path) + + +def test_smoke_tiny_model_mrope_section_matches_head_dimension(): + builder = _load_smoke_helper("build_qwen3_tts_tiny_random") + + section = builder._scaled_mrope_section([24, 20, 20], head_dim=64) + + assert section == [12, 10, 10] + assert sum(section) == 64 // 2 + + +def test_qwen_tts_registers_and_runs_without_a_transformers_compatibility_layer(tmp_path, monkeypatch): + transformers = pytest.importorskip("transformers") + if importlib.util.find_spec("qwen_tts") is None: + pytest.skip("qwen-tts is an optional dependency") + + from qwen_tts.core.models.configuration_qwen3_tts import Qwen3TTSConfig + from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration + from transformers import AutoConfig, AutoModelForTextToWaveform + + assert int(transformers.__version__.split(".", maxsplit=1)[0]) >= 5 + AutoConfig.register("qwen3_tts", Qwen3TTSConfig, exist_ok=True) + AutoModelForTextToWaveform.register( + Qwen3TTSConfig, + Qwen3TTSForConditionalGeneration, + exist_ok=True, + ) + assert AutoConfig.for_model("qwen3_tts").__class__ is Qwen3TTSConfig + assert AutoModelForTextToWaveform._model_mapping[Qwen3TTSConfig] is Qwen3TTSForConditionalGeneration + + predictor = { + "vocab_size": 32, + "hidden_size": 8, + "intermediate_size": 16, + "num_hidden_layers": 1, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "head_dim": 4, + "max_position_embeddings": 64, + "num_code_groups": 16, + "layer_types": ["full_attention"], + "pad_token_id": None, + } + talker = { + "code_predictor_config": predictor, + "vocab_size": 64, + "hidden_size": 8, + "intermediate_size": 16, + "num_hidden_layers": 1, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "max_position_embeddings": 64, + "num_code_groups": 16, + "text_hidden_size": 8, + "text_vocab_size": 80, + "codec_eos_token_id": 50, + "codec_nothink_id": 51, + "codec_think_bos_id": 52, + "codec_think_eos_id": 53, + "codec_pad_id": 54, + "codec_bos_id": 55, + "spk_id": {}, + "codec_language_id": {}, + "rope_scaling": { + "rope_type": "default", + "type": "default", + "mrope_section": [1, 1, 0], + "interleaved": True, + }, + } + config = Qwen3TTSConfig( + talker_config=talker, + speaker_encoder_config={}, + tts_model_type="custom", + tokenizer_type="12hz", + tts_pad_token_id=60, + tts_bos_token_id=61, + tts_eos_token_id=62, + ) + model = Qwen3TTSForConditionalGeneration(config) + output = model.talker( + inputs_embeds=torch.randn(2, 5, 8), + attention_mask=torch.ones(2, 5, dtype=torch.long), + use_cache=False, + output_hidden_states=False, + ) + + assert output.logits.shape == (2, 5, 64) + + import vllm_omni.platforms as platforms + from vllm_omni.platforms.interface import UnspecifiedOmniPlatform + + monkeypatch.setattr(platforms, "_current_omni_platform", UnspecifiedOmniPlatform()) + from verl_omni.pipelines.qwen3_tts.talker_forward import ( + TalkerTokens, + build_talker_batch, + codec0_input_embeddings, + ) + + codes = torch.randint(1, 31, (3, 16), dtype=torch.long) + batch = build_talker_batch( + [torch.tensor([1, 2, 3])], + [codes], + TalkerTokens.from_config(model.config), + sub_codebook_vocab=32, + ) + embeddings = codec0_input_embeddings(model.talker, batch, torch.zeros(1, 8)) + assert embeddings.shape == (*batch.input_ids.shape[:2], 8) + assert torch.isfinite(embeddings).all() + + from verl_omni.pipelines.qwen3_tts.talker_training_adapter import Qwen3TTSTalkerAdapter + + speaker_path = tmp_path / "speaker.json" + speaker_path.write_text(json.dumps([0.0] * 8), encoding="utf-8") + configured = Qwen3TTSTalkerAdapter.configure_model( + model, + SimpleNamespace( + use_remove_padding=False, + override_config={"tts_spk_embed_path": str(speaker_path), "tts_language": "Auto"}, + ), + ) + trainable_names = {name for name, parameter in configured.named_parameters() if parameter.requires_grad} + assert trainable_names + assert all(name.startswith(("talker.model.", "talker.codec_head.")) for name in trainable_names) + assert any(not parameter.requires_grad for parameter in configured.parameters()) + assert configured.get_input_embeddings() is configured.talker.model.codec_embedding diff --git a/tests/special_e2e/build_qwen3_tts_tiny_random.py b/tests/special_e2e/build_qwen3_tts_tiny_random.py new file mode 100644 index 000000000..74c3eddd7 --- /dev/null +++ b/tests/special_e2e/build_qwen3_tts_tiny_random.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Build a tiny random 16-codebook Qwen3-TTS checkpoint for GPU smoke tests.""" + +import argparse +import json +import shutil +from pathlib import Path + +import torch +from safetensors.torch import save_file + + +def _copy_files(source: Path, destination: Path, names: tuple[str, ...]) -> None: + for name in names: + source_file = source / name + if source_file.exists(): + shutil.copy2(source_file, destination / name) + + +def _save_model(model, output_dir: Path) -> None: + state_dict = {name: tensor.detach().cpu().contiguous().clone() for name, tensor in model.state_dict().items()} + save_file(state_dict, output_dir / "model.safetensors", metadata={"format": "pt"}) + + +def _write_config(source: Path, destination: Path, updates) -> None: + config = json.loads(source.read_text(encoding="utf-8")) + updates(config) + destination.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + +def _scaled_mrope_section(section: list[int], head_dim: int) -> list[int]: + target = head_dim // 2 + total = sum(section) + scaled = [value * target // total for value in section] + scaled[-1] += target - sum(scaled) + return scaled + + +def _set_model_codebooks(config: dict) -> None: + talker_config = config["talker_config"] + talker_config["num_code_groups"] = 16 + talker_config["code_predictor_config"]["num_code_groups"] = 16 + rope_scaling = talker_config["rope_scaling"] + rope_scaling["mrope_section"] = _scaled_mrope_section(rope_scaling["mrope_section"], talker_config["head_dim"]) + + +def _set_tokenizer_quantizers(config: dict) -> None: + config["dtype"] = "bfloat16" + config["encoder_valid_num_quantizers"] = 16 + config["encoder_config"]["num_quantizers"] = 16 + config["decoder_config"]["num_quantizers"] = 16 + + +def build(source_model_path: Path, output_dir: Path, seed: int = 42) -> Path: + from qwen_tts.core.models.configuration_qwen3_tts import Qwen3TTSConfig + from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration + from qwen_tts.core.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Config + from qwen_tts.core.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Model + + torch.manual_seed(seed) + output_dir.mkdir(parents=True, exist_ok=True) + + model_config = Qwen3TTSConfig.from_pretrained(source_model_path) + model_config.talker_config.num_code_groups = 16 + model_config.talker_config.code_predictor_config.num_code_groups = 16 + rope_scaling = model_config.talker_config.rope_scaling + rope_scaling["mrope_section"] = _scaled_mrope_section( + rope_scaling["mrope_section"], model_config.talker_config.head_dim + ) + model = Qwen3TTSForConditionalGeneration(model_config).to(torch.bfloat16) + _save_model(model, output_dir) + _write_config( + source_model_path / "config.json", + output_dir / "config.json", + _set_model_codebooks, + ) + _copy_files( + source_model_path, + output_dir, + ("generation_config.json", "merges.txt", "preprocessor_config.json", "tokenizer_config.json", "vocab.json"), + ) + + tokenizer_source = source_model_path / "speech_tokenizer" + tokenizer_output = output_dir / "speech_tokenizer" + tokenizer_output.mkdir(exist_ok=True) + tokenizer_config = Qwen3TTSTokenizerV2Config.from_pretrained(tokenizer_source) + tokenizer_config.encoder_valid_num_quantizers = 16 + tokenizer_config.encoder_config.num_quantizers = 16 + tokenizer_config.decoder_config.num_quantizers = 16 + tokenizer = Qwen3TTSTokenizerV2Model(tokenizer_config).to(torch.bfloat16) + _save_model(tokenizer, tokenizer_output) + _write_config( + tokenizer_source / "config.json", + tokenizer_output / "config.json", + _set_tokenizer_quantizers, + ) + _copy_files(tokenizer_source, tokenizer_output, ("configuration.json", "preprocessor_config.json")) + return output_dir + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--source-model-path", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + print(build(args.source_model_path, args.output_dir, args.seed)) + + +if __name__ == "__main__": + main() diff --git a/tests/special_e2e/create_dummy_qwen3_tts_grpo_data.py b/tests/special_e2e/create_dummy_qwen3_tts_grpo_data.py new file mode 100644 index 000000000..91d1a8a07 --- /dev/null +++ b/tests/special_e2e/create_dummy_qwen3_tts_grpo_data.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Create deterministic Qwen3-TTS GRPO smoke data and speaker conditioning.""" + +import argparse +import json +import math +from pathlib import Path + +import pandas as pd + + +def _speaker_dimension(model_config_path: Path) -> int: + model_config = json.loads(model_config_path.read_text(encoding="utf-8")) + talker_dimension = model_config["talker_config"]["hidden_size"] + speaker_dimension = model_config["speaker_encoder_config"]["enc_dim"] + if talker_dimension != speaker_dimension: + raise ValueError( + f"speaker encoder output must match the talker hidden size: {speaker_dimension} != {talker_dimension}" + ) + if not isinstance(speaker_dimension, int) or speaker_dimension <= 0: + raise ValueError(f"speaker dimension must be a positive integer, got {speaker_dimension!r}") + return speaker_dimension + + +def _row(text: str, sample_id: str, split: str) -> dict: + extra_info = {"id": sample_id, "split": split} + return { + "data_source": "tts", + "prompt": [{"role": "user", "content": text}], + "reward_model": {"style": "model", "ground_truth": text}, + "extra_info": extra_info, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model-config", type=Path, required=True) + args = parser.parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + train_texts = ( + "Please read this sentence at a calm and steady pace.", + "A short speech sample checks the complete training path.", + "Clear pronunciation makes this audio easy to inspect.", + "The weather is pleasant and the morning train is on time.", + "Four simple prompts are enough for one smoke-test batch.", + "This second batch verifies another optimizer update.", + "Generated speech is decoded before the reward is computed.", + "The final checkpoint confirms that training completed.", + ) + validation_texts = ( + "This is fixed validation sample one.", + "This is fixed validation sample two.", + "This is fixed validation sample three.", + "This is fixed validation sample four.", + ) + train_rows = [_row(text, f"train-{index}", "train") for index, text in enumerate(train_texts)] + validation_rows = [_row(text, f"validation-{index}", "validation") for index, text in enumerate(validation_texts)] + pd.DataFrame(train_rows).to_parquet(args.output_dir / "train.parquet", index=False) + pd.DataFrame(validation_rows).to_parquet(args.output_dir / "validation.parquet", index=False) + + speaker_dimension = _speaker_dimension(args.model_config) + speaker = [1.0 / math.sqrt(speaker_dimension)] * speaker_dimension + (args.output_dir / "speaker.json").write_text(json.dumps(speaker), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/special_e2e/qwen3_tts_dummy_reward.py b/tests/special_e2e/qwen3_tts_dummy_reward.py new file mode 100644 index 000000000..3cda6b744 --- /dev/null +++ b/tests/special_e2e/qwen3_tts_dummy_reward.py @@ -0,0 +1,23 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""CPU-only reward used by the Qwen3-TTS execution smoke test.""" + +import numpy as np + + +def compute_score(solution_audio, **kwargs): + del kwargs + waveform, sample_rate = solution_audio + duration_s = np.asarray(waveform).size / sample_rate + return {"score": float(duration_s), "duration_s": float(duration_s)} diff --git a/tests/special_e2e/run_qwen3_tts_grpo_smoke.sh b/tests/special_e2e/run_qwen3_tts_grpo_smoke.sh new file mode 100755 index 000000000..80fa691b0 --- /dev/null +++ b/tests/special_e2e/run_qwen3_tts_grpo_smoke.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Qwen3-TTS full-parameter GRPO e2e smoke: tiny model, two updates. +# This validates execution only; the in-process CPU reward is not a quality reward. + +set -xeuo pipefail + +export NCCL_IB_DISABLE=1 +export CPATH=/usr/include${CPATH:+:${CPATH}} +export RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${REPO_ROOT}" + +NUM_GPUS="${NUM_GPUS:-2}" +[[ "${NUM_GPUS}" =~ ^[0-9]+$ && "${NUM_GPUS}" -ge 2 ]] || { + echo "Qwen3-TTS smoke requires at least two GPUs" >&2 + exit 2 +} +PYTHON_BIN="${PYTHON_BIN:-python3}" +MODEL_REPO="${MODEL_REPO:-optimum-intel-internal-testing/tiny-random-qwen3-tts}" +MODEL_REVISION="${MODEL_REVISION:-6374d605b31381cac6f9577f5e742af2f76ba79c}" +WORK_DIR="${WORK_DIR:-${TMPDIR:-/tmp}/qwen3_tts_grpo_smoke_${USER:-user}_$$}" +DATA_DIR="${WORK_DIR}/data" +OUTPUT_DIR="${OUTPUT_DIR:-${WORK_DIR}/output}" +mkdir -p "${WORK_DIR}" "${OUTPUT_DIR}" + +"${PYTHON_BIN}" -c \ + 'from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration; import onnxruntime, soundfile, librosa, sox' \ + || { echo "Qwen3-TTS smoke dependencies must be installed by gpu-smoke-prepare" >&2; exit 2; } + +MODEL_PATH="${MODEL_PATH:-}" +if [[ -z "${MODEL_PATH}" ]]; then + SOURCE_MODEL_PATH="$("${PYTHON_BIN}" -c \ + 'import sys; from huggingface_hub import snapshot_download; print(snapshot_download(sys.argv[1], revision=sys.argv[2]))' \ + "${MODEL_REPO}" "${MODEL_REVISION}")" + # The published tiny checkpoint has four codebooks; the production actor + # contract has 16, so rebuild its small architecture with random 16-codebook weights. + MODEL_PATH="${WORK_DIR}/tiny-random-qwen3-tts-16-codebooks" + "${PYTHON_BIN}" tests/special_e2e/build_qwen3_tts_tiny_random.py \ + --source-model-path "${SOURCE_MODEL_PATH}" \ + --output-dir "${MODEL_PATH}" +fi +[[ -f "${MODEL_PATH}/config.json" ]] || { echo "Invalid MODEL_PATH: ${MODEL_PATH}" >&2; exit 2; } + +"${PYTHON_BIN}" tests/special_e2e/create_dummy_qwen3_tts_grpo_data.py \ + --output-dir "${DATA_DIR}" \ + --model-config "${MODEL_PATH}/config.json" + +MODEL_PATH="${MODEL_PATH}" \ +TRAIN_FILE="${DATA_DIR}/train.parquet" \ +VAL_FILE="${DATA_DIR}/validation.parquet" \ +SPK_EMBED_PATH="${DATA_DIR}/speaker.json" \ +SCORER_URL="http://unused.invalid/score" \ +OUTPUT_DIR="${OUTPUT_DIR}" \ +NUM_GPUS="${NUM_GPUS}" \ +TOTAL_TRAINING_STEPS=2 \ +TEST_FREQ=-1 \ +SAVE_FREQ=-1 \ +RESUME_MODE=disable \ +PYTHON_BIN="${PYTHON_BIN}" \ +bash examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh \ + actor_rollout_ref.rollout.n=2 \ + actor_rollout_ref.rollout.agent.num_workers=2 \ + actor_rollout_ref.rollout.max_num_seqs=4 \ + "reward.custom_reward_function.path=${REPO_ROOT}/tests/special_e2e/qwen3_tts_dummy_reward.py" \ + reward.custom_reward_function.name=compute_score \ + trainer.val_before_train=false \ + trainer.log_val_generations=0 \ + "$@" + +"${PYTHON_BIN}" - "${OUTPUT_DIR}/train.log" <<'PY' +import re +import sys +from pathlib import Path + +steps = [int(step) for step in re.findall(r"training/global_step:(\d+)(?!\d)", Path(sys.argv[1]).read_text())] +if not steps or max(steps) != 2: + raise SystemExit(f"Expected training/global_step to reach exactly 2, observed {steps!r}") +PY +echo "Qwen3-TTS GRPO e2e smoke passed; artifacts: ${WORK_DIR}" diff --git a/tests/special_sanity/test_gpu_smoke_selector_on_cpu.py b/tests/special_sanity/test_gpu_smoke_selector_on_cpu.py new file mode 100644 index 000000000..cce4a4a77 --- /dev/null +++ b/tests/special_sanity/test_gpu_smoke_selector_on_cpu.py @@ -0,0 +1,44 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.util +import sys +from pathlib import Path + + +def _load_selector(): + path = Path(__file__).parents[1] / "gpu_smoke/select_gpu_smoke_groups.py" + spec = importlib.util.spec_from_file_location("gpu_smoke_selector_under_test", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_qwen3_tts_smoke_files_select_only_omni_e2e_group(): + selector = _load_selector() + + selected = selector.select_group_names( + [ + ".github/qwen_tts_pin.txt", + "examples/grpo_trainer/qwen3_tts/run_qwen3_tts_grpo.sh", + "tests/special_e2e/build_qwen3_tts_tiny_random.py", + "tests/special_e2e/run_qwen3_tts_grpo_smoke.sh", + "tests/special_e2e/create_dummy_qwen3_tts_grpo_data.py", + "tests/special_e2e/qwen3_tts_dummy_reward.py", + ] + ) + + assert selected == ["ci-e2e-omni"] diff --git a/tests/workers/rollout/rollout_vllm/test_qwen3_tts_rollout_on_cpu.py b/tests/workers/rollout/rollout_vllm/test_qwen3_tts_rollout_on_cpu.py new file mode 100644 index 000000000..b412bffba --- /dev/null +++ b/tests/workers/rollout/rollout_vllm/test_qwen3_tts_rollout_on_cpu.py @@ -0,0 +1,454 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""CPU contracts for Qwen3-TTS's multi-stage rollout integration.""" + +import subprocess +import sys +from types import SimpleNamespace + +import pytest +import torch +from tensordict import TensorDict + +pytest.importorskip("verl") +pytest.importorskip("vllm_omni") + +from verl.experimental.agent_loop.agent_loop import AgentLoopMetrics, AgentLoopOutput +from verl.utils.tensordict_utils import list_of_dict_to_tensordict +from vllm import SamplingParams + +from verl_omni.agent_loop.single_turn_agent_loop import OmniSingleTurnAgentLoop +from verl_omni.pipelines.model_base import OmniRolloutPipelineBase +from verl_omni.pipelines.qwen3_tts import omni_rollout_adapter +from verl_omni.pipelines.qwen3_tts.omni_rollout_adapter import ( + Qwen3TTSRolloutAdapter, + prepare_code2wav_input_for_policy_replay, +) +from verl_omni.pipelines.qwen3_tts.rollout_utils import QWEN3_TTS_REPLAY_KEY +from verl_omni.pipelines.qwen3_tts.talker_training_adapter import Qwen3TTSTalkerAdapter +from verl_omni.workers.rollout.vllm_rollout.vllm_omni_ar_strategy import ARStrategy +from verl_omni.workers.rollout.vllm_rollout.vllm_omni_async_server import vLLMOmniHttpServer + + +class _Tokenizer: + def decode(self, token_ids, **kwargs): + return "first text" if token_ids == [1] else "other text" + + def __call__(self, text, **kwargs): + return {"input_ids": list(range(len(text)))} + + +def test_external_module_import_registers_omni_agent_loop(): + code = """ +import vllm.utils.import_utils as import_utils + +NoGPU = type("NoGPU", (), { + "nvmlInit": staticmethod(lambda: None), + "nvmlDeviceGetCount": staticmethod(lambda: 0), + "nvmlShutdown": staticmethod(lambda: None), +}) +import_utils.import_pynvml = lambda: NoGPU + +import vllm_omni.platforms as platforms +from vllm_omni.platforms.interface import UnspecifiedOmniPlatform + +platforms._current_omni_platform = UnspecifiedOmniPlatform() + +import verl_omni +from verl.experimental.agent_loop.agent_loop import _agent_loop_registry + +target = _agent_loop_registry[\"omni_single_turn_agent\"][\"_target_\"] +assert target == \"verl_omni.agent_loop.single_turn_agent_loop.OmniSingleTurnAgentLoop\" +""" + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_optional_rollout_hooks_preserve_existing_ar_defaults(): + first, final = object(), object() + + assert OmniRolloutPipelineBase.supports_async_chunk is True + assert OmniRolloutPipelineBase.weight_sync_stage_ids() is None + assert OmniRolloutPipelineBase.prepare_engine_prompt([], None, {}) is None + assert ( + OmniRolloutPipelineBase.postprocess_agent_loop_output( + final, + tokenizer=None, + response_length=8, + ) + is final + ) + assert OmniRolloutPipelineBase.combine_engine_outputs([final], {}) == (final, {}) + with pytest.raises(NotImplementedError, match="multiple final outputs"): + OmniRolloutPipelineBase.combine_engine_outputs([first, final], {}) + with pytest.raises(RuntimeError, match="no outputs"): + OmniRolloutPipelineBase.combine_engine_outputs([], {}) + + +def test_omni_single_turn_agent_resolves_registered_pipeline_adapter(): + rollout_config = SimpleNamespace(engine_kwargs={"vllm_omni": {"pipeline_name": "qwen3_tts_rl"}}) + + assert OmniSingleTurnAgentLoop._resolve_rollout_adapter(rollout_config) is Qwen3TTSRolloutAdapter + + missing_config = SimpleNamespace(engine_kwargs={"vllm_omni": {"pipeline_name": "missing"}}) + with pytest.raises(ValueError, match="requires a registered"): + OmniSingleTurnAgentLoop._resolve_rollout_adapter(missing_config) + + +def test_rollout_pipeline_registers_upstream_talker(monkeypatch): + registered_pipelines = [] + monkeypatch.setattr( + omni_rollout_adapter, + "register_pipeline", + lambda pipeline: registered_pipelines.append(pipeline), + ) + + Qwen3TTSRolloutAdapter.ensure_pipeline_registered() + + assert registered_pipelines == [omni_rollout_adapter.QWEN3_TTS_RL_PIPELINE] + assert registered_pipelines[0].model_arch == omni_rollout_adapter.QWEN3_TTS_PIPELINE.model_arch + + +def test_rollout_adapter_builds_unique_prompt_and_scopes_weight_sync(tmp_path): + speaker = tmp_path / "speaker.json" + speaker.write_text("[0.0, 1.0]") + model_config = SimpleNamespace( + tokenizer=_Tokenizer(), + override_config={"tts_spk_embed_path": str(speaker), "tts_language": "Auto"}, + hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_eos_token_id=2150)), + ) + + first = Qwen3TTSRolloutAdapter.prepare_engine_prompt([1], model_config, {}) + second = Qwen3TTSRolloutAdapter.prepare_engine_prompt([2], model_config, {}) + + assert first["additional_information"]["text"] == ["first text"] + assert first["cache_salt"] != second["cache_salt"] + assert Qwen3TTSRolloutAdapter.weight_sync_stage_ids("full") == [0] + stages = Qwen3TTSRolloutAdapter.build_stage_configs("full") + assert [stage.final_output_type for stage in stages if stage.final_output] == ["latent", "audio"] + assert stages[0].sampling_constraints["min_tokens"] == 2 + assert stages[1].sync_process_input_func.endswith(".prepare_code2wav_input_for_policy_replay") + + +def test_code2wav_placeholder_uses_retained_policy_token_count_without_mutation(): + completion = SimpleNamespace( + cumulative_token_ids=[101, 201, 202, 2150], + multimodal_output=None, + ) + source_output = SimpleNamespace(finished=True, outputs=[completion]) + + prepared = prepare_code2wav_input_for_policy_replay([source_output]) + + assert len(prepared) == 1 + assert len(prepared[0]["prompt_token_ids"]) == 3 * 16 + assert completion.multimodal_output is None + + +def test_ar_strategy_resolves_qwen3_tts_adapter(monkeypatch): + server = SimpleNamespace(_rollout_flags={}) + strategy = ARStrategy(server) + deploy_calls = [] + monkeypatch.setattr( + strategy, + "_write_deploy_config", + lambda engine_kwargs, pipeline_name, adapter_cls, pipeline_mode: deploy_calls.append( + (pipeline_name, adapter_cls, pipeline_mode) + ), + ) + engine_kwargs = { + "output_mode": "ar", + "pipeline_name": "qwen3_tts_rl", + "pipeline_mode": "full", + "async_chunk": False, + } + + strategy.preprocess_engine_kwargs(engine_kwargs) + + assert deploy_calls == [("qwen3_tts_rl", Qwen3TTSRolloutAdapter, "full")] + assert strategy._rollout_adapter is Qwen3TTSRolloutAdapter + assert engine_kwargs == {"async-chunk": False} + + +@pytest.mark.parametrize("async_chunk", [None, True]) +def test_ar_strategy_requires_non_chunked_qwen3_tts_rollout(async_chunk): + strategy = ARStrategy(SimpleNamespace(_rollout_flags={})) + engine_kwargs = { + "pipeline_name": "qwen3_tts_rl", + "pipeline_mode": "full", + } + if async_chunk is not None: + engine_kwargs["async_chunk"] = async_chunk + + with pytest.raises(ValueError, match="requires async_chunk=false"): + strategy.preprocess_engine_kwargs(engine_kwargs) + + +def test_rollout_adapter_requires_speaker_embedding(): + model_config = SimpleNamespace( + tokenizer=_Tokenizer(), + override_config={"tts_language": "Auto"}, + hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_eos_token_id=2150)), + ) + + with pytest.raises(ValueError, match="requires tts_spk_embed_path"): + Qwen3TTSRolloutAdapter.prepare_engine_prompt([1], model_config, {}) + + +def test_qwen3_tts_adapters_require_explicit_language(tmp_path): + speaker = tmp_path / "speaker.json" + speaker.write_text("[0.0, 1.0]") + model_config = SimpleNamespace( + tokenizer=_Tokenizer(), + override_config={"tts_spk_embed_path": str(speaker)}, + hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_eos_token_id=2150)), + ) + + with pytest.raises(ValueError, match="supports only tts_language=Auto"): + Qwen3TTSRolloutAdapter.prepare_engine_prompt([1], model_config, {}) + with pytest.raises(ValueError, match="supports only tts_language=Auto"): + Qwen3TTSTalkerAdapter.configure_model(SimpleNamespace(config=SimpleNamespace()), model_config) + + +def test_talker_adapter_rejects_remove_padding_before_model_configuration(): + model_config = SimpleNamespace(use_remove_padding=True) + + with pytest.raises(ValueError, match="use_remove_padding=false"): + Qwen3TTSTalkerAdapter.configure_model(SimpleNamespace(), model_config) + + +def test_talker_adapter_pads_exact_rollout_fields_for_actor_forward(): + model_inputs = {"input_ids": torch.zeros(2, 6, dtype=torch.long)} + payloads = [ + {"text_ids": [1, 2, 6], "audio_codes": torch.ones(3, 16, dtype=torch.long)}, + {"text_ids": [3, 4, 5], "audio_codes": torch.full((2, 16), 2, dtype=torch.long)}, + ] + micro_batch = list_of_dict_to_tensordict( + [ + AgentLoopOutput( + prompt_ids=[1], + response_ids=[2], + response_mask=[1], + metrics=AgentLoopMetrics(), + extra_fields={QWEN3_TTS_REPLAY_KEY: item}, + ).as_dict() + for item in payloads + ] + ) + + prepared = Qwen3TTSTalkerAdapter.prepare_model_inputs(model_inputs, micro_batch, None) + + assert prepared["tts_text_ids"].shape == (2, 3) + assert prepared["tts_audio_codes"].shape == (2, 3, 16) + assert prepared["text_len"].tolist() == [3, 3] + assert prepared["response_len"].tolist() == [3, 2] + assert not prepared["tts_audio_codes"][1, 2].any() + + +def test_talker_adapter_requires_namespaced_replay_payload(): + model_inputs = {"input_ids": torch.zeros(1, 4, dtype=torch.long)} + micro_batch = TensorDict({}, batch_size=[1]) + + with pytest.raises(RuntimeError, match=QWEN3_TTS_REPLAY_KEY): + Qwen3TTSTalkerAdapter.prepare_model_inputs(model_inputs, micro_batch, None) + + +def test_rollout_adapter_combines_policy_codes_and_waveform(): + token_ids = [101, 102, 2150] + generated = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) + 1 + generated[:, 0] = torch.tensor(token_ids) + policy = SimpleNamespace( + stage_id=0, + outputs=[SimpleNamespace(token_ids=token_ids)], + multimodal_output={"codes": {"audio": torch.cat((torch.zeros(12, 16), generated))}}, + ) + decoder = SimpleNamespace( + stage_id=1, + outputs=[], + multimodal_output={"audio": torch.ones(2400), "sr": 24_000}, + ) + prompt = {"additional_information": {"text": ["first text"]}} + + selected, fields = Qwen3TTSRolloutAdapter.combine_engine_outputs([policy, decoder], prompt) + + assert selected is policy + torch.testing.assert_close(fields["tts_audio_codes"], generated.long()) + torch.testing.assert_close(fields["audio"], torch.ones(2400)) + assert fields["audio_sample_rate"] == 24_000 + assert fields["tts_text"] == "first text" + + +def test_rollout_adapter_rejects_non_mono_waveform(): + token_ids = [101, 102, 2150] + generated = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) + 1 + generated[:, 0] = torch.tensor(token_ids) + policy = SimpleNamespace( + stage_id=0, + outputs=[SimpleNamespace(token_ids=token_ids)], + multimodal_output={"codes": {"audio": torch.cat((torch.zeros(12, 16), generated))}}, + ) + decoder = SimpleNamespace( + stage_id=1, + outputs=[], + multimodal_output={"audio": torch.ones(2, 2400), "sr": 24_000}, + ) + prompt = {"additional_information": {"text": ["first text"]}} + + with pytest.raises(RuntimeError, match="one-dimensional mono waveform"): + Qwen3TTSRolloutAdapter.combine_engine_outputs([policy, decoder], prompt) + + +def test_rollout_adapter_prepares_actor_policy_sequence(): + codes = torch.arange(5 * 16, dtype=torch.long).reshape(5, 16) + output = SimpleNamespace( + prompt_ids=[9, 8], + response_ids=[7, 6, 5, 4, 3], + response_mask=[1] * 5, + response_logprobs=[-0.1, -0.2, -0.3, -0.4, -0.5], + extra_fields={ + "tts_audio_codes": codes, + "tts_text": "first text", + "audio": torch.ones(2400), + "audio_sample_rate": 24_000, + }, + ) + + result = Qwen3TTSRolloutAdapter.postprocess_agent_loop_output( + output, + tokenizer=_Tokenizer(), + response_length=3, + ) + + assert result is output + assert result.prompt_ids == [0] + assert result.response_ids == codes[:3, 0].tolist() + assert result.response_mask == [1, 1, 1] + assert result.response_logprobs == [-0.1, -0.2, -0.3] + assert "tts_audio_codes" not in result.extra_fields + assert "tts_text" not in result.extra_fields + replay = result.extra_fields[QWEN3_TTS_REPLAY_KEY] + torch.testing.assert_close(replay["audio_codes"], codes[:3]) + assert replay["text_ids"] + assert result.extra_fields["audio_sample_rate"] == 24_000 + + +def test_ar_strategy_prepares_stage_specific_sampling_params(): + class Adapter: + @staticmethod + def prepare_engine_prompt(**kwargs): + return { + "prompt_token_ids": [1, 1, 1, 1], + "additional_information": {"text": ["hello"]}, + } + + server = SimpleNamespace( + model_config=SimpleNamespace(), + config=SimpleNamespace( + max_model_len=64, + prompt_length=16, + response_length=8, + repetition_penalty=1.0, + ), + engine=SimpleNamespace(default_sampling_params_list=[SamplingParams(), SimpleNamespace(stage="decoder")]), + ) + strategy = ARStrategy(server) + strategy._rollout_adapter = Adapter + strategy._rollout_output_modalities = ["latent", "audio"] + strategy._policy_stage_index = 0 + strategy._policy_sampling_constraints = {} + + prompt, params = strategy.preprocess_input( + [5, 6], + {"temperature": 0.8, "logprobs": True}, + {}, + None, + None, + ) + + assert prompt["additional_information"]["max_new_tokens"] == [8] + assert len(params) == 2 + assert params[0].max_tokens == 8 + assert params[0].temperature == pytest.approx(0.8) + assert params[0].logprobs == 0 + assert params[1].stage == "decoder" + + +@pytest.mark.parametrize( + ("adapter_prompt", "message"), + [ + ({"additional_information": {"text": ["hello"]}}, "must contain prompt_token_ids"), + ([1, 2], "must return a dict or None"), + ], +) +def test_ar_strategy_rejects_invalid_adapter_prompt(adapter_prompt, message): + class Adapter: + @staticmethod + def prepare_engine_prompt(**kwargs): + return adapter_prompt + + server = SimpleNamespace( + model_config=SimpleNamespace(), + config=SimpleNamespace(max_model_len=64, prompt_length=16, response_length=8), + ) + strategy = ARStrategy(server) + strategy._rollout_adapter = Adapter + + with pytest.raises((RuntimeError, TypeError), match=message): + strategy.preprocess_input([5, 6], {}, {}, None, None) + + +@pytest.mark.asyncio +async def test_ar_strategy_retains_requested_stage_outputs_and_targets_weight_sync(): + completion = SimpleNamespace(token_ids=[7], logprobs=None, finish_reason="stop", num_preempted=0) + policy = SimpleNamespace(request_id="request-0", outputs=[completion]) + + class Engine: + def __init__(self): + self.generate_kwargs = None + self.rpc_kwargs = None + + async def generate(self, **kwargs): + self.generate_kwargs = kwargs + yield policy + + async def collective_rpc(self, **kwargs): + self.rpc_kwargs = kwargs + return "rpc-result" + + class Adapter: + @staticmethod + def combine_engine_outputs(outputs, prompt): + assert outputs == [policy] + return policy, {"audio_sample_rate": 24_000} + + server = object.__new__(vLLMOmniHttpServer) + server.engine = Engine() + server.global_steps = 3 + strategy = ARStrategy(server) + strategy._rollout_output_modalities = ["latent", "audio"] + strategy._rollout_adapter = Adapter + strategy._weight_sync_stage_ids = [0] + server._generate_strategy = strategy + + result = await strategy.run_generation({"prompt_token_ids": [1]}, SamplingParams(), "request-0", None, 0) + rpc_result = await server.collective_rpc("update_weights_from_ipc", kwargs={"base_sync_done": True}) + + assert result is policy + assert not hasattr(result, "_verl_omni_rollout_fields") + assert strategy._rollout_fields_by_request_id == {"request-0": {"audio_sample_rate": 24_000}} + assert server.engine.generate_kwargs["output_modalities"] == ["latent", "audio"] + assert server.engine.rpc_kwargs["stage_ids"] == [0] + assert rpc_result is None + + output = strategy.process_output(result, SamplingParams(), {}) + assert output.extra_fields == {"global_steps": 3, "audio_sample_rate": 24_000} + assert strategy._rollout_fields_by_request_id == {} diff --git a/verl_omni/pipelines/__init__.py b/verl_omni/pipelines/__init__.py index a1acf099d..ab3c9ca10 100644 --- a/verl_omni/pipelines/__init__.py +++ b/verl_omni/pipelines/__init__.py @@ -20,6 +20,7 @@ minimax_h3_diffusion_nft, minimax_h3_flow_grpo, qwen3_omni, + qwen3_tts, qwen_image_diffusion_nft, qwen_image_dpo, qwen_image_dual_grpo, @@ -37,6 +38,7 @@ from .minimax_h3_diffusion_nft import * # noqa: F401, F403 from .minimax_h3_flow_grpo import * # noqa: F401, F403 from .qwen3_omni import * # noqa: F401, F403 +from .qwen3_tts import * # noqa: F401, F403 from .qwen_image_diffusion_nft import * # noqa: F401, F403 from .qwen_image_dpo import * # noqa: F401, F403 from .qwen_image_dual_grpo import * # noqa: F401, F403 @@ -48,6 +50,7 @@ from .wan22_dance_grpo import * # noqa: F401, F403 __all__ = list(qwen3_omni.__all__) +__all__ += list(qwen3_tts.__all__) __all__ += list(qwen_image_flow_grpo.__all__) __all__ += list(qwen_image_diffusion_nft.__all__) __all__ += list(qwen_image_mix_grpo.__all__) diff --git a/verl_omni/pipelines/qwen3_tts/__init__.py b/verl_omni/pipelines/qwen3_tts/__init__.py new file mode 100644 index 000000000..5831f3727 --- /dev/null +++ b/verl_omni/pipelines/qwen3_tts/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .omni_rollout_adapter import Qwen3TTSRolloutAdapter +from .talker_training_adapter import Qwen3TTSTalkerAdapter + +__all__ = ["Qwen3TTSTalkerAdapter", "Qwen3TTSRolloutAdapter"] diff --git a/verl_omni/pipelines/qwen3_tts/omni_rollout_adapter.py b/verl_omni/pipelines/qwen3_tts/omni_rollout_adapter.py new file mode 100644 index 000000000..266e0338e --- /dev/null +++ b/verl_omni/pipelines/qwen3_tts/omni_rollout_adapter.py @@ -0,0 +1,264 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Qwen3-TTS two-stage rollout adapter.""" + +import hashlib +from dataclasses import replace +from functools import lru_cache +from types import SimpleNamespace + +import torch +from vllm_omni.config.pipeline_registry import register_pipeline +from vllm_omni.config.stage_config import PipelineConfig +from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE + +from verl_omni.pipelines.model_base import OmniRolloutPipelineBase +from verl_omni.pipelines.qwen3_tts.rollout_utils import QWEN3_TTS_REPLAY_KEY, align_audio_codes +from verl_omni.pipelines.qwen3_tts.talker_forward import ( + NUM_CODEBOOKS, + TEXT_PROMPT_TRAILER_TOKENS, + build_assistant_text, + load_speaker_xvector, + require_auto_language, +) + +_PIPELINE_ID = "qwen3_tts_rl" + + +def prepare_code2wav_input_for_policy_replay( + source_outputs: list, + prompt=None, + _requires_multimodal_data: bool = False, +) -> list: + """Size Code2Wav placeholders from the retained Talker policy trajectory. + + The pinned sync engine keeps codec values on the worker connector while + the orchestrator sees only cumulative policy tokens. Code2Wav still needs + a placeholder sized to 16 codebooks for every generated codec frame. + """ + from vllm_omni.model_executor.stage_input_processors.qwen3_tts import talker2code2wav_token_only + + normalized_outputs = [] + for source_output in source_outputs: + completions = getattr(source_output, "outputs", None) + if not getattr(source_output, "finished", False): + normalized_outputs.append(source_output) + continue + if not completions: + raise RuntimeError("Qwen3-TTS Talker finished without a completion.") + + completion = completions[0] + token_ids = list(getattr(completion, "cumulative_token_ids", None) or []) + if len(token_ids) < 2: + raise RuntimeError("Qwen3-TTS Talker produced no codec frames for Code2Wav.") + frame_count = len(token_ids) - 1 + # This tensor supplies only the placeholder shape. The worker + # connector sends the actual 16-codebook values to Code2Wav. + multimodal_output = { + "codes": {"audio": torch.ones((frame_count, NUM_CODEBOOKS), dtype=torch.long)}, + } + + completion_proxy = SimpleNamespace( + cumulative_token_ids=token_ids, + multimodal_output=multimodal_output, + ) + normalized_outputs.append( + SimpleNamespace( + finished=source_output.finished, + outputs=[completion_proxy], + ) + ) + + return talker2code2wav_token_only( + normalized_outputs, + prompt=prompt, + _requires_multimodal_data=_requires_multimodal_data, + ) + + +QWEN3_TTS_RL_PIPELINE = PipelineConfig( + model_type=_PIPELINE_ID, + model_arch=QWEN3_TTS_PIPELINE.model_arch, + stages=( + replace( + QWEN3_TTS_PIPELINE.stages[0], + final_output=True, + final_output_type="latent", + sampling_constraints={ + **QWEN3_TTS_PIPELINE.stages[0].sampling_constraints, + "min_tokens": 2, + }, + ), + replace( + QWEN3_TTS_PIPELINE.stages[1], + sync_process_input_func=f"{__name__}.prepare_code2wav_input_for_policy_replay", + ), + ), +) + + +@lru_cache(maxsize=4) +def _load_speaker_vector(path: str) -> list[float]: + return load_speaker_xvector(path).reshape(-1).tolist() + + +@OmniRolloutPipelineBase.register(_PIPELINE_ID) +class Qwen3TTSRolloutAdapter(OmniRolloutPipelineBase): + supports_async_chunk = False + + @classmethod + def _check_mode(cls, pipeline_mode): + if pipeline_mode != "full": + raise ValueError("Qwen3-TTS RL supports only pipeline_mode='full'.") + + @classmethod + def build_stage_configs(cls, pipeline_mode="full"): + cls._check_mode(pipeline_mode) + return list(QWEN3_TTS_RL_PIPELINE.stages) + + @classmethod + def get_pipeline_id(cls, pipeline_mode="full"): + cls._check_mode(pipeline_mode) + return _PIPELINE_ID + + @classmethod + def ensure_pipeline_registered(cls, pipeline_mode="full"): + cls._check_mode(pipeline_mode) + register_pipeline(QWEN3_TTS_RL_PIPELINE) + + @classmethod + def weight_sync_stage_ids(cls, pipeline_mode="full"): + """Sync actor weights only to stage 0; stage 1 is the frozen decoder.""" + cls._check_mode(pipeline_mode) + return [0] + + @classmethod + def get_stage_engine_extras(cls, stage_id, pipeline_mode="full"): + cls._check_mode(pipeline_mode) + if stage_id == 0: + return {} + if stage_id == 1: + return {"max_model_len": 65536, "max_num_batched_tokens": 65536} + raise ValueError(f"Qwen3-TTS has no rollout stage {stage_id}.") + + @classmethod + def postprocess_agent_loop_output(cls, output, *, tokenizer, response_length): + """Map the 16-codebook rollout to codec-0 policy tokens and replay fields.""" + extra = output.extra_fields + if "tts_audio_codes" not in extra or "tts_text" not in extra: + raise RuntimeError("Qwen3-TTS rollout did not return codec codes and text.") + if QWEN3_TTS_REPLAY_KEY in extra: + raise RuntimeError(f"Qwen3-TTS rollout unexpectedly returned reserved field {QWEN3_TTS_REPLAY_KEY!r}.") + codes, text = extra["tts_audio_codes"], extra["tts_text"] + if not isinstance(text, str): + raise TypeError(f"Qwen3-TTS rollout text must be a string, got {type(text).__name__}.") + codes = torch.as_tensor(codes, dtype=torch.long) + if codes.ndim != 2 or codes.shape[-1] != 16: + raise ValueError("Qwen3-TTS codec codes must have shape (frames, 16).") + codes = codes[:response_length] + policy_ids = codes[:, 0].tolist() + if not policy_ids: + raise RuntimeError("Qwen3-TTS rollout returned an empty codec trajectory.") + if output.response_logprobs is not None: + if len(output.response_logprobs) < len(policy_ids): + raise RuntimeError("Qwen3-TTS rollout logprobs are shorter than the policy trajectory.") + output.response_logprobs = output.response_logprobs[: len(policy_ids)] + text_ids = tokenizer(build_assistant_text(text), return_tensors="pt", padding=False)["input_ids"] + text_ids = torch.as_tensor(text_ids, dtype=torch.long) + if text_ids.ndim == 1: + text_ids = text_ids.unsqueeze(0) + if text_ids.ndim != 2 or text_ids.shape[1] <= TEXT_PROMPT_TRAILER_TOKENS: + raise ValueError("Qwen3-TTS assistant text tokenization returned an invalid sequence.") + extra[QWEN3_TTS_REPLAY_KEY] = { + "text_ids": text_ids[:, :-TEXT_PROMPT_TRAILER_TOKENS].reshape(-1).tolist(), + "audio_codes": codes, + } + del extra["tts_audio_codes"] + del extra["tts_text"] + output.prompt_ids = [0] + output.response_ids = policy_ids + output.response_mask = [1] * len(policy_ids) + return output + + @classmethod + def prepare_engine_prompt(cls, prompt_ids, model_config, multi_modal_data, mm_processor_kwargs=None): + """Build the Base-task prompt and fixed-speaker conditioning for vLLM-Omni.""" + text = model_config.tokenizer.decode(prompt_ids, skip_special_tokens=True).strip() + if not text: + raise ValueError("Qwen3-TTS received an empty text prompt.") + speaker_path = model_config.override_config.get("tts_spk_embed_path") + if not speaker_path: + raise ValueError("Qwen3-TTS GRPO requires tts_spk_embed_path for the validated non-streaming replay.") + language = require_auto_language(model_config.override_config.get("tts_language")) + additional_information = { + "task_type": ["Base"], + "text": [text], + "language": [language], + "non_streaming_mode": [True], + "x_vector_only_mode": [True], + "voice_clone_prompt": [{"ref_spk_embedding": _load_speaker_vector(speaker_path)}], + } + assistant_ids = model_config.tokenizer(build_assistant_text(text), padding=False)["input_ids"] + assistant_ids = torch.as_tensor(assistant_ids).reshape(-1).tolist() + prompt_length = len(assistant_ids) + 2 + identity = "\0".join((text, str(language), str(speaker_path))).encode() + return { + "prompt_token_ids": [1] * prompt_length, + "additional_information": additional_information, + "cache_salt": hashlib.sha256(identity).hexdigest(), + } + + @classmethod + def combine_engine_outputs(cls, outputs, prompt): + """Combine stage-0 policy tokens with codec and waveform outputs.""" + policy_outputs = [output for output in outputs if output.stage_id == 0] + decoder_outputs = [output for output in outputs if output.stage_id == 1] + if not policy_outputs: + raise RuntimeError("Qwen3-TTS rollout produced no stage-0 policy output.") + if not decoder_outputs: + raise RuntimeError("Qwen3-TTS rollout produced no stage-1 decoder output.") + + policy_output = policy_outputs[-1] + decoder_output = decoder_outputs[-1] + if len(policy_output.outputs) != 1: + raise RuntimeError( + f"Qwen3-TTS stage 0 must return exactly one completion, got {len(policy_output.outputs)}." + ) + token_ids = list(policy_output.outputs[0].token_ids) + if not token_ids: + raise RuntimeError("Qwen3-TTS rollout returned an empty stage-0 policy trajectory.") + + try: + audio_codes = torch.as_tensor(policy_output.multimodal_output["codes"]["audio"]).detach().cpu() + waveform = torch.as_tensor(decoder_output.multimodal_output["audio"]).detach().cpu().float() + sample_rate = torch.as_tensor(decoder_output.multimodal_output["sr"]) + except (KeyError, TypeError, ValueError, RuntimeError) as error: + raise RuntimeError("Qwen3-TTS rollout output does not match the pinned two-stage contract.") from error + if waveform.ndim != 1: + raise RuntimeError("Qwen3-TTS stage 1 must return a one-dimensional mono waveform.") + if waveform.numel() == 0: + raise RuntimeError("Qwen3-TTS stage 1 returned an empty waveform.") + if sample_rate.numel() != 1: + raise RuntimeError("Qwen3-TTS stage 1 must return one scalar sample rate.") + sample_rate_value = float(sample_rate.item()) + if sample_rate_value <= 0 or not sample_rate_value.is_integer(): + raise RuntimeError(f"Qwen3-TTS stage 1 returned an invalid sample rate: {sample_rate_value!r}.") + + fields = { + "tts_audio_codes": align_audio_codes(audio_codes, token_ids), + "tts_text": prompt["additional_information"]["text"][0], + "audio": waveform, + "audio_sample_rate": int(sample_rate_value), + } + return policy_output, fields diff --git a/verl_omni/pipelines/qwen3_tts/rollout_utils.py b/verl_omni/pipelines/qwen3_tts/rollout_utils.py new file mode 100644 index 000000000..24ce2949a --- /dev/null +++ b/verl_omni/pipelines/qwen3_tts/rollout_utils.py @@ -0,0 +1,66 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Helpers for validating Qwen3-TTS rollout outputs.""" + +import torch + +QWEN3_TTS_REPLAY_KEY = "qwen3_tts_talker_replay" + + +def align_audio_codes(audio_codes: torch.Tensor, token_ids: list[int]) -> torch.Tensor: + """Recover residual codebooks using codec-0 policy tokens as an exact invariant. + + The engine can prepend placeholder rows and need not emit residual codes + for the last sampled token, because that token is never consumed as the + context for another policy token. Thus ``token_ids[:-1]`` must match + exactly; a final residual row is retained only when the engine provides it. + """ + if audio_codes.ndim != 2 or audio_codes.shape[-1] != 16: + raise ValueError("Qwen3-TTS codec codes must have shape (frames, 16).") + if not token_ids: + return audio_codes[:0].long() + raw_codes = audio_codes.long() + policy_ids = torch.as_tensor(token_ids, dtype=torch.long, device=raw_codes.device) + required = len(token_ids) - 1 + candidates: list[tuple[int, int]] = [] + if required == 0: + candidates.append((0, 0)) + else: + for start in range(raw_codes.shape[0] - required + 1): + if torch.equal(raw_codes[start : start + required, 0], policy_ids[:required]): + has_final = int( + raw_codes.shape[0] - start >= len(token_ids) + and raw_codes[start + required, 0] == policy_ids[required] + ) + candidates.append((required + has_final, start)) + if not candidates: + raise RuntimeError( + "Could not exactly align Qwen3-TTS residual codebooks with the sampled codec-0 policy: " + f"response_length={len(token_ids)}, raw_codec_rows={raw_codes.shape[0]}." + ) + + copy_length = max(length for length, _ in candidates) + best_starts = [start for length, start in candidates if length == copy_length] + if len(best_starts) != 1: + raise RuntimeError( + "Ambiguous Qwen3-TTS codec alignment: multiple residual-codebook spans match the sampled policy." + ) + start = best_starts[0] + codes = raw_codes.new_zeros((len(token_ids), 16)) + if copy_length: + codes[:copy_length] = raw_codes[start : start + copy_length] + codes[:, 0] = policy_ids + if required and not torch.equal(codes[:required, 0], policy_ids[:required]): + raise RuntimeError("Qwen3-TTS codec alignment invariant failed after recovery.") + return codes diff --git a/verl_omni/pipelines/qwen3_tts/talker_forward.py b/verl_omni/pipelines/qwen3_tts/talker_forward.py new file mode 100644 index 000000000..a1acc763a --- /dev/null +++ b/verl_omni/pipelines/qwen3_tts/talker_forward.py @@ -0,0 +1,250 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Teacher-forced codec-0 forward math for Qwen3-TTS.""" + +import json +from dataclasses import dataclass + +import torch + +NUM_CODEBOOKS = 16 +TEXT_PROMPT_TRAILER_TOKENS = 5 + + +def build_assistant_text(text: str) -> str: + return f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n" + + +def load_speaker_xvector(path: str) -> torch.Tensor: + with open(path) as file: + vector = json.load(file) + return torch.tensor(vector, dtype=torch.float32).reshape(1, -1) + + +@dataclass +class TalkerTokens: + tts_pad: int + tts_bos: int + tts_eos: int + codec_pad: int + codec_bos: int + codec_eos: int + codec_nothink: int + codec_think_bos: int + codec_think_eos: int + + @classmethod + def from_config(cls, config): + talker = config.talker_config + return cls( + int(config.tts_pad_token_id), + int(config.tts_bos_token_id), + int(config.tts_eos_token_id), + int(talker.codec_pad_id), + int(talker.codec_bos_id), + int(talker.codec_eos_token_id), + int(talker.codec_nothink_id), + int(talker.codec_think_bos_id), + int(talker.codec_think_eos_id), + ) + + +@dataclass +class TalkerBatch: + input_ids: torch.Tensor + codec_ids: torch.Tensor + text_embedding_mask: torch.Tensor + codec_embedding_mask: torch.Tensor + codec_mask: torch.Tensor + attention_mask: torch.Tensor + codec_lens: list[int] + logit_start: list[int] + speaker_slots: list[int] + + +def build_talker_batch( + text_ids, + audio_codes, + tokens, + *, + sub_codebook_vocab: int, + device=None, +) -> TalkerBatch: + if not text_ids or len(text_ids) != len(audio_codes): + raise ValueError("Qwen3-TTS teacher forcing requires matching non-empty text and codec batches.") + if sub_codebook_vocab <= 0: + raise ValueError(f"sub_codebook_vocab must be positive, got {sub_codebook_vocab}.") + if any(ids.reshape(-1).numel() < 3 for ids in text_ids): + raise ValueError("Qwen3-TTS teacher-forcing text sequences must contain at least three prefix tokens.") + if any(codes.ndim != 2 or codes.shape[-1] != NUM_CODEBOOKS for codes in audio_codes): + raise ValueError(f"Qwen3-TTS codec codes must have shape (frames, {NUM_CODEBOOKS}).") + text_lens = [int(ids.reshape(-1).shape[0]) for ids in text_ids] + codec_lens = [int(codes.shape[0]) for codes in audio_codes] + speaker_slot = 6 + text_start = 8 + seq_len = max(t + c for t, c in zip(text_lens, codec_lens, strict=True)) + 8 + batch_size = len(text_ids) + input_ids = torch.zeros((batch_size, seq_len, 2), dtype=torch.long, device=device) + codec_ids = torch.zeros((batch_size, seq_len, NUM_CODEBOOKS), dtype=torch.long, device=device) + text_mask = torch.zeros((batch_size, seq_len), dtype=torch.bool, device=device) + codec_embedding_mask = torch.zeros_like(text_mask) + codec_mask = torch.zeros_like(text_mask) + attention_mask = torch.zeros((batch_size, seq_len), dtype=torch.long, device=device) + + for index, (sample_text, sample_codes) in enumerate(zip(text_ids, audio_codes, strict=True)): + ids = sample_text.reshape(-1).to(device=device, dtype=torch.long) + codes = sample_codes.to(device=device, dtype=torch.long) + if torch.any((codes[:, 1:] < 0) | (codes[:, 1:] >= sub_codebook_vocab)): + raise ValueError("Qwen3-TTS residual codec IDs are outside the code-predictor vocabulary.") + text_len, codec_len = text_lens[index], codec_lens[index] + + input_ids[index, :3, 0] = ids[:3] + input_ids[index, 3:speaker_slot, 0] = tokens.tts_pad + input_ids[index, speaker_slot, 0] = tokens.tts_pad + input_ids[index, speaker_slot + 1, 0] = tokens.tts_bos + input_ids[index, text_start : text_start + text_len - 3, 0] = ids[3:] + input_ids[index, text_start + text_len - 3, 0] = tokens.tts_eos + input_ids[index, text_start + text_len - 2 : text_start + text_len + codec_len, 0] = tokens.tts_pad + text_mask[index, : text_start + text_len + codec_len] = True + + input_ids[index, 3:speaker_slot, 1] = torch.tensor( + [tokens.codec_nothink, tokens.codec_think_bos, tokens.codec_think_eos], device=device + ) + input_ids[index, speaker_slot + 1, 1] = tokens.codec_pad + input_ids[index, text_start : text_start + text_len - 2, 1] = tokens.codec_pad + input_ids[index, text_start + text_len - 2, 1] = tokens.codec_bos + start = text_start + text_len - 1 + input_ids[index, start : start + codec_len, 1] = codes[:, 0] + input_ids[index, start + codec_len, 1] = tokens.codec_eos + codec_ids[index, start : start + codec_len] = codes + codec_embedding_mask[index, 3 : text_start + text_len + codec_len] = True + codec_embedding_mask[index, speaker_slot] = False + codec_mask[index, start : start + codec_len] = True + attention_mask[index, : text_start + text_len + codec_len] = True + + return TalkerBatch( + input_ids, + codec_ids, + text_mask.unsqueeze(-1), + codec_embedding_mask.unsqueeze(-1), + codec_mask, + attention_mask, + codec_lens, + [text_start + text_len - 2 for text_len in text_lens], + [speaker_slot] * batch_size, + ) + + +def require_auto_language(language) -> str: + """Limit RL training to the prompt layout validated by the actor forward.""" + normalized = str(language).strip() + if normalized.lower() != "auto": + raise ValueError( + "Qwen3-TTS RL currently supports only tts_language=Auto; " + "language-specific codec prefixes require a separately validated actor forward." + ) + return "Auto" + + +def codec0_input_embeddings(talker, batch: TalkerBatch, speaker_embedding: torch.Tensor) -> torch.Tensor: + ids = batch.input_ids + text_embeddings = talker.text_projection(talker.model.text_embedding(ids[:, :, 0])) + codec_embeddings = talker.model.codec_embedding(ids[:, :, 1]) * batch.codec_embedding_mask + codec_embeddings = codec_embeddings.clone() + sample_indices = torch.arange(codec_embeddings.shape[0], device=codec_embeddings.device) + codec_embeddings[sample_indices, batch.speaker_slots] = speaker_embedding.to(codec_embeddings.dtype) + embeddings = text_embeddings * batch.text_embedding_mask + codec_embeddings + sub_embeddings = talker.code_predictor.get_input_embeddings() + codec_mask = batch.codec_mask.unsqueeze(-1) + for codebook in range(1, NUM_CODEBOOKS): + embeddings += sub_embeddings[codebook - 1](batch.codec_ids[:, :, codebook]) * codec_mask + return embeddings + + +def codec0_logits(talker, batch: TalkerBatch, speaker_embedding: torch.Tensor) -> torch.Tensor: + embeddings = codec0_input_embeddings(talker, batch, speaker_embedding) + output = talker( + inputs_embeds=embeddings[:, :-1], + attention_mask=batch.attention_mask[:, :-1], + use_cache=False, + output_hidden_states=False, + ) + return output.logits + + +def mask_codec0_logits(logits: torch.Tensor, codebook_vocab: int, codec_eos_token_id: int) -> torch.Tensor: + """Match the codec-token vocabulary exposed by the rollout model.""" + if not 1 < codebook_vocab <= logits.shape[-1]: + raise ValueError(f"codebook_vocab must be in [2, {logits.shape[-1]}], got {codebook_vocab}.") + if not 0 <= codec_eos_token_id < logits.shape[-1]: + raise ValueError(f"codec_eos_token_id must be in [0, {logits.shape[-1]}), got {codec_eos_token_id}.") + valid = torch.zeros(logits.shape[-1], dtype=torch.bool, device=logits.device) + valid[1:codebook_vocab] = True + valid[codec_eos_token_id] = True + return logits.masked_fill(~valid, -1e4) + + +def tts_actor_logits( + model, + input_ids, + attention_mask, + tts_text_ids, + tts_audio_codes, + response_len, + text_len, + speaker_embedding, +) -> torch.Tensor: + batch_size, output_len = input_ids.shape + texts, codes, response_starts = [], [], [] + for index in range(batch_size): + response_size, text_size = int(response_len[index]), int(text_len[index]) + response_start = int(attention_mask[index].sum()) - response_size + if response_start < 1 or response_start + response_size > output_len: + raise ValueError("Invalid Qwen3-TTS response alignment.") + policy_ids = input_ids[index, response_start : response_start + response_size].long() + codec0_ids = tts_audio_codes[index, :response_size, 0].long() + if not torch.equal(policy_ids, codec0_ids): + mismatch = int(torch.nonzero(policy_ids != codec0_ids, as_tuple=False)[0]) + raise RuntimeError( + f"Qwen3-TTS codec trajectory is not aligned with actor labels: sample={index}, frame={mismatch}." + ) + texts.append(tts_text_ids[index, :text_size].long()) + codes.append(tts_audio_codes[index, :response_size].long()) + response_starts.append(response_start) + + talker = model.talker + sub_vocab = int(talker.code_predictor.get_input_embeddings()[0].num_embeddings) + tokens = TalkerTokens.from_config(model.config) + batch = build_talker_batch( + texts, + codes, + tokens, + device=input_ids.device, + sub_codebook_vocab=sub_vocab, + ) + logits = mask_codec0_logits( + codec0_logits(talker, batch, speaker_embedding), + sub_vocab, + int(model.config.talker_config.codec_eos_token_id), + ) + if int(input_ids.min()) < 0 or int(input_ids.max()) >= logits.shape[-1]: + raise ValueError("Qwen3-TTS actor token IDs are outside the codec-0 logit vocabulary.") + output_vocab = logits.shape[-1] + aligned = logits.new_zeros((batch_size, output_len, output_vocab)) + for index, response_start in enumerate(response_starts): + codec_len = batch.codec_lens[index] + target = slice(response_start - 1, response_start - 1 + codec_len) + source = batch.logit_start[index] + aligned[index, target] = logits[index, source : source + codec_len] + return aligned diff --git a/verl_omni/pipelines/qwen3_tts/talker_training_adapter.py b/verl_omni/pipelines/qwen3_tts/talker_training_adapter.py new file mode 100644 index 000000000..82c5b7954 --- /dev/null +++ b/verl_omni/pipelines/qwen3_tts/talker_training_adapter.py @@ -0,0 +1,184 @@ +# Copyright 2026 Bytedance Ltd. and/or its affiliates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Qwen3-TTS talker actor adapter.""" + +import logging +import types +from collections.abc import Mapping +from typing import Any + +import torch +from transformers import AutoModelForTextToWaveform +from verl.utils import tensordict_utils as tu + +from verl_omni.pipelines.model_base import OmniModelBase +from verl_omni.pipelines.qwen3_tts.rollout_utils import QWEN3_TTS_REPLAY_KEY +from verl_omni.pipelines.qwen3_tts.talker_forward import ( + load_speaker_xvector, + require_auto_language, + tts_actor_logits, +) + + +def _speaker_embedding(model, batch_size, device, dtype): + return model._verl_tts_speaker_embedding.to(device=device, dtype=dtype).expand(batch_size, -1) + + +def _qwen3_tts_forward( + self, + input_ids=None, + attention_mask=None, + tts_text_ids=None, + tts_audio_codes=None, + response_len=None, + text_len=None, + **kwargs, +): + from transformers.modeling_outputs import CausalLMOutputWithPast + + required_inputs = (input_ids, attention_mask, tts_text_ids, tts_audio_codes, response_len, text_len) + if any(value is None for value in required_inputs): + raise RuntimeError("Qwen3-TTS forward is missing exact rollout codec fields.") + speaker = _speaker_embedding(self, input_ids.shape[0], input_ids.device, next(self.talker.parameters()).dtype) + return CausalLMOutputWithPast( + logits=tts_actor_logits( + self, + input_ids, + attention_mask, + tts_text_ids, + tts_audio_codes, + response_len, + text_len, + speaker, + ) + ) + + +def _get_input_embeddings(self): + return self.talker.model.codec_embedding + + +def _set_input_embeddings(self, value): + self.talker.model.codec_embedding = value + + +@OmniModelBase.register("Qwen3TTSForConditionalGeneration", stage="talker") +class Qwen3TTSTalkerAdapter(OmniModelBase): + auto_model_class = AutoModelForTextToWaveform + + @classmethod + def register_auto_classes(cls) -> None: + from qwen_tts.core.models.configuration_qwen3_tts import Qwen3TTSConfig + from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration + from transformers import AutoConfig, AutoModelForTextToWaveform + + AutoConfig.register("qwen3_tts", Qwen3TTSConfig, exist_ok=True) + AutoModelForTextToWaveform.register( + Qwen3TTSConfig, + Qwen3TTSForConditionalGeneration, + exist_ok=True, + ) + + @classmethod + def get_strip_modules(cls, model_config): + return ["speaker_encoder", "speech_tokenizer", "code2wav"] + + @classmethod + def configure_model(cls, module, model_config): + if getattr(model_config, "use_remove_padding", False): + raise ValueError("Qwen3-TTS Talker training requires actor_rollout_ref.model.use_remove_padding=false.") + module = super().configure_model(module, model_config) + module.config.tts_spk_embed_path = model_config.override_config.get("tts_spk_embed_path") + module.config.tts_language = require_auto_language(model_config.override_config.get("tts_language")) + if not module.config.tts_spk_embed_path: + raise ValueError("Qwen3-TTS GRPO requires tts_spk_embed_path for the validated non-streaming replay.") + module._verl_tts_speaker_embedding = load_speaker_xvector(module.config.tts_spk_embed_path) + module.forward = types.MethodType(_qwen3_tts_forward, module) + module.get_input_embeddings = types.MethodType(_get_input_embeddings, module) + module.set_input_embeddings = types.MethodType(_set_input_embeddings, module) + module._no_split_modules = ["Qwen3TTSTalkerDecoderLayer", "Qwen3TTSDecoderLayer"] + trainable = 0 + for name, parameter in module.named_parameters(): + parameter.requires_grad_(name.startswith(("talker.model.", "talker.codec_head."))) + trainable += int(parameter.requires_grad) + logging.getLogger(__name__).info("Qwen3-TTS talker adapter enabled %d trainable parameter tensors", trainable) + return module + + @classmethod + def configure_processor(cls, model_path: str, model_config) -> Any: + return None + + @classmethod + def configure_tokenizer(cls, model_path: str, model_config): + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=model_config.trust_remote_code) + tokenizer.chat_template = "{% for message in messages %}{{ message['content'] }}{% endfor %}" + if tokenizer.pad_token_id is None: + if tokenizer.eos_token_id is None: + raise ValueError("Qwen3-TTS tokenizer must define either pad_token_id or eos_token_id.") + tokenizer.pad_token_id = tokenizer.eos_token_id + model_config.hf_config.talker_config.tie_word_embeddings = False + return tokenizer + + @classmethod + def prepare_model_inputs(cls, model_inputs, micro_batch, model_config): + del model_config + # The online V1 trainer retains AgentLoopOutput.extra_fields as one mapping per sample. + sample_extra_fields = tu.get(micro_batch, "extra_fields") + if sample_extra_fields is None: + raise RuntimeError(f"Qwen3-TTS actor inputs require the {QWEN3_TTS_REPLAY_KEY!r} replay payload.") + if not isinstance(sample_extra_fields, list) or any( + not isinstance(item, Mapping) for item in sample_extra_fields + ): + raise TypeError("Qwen3-TTS actor extra_fields must be a list of mappings.") + if len(sample_extra_fields) != model_inputs["input_ids"].shape[0]: + raise RuntimeError("Qwen3-TTS actor extra_fields do not match the actor batch size.") + if any(QWEN3_TTS_REPLAY_KEY not in item for item in sample_extra_fields): + raise RuntimeError(f"Qwen3-TTS actor inputs require the {QWEN3_TTS_REPLAY_KEY!r} replay payload.") + + fields = [item[QWEN3_TTS_REPLAY_KEY] for item in sample_extra_fields] + if any(not isinstance(item, Mapping) for item in fields): + raise TypeError(f"Qwen3-TTS {QWEN3_TTS_REPLAY_KEY!r} must be a list of mappings.") + if any("text_ids" not in item or "audio_codes" not in item for item in fields): + raise RuntimeError("Qwen3-TTS replay payloads must contain text_ids and audio_codes.") + + texts = [torch.as_tensor(item["text_ids"], dtype=torch.long).reshape(-1) for item in fields] + codes = [torch.as_tensor(item["audio_codes"], dtype=torch.long) for item in fields] + if any(item.numel() < 3 for item in texts): + raise ValueError("Qwen3-TTS actor text fields must contain at least three prefix tokens.") + if any(item.ndim != 2 or item.shape[-1] != 16 for item in codes): + raise ValueError("Qwen3-TTS codec codes must have shape (frames, 16).") + if any(item.shape[0] == 0 for item in codes): + raise ValueError("Qwen3-TTS actor codec fields must contain at least one frame.") + device = model_inputs["input_ids"].device + text_buffer = torch.zeros((len(fields), max(item.numel() for item in texts)), dtype=torch.long, device=device) + code_buffer = torch.zeros( + (len(fields), max(item.shape[0] for item in codes), 16), dtype=torch.long, device=device + ) + text_lens = torch.empty(len(fields), dtype=torch.long, device=device) + response_lens = torch.empty_like(text_lens) + for index, (text, code) in enumerate(zip(texts, codes, strict=True)): + text_buffer[index, : text.numel()] = text.to(device) + code_buffer[index, : code.shape[0]] = code.to(device) + text_lens[index], response_lens[index] = text.numel(), code.shape[0] + model_inputs.update( + { + "tts_text_ids": text_buffer, + "tts_audio_codes": code_buffer, + "text_len": text_lens, + "response_len": response_lens, + } + ) + return model_inputs